java-kotlin
JVM reference pack for Java 21-25, Kotlin 2.x with K2, Spring Boot 3.5+/4.x, persistence, JVM performance, Gradle/Maven, testing, Android/Compose, observability, security, and migrations. Use when writing, reviewing, tuning, or modernizing Java or Kotlin services and libraries; when choosing JVM frameworks, build tooling, concurrency model, or GC strategy; when diagnosing JVM production incidents; or when planning Java, Kotlin, Spring Boot, or Android upgrades.
Java / Kotlin
Production reference for JVM work: Java 21-25 language features and runtime, Kotlin 2.x with the K2 compiler, Spring Boot, persistence, performance, build tooling, testing, Android, observability/security, and migration paths. Content targets the state of the ecosystem in 2026. The LTS floor is Java 21; Java 25 is the current LTS line; feature releases ship every six months, so verify the current release upstream before pinning.
Version floors
| Component | Floor | Notes |
| --- | --- | --- |
| Java | 21 (LTS) | New code should not target below 21. Java 25 is the current LTS; 26/27 are feature releases. |
| Kotlin | 2.0 (K2) | K2 is the only supported compiler frontend going forward; prefer 2.1+ for tooling maturity. |
| Spring Boot | 3.5 (maintenance) / 4.x (current) | Boot 3.x requires Java 17; the 4.x line moves to Framework 7 and Jakarta EE 11. Verify exact versions upstream. |
| Jakarta EE | 10 / 11 | javax.* is dead on these lines; all imports are jakarta.*. |
| Hibernate | 6.6+ / 7.x | Hibernate 7 aligns with Jakarta Persistence 3.2. Verify support matrix upstream. |
| Gradle | 8.x / 9.x | Kotlin DSL is the default; a JVM 17+ toolchain runs the daemon. |
| Android | minSdk 24+ | targetSdk must track the annual Play requirement; AGP and Kotlin at latest stable. |
| Coroutines | 1.9+ | Structured concurrency semantics are stable; check dispatcher/API additions per minor. |
Non-negotiable core rules
- Constructor injection only. No field injection, no
@Autowiredon fields, no service locator. It keeps dependencies explicit, immutable, and testable. - Immutability by default. Records, Kotlin
val,data class, sealed hierarchies, immutable collections. Mutability is a deliberate, documented choice. - Validate at boundaries, trust internals. Jakarta Validation on controllers, messaging consumers, and
@ConfigurationProperties; domain logic assumes already-valid input. - Know your threads. Never block an event loop; never assume a request is on a platform thread. Reactive only when the workload justifies it (
./references/03-spring-boot.md). - One unit of work, one transaction, declared at the service boundary. No HTTP calls, no messaging, no file I/O inside an open transaction (
./references/04-persistence.md). - Production schemas change only through versioned migrations.
ddl-auto=updateis forbidden outside tests (./references/04-persistence.md). - Every remote call has a timeout, every pool has a bound. HTTP clients, DB pools, executors, and queues without limits are outage generators.
- Observability is part of the definition of done. Health, metrics, traces, and structured logs with correlation IDs ship with the feature (
./references/09-observability-security.md). - Virtual threads remove thread scarcity, not backpressure. Pool what is genuinely scarce: DB connections, file handles, downstream quotas (
./references/05-jvm-performance.md). - Measure before tuning. Percentiles over averages, JFR and async-profiler over intuition, JMH for microbenchmarks, load tests for macro behavior.
- Pin and lock everything. Toolchains, dependency versions, and checksums; builds must be reproducible and reviewable (
./references/06-build-tooling.md). - Tests are deterministic. No
Thread.sleep, no order dependence, no shared mutable fixtures; use Testcontainers for real dependencies (./references/07-testing.md). - Secrets and PII never touch logs, dumps, or artifacts. Secrets come from a managed store with rotation (
./references/09-observability-security.md). - Treat deprecations as deadlines. Track compiler warnings,
jdeprscan, and removal JEPs; never add new usage of APIs slated for removal (./references/01-java-core.md,./references/10-migration-modernization.md). - Prefer incremental migration behind seams. Big-bang rewrites of Java, Kotlin, or Spring upgrades fail; use the strangler patterns (
./references/10-migration-modernization.md).
Decision tables
Language and platform
| Situation | Choose | Avoid |
| --- | --- | --- |
| Greenfield JVM service, team fluent in Kotlin | Kotlin 2.x with K2 | Kotlin as a "Java with less typing" without learning null safety and coroutines |
| Library consumed by Java and Kotlin clients | Java 21+ records/sealed types | Kotlin-only APIs leaking coroutines, default arguments, or Unit-returning SAMs |
| Existing Java service, small team | Stay on Java, upgrade to 21+ | Rewriting to Kotlin for its own sake |
| Shared Kotlin/Android/iOS product logic | Kotlin Multiplatform | Forcing KMP where platform-native code is small |
| New code in a mixed repo | Kotlin modules with JSpecify-annotated Java boundaries | Mixed mutability/null conventions without enforcement |
Web and API stack
| Workload | Choose | Notes |
| --- | --- | --- |
| CRUD, blocking JDBC/JPA, typical service | Spring MVC on virtual threads | Simplest correct model; virtual threads handle concurrency (./references/03-spring-boot.md) |
| High-fanout streaming I/O, non-blocking drivers, backpressure | WebFlux | Requires an end-to-end reactive stack; never mix blocking JDBC on event loops |
| Internal synchronous RPC | MVC or gRPC; RestClient for calls | Prefer HTTP interfaces (@HttpExchange) over hand-rolled clients |
| Startup-sensitive (serverless, CLI, scale-to-zero) | Spring AOT or GraalVM native image | Budget for reflection/resource hints and integration test cost (./references/10-migration-modernization.md) |
| Simple, few dependencies, cold-path batch | Plain Java/Kotlin, no framework | Frameworks cost startup and memory |
Persistence
| Situation | Choose | Notes |
| --- | --- | --- |
| Rich domain model, CRUD plus relationships | JPA/Hibernate | Watch N+1 and lazy loading (./references/04-persistence.md) |
| Reporting, analytics, dynamic filters, SQL-first | jOOQ | Type-safe SQL without JPQL contortions |
| Append-only/event/streaming writes | Plain JDBC or jOOQ | ORM dirty checking adds nothing |
| Multi-DB enterprise deployments | Liquibase | XML/YAML changelogs and rollback support |
| SQL-first, single-DB, DBA-reviewed migrations | Flyway | Immutable versioned SQL scripts |
Runtime and GC
| Situation | Choose | Notes |
| --- | --- | --- |
| Latency SLOs, large heaps (tens of GB) | Generational ZGC | Allocation rate is the real tuning lever (./references/05-jvm-performance.md) |
| Balanced default web service | G1 (default) | Tune only with JFR evidence |
| Batch/throughput, pause-insensitive | Parallel GC | Highest throughput, long pauses |
| CPU-bound work | Platform threads sized to cores | Virtual threads add overhead without benefit |
| Blocking I/O fan-out (JDBC, HTTP) | Virtual threads | Eliminates thread-pool tuning; keep DB pools sized to the database |
Reference index
| File | Scope | Load when |
| --- | --- | --- |
| references/01-java-core.md | Java 21-25: records, sealed types, pattern matching, virtual threads, structured concurrency, scoped values, sequenced collections, deprecations and removals | Writing or reviewing modern Java; deciding on preview features; planning Java upgrades |
| references/02-kotlin-core.md | Kotlin 2.x/K2: null safety, data/sealed classes, extensions, scope functions, coroutines, Flow, channels, multiplatform | Writing Kotlin; designing coroutine-based async; mixed Java/Kotlin codebases |
| references/03-spring-boot.md | Spring Boot 3.5+/4.x: DI, configuration, MVC vs WebFlux, validation, error handling, security, actuators, AOT/native | Building or reviewing Spring services; choosing web stack or upgrading Boot |
| references/04-persistence.md | JPA/Hibernate, jOOQ, transactions, N+1 and fetch strategies, Flyway/Liquibase, pooling, caching | Modeling data access; fixing lazy-loading or transaction bugs; choosing an ORM vs SQL layer |
| references/05-jvm-performance.md | JIT, GC selection (G1, generational ZGC, Shenandoah), JFR, heap sizing, thread vs virtual-thread scaling, profiling | Tuning production JVM; diagnosing latency, memory, or CPU incidents |
| references/06-build-tooling.md | Gradle Kotlin DSL, version catalogs, toolchains, Maven comparison, multi-module builds, locking, CI | Setting up or repairing builds; migrating Maven builds; speeding up CI |
| references/07-testing.md | JUnit 5, Kotest, MockK/Mockito, Testcontainers, ArchUnit, PIT mutation testing, CI test strategy | Writing tests; making CI reliable; adding contract, architecture, or mutation coverage |
| references/08-android.md | Jetpack Compose, lifecycle/ViewModel, Room, coroutines, performance, testing, release | Building Android apps; reviewing Compose or Room code; preparing Play releases |
| references/09-observability-security.md | Micrometer, OpenTelemetry, structured logging, authn/authz patterns, secrets, supply chain, hardening | Adding observability; implementing auth; preparing security or supply-chain review |
| references/10-migration-modernization.md | Java 8/11/17 to 21+, Kotlin migration, Spring Boot upgrades, GraalVM native, strangler patterns | Planning upgrades; modernizing legacy JVM systems; choosing native images |
How to use this pack
- Read this file, then load only the references the task needs.
- Follow cross-links inside references for adjacent topics; every link is relative and local to this pack.
- Treat version ranges as floors and ranges, not pins. Confirm the current releases and support windows upstream before locking a version line.
- Prefer the checklists at the end of each reference as review gates.
Port
- Port id —
skill://senior/java-kotlin(version inmetadata.port-version). - Kind — read-only reference pack; no side effects, no tools required.
- Entrypoint — this
SKILL.md; depth lives inreferences/. - Load modes
- Installed agent:
skill({ name: "java-kotlin" })in OpenCode; Claude Code reads<skills-dir>/java-kotlin/SKILL.md. - Orchestrator: read
SKILL.md, then load only the references the task needs. - Not installed: consumers MUST degrade gracefully using their own guidance and report the degraded mode. Never block on the pack.
- Installed agent:
- Consumer contract —
metadata.consumerslists the agents that may load it; consumers reference it asload skill java-kotlin (optional). - Stability —
stable; breaking changes bumpport-versionmajor.
References (10)
01-java-core
Java Core (21-25)
Scope: modern Java language and runtime features every JVM engineer in this pack is expected to know.
Release model and floors
| Release | Type | Language highlights |
| --- | --- | --- |
| 21 | LTS | Virtual threads, pattern matching for switch, record patterns, sequenced collections |
| 22 | Feature | Unnamed variables, FFM API final, string templates preview (later withdrawn) |
| 23 | Feature | Primitive patterns preview, module imports preview, generational ZGC default |
| 24 | Feature | Synchronized no longer pins virtual threads, security manager permanently disabled |
| 25 | LTS | Scoped values, compact source files/instance main, flexible constructor bodies, compact object headers |
| 26+ | Feature | Ship every six months; verify which previews have finalized upstream |
Rules:
- New services target Java 21 at minimum; the current LTS (25) is the default target for production.
- Libraries should compile with
--release 21even when built on a newer JDK, unless they truly need newer APIs. - Never depend on a preview feature in production. Preview APIs can change incompatibly between releases and require
--enable-previewat both compile and run time. - Keep a JDK matrix in CI: build on the current LTS, test on the next feature release to surface removals early (see
./06-build-tooling.md).
Records
Records are transparent, immutable data carriers. Use them for DTOs, events, value objects, query rows, and configuration properties.
public record Money(BigDecimal amount, Currency currency) {
public Money {
Objects.requireNonNull(amount, "amount");
Objects.requireNonNull(currency, "currency");
if (amount.scale() > currency.getDefaultFractionDigits()) {
throw new IllegalArgumentException("scale exceeds currency precision");
}
}
static Money zero(Currency c) { return new Money(BigDecimal.ZERO, c); }
}
Key facts:
- The canonical constructor can be compact (no parameter list) for validation and normalization.
- Records are shallowly immutable: a record holding a
Liststill exposes a mutable list. Defensive-copy collections in the compact constructor when the collection is not immutable. - Records get
equals,hashCode, andtoString. Do not override them unless there is a strong reason; identity semantics for entities belong on plain classes. - Records cannot extend a class (they extend
java.lang.Record) but can implement interfaces and be components of sealed hierarchies. - Local records are allowed and useful inside methods for intermediate results.
recordcomponents are final fields; accessor names are the component names (amount(), notgetAmount()). Serialization bindings may need explicit names.- Java serialization of records is possible but discouraged; use JSON/Protobuf in services.
Anti-patterns:
- Using records as JPA entities. Hibernate cannot proxy final classes and dirty-checking assumes mutability; use classes for entities (see
./04-persistence.md). - Records with many components acting as grab-bag parameter objects; split them.
- Static factories that bypass the canonical constructor with
setAccessibletricks; validation belongs in the compact constructor.
Sealed types and algebraic data types
Sealed interfaces/classes restrict who may implement or extend them, enabling exhaustive reasoning.
public sealed interface Payment permits CardPayment, BankTransfer, WalletPayment {}
public record CardPayment(String last4, Instant at) implements Payment {}
public record BankTransfer(String iban, Instant at) implements Payment {}
public record WalletPayment(String walletId, Instant at) implements Payment {}
String label(Payment p) {
return switch (p) {
case CardPayment c -> "card " + c.last4();
case BankTransfer b -> "transfer " + b.iban();
case WalletPayment w -> "wallet " + w.walletId();
};
}
Notes:
- Permitted subtypes must be in the same module (or same package for the unnamed module);
permitscan be omitted when subtypes are in the same source file. - Subtypes must be
final,sealed, ornon-sealed. Markingnon-sealedreopens the hierarchy and forces default branches again. - Sealed + records is the idiomatic Java ADT. Combined with pattern matching it replaces visitor-heavy designs.
- Prefer sealed over enums when variants carry different data.
Pattern matching for switch
Final in Java 21, extended by record patterns.
sealed interface Shape permits Circle, Rect, Group {}
record Circle(double radius) implements Shape {}
record Rect(double w, double h) implements Shape {}
record Group(List<Shape> shapes) implements Shape {}
double area(Shape s) {
return switch (s) {
case Circle(double r) -> Math.PI * r * r;
case Rect(double w, double h) -> w * h;
case Group(List<Shape> shapes) -> shapes.stream().mapToDouble(this::area).sum();
};
}
Rules and gotchas:
- Exhaustiveness is checked for sealed hierarchies;
nullis still a separate case unless explicitly matched (case null -> ...). - Order matters. Put specific cases before broad type patterns; a
case Object olater makes some later cases unreachable (compile error). - Guarded patterns via
whenclauses are supported in current JDKs; verify the status of any newer guard syntax in your release. - Pattern variables are flow-scoped; they are definitely assigned in the matching branch only.
- Boxing of primitive patterns is still evolving as preview; do not build APIs on it yet.
Virtual threads
Virtual threads are cheap, JVM-scheduled threads designed for blocking I/O. They do not make CPU faster; they remove thread-pool sizing and context-switch costs from blocking workloads.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Response>> futures = ids.stream()
.map(id -> executor.submit(() -> client.fetch(id)))
.toList();
for (Future<Response> f : futures) {
results.add(f.get());
}
}
Guidelines:
- Create one virtual thread per task. Do not pool virtual threads; pooling defeats their purpose.
- Use
Thread.ofVirtual().name("fetch-", 0).factory()when you need naming conventions for diagnostics. - Blocking JDBC, HTTP, file I/O, and
Thread.sleepare exactly the workloads virtual threads improve. - Before JDK 24,
synchronizedblocks pinned the carrier. JDK 24 removes that limitation; on 21-23, replace hotsynchronizedwithReentrantLockaround blocking calls. - Native/FFM calls and class initializers can still pin; verify pinning with JFR events (
jdk.VirtualThreadPinned) on your runtime. ThreadLocalon a per-task virtual thread is fine but multiplies with thread count; preferScopedValue(25+) for request context.- CPU-bound work benefits from platform threads sized to cores; virtual threads add scheduling overhead without parallelism gains.
- Never share a virtual thread's mutable state with other tasks. Each task should own its data.
- Executors created with
newVirtualThreadPerTaskExecutorareAutoCloseable; closing waits for tasks. Prefer structured concurrency when it is available on your release to avoid leaking tasks.
Anti-patterns:
- A fixed virtual-thread pool to "limit concurrency". Use a semaphore or
ScopedValue-based limiter, or bound the downstream resource instead. - Using virtual threads to justify synchronous fan-out into an unbounded number of downstream calls; you still need concurrency limits.
- Assuming virtual threads help throughput-bound database work: the database connection pool is the bottleneck, not threads (see
./04-persistence.md).
Structured concurrency
Structured concurrency groups related concurrent subtasks so failure, cancellation, and lifetime are handled as a unit. It has been previewed repeatedly; confirm whether it is final in the JDK you target before using it in production.
Response handle(String id) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<User> user = scope.fork(() -> users.find(id));
Subtask<Orders> orders = scope.fork(() -> orders.forUser(id));
scope.join().throwIfFailed();
return new Response(user.get(), orders.get());
}
}
Guidelines while it remains preview:
- The shape is stable enough to design for, but pin the JDK version and accept churn; preview APIs change incompatibly.
- Use
ShutdownOnFailurefor fail-fast fan-out,ShutdownOnSuccessfor racing equivalent sources with different latencies. - Always use try-with-resources so the scope is closed and stragglers are cancelled.
- Treat cancellation as expected: subtasks must respond to interruption and clean up.
- Until final,
CompletableFuturewith explicit timeouts plus virtual-thread executors remains the production-safe pattern.
Scoped values
Scoped values (final in Java 25) carry immutable context to callees without method parameters and without ThreadLocal lifetime hazards.
private static final ScopedValue<RequestContext> CONTEXT = ScopedValue.newInstance();
Response handle(Request req) {
return ScopedValue.where(CONTEXT, RequestContext.from(req))
.call(() -> service.process(req));
}
RequestContext current() { return CONTEXT.get(); }
Rules:
- Scoped values are immutable and visible only inside the dynamic scope of the
where(...).call(...); there is noset. - They are inherited by child threads/structured subtasks; this is the intended propagation path for request IDs, tenants, and deadlines.
- Do not use them as hidden mutable state; they are for ambient read-only context.
- Prefer
orElse/isBoundwhen context is optional; throwingNoSuchElementExceptionfor normal control flow is a smell. - On JDK 21-24,
ThreadLocalplus explicit cleanup remains the fallback; document the migration once you move to 25 (see./10-migration-modernization.md).
Sequenced collections
Java 21 added SequencedCollection, SequencedSet, and SequencedMap with uniform first/last/reversed access.
SequencedMap<Instant, String> events = new LinkedHashMap<>();
events.put(t1, "created");
events.put(t2, "updated");
events.putFirst(t0, "draft");
String newest = events.lastEntry().getValue();
SequencedMap<Instant, String> newestFirst = events.reversed();
Where it applies:
List,Deque,LinkedHashSet,SortedSet,LinkedHashMap,SortedMapall expose the sequenced views.DequegainedaddFirst/addLast,getFirst/getLast,removeFirst/removeLastin the sequenced naming; prefer these for intent.Collections.unmodifiableSequencedCollection(...)and friends preserve sequencing in wrappers.- Use
getFirst()/getLast()instead ofget(0)/get(size()-1)for clarity and O(1) on deques.
Compact source files and instance main
Java 25 allows single-file programs without class ceremony, useful for tooling, demos, and small scripts.
void main() {
IO.println("hello " + (args.length > 0 ? args[0] : "world"));
}
- Implicitly declared classes still compile to normal class files; they are syntactic sugar.
- Not for production services: no package declaration, limited module story, tooling assumptions differ.
- Module import declarations (
import module java.base;) reduce import noise; use sparingly in application code because wildcard module imports hurt readability and can create ambiguity.
Deprecations, removals, and migration hazards
| Item | Status | Action |
| --- | --- | --- |
| Security manager | Permanently disabled in 24 | Remove SecurityManager usage and related flags; use OS sandboxing/modules |
| sun.misc.Unsafe memory access | Warned in 23-24, removal planned | Migrate to VarHandle, MethodHandles, or FFM (java.lang.foreign) |
| finalize() | Deprecated for removal | Replace with Cleaner, try-with-resources, or explicit close |
| Thread.stop/suspend/resume | Unsupported operations long-term | Use interruption and cancellation |
| String templates | Withdrawn after preview | Do not design around them; use String.format, builders, or templating engines |
| 32-bit x86 port | Removed in 25 | Move to 64-bit; check native dependencies |
| Non-generational ZGC | Removed in 24 | Use -XX:+UseZGC generational mode only |
| javax.* EE packages | Gone from Jakarta EE 10+ stacks | Migrate to jakarta.* (see ./10-migration-modernization.md) |
| JAXB, JAX-WS, CORBA, Nashorn | Removed from the JDK releases ago | Add explicit dependencies or replace |
Migration practice:
- Run
jdeps --jdk-internalsandjdeprscanon every artifact as part of upgrades. - Compile with
-Xlint:all -Werrorin CI so new deprecation warnings fail the build before they accumulate. - Use
--add-opens/--add-exportsonly as a temporary bridge; each one is a tracked task with an owner. - Test with the strongest encapsulation (
--illegal-access=denyis the default on modern JDKs) early; reflection failures usually surface at runtime, not compile time.
Anti-patterns checklist
- [ ] No production dependency on preview features without an explicit, reviewed exception.
- [ ] No
synchronizedaround blocking calls on JDK 21-23 virtual-thread paths. - [ ] No records as JPA entities or as long-lived mutable state.
- [ ] No
ThreadLocalrequest context without cleanup and without a documented upgrade path to scoped values. - [ ] No new use of
Unsafe,finalize, or removed JDK modules. - [ ] Exhaustive
switchover sealed types has no unnecessarydefaultbranch masking new variants. - [ ] Executors and scopes are closed; nothing leaks tasks past the request boundary.
- [ ] CI compiles with lint warnings as errors and tests on the intended release floor.
02-kotlin-core
Kotlin Core (2.x, K2)
Scope: idiomatic Kotlin 2.x on the JVM: null safety, data/sealed classes, extensions, scope functions, coroutines, Flow, channels, and multiplatform orientation.
Compiler baseline
| Item | Status |
| --- | --- |
| K2 frontend | Default since Kotlin 2.0; single compiler for all targets |
| Language version | Pin per module (languageVersion, apiVersion); avoid straddling majors in one build |
| Toolchain | jvmToolchain(21) or newer; Kotlin can emit older bytecode targets via jvmTarget |
| Explicit API mode | Enable for libraries (explicitApi()) so public declarations have explicit visibility and types |
| Warnings | Treat as errors in CI (allWarningsAsErrors) after a burn-down |
| KSP | Preferred over kapt for annotation processing (Room, Hilt, Dagger); kapt is legacy and slow |
| Preview features | Opt in per module and document; do not enable globally |
Compatibility rules:
- Kotlin metadata is forward-only: older compilers cannot read classes produced by newer ones. Keep the compiler version at or above the highest library metadata version.
- Java interop should assume JSpecify-annotated Java boundaries (
@NullMarked) to avoid platform types (see./01-java-core.mdand./10-migration-modernization.md). - Keep
kotlin-stdlibaligned with the compiler; mixing remote compiler versions across modules is a common source of cryptic errors.
Null safety
Null safety is the primary reason to choose Kotlin. Use it fully.
data class User(val id: Long, val email: String, val phone: String?)
fun normalize(user: User): String =
user.phone?.trim()?.takeIf { it.isNotEmpty() } ?: "unknown"
fun requireEmail(user: User) {
val email = requireNotNull(user.email.takeIf { it.contains('@') }) {
"invalid email for user ${user.id}"
}
send(email)
}
Idioms and rules:
?.,?:,takeIf,takeUnless,letare the daily tools. Prefer?: error(...)/requireNotNullover!!.!!is acceptable only where a precondition was already enforced and the invariant is locally obvious; every!!is a review flag.- Platform types from Java (
String!) have unknown nullability. Eliminate them at the boundary: annotate Java APIs with@NullMarked/@Nullable, or wrap inObjects.requireNonNull. lateinitis for framework-injected mutable fields only; not for constructor-optional state.Delegates.notNull()is for primitives.- Prefer sealed results or
Resultover nullable plus exceptions for business outcomes; use exceptions for programming errors. - Smart casts work after
ischecks and null checks, but fail across mutablevarcaptured in closures or custom getters; use localvalcopies. - Collection nullability:
List<String?>versusList<String>?are different; be deliberate in APIs. firstOrNull,singleOrNull,associateByNotNullavoid nullable-losing conversions; measurefirst { }usage in hot paths (throws).
Anti-patterns:
!!sprinkled to silence the compiler; it converts compile-time safety into runtime crashes.- Using
lateinitto avoid constructor parameters in tests; inject dependencies instead. - Returning
nullwhere an empty collection or a sealedNotFoundis clearer. - Trusting platform types in public API signatures.
Data and sealed classes
sealed interface UiState {
data object Loading : UiState
data class Ready(val items: List<Item>) : UiState
data class Failed(val cause: Throwable) : UiState
}
data class OrderLine(val sku: String, val qty: Int, val unitPrice: Money) {
init { require(qty > 0) { "qty must be positive" } }
val total: Money get() = unitPrice * qty
}
Rules:
data classgivesequals/hashCode/copy/toString/componentN. Properties declared outside the primary constructor are excluded fromequals; do not rely on them for identity.- Never put mutable arrays or mutable collections in a data class without defensive copies;
equalswill surprise you. data object(stable since 1.9) for singletons with value equality; do not useobjectwith manualequals.- Sealed interfaces are preferred over sealed classes for hierarchies that need multiple inheritance shapes.
- Exhaustive
whenover a sealed hierarchy replaceselse; adding a variant should break compilation at every decision point. - Value classes (
@JvmInline value class UserId(val raw: Long)) prevent primitive mixups with zero allocation in most paths; note boxing when used generically or as nullable. - Nested sealed hierarchies keep domain states local to their feature module.
Anti-patterns:
- Sealed classes with a catch-all
elsebranch; you lose exhaustiveness. - Data classes for JPA/Hibernate entities: generated
equals/hashCodeand mutability fight the persistence model (see./04-persistence.md). - Copying large data classes in hot loops;
copyis shallow but still allocates and re-validatesinitblocks.
Extension functions and properties
fun String.maskEmail(): String {
val at = indexOf('@')
return if (at <= 1) "***" else "${first()}***${substring(at)}"
}
val Instant.isWeekend: Boolean
get() = dayOfWeek in setOf(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY)
Rules:
- Extensions are static dispatch: they are resolved at compile time by the receiver's declared type, not overridden at runtime. Do not use them to fake polymorphism.
- Keep extensions close to the type they enrich or in a feature module; wildcard import of a general extensions package hurts discoverability.
- For libraries, use
@JvmNameto control the generated static name and avoid clashes;@JvmStaticon companion functions for Java ergonomics. inline+reifiedenables type checks and avoids lambda allocation, but inlined public functions become part of the ABI; recompilation of consumers is required on change.- Extensions cannot access private members; if you need internals, the function belongs in the type.
- Avoid extension functions on
Any,Unit, or overly generic receivers; they pollute all call sites.
Anti-patterns:
fun <T> T.toX()chains that read like DSL but hide control flow.- Extension properties with side effects; properties imply cheap, side-effect-free access.
- Building a parallel "utility" library of extensions that shadows standard library semantics (
String.add,List.concat).
Scope functions
| Function | Receiver | Returns | Typical use |
| --- | --- | --- | --- |
| let | it | lambda result | Null checks, mapping a value, local scoping of a name |
| run | this | lambda result | Compute a value from an object; block with last expression |
| with | this (arg) | lambda result | Multiple members of a non-null object without repetition |
| apply | this | receiver | Configure an object during construction/builder |
| also | it | receiver | Side effects (logging, validation) in a chain |
Rules:
- One scope function per chain, at most; nested
let/run/applypyramids are a readability failure. - Do not use
alsofor mutation that changes meaning; side effects should be obvious. runon a nullable receiver (user?.run { ... }) is idiomatic for "if present, do several things".withfor reads,applyfor writes.
Anti-patterns:
x?.let { ... } ?: throw ...whererequireNotNullreads better.- Using
applyon objects that have no builder semantics; it hides the real construction logic. - Mixing
itandthislambdas in the same expression.
Coroutines
Structured concurrency is the core rule: every coroutine has a parent scope, and a scope does not complete until its children do.
class UserService(
private val users: UserClient,
private val orders: OrderClient,
private val scope: CoroutineScope,
) {
suspend fun profile(id: Long): Profile = coroutineScope {
val user = async { users.get(id) }
val orders = async { orders.forUser(id) }
Profile(user.await(), orders.await())
}
fun refreshInBackground(id: Long) {
scope.launch { runCatching { profile(id) }.onFailure(log::warn) }
}
}
Rules:
suspendfunctions must be main-safe: switch toDispatchers.IO(or a dedicated dispatcher) around blocking work; never block the caller's dispatcher.runBlockingis only formain()and tests; never inside a coroutine or a server request handler.- Use
coroutineScope { }for fan-out that must fail as a unit;supervisorScope { }when sibling failures must not cancel each other. - Install an exception strategy at the boundary:
CoroutineExceptionHandleron root launches, or catch insidelaunch(an uncaught exception in a child cancels the parent). CancellationExceptionmust always be rethrown; catching it generically breaks structured cancellation and timeouts.- Timeouts: wrap with
withTimeout/withTimeoutOrNull; prefer deadline propagation over nested ad-hoc timeouts. - Dispatchers are not for thread affinity; do not assume
Dispatchers.Defaultruns on a specific thread (usenewSingleThreadContextonly when truly needed, and close it). Dispatchers.IOhas a bounded parallelism (tens of threads by default); for large blocking fan-outs prefer a virtual-thread dispatcher viaExecutors.newVirtualThreadPerTaskExecutor().asCoroutineDispatcher()(see./01-java-core.md,./05-jvm-performance.md).- Coroutine context elements propagate; avoid storing mutable state in contexts. Use
ThreadLocalonly around blocking library calls that require it. - Cancellation is cooperative: tight CPU loops must check
ensureActive()/isActive; blocking calls must be interruptible.
Testing coroutines:
runTestwithStandardTestDispatcher/TestScopevirtual time; neverrunBlockingin tests.- Inject dispatchers; never hardcode
Dispatchers.IOin classes under test. - Use Turbine for Flow assertions (see
./07-testing.md).
Anti-patterns:
GlobalScope.launchanywhere: unstructured, untracked work.asyncon the same dispatcher as an immediateawait, gaining no concurrency.- Catching
Throwableand swallowingCancellationException. - Blocking inside
Dispatchers.Default(the CPU pool starves).
Flow
Cold flows are lazy streams; hot flows (StateFlow, SharedFlow) are shared state/events.
fun search(query: Flow<String>): Flow<List<Result>> =
query
.debounce(250)
.distinctUntilChanged()
.flatMapLatest { q -> flow { emit(api.search(q)) } }
.catch { e -> emit(emptyList()) }
.flowOn(Dispatchers.IO)
val state: StateFlow<UiState> = searchFlow
.map<_, UiState> { UiState.Ready(it) }
.stateIn(scope, SharingStarted.WhileSubscribed(5_000), UiState.Loading)
Rules:
- Build cold flows with
flow { }andchannelFlow { }for concurrent emission; keep them side-effect free until collected. flowOnchanges the upstream context; operators before it run on the new dispatcher. Do not useflowOnonSharedFlow/StateFlow.- Context preservation: you cannot emit from a different context inside
flow { }; usechannelFlow/flowOn/withContextcorrectly. flatMapLatestcancels superseded work, ideal for search/autocomplete.flatMapMergefor fan-out,flatMapConcatfor ordering.- Hot sharing:
stateInfor UI state,shareInfor shared side-effect streams. UnderstandSharingStarted.Eagerly/Lazily/WhileSubscribedlifetime semantics. StateFlowconflates and always has a value; it is not an event bus. For one-shot events useChannelwithreceiveAsFlowor an explicit event queue.- Backpressure:
buffer,conflate,collectLatestare different tools;conflatedrops intermediate values,collectLatestcancels slow collectors. - Exceptions in flows terminate collection; place
catchbefore terminal operators and considerretryWhenfor transient upstream failures. - Never collect a cold flow multiple times expecting shared work; convert with
shareIn/stateIn.
Anti-patterns:
MutableStateFlowexposed publicly; exposeStateFlowand keep mutation private.- Using Flow for request/response when a simple
suspendfunction suffices. collectinsidelaunchon a UI scope without lifecycle awareness (see./08-android.md).
Channels
Channels are hot, buffered hand-off primitives for 1:1 or many-producer/consumer communication.
fun produceEvents(scope: CoroutineScope): ReceiveChannel<Event> =
scope.produce(capacity = 64) {
while (isActive) send(nextEvent())
}
Rules:
- Prefer Flow unless you need multiple consumers of the same stream or explicit capacity semantics.
- Capacities:
RENDEZVOUS(default),BUFFERED,CONFLATED,UNLIMITED, or a fixed size.UNLIMITEDis a memory leak waiting for a busy producer. Channelis single-consumer by default;fan-outdistributes elements across consumers. UseBroadcastChannel-style sharing viashareInfor multiple collectors.- Close channels in
finally; iterate withfor (e in channel)so closure ends the loop. select/onReceivefor multiplexing;producefor producer-side structured lifetime.
Anti-patterns:
- Using a channel as an unbounded queue between subsystems; use a real broker or bounded Flow with backpressure.
- Leaking producer coroutines by not tying them to a scope.
- Sending from multiple threads without a capacity that matches the consumer rate.
Multiplatform overview
Kotlin Multiplatform (KMP) shares Kotlin business logic across JVM/Android/iOS/JS/Wasm/native targets.
| Concern | Approach |
| --- | --- |
| Platform differences | expect/actual declarations, source sets (commonMain, androidMain, iosMain) |
| Networking | Ktor client with per-platform engines |
| Serialization | kotlinx.serialization (compiler plugin); avoid reflection-based JSON in common code |
| Persistence | SQLDelight (common SQL) or Room KMP where supported; platform-specific KV stores via expect/actual |
| UI sharing | Compose Multiplatform for shared UI where the product allows it; keep platform-native where it does not |
| iOS interop | Objective-C/Swift export; suspend functions map to completion handlers, generics and sealed classes have bridging limits (see also ./08-android.md) |
| Concurrency model | Kotlin/Native memory model is the modern relaxed model; still avoid sharing mutable state without synchronization |
| Build | Gradle multiplatform plugin; pin Kotlin and library versions together; expect longer compile times and bigger CI matrices |
Guidance:
- Share logic with a stable API, not everything. Platform-specific UX code stays native.
- Keep the common API surface small; every shared abstraction is a compatibility promise.
- Test common code with
commonTestand platform-specific tests for actual implementations. - Verify library compatibility with your Kotlin version before adopting KMP in a product.
Interop and annotation quick reference
| Annotation | Effect |
| --- | --- |
| @JvmStatic | Companion/object member exposed as static |
| @JvmField | Property exposed as a field, no accessors |
| @JvmOverloads | Generate overloads for default arguments |
| @JvmName | Rename generated JVM member |
| @Throws | Declare checked exceptions for Java callers |
| @JvmInline | Value class |
| @OptIn | Acknowledge experimental API usage |
| fun interface | SAM interface usable with lambdas |
Anti-patterns:
- Publishing Kotlin APIs whose Java view is unusable (default arguments, inline classes, sealed classes).
- Relying on Kotlin's
internalfor security; it maps to public with name mangling. - Using
@JvmOverloadson AndroidViewsubclasses without checking constructor inheritance.
Review checklist
- [ ] No
!!without a documented nearby invariant. - [ ] Platform types eliminated at Java boundaries.
- [ ] Coroutines tied to structured scopes; no
GlobalScope. - [ ]
CancellationExceptionalways rethrown; timeouts on all external calls. - [ ] Flows have defined sharing, backpressure, and error handling.
- [ ] Channels bounded and closed, or replaced with Flow.
- [ ] Explicit API mode and
allWarningsAsErrorson for libraries. - [ ] KMP only where the sharing payoff is concrete and tested on all targets.
03-spring-boot
Spring Boot
Scope: building and reviewing Spring Boot services: dependency injection, configuration, web stack choice, validation, error handling, security, actuators, and AOT/native.
Version baseline
| Component | Range | Notes |
| --- | --- | --- |
| Spring Boot 3.x | 3.5+ (maintenance) | Java 17 baseline, Jakarta EE 10, Framework 6, Security 6 |
| Spring Boot 4.x | current line | Framework 7, Jakarta EE 11, JSpecify null-safety, API versioning; verify exact versions and renamed modules upstream |
| Java | 17+ for Boot 3.x, 21 recommended | Boot 4 targets the modern JDK; run on 21 or 25 LTS |
| Kotlin | 2.x | K2 compiler, coroutine support; kotlin("plugin.spring") for open classes |
| Build | Gradle Kotlin DSL or Maven | Gradle preferred in this pack (./06-build-tooling.md) |
| Native | GraalVM for JDK 21/25 | Spring AOT plus runtime hints; test thoroughly (./10-migration-modernization.md) |
Rules:
- Always use a dependency management BOM or the Boot plugin's managed versions; never hand-pin framework artifacts.
- Do not run a Boot 4 major upgrade and a Java major upgrade in the same PR; upgrade one axis at a time.
- Keep
spring-boot-starter-*dependencies minimal; each starter adds auto-configuration you must understand and secure.
Dependency injection
Constructor injection with final fields; the container is a wiring detail, not a pattern.
@RestController
@RequestMapping("/api/v1/users")
class UserController {
private final UserService service;
UserController(UserService service) { this.service = service; }
@GetMapping("/{id}")
UserResponse get(@PathVariable long id) {
return UserResponse.from(service.find(id));
}
}
Guidelines:
- One public constructor per bean; no
@Autowiredneeded with a single constructor (Java and Kotlin). @Componentfor classes you own;@Beanmethods for third-party types and explicit wiring. Keep@Configurationclasses grouped by concern.- Prefer
ApplicationContext-free domain code: no@Autowiredfields in domain objects, noBeanFactorylookups in logic. @Qualifierbeats@Primarywhen two beans of a type are legitimate;ObjectProvider<T>for optional/lazy dependencies.- Circular dependencies indicate a design problem. Do not paper over them with
@Lazywithout a plan to break the cycle. @ConfigurationPropertiesbeans should be records or immutable classes; validate at startup.- In Kotlin, class bodies are final by default. The Spring plugin opens
@Component/@Configurationclasses; use@ConfigurationPropertiesdata classes with@ConstructorBindingsemantics (constructor binding is the default in Boot 3+). - Request scope and prototype scope need proxies and careful lifecycle handling; prefer passing data as parameters.
Anti-patterns:
- Field injection with
@Autowired; it hides dependencies and blocks immutability. @ComponentScanon the application class spanning unrelated packages; keep scans narrow.- Static/global access to beans via a holder; breaks tests and ordering.
- Injecting a
Map<String, Foo>and doing runtime dispatch when strategy selection is a domain decision.
Configuration
@ConfigurationProperties(prefix = "app.payments")
@Validated
public record PaymentsProperties(
@NotBlank String baseUrl,
@Positive Duration timeout,
@Min(1) @Max(64) int maxConnections,
List<String> allowedCurrencies) {}
Rules:
- Externalize all environment-specific values. No defaults that silently point at production, no secrets in
application.yml. - Use profile-specific files (
application-prod.yml) and environment variables in deploy environments; usespring.config.importfor vaults/config servers. - Validate configuration at startup:
@Validated+ Bean Validation on properties records; fail fast beats undefined behavior at runtime. - Prefer typed properties over scattered
@Value;@Valueis for one-off, non-domain values only. - Relaxed binding maps
APP_PAYMENTS_BASE_URLtoapp.payments.base-url; document the environment mapping. - Immutable maps/lists in properties; defensive-copy if the source can be mutated.
- Feature flags live in config, not code; but avoid config that changes code paths so much that testing them all is impossible.
- Keep
application.ymlshort; if it grows past a page, split by concern withspring.config.import.
Anti-patterns:
- Secrets committed in YAML or default passwords in test configs that bleed into production.
@Value("${...}")with:defaults masking missing configuration.- Profile proliferation (
dev1,dev2,local-john) instead of documented base plus overrides.
Web stack: MVC vs WebFlux
| Criterion | Spring MVC | WebFlux |
| --- | --- | --- |
| Concurrency model | Thread-per-request (platform or virtual) | Event loop, non-blocking |
| Blocking libraries | Fine (JDBC, JPA, blocking HTTP) | Forbidden on event loops |
| Backpressure | Not applicable per request | Flux/Mono backpressure |
| Virtual threads | Excellent fit: spring.threads.virtual.enabled=true | Largely unnecessary |
| Streaming/SSE/websockets with many connections | Limited threads help, but memory per connection is the cost | Strong fit |
| Complexity | Low | High; reactive all the way down |
| Debugging | Familiar stacks | Async stack traces, harder context propagation |
Decision rules:
- Default to MVC. Use virtual threads for I/O concurrency rather than adopting reactive code.
- Choose WebFlux only when you need non-blocking end-to-end (R2DBC, reactive clients), streaming with backpressure, or extreme connection counts with low memory.
- Never mix blocking JDBC into a WebFlux handler; offload to
boundedElasticonly as a stopgap and measure. - Do not migrate a working MVC service to WebFlux for performance without a measured problem and a plan for the full chain.
- For synchronous outbound calls prefer
RestClient(blocking) orWebClient(reactive) and declare clients as HTTP interfaces with@HttpExchange.
@HttpExchange(url = "/v1/orders", accept = "application/json")
interface OrdersClient {
@GetExchange("/{id}")
Order get(@PathVariable String id);
}
- Register clients with
HttpServiceProxyFactoryor the Boot auto-configuration; configure timeouts and retries explicitly. - Timeouts are mandatory on every client: connect, read, and total request timeout. Retries only for idempotent operations with jittered backoff and a budget.
Anti-patterns:
- Returning entities directly from controllers; use DTOs/records.
- Blocking calls in reactive chains (
block(), JDBC,Thread.sleep). - Unbounded
WebClientfan-out; apply concurrency limits and timeouts. - Building a "generic" controller abstraction; the framework already provides routing.
Validation and error handling
record CreateUser(@NotBlank @Email String email, @Min(18) int age) {}
@RestControllerAdvice
class ApiErrors extends ResponseEntityExceptionHandler {
@ExceptionHandler(NotFound.class)
ProblemDetail notFound(NotFound e) {
ProblemDetail p = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
p.setType(URI.create("https://api.example.com/problems/not-found"));
return p;
}
}
Rules:
- Validate at the boundary:
@Validon request bodies and query parameters,@Validatedon configuration properties and beans with method validation. - Use RFC 9457
ProblemDetailfor error responses; consistent problem types and machine-readabletypeURIs. - Map exceptions to HTTP statuses in one place (
@RestControllerAdvice); controllers should not catch what they cannot handle. - Never leak stack traces, SQL, class names, or internal identifiers in error payloads; log them with a correlation ID instead.
- Distinguish client errors (400/409/422) from server errors (500); do not return 200 with an error body.
- Validation messages are user-facing strings: keep them in a message source for i18n and avoid English-only inline text in libraries.
- For Kotlin, use
@field:targeted annotations on constructor properties when validation annotations must land on fields.
Anti-patterns:
@Validon the controller class instead of parameters.- Catching
Exceptionin controllers and returning500for everything. - Custom error format that changes shape per endpoint.
- Throwing exceptions from
equals/hashCodeor domain invariants during serialization.
Security
@Configuration
@EnableMethodSecurity
class SecurityConfig {
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
return http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(AbstractHttpConfigurer::disable)
.build();
}
}
Guidelines:
- Lambda DSL only.
WebSecurityConfigurerAdapteris gone; do not copy old tutorials. - Deny by default:
anyRequest().authenticated(). Explicitly permit the small public surface. - Stateless APIs: JWT resource server validation (issuer, audience, JWKS rotation, clock skew). Sessions and cookies only for browser apps with CSRF enabled.
- Method security (
@PreAuthorize,@PostAuthorize) for domain-level authorization; do not scatter role checks in controllers. - Password storage: Argon2id or bcrypt with per-user salt; never SHA/MD5, never custom crypto.
- CSRF applies to cookie/session-based clients. Disabling it is safe only for token-authenticated, non-browser clients.
- CORS: explicit origins, methods, headers; never
*with credentials. - Headers/transport: HSTS, secure cookies (
HttpOnly,Secure,SameSite), TLS everywhere in production; trust proxies only when configured deliberately. - Security events are logged; sensitive data in auth failures is not (see
./09-observability-security.md). - Dependencies: pin and scan; Security advisories map to Boot patch releases — stay current on the maintenance line.
Anti-patterns:
- Rolling your own JWT parsing/validation instead of the resource server support.
- Role checks encoded as URL string matching with path patterns that can be bypassed by encoding tricks.
- Shared long-lived API keys in config files.
- Disabling CSRF on cookie-authenticated endpoints "because tests fail".
Actuators
- Expose only what is needed:
management.endpoints.web.exposure.include=health,info,prometheusby default; everything else through the management port, bound to the internal network. - Health groups:
livenessmust be independent of dependencies (DB down must not kill the pod);readinessmay include critical dependencies. management.endpoint.health.show-details=when-authorizedat most; details leak topology.- Metrics should flow to a registry (Prometheus/OTLP) with consistent tags; avoid high-cardinality tags like user IDs (
./09-observability-security.md). - Startup probes for slow-starting apps;
management.endpoint.startup.enabled=truewithApplicationStartuptracking where needed. - Thread dumps, heap dumps, env/configprops endpoints are powerful attack surface; secure or disable them.
Anti-patterns:
- Exposing
/actuator/env,/actuator/heapdump, or/actuator/loggersto the public internet. - Liveness checks hitting the database; restarts will cascade during DB incidents.
- Custom health indicators that perform expensive upstream calls on every probe.
AOT, native, and startup
- Spring AOT processes the context at build time: reflection hints, proxy hints, resource hints. Most auto-configuration ships hints; third-party libraries may not.
- GraalVM native image gives fast startup and low memory for CLI/serverless, at the cost of build time, closed-world constraints, and slower peak optimization in some workloads.
- Native constraints: no dynamic class loading, limited reflection, runtime proxies and serialization need registration,
ClassPathScanningCandidateComponentProviderbehavior differs. - Build with the Boot Gradle/Maven plugin plus the GraalVM native plugin; run the full integration test suite against the native binary, not just unit tests.
- JVM AOT caches (class-loading and method-profiling AOT in recent JDKs) reduce startup without a native image; consider them before taking the native complexity (
./10-migration-modernization.md,./05-jvm-performance.md). - Keep profiles and conditional beans simple; AOT makes complex
@Conditionalgraphs hard to reason about. - Measure: startup time, RSS, first-request latency, throughput under sustained load. Do not choose native on startup alone.
Anti-patterns:
- Assuming native works because the app starts; exercising only health endpoints.
- Reflection-heavy serialization/config libraries without hints.
- Two build pipelines (JVM and native) that diverge in configuration.
Testing slices (pointer)
@WebMvcTest,@DataJpaTest,@JdbcTest,@SpringBootTestand@ServiceConnectionare covered in./07-testing.md.
Review checklist
- [ ] Constructor injection; no field injection or context lookups in domain code.
- [ ] Config validated at startup, secrets externalized, no production defaults.
- [ ] MVC chosen by default; virtual threads enabled; blocking calls have timeouts.
- [ ] ProblemDetail error model; no internal details leaked.
- [ ] Security: deny by default, stateless JWT or CSRF-correct sessions, method security, no wildcard CORS.
- [ ] Actuator surface minimal and secured; liveness independent of dependencies.
- [ ] Native/AOT evaluated against measured startup and memory goals.
- [ ] Boot maintenance line kept current; upgrade path to the next major planned (
./10-migration-modernization.md).
04-persistence
Persistence (JPA, jOOQ, Migrations, Pooling)
Scope: data access on the JVM: JPA/Hibernate modeling, jOOQ, transaction semantics, fetch strategies, schema migration, connection pooling, and caching.
Stack baseline
| Component | Range | Notes |
| --- | --- | --- |
| Jakarta Persistence | 3.1 / 3.2 | Hibernate 6.6+ and 7.x respectively; verify the matrix upstream |
| Hibernate | 6.6+ / 7.x | Hibernate 6 removed legacy javax and many old APIs |
| jOOQ | 3.x | Code generation from your schema; licensing differs by database and edition |
| Flyway | 10+ / 11+ | Database modules are separate artifacts; some database support is commercial — verify licensing |
| Liquibase | 4.x | XML/YAML/SQL changelogs, rollback support |
| HikariCP | current | Default pool in Spring Boot; keep the driver on the same support window |
| Spring Data JPA | 3.x / 4.x | Repository abstraction; do not let it hide fetch behavior |
Entity design
@Entity
@Table(name = "orders")
public class Order {
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_seq")
private Long id;
@Version private long version;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "customer_id")
private Customer customer;
@ElementCollection
@CollectionTable(name = "order_lines", joinColumns = @JoinColumn(name = "order_id"))
private List<OrderLine> lines = new ArrayList<>();
}
Rules:
- Entities are mutable identity objects: no records, no
finalclasses (proxying), no Lombok@Data(generatedequals/hashCodebreaks proxy semantics and lazy loading). - Identity: use generated numeric IDs; implement
equals/hashCodeon a business key or database ID with a null-safe, stable contract. Never use all fields (mutability) and never use the defaultObjectidentity (proxy and set operations fail). - Association defaults:
ManyToOne/OneToOneLAZY; never rely onEAGER.@Basic(fetch=LAZY)on large columns does little without bytecode enhancement — prefer separate tables or projections for blobs. - Bidirectional associations: keep one owning side, provide add/remove methods that maintain both sides, avoid cascades that delete more than intended.
@Versionoptimistic locking on aggregates modified by concurrent users; handleOptimisticLockExceptionwith retry or user-visible conflict.- Use
Instant/LocalDate/OffsetDateTime; avoidjava.util.Date, calendar, and timezone-naive types. Store timestamps in UTC. - Enums:
@Enumerated(EnumType.STRING); ordinal mapping breaks on reordering. - Generated schema is not the contract: use migrations in production (
ddl-auto=validateat most). Hibernate schema generation is for tests only. - Prefer
@Columnlengths/precision explicit; never let the database pick types silently for money (NUMERIC, notFLOAT).
Anti-patterns:
- Lazy loading in view/templating layers after the transaction closed.
@Data/@EqualsAndHashCodeon entities.- Storing large collections of entities as
@ElementCollectionwithout bounding their size. - God entities spanning many aggregates; split by transactional boundary.
Fetch strategies and N+1
N+1 is the default failure mode: one query for parents, then one per child.
| Strategy | Use when | Notes |
| --- | --- | --- |
| JOIN FETCH in JPQL | Small, known fetch set; pagination not combined | Collection fetch join plus setMaxResults triggers in-memory pagination |
| @EntityGraph | Varying fetch plans per query | Declarative; can also trigger in-memory pagination with collections |
| Batch fetching (@BatchSize, hibernate.default_batch_fetch_size) | Many small lazy loads | Turns N+1 into N/batch queries; safe with pagination |
| @Subselect/@Fetch(SUBSELECT) | Fetching the same collection for many parents | One extra query per collection |
| DTO projection (select new ...) | Read-mostly screens, reports | Fastest; no persistence context bookkeeping |
| Two-query pattern (ids, then fetch by ids) | Paginated aggregates with collections | Predictable and safe with pagination |
Detection:
- Enable SQL logging in development and read the log; count statements per request.
- Use
hibernate.generate_statistics=trueand inspect query counts in tests; assert query counts on critical paths. - Datasource proxies (p6spy, datasource-proxy) or Hibernate
StatementInspectorin test scope; fail tests when a request issues more than N statements. - JFR/telemetry can show SQL hotspots in production (
./05-jvm-performance.md).
Rules:
- Every repository method that returns an aggregate for display should declare its fetch needs; there is no correct global default.
- Avoid
EAGERto "fix" N+1; it moves the problem and makes unrelated queries heavy. - Pagination and collection fetch joins do not mix in JPA 3.x; use the two-query pattern or keyset pagination with DTOs.
Slice<T>for "is there more" checks avoids a count query;Page<T>issues an extracount(*)— disable it when the UI does not need totals.- Offset pagination degrades deep in large tables; use keyset/seek pagination (
WHERE (created_at, id) < (:last_created, :last_id) ORDER BY created_at DESC, id DESC).
Anti-patterns:
findAll()on a large table feeding a list endpoint.@OneToMany(fetch = EAGER)on multiple collections (cartesian explosion).- Open Session in View (OSIV) left on in production to mask lazy loading: it holds connections through rendering and hides N+1.
toString()on entities generated by Lombok triggering lazy loads during logging.
Transactions
@Service
public class TransferService {
private final Accounts accounts;
@Transactional
public void transfer(long from, long to, BigDecimal amount) {
accounts.debit(from, amount);
accounts.credit(to, amount);
}
}
Propagation reference:
| Propagation | Behavior | Use |
| --- | --- | --- |
| REQUIRED (default) | Join existing or start new | Normal service methods |
| REQUIRES_NEW | Suspend outer, new transaction | Auditing/outbox that must commit independently |
| NESTED | Savepoint inside outer | Partial rollback within one transaction (driver support required) |
| MANDATORY | Fail without existing | Internal methods that must run in a caller's unit of work |
| NOT_SUPPORTED | Suspend, run without transaction | Long reads/reports that should not hold connections |
| NEVER | Fail if a transaction exists | Strict stateless operations |
Rules:
- Transactions belong on service methods, not controllers or repositories; the service defines the unit of work.
@Transactionalon self-invocation does nothing (proxy bypass). Extract to another bean or use programmaticTransactionTemplate.- Rollback defaults: unchecked exceptions roll back; checked exceptions do not. Set
rollbackForexplicitly when needed. readOnly = trueon query-only methods: skip dirty checking, hints to the database, and blocks accidental writes.- Keep transactions short. Never call HTTP, messaging, or file systems inside one; use outbox or post-commit hooks.
- Isolation: default
READ_COMMITTED; raise only with a demonstrated anomaly and a plan for retries/locking. - Timeouts: set
@Transactional(timeout = ...)and database statement timeouts; an unbounded transaction holds connections and locks. - Kotlin classes are final; apply
kotlin("plugin.spring")(all-open) so proxying works. - Nested transaction managers (JPA + JDBC, multiple datasources) need
ChainedTransactionManager-style patterns or XA only when truly required; prefer single-datasource designs.
Anti-patterns:
@Transactionalon private/self-called methods.- Catching exceptions inside a transaction and continuing after a constraint violation (the context is poisoned; consider flushing and rolling back).
- Distributed transactions across services; use sagas/outbox (
./03-spring-boot.md). - Holding a transaction open while waiting on user input or a slow downstream call.
Locking and concurrency
- Optimistic:
@Version+ retry on conflict. Default for user-facing edits. - Pessimistic:
@Lock(PESSIMISTIC_WRITE)with short transactions for contention-heavy counters/queues; beware deadlocks — acquire locks in a consistent order. SELECT ... FOR UPDATE SKIP LOCKEDvia native query or jOOQ for work queues; this outperforms JPA locking in most queue implementations.- Database constraints are the last line of defense: unique keys, foreign keys, check constraints; don't rely solely on application checks.
- Idempotency: unique business keys on commands/outbox tables to deduplicate retries.
jOOQ
Choose jOOQ when the SQL matters more than the object graph: reports, dynamic filters, bulk operations, complex joins.
List<RevenueRow> rows = dsl
.select(CUSTOMER.NAME, sum(ORDER_.AMOUNT).as("revenue"))
.from(ORDER_)
.join(CUSTOMER).on(CUSTOMER.ID.eq(ORDER_.CUSTOMER_ID))
.where(ORDER_.CREATED_AT.greaterOrEqual(from))
.groupBy(CUSTOMER.NAME)
.orderBy(field("revenue").desc())
.fetchInto(RevenueRow.class);
Rules:
- Generate code from migrations/schema in CI; commit generated code or regenerate deterministically; never let generated code drift from the database.
- Use jOOQ with the Spring
TransactionManager(DSLContextbacked by the same datasource) so JPA and jOOQ share transactions when mixed. - Map to records/DTOs directly; jOOQ can fetch into records or manual mappers.
- Prefer jOOQ over JPQL for
INSERT ... ON CONFLICT, CTEs, window functions,MERGE, and vendor-specific SQL. - Keep SQL in the data layer; do not sprinkle DSL calls through services.
- Licensing: verify which databases/editions are covered by the open-source editions (
./10-migration-modernization.md).
Anti-patterns:
- Using jOOQ to reimplement an ORM (loading entities and manually managing identity).
- Dynamic SQL built with string concatenation instead of the DSL.
- Skipping code generation and hand-writing table references, causing drift.
Migrations (Flyway and Liquibase)
| Criterion | Flyway | Liquibase | | --- | --- | --- | | Authoring | Plain SQL (primary) | XML/YAML/JSON/SQL | | Mental model | Immutable versioned scripts | Changesets with rollback and preconditions | | Multi-database support | Varies by database/edition | Strong across databases | | Rollback | Manual/undo scripts (paid in some editions) | Built-in rollback blocks | | Best for | SQL-first teams, DBA review, one main database | Enterprise, many databases, policy-driven changes |
Rules:
- Migrations are immutable once applied anywhere. Fix mistakes with a new migration.
- One logical change per migration; small, reviewable, and independently deployable.
- Expand/contract for zero-downtime schema changes: add new column (nullable/default) → dual-write/backfill → switch reads → stop writes → drop old column in a later release.
- Never mix destructive DDL and application deploys in one step for rolling deployments.
- Test migrations against a copy of production-like data; verify duration and lock behavior on large tables.
- Baselines: for existing databases, baseline at a known schema version and keep the baseline in version control.
ddl-auto=validatein production plus migration-driven schema is the only acceptable combination with Hibernate.- CI: run migrations on an empty database and on a pre-migration snapshot; fail on drift (schema diff empty).
Anti-patterns:
- Editing an applied migration to fix a typo (checksum failure and environment drift).
- Long-running
ALTER TABLErewriting a large table during peak hours without a plan (NOT VALIDconstraints, concurrent index builds). - Application code that assumes a migration from a future release already ran.
Connection pooling (HikariCP)
Sizing:
- Formula from the HikariCP/PostgreSQL community:
connections = ((core_count * 2) + effective_spindle_count), then cap by the database's connection limit and reserved admin connections. More connections usually reduce throughput. - Small pools with fast queries beat large pools with queueing; measure with database-side metrics, not application guesses.
maximumPoolSizeis per application instance: total connections = instances × pool size. Budget globally.connectionTimeoutshort (e.g., seconds) so requests fail fast instead of queueing forever;validationTimeout,idleTimeout,maxLifetimeshorter than the database/proxy idle kill.- Leak detection threshold for staging/dev only; leaks in production show up as pool starvation.
- Transaction boundaries determine connection hold time: long transactions exhaust the pool regardless of size.
Virtual threads and pools:
- Virtual threads do not remove the need to bound database connections. The database is the scarce resource.
- With virtual threads, backlog moves from the pool queue to the database; keep pool sizes conservative and set statement timeouts.
Anti-patterns:
- Setting
maximumPoolSizeto hundreds because "requests are slow". - Holding connections open across user think time.
- Sharing one pool across services with different latency profiles without isolation (
readOnlyreplica pools separate).
Caching
- First-level cache (persistence context) is per transaction; do not rely on it across requests.
EntityManager.clear()in batch loops to bound memory. - Second-level cache: only for read-heavy, rarely changing data. Shared mutable entities across nodes need invalidation via the provider (e.g., clustered invalidation) or short TTLs. Never cache entities with sensitive per-user data globally.
- Query cache is invalidated by any change to the involved tables; it is rarely a win in write-heavy systems. Measure before enabling.
- Spring
@Cacheable/@CacheEvict: remember self-invocation bypass and key design; prefer explicit cache services for complexity. - Caffeine for in-process caches with size and TTL bounds; Redis/Valkey for shared caches with network latency and serialization costs.
- Cache keys must include tenant/user where data is scoped; otherwise cross-tenant leakage.
- Prefer HTTP caching semantics for public read APIs (ETag, Cache-Control) over application caches.
Anti-patterns:
- Caching without expiry or size bounds.
- Caching exceptions/failures (negative caching) by accident.
- Two cache layers with contradictory TTLs producing stale reads that are impossible to reason about.
Bulk operations and batching
- Set
hibernate.jdbc.batch_size(e.g., 20-100) andorder_inserts/order_updates; verify with SQL logs that batching actually happens (identity generation disables insert batching in some configurations). saveAlldoes not guarantee batching by itself; flush/clear periodically in loops.StatelessSessionor jOOQ/JDBC for large imports; ORM identity tracking is pure overhead there.@Modifying(clearAutomatically = true, flushAutomatically = true)on bulk updates/deletes to keep the persistence context consistent.- Use
exists/countqueries instead of loading collections to check emptiness at scale.
Review checklist
- [ ] Fetch plans explicit per query; N+1 verified with statement counting tests.
- [ ]
@Versionon mutable aggregates; unique constraints for idempotency. - [ ] Transactions at service boundary, short, with timeouts and explicit rollback rules.
- [ ] Schema only via migrations; migrations immutable and tested against realistic data.
- [ ] Pool sized from database capacity with global budget, fail-fast timeouts.
- [ ] Caching configured with bounds, TTLs, tenant scoping, and invalidation story.
- [ ] Bulk paths use batching/stateless sessions; batch loops clear the context.
- [ ] jOOQ codegen deterministic and committed/regenerated in CI.
05-jvm-performance
JVM Performance
Scope: measuring and tuning JVM services: JIT behavior, garbage collector choice, JFR, heap sizing, thread scaling, and profiling workflow.
Method before flags
- Define the objective: latency SLO (p50/p95/p99), throughput, memory footprint, startup time, or cost.
- Establish a baseline with production-like load. Numbers without a load model are noise.
- Measure with the right tool: JMH for microbenchmarks, JFR for production, load generators (k6, Gatling, wrk) for macro.
- Change one variable at a time; keep a written record of flag, expectation, and result.
- Re-measure after every dependency, JDK, or traffic change. Performance is not a permanent property.
Golden rules:
- Percentiles, never averages. A p99 that trips the SLO is the user-visible problem.
- Allocation rate drives GC pressure; reducing garbage usually beats GC tuning.
- Latency budgets decompose: queue time + service time + downstream + GC pauses. Identify which term dominates before tuning.
- Beware coordinated omission in load tests; use constant-rate generators with correct latency accounting.
- Do not tune the JVM when the database, network, or lock contention is the bottleneck (
./04-persistence.md).
JIT basics
| Stage | Role | | --- | --- | | Interpreter | Executes bytecode immediately; collects profile data | | C1 (client) | Fast compilation, light optimization; good for short-lived methods | | C2 (server) | Aggressive optimization after profiles mature; peak performance | | Tiered compilation | Default: interpreter → C1 → C2 based on invocation/loop counters |
What C2 does:
- Inlining (the gateway to most other optimizations), devirtualization, escape analysis (scalar replacement, lock elision), loop unrolling, constant folding.
- Deoptimization: when an assumption breaks (new subclass loaded, type profile mismatch), the method falls back and may be recompiled.
- Inlining limits:
-XX:MaxInlineSize,-XX:FreqInlineSize; rarely worth changing. Prefer small, monomorphic call sites in hot paths.
Hot-path guidance:
- Keep hot code simple and monomorphic; avoid megamorphic dispatch (many implementations of the same interface at one call site).
- Avoid allocation in hot loops (boxing, varargs, streams over primitives, string concatenation) when profiling shows allocation pressure.
-XX:+PrintCompilationand-XX:+UnlockDiagnosticVMOptions -XX:+PrintInliningfor investigation, not production.- Warmup matters: serverless and short-lived processes pay compilation cost every cold start; consider AOT caches or native images (
./10-migration-modernization.md). - Do not micro-tune what the profiler does not show. Measure allocation and CPU with JFR/async-profiler first.
Anti-patterns:
- Adding complexity for CPU wins that are lost in database or network time.
- Running microbenchmarks without JMH (dead-code elimination, constant folding, no warmup).
- Disabling tiered compilation or forcing C2-only because "it is faster".
Garbage collector selection
| Collector | Best for | Pause profile | Status | | --- | --- | --- | --- | | G1 (default) | Balanced web services, heaps ~4-32 GB | Tens to low hundreds of ms, region-based | Mature default | | Generational ZGC | Latency SLOs, large heaps, high allocation | Sub-millisecond to low ms | Production since 23, default generational mode | | Generational Shenandoah | Latency with lower footprint, no large-heap requirement | Low ms | Available and maturing; verify status in your JDK | | Parallel | Batch/throughput, pause-insensitive | Long stop-the-world pauses | Mature | | Serial | Tiny heaps, single-core containers | Full pauses, short heaps | Mature | | Epsilon | Testing allocation behavior, latency measurement | None (never collects) | Diagnostic only |
Selection guidance:
- Start with G1. Move to generational ZGC when p99 latency is dominated by GC pauses or heap/allocations grow beyond what G1 handles smoothly.
- For pure throughput batch jobs, Parallel often wins on CPU efficiency; measure instead of assuming.
- ZGC's CPU overhead is real; latency-sensitive but CPU-constrained services may prefer Shenandoah or G1.
- Never run non-generational ZGC: it was removed; generational is the only mode.
- Keep collectors consistent across environments; GC behavior differences cause "works in staging" incidents.
Key flags (prefer minimal tuning):
# Balanced default
-XX:+UseG1GC -XX:MaxGCPauseMillis=200
# Low-latency large heap
-XX:+UseZGC -XX:+ZGenerational
# Throughput batch
-XX:+UseParallelGC
# Always useful on modern JVMs
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/dumps
-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags:filecount=5,filesize=20M
Rules:
- Log GC always. GC logs are cheap and invaluable; structured unified logging (
-Xlog) replaced the old flags. MaxGCPauseMillisis a goal, not a guarantee; for ZGC it is irrelevant.- Tuning knobs like region size, IHOP (
InitiatingHeapOccupancyPercent), and survivor sizes should only move with JFR evidence. - Watch allocation rate (
jdk.ObjectAllocationSample), promotion rate, and GC cause in JFR. - Container CPU limits throttle GC threads; ensure
ActiveProcessorCountreflects the limit and GC thread counts are sane.
Anti-patterns:
- Copy-pasting flag sets from blog posts without understanding heap sizes and allocation profiles.
- Setting
-Xmn/new-gen sizes on collectors that manage them dynamically. - Disabling explicit GC (
-XX:+DisableExplicitGC) without knowing what calls it (System.gc()in libraries). - Ignoring GC logs until an incident.
Heap sizing and memory anatomy
Process memory is more than heap:
| Region | Notes |
| --- | --- |
| Heap (-Xmx) | Java objects; GC-managed |
| Metaspace | Class metadata; grows with frameworks/proxies; cap with MaxMetaspaceSize |
| Code cache | JIT-compiled code; bounded by ReservedCodeCacheSize |
| Thread stacks | ~0.5-1 MB per platform thread; virtual threads are heap objects |
| Direct/native buffers | Netty, NIO; bounded by MaxDirectMemorySize |
| GC structures | Marking bitmaps, remembered sets; grows with heap |
| Class data sharing / AOT | Shared archive mappings |
Sizing rules:
- Set
-Xmsequal to-Xmxin containers to avoid resizing churn and fragmentation. - Container awareness is on by default: the JVM reads cgroup limits. Use
-XX:MaxRAMPercentage(e.g., 70-75) instead of absolute-Xmxin autoscaled environments. - Leave headroom beyond the heap: metaspace, code cache, stacks, and direct buffers can add hundreds of MB. A container OOM-killed at the limit with a healthy heap is a sizing bug.
- Cap
MaxMetaspaceSizeto surface classloader leaks asOutOfMemoryError: Metaspaceinstead of node exhaustion. - Thread-per-request services with large platform-thread pools burn stack memory; virtual threads trade that for heap objects.
- Use
-XX:NativeMemoryTracking=summaryin staging (not production) andjcmd <pid> VM.native_memory summaryto reconcile.
OOM taxonomy:
| Error | Meaning | Typical cause |
| --- | --- | --- |
| Java heap space | Heap exhausted | Leak, cache without bounds, oversized fetch |
| GC overhead limit exceeded | Heap ~full, GC thrash | Same, plus undersized heap |
| Metaspace | Class metadata exhausted | Redeploys, dynamic proxies, classloader leak |
| Compressed class space | Class pointers exhausted | Same classloader leaks |
| Direct buffer memory | NIO allocations exceed cap | Netty leaks or missing release |
| unable to create native thread | OS thread limit | Thread leak or huge pools |
| Container killed (OOMKilled) | cgroup limit exceeded | Heap + off-heap over the limit |
Anti-patterns:
- Chasing heap size when off-heap/direct memory is the real consumer.
- Using heap dumps as the only diagnostic: capture thread dumps, JFR, and GC logs in parallel.
- Huge
-Xmxwith G1 and no pause SLO; pauses scale with live set/region work.
JFR and profiling workflow
JFR (Java Flight Recorder):
- Built into the JVM, low overhead (about 1% at default settings), safe for production.
- Continuous recording with a disk-backed repository:
-XX:StartFlightRecording:disk=true,maxsize=512m,filename=/var/rec/ongoing.jfr. - Or start on demand:
jcmd <pid> JFR.start name=incident settings=profile duration=5m filename=/tmp/incident.jfr. - Key events:
jdk.ExecutionSample,jdk.ObjectAllocationSample,jdk.JavaMonitorEnter,jdk.ThreadPark,jdk.GCPhasePause,jdk.VirtualThreadPinned,jdk.SocketRead/Write,jdk.FileRead/Write,jdk.ThreadStart. - Analyze with JDK Mission Control (JMC) or the
jfrCLI (jfr summary,jfr print --events jdk.ExecutionSample).
async-profiler:
- Flame graphs for CPU, allocation (
-e alloc), lock (-e lock), and wall-clock (-e wall) profiles. - Wall-clock profiling is the right tool for latency work: it shows blocked time, which CPU sampling misses.
- Safe-mode profilers (JFR-based,
-e itimer) avoid safepoint bias when needed. -d 60 -f out.htmlfor a 60-second incident capture; attach to a PID without restarting.
Workflow:
- Reproduce with a load model matching production traffic mix.
- Capture JFR + async-profiler wall/CPU for the affected window.
- Classify: CPU-bound, allocation/GC-bound, lock/park-bound, I/O-bound, or downstream-bound.
- Fix the dominant term; re-measure.
- For leaks: enable heap dumps, take two dumps separated by time, diff dominator trees in Eclipse MAT or
jcmd GC.class_histogramfor a quick view.
Common findings and fixes:
| Finding | Likely fix |
| --- | --- |
| High jdk.ObjectAllocationSample on JSON/serialization | Streaming serializers, reuse buffers, project only needed fields |
| Lock contention on a monolith object | Narrow locks, concurrent collections, striping, or split state |
| Threads parked on DB sockets | Connection pool too small, slow queries, or missing indexes (./04-persistence.md) |
| Frequent young GC with short-lived survivors | Increase eden or reduce allocation rate; check promotion |
| CPU in regex/string ops | Precompile patterns, avoid String.split in loops, use indexOf |
| Pinning events | Replace synchronized/native calls on virtual-thread paths (JDK 21-23) |
| Classloader growth after redeploys | Framework proxy leaks, unclosed contexts, thread locals holding classloaders |
Threads vs virtual threads at scale
- Platform threads: roughly 1 MB stack each plus scheduling cost; a few thousand is the practical ceiling before memory and context switching dominate.
- Virtual threads: heap-allocated continuations mounted on a small carrier pool (default parallelism = available processors). Hundreds of thousands are feasible for blocking I/O workloads.
- Scaling model: platform-thread services scale by pool sizing; virtual-thread services scale by heap and downstream capacity.
- Carrier pool tuning exists (
jdk.virtualThreadScheduler.parallelism,maxPoolSize) but is rarely the right lever; fix pinning and downstream limits first. - What breaks first after adopting virtual threads is usually the database pool or a downstream rate limit, not the JVM. Add concurrency limiters (semaphores, rate limiters, bulkheads).
- CPU-bound work should stay on a bounded pool (
Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors())orForkJoinPool); virtual threads add scheduling overhead without parallel speedup. - Thread locals on virtual threads multiply with task count; prefer scoped values (
./01-java-core.md).
Anti-patterns:
- Pooling virtual threads to "protect" resources.
- Migrating to virtual threads while leaving
synchronizedblocking sections on JDK 21-23. - Assuming virtual threads fix slow downstreams; they only remove thread scarcity.
Microbenchmarking with JMH
@Benchmark
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 10, time = 1)
@Fork(3)
public long parse(BenchmarkState state) {
return state.parser.parse(state.input).size();
}
Rules:
- Use JMH; never
System.nanoTime()loops. JMH handles warmup, dead-code elimination, forks, and statistics. - Consume results (
Blackhole) and avoid loop-invariant hoisting. - Report p50/p99 (
Mode.SampleTime) when tail behavior matters. - Benchmark realistic data sizes and shapes; microbenchmarks validate hypotheses, they do not predict system performance.
- Keep JMH benchmarks in a separate source set/module so they never ship in production artifacts.
Production safety checklist
- [ ] Latency SLO defined in percentiles, with a load model that reflects production.
- [ ] JFR continuous recording enabled with bounded disk usage; GC logging on.
- [ ] Heap sized with off-heap headroom;
Xms == Xmx; container limits honored. - [ ] Collector chosen deliberately and documented; no cargo-cult flags.
- [ ] Thread model documented (platform vs virtual); pinning checked on JDK 21-23.
- [ ] Downstream limits (DB pool, HTTP concurrency, rate limits) set from capacity, not hope.
- [ ] Heap dumps and thread dumps captured automatically on OOM/crash.
- [ ] Performance regression tests or canary checks in CI/CD for critical paths.
- [ ] Runbooks for CPU, memory, and latency incidents reference actual dashboards and JFR commands.
06-build-tooling
Build Tooling (Gradle, Maven, CI)
Scope: reliable, reproducible JVM builds: Gradle Kotlin DSL, version catalogs, toolchains, multi-module structure, dependency locking, and CI.
Gradle baseline
| Item | Guidance |
| --- | --- |
| Version | Gradle 8.x/9.x; keep within the current supported line; verify upstream |
| DSL | Kotlin DSL (build.gradle.kts) is the default; Groovy DSL is legacy |
| Daemon JVM | Gradle 9 requires a JVM 17+ to run the daemon; build JVMs are separate |
| Wrapper | Always commit gradlew, gradlew.bat, gradle/wrapper/*; never rely on a system Gradle |
| Configuration cache | Enable and keep compatible; it is the largest CI speedup |
| Build cache | Local plus remote (CI) cache with stable keys |
| Repositories | mavenCentral() as primary; use repository content filtering and dependency verification |
plugins {
java
id("org.springframework.boot") version "x.y.z"
id("io.spring.dependency-management") version "x.y.z"
}
java {
toolchain { languageVersion = JavaLanguageVersion.of(21) }
}
tasks.test {
useJUnitPlatform()
maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1)
}
tasks.withType<JavaCompile>().configureEach {
options.release = 21
options.compilerArgs.addAll(listOf("-Xlint:all", "-Werror"))
}
Rules:
- Use
plugins { }with explicit versions (or version catalog aliases); noapply plugin:orbuildscriptclasspath unless unavoidable. - Enable configuration cache (
org.gradle.configuration-cache=true) and build cache; treat cache misses/incompatibilities as bugs. - Avoid
doFirst/doLastwith mutable project state; use typed tasks and lazy APIs (tasks.register,providers,layout). - Never resolve configurations at configuration time (
configurations.runtimeClasspath.get()in task graph); let tasks do the work. - Pin wrapper checksum (
distributionSha256Sum) and verify it in CI. - Keep build scripts small: shared logic goes into convention plugins, not copy-paste.
Anti-patterns:
cleanas a standard build step; it hides incremental build bugs and slows CI.--no-daemoneverywhere (fine for one-shot containers, slow for developer machines).allprojects { }/subprojects { }blocks in modern builds; they break project isolation and configuration cache.- Dynamic versions (
1.+,latest.release) and changing modules (-SNAPSHOT) in release builds.
Version catalogs
gradle/libs.versions.toml:
[versions]
kotlin = "2.x"
springBoot = "3.5.x"
junit = "5.x"
[libraries]
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
assertj = { module = "org.assertj:assertj-core", version = "3.x" }
[bundles]
testing = ["junit-jupiter", "assertj"]
[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
spring-boot = { id = "org.springframework.boot", version.ref = "springBoot" }
Rules:
- One catalog per repository, committed; no per-module version drift.
- Reference by alias (
libs.junit.jupiter); never hardcode a version in a module script when the catalog has it. - Use
bundlesfor cohesive test/runtime sets, not for arbitrary grouping. - Catalogs are for versions and coordinates, not logic. Logic belongs in convention plugins.
- Publish an internal platform/BOM for multi-repo organizations; catalogs do not cross repositories by themselves.
- Keep
[versions]names meaningful (springBoot, notv1).
Anti-patterns:
- Mixed catalogs and literal versions in the same build.
- Catalog entries that duplicate a BOM already managed by the platform (
implementation(platform(...))plus versions on managed artifacts). - Using the catalog to pin transitive internals via forced versions.
Toolchains and language levels
- Toolchains decouple the Gradle daemon JVM from the compiler JVM:
java.toolchain.languageVersionandkotlin { jvmToolchain(21) }. - Auto-provisioning via the Foojay resolver plugin; in air-gapped CI, provision JDKs in the image and configure
org.gradle.java.installations.paths. - For libraries, set
options.release(Java) to the minimum supported version; KotlinjvmTargetshould match (e.g., target 17 bytecode while building on 21). - Kotlin/Java mixed modules: align
jvmTargetand Javarelease, enable-Xjdk-releasefor Kotlin when targeting older JDKs. - Test on multiple toolchains in CI (current LTS and next feature release) without changing the primary build.
- Never compile with a JDK below the language level of the source; the compiler will refuse or emit subtly different code.
Maven comparison
| Criterion | Gradle | Maven |
| --- | --- | --- |
| Model | Programmatic (Kotlin DSL), flexible | Declarative POM, convention-driven |
| Incremental builds | Strong (up-to-date checks, build cache) | Limited; whole-module rebuilds |
| Multi-module | Flexible, project isolation possible | Simple, predictable reactor |
| Performance | Better for large incremental builds | Slower; mvnd helps |
| Ecosystem | Gradle-first for Android/Kotlin, growing for JVM | Dominant in enterprise Java, strong tooling/IDE parity |
| Learning curve | Higher; scripts are code | Lower; XML boilerplate |
| Version | Gradle 8.x/9.x | Maven 3.9+; Maven 4.x line maturing (verify upstream) |
Guidance:
- Choose one per repository; mixed Gradle/Maven builds add maintenance without benefit.
- Stay on Gradle if the repo is Kotlin/Android or needs custom logic; stay on Maven for conservative enterprise stacks where standards and stability dominate.
- Migrate only with a reason (build time, Kotlin support, custom logic); a working Maven build is not a problem to fix.
- Keep the wrapper (
mvnw) committed and use-B(batch) and-ntp(no transfer progress) in CI.
Multi-module builds
settings.gradle.kts
gradle/libs.versions.toml
build-logic/ # convention plugins (included build)
src/main/kotlin/
app/
build.gradle.kts
core-domain/
core-data/
adapters/web/
Rules:
- Prefer
build-logicincluded build overbuildSrcfor convention plugins: faster, cacheable, testable. settings.gradle.ktsdeclaresrootProject.nameandinclude(...); keep a flat, explicit module list.- Dependencies:
apionly when a type is exposed in public signatures; otherwiseimplementationto avoid leaky classpaths. - No cyclic project dependencies. Extract shared abstractions downward, not sideways.
- Use BOM platforms (
platform("org.springframework.boot:spring-boot-dependencies:x.y.z")) for aligned versions. - Module boundaries mirror the architecture (
./01-java-core.mdfor language-level structure); do not create modules for their own sake. - Keep configuration cache and project isolation compatibility: no cross-project
project(":x")access at configuration time. - Version the whole repo together (monorepo) unless modules have genuinely independent release cadences.
Anti-patterns:
- God
build.gradle.kts(hundreds of lines) duplicated across modules. implementation project(":everything")creating a de facto monolith.- Modules that only exist to hold two classes.
buildSrcused for application logic instead of build configuration.
Dependency management and locking
- One version per dependency line across modules; resolve conflicts deliberately with constraints/platforms, not
force. - Investigate conflicts with
./gradlew dependencyInsight --dependency <group:artifact> --configuration runtimeClasspath. - Reproducible builds: enable dependency locking (
./gradlew dependencies --write-locks), commit lockfiles, and use--update-locksonly in reviewed changes. - Enable dependency verification (
gradle/verification-metadata.xml) with checksums/signatures for all resolved artifacts. - Reject dynamic and changing versions in release configurations.
- Document the policy for transitive vulnerabilities: update cadence, exception process, and owner (
./09-observability-security.md). - For Maven, the dependency management section plus
flattenfor consumer POMs; Maven 4 separates build and consumer POMs (verify upstream). - Publish SBOMs from the build (CycloneDX plugin) so consumers can scan without rebuilding.
Anti-patterns:
forcein resolution strategy without resolving upstream causes.- Ignoring
dependencyUpdatesreports until they become upgrades under incident pressure. - Locking only the app, not tests/tools, so CI still resolves floating versions.
CI pipeline
# Pseudocode shape, not tool-specific
steps:
- restore caches (Gradle caches keyed by wrapper + lock hashes)
- ./gradlew --build-cache check
- ./gradlew test --tests ... on shards
- ./gradlew pitest (nightly, not per-commit)
- ./gradlew dependencies --write-locks (only in an explicit update job)
- publish SBOM + artifacts (signed, from tagged commits)
Rules:
- Cache keys include the wrapper checksum, version catalogs, and lockfiles; never cache secrets.
- Use configuration cache and build cache; a warm remote cache should make unchanged modules near-instant.
- Split fast (unit/lint/ArchUnit) from slow (integration/Testcontainers/mutation) jobs; fail fast on the fast suite.
- Run test sharding/parallel forks to cut wall time; keep resource limits aligned with container CPU/memory.
- Matrix the JDK versions you support for compile/test, even if you publish from one.
- Publish only from tags; artifacts must be reproducible and signed; fail on snapshot/dynamic dependencies.
- Keep Gradle/Maven plugin versions pinned; build plugins are production code.
- Nightly jobs: mutation testing, dependency updates PRs, JDK-ea compatibility probes.
Anti-patterns:
- A single monolithic CI job that takes an hour; nobody iterates on it.
- Caching
build/outputs without correct inputs; produces stale artifacts. - Publishing from a developer machine or an unversioned branch.
- Treating CI configuration as untouchable; build times are a feature.
Review checklist
- [ ] Wrapper committed, checksum pinned, single Gradle/Maven version across the repo.
- [ ] Kotlin DSL with typed tasks; configuration cache and build cache enabled.
- [ ] Version catalog is the single source of dependency versions; no dynamic versions.
- [ ] Toolchains declared; release bytecode level explicit and tested.
- [ ] Multi-module layout has no cycles;
apivsimplementationdeliberate. - [ ] Dependency locking and verification enabled for release builds.
- [ ] CI splits fast and slow suites, caches correctly, and publishes signed, reproducible artifacts.
- [ ] Build logic tested (convention plugins) and reviewed like application code.
07-testing
Testing (JVM)
Scope: a working test strategy for JVM systems: JUnit 5, Kotest, mocking, Testcontainers, architecture tests, mutation testing, and CI reliability.
Test strategy
| Layer | Purpose | Tools | Speed |
| --- | --- | --- | --- |
| Unit | Domain logic, pure functions, algorithms | JUnit 5, Kotest, AssertJ | Milliseconds |
| Slice | One integration seam (web, JPA, JSON) | @WebMvcTest, @DataJpaTest, MockMvc/WebTestClient | Seconds |
| Integration | Real dependencies, full context | @SpringBootTest, Testcontainers | Seconds to minutes |
| Contract | Consumer/provider compatibility | Pact, Spring Cloud Contract | Seconds |
| Architecture | Structural rules | ArchUnit, Konsist | Seconds |
| Mutation | Test suite quality on critical modules | PIT (pitest) | Minutes (nightly) |
| Performance | Regression thresholds | JMH, Gatling/k6 | Minutes (scheduled) |
Rules:
- The pyramid is about feedback speed, not counts. Push logic down into unit-testable code and keep integration tests for wiring and real-dependency behavior.
- Test behavior and contracts, not implementation details. Refactoring internals should not break tests.
- One logical assertion per test concept; multiple
assertAllassertions are fine. - Arrange-Act-Assert (or Given-When-Then) structure and names that describe the business rule, not the method name.
- Tests are production code: reviewed, refactored, and owned. Delete tests that cannot fail.
Anti-patterns:
- Ice-cream cone: mostly end-to-end tests, slow and flaky.
- Assertion-free tests ("does not throw").
- Testing getters/setters/framework behavior.
- Shared mutable fixtures across test classes.
- Coverage percentage as the only quality gate.
JUnit 5 (Jupiter)
class PricingTest {
@ParameterizedTest
@MethodSource("discountCases")
void appliesDiscounts(BigDecimal base, int percent, BigDecimal expected) {
assertThat(Pricing.apply(base, percent)).isEqualByComparingTo(expected);
}
static Stream<Arguments> discountCases() {
return Stream.of(
arguments(new BigDecimal("100.00"), 10, new BigDecimal("90.00")),
arguments(new BigDecimal("99.99"), 0, new BigDecimal("99.99")));
}
@Test
void rejectsNegative() {
assertThatThrownBy(() -> Pricing.apply(BigDecimal.ONE, -1))
.isInstanceOf(IllegalArgumentException.class);
}
}
Features worth using:
@Nestedfor scenario grouping;@DisplayNamefor readable reports;@Tagfor suite selection.- Lifecycle:
@BeforeEach/@AfterEachper test;@BeforeAll/@AfterAllstatic or@TestInstance(PER_CLASS). Prefer per-test isolation. @TempDirfor filesystem tests;@Timeoutfor hang detection;assertTimeoutPreemptivelyonly when necessary (it runs in another thread and can leak state).- Extensions (
@ExtendWith): manage resources, parameter resolution, and lifecycle.@RegisterExtensionfor per-test setup. - Conditional execution:
@EnabledIfEnvironmentVariable,@EnabledOnOs; avoid silent skips in CI. - Dynamic tests and
@TestFactoryfor generated cases. - Parallel execution: opt in via
junit-platform.properties(junit.jupiter.execution.parallel.enabled=true); tests must be independent. Do not parallelize Testcontainers-heavy suites without resource planning. - Assertions: prefer AssertJ (
assertThat) for readable failure messages; useassertAllto see all failures.
Anti-patterns:
@Disabledwithout a linked issue and owner.- Depending on execution order between tests.
Thread.sleepfor synchronization; use Awaitility (await().atMost(...)) or latches.- Catch-and-ignore in tests, hiding failures under green builds.
Kotest (Kotlin)
class OrderSpec : StringSpec({
"rejects empty cart" {
shouldThrow<IllegalArgumentException> { Order(emptyList()).checkout() }
}
"totals lines" {
val order = Order(listOf(Line("a", 2, Money("3.00"))))
order.total() shouldBe Money("6.00")
}
})
Guidance:
- Spec styles:
StringSpec,DescribeSpec,BehaviorSpec,FunSpec— pick one per module/repo for consistency. - Assertions (
shouldBe,shouldThrow) cover most needs; matchers compose well for collections. - Property testing with
kotest-propertyfor parsers, invariants, and round-trip serialization. - Data-driven testing via
withData/forAll. - Kotest runs on the JUnit Platform and coexists with JUnit 5; do not mix styles within one test class.
- Coroutines:
runTest,TestScope,StandardTestDispatcher; use Turbine to assert Flow emissions. - Use Kotest for idiomatic Kotlin suites; JUnit 5 remains fine and is required by some tooling (e.g., PIT plugin maturity).
Anti-patterns:
- Mixing Kotest and JUnit assertions in the same class.
- Overusing
Eventually/eventuallywhere deterministic awaiting is possible. - Property tests without shrinking-friendly generators; failures must be reproducible.
Mocking
| Tool | Language | Notes |
| --- | --- | --- |
| Mockito | Java | @MockitoExtension, strict stubs by default; mature |
| MockK | Kotlin | Kotlin-first: coEvery, verify, value classes, extension mocking; watch relaxed mocks |
| Fakes | Any | Hand-written in-memory implementations; fastest and most reliable |
| Spring @MockBean/@MockitoBean | Java/Kotlin | Spring context replacement; keep slice tests small |
Rules:
- Do not mock types you do not own. Wrap third-party clients in an interface you control and fake that.
- Prefer fakes for stateful collaborators (repositories, gateways); mocks for verifying interactions that are themselves the contract.
- Mock at architecture seams, not every dependency. A test full of mocks is a test of the mocks.
- Verify outcomes (returned data, persisted state) first; verify interactions only when the interaction is observable behavior (e.g., "must publish exactly one event").
- Strict stubs catch unused stubbing: remove dead stubs instead of relaxing strictness.
- Avoid mocking data classes/records; construct real instances.
relaxed = truein MockK hides mistakes; use it sparingly and never in tests asserting behavior.- Mocking static/object members (
mockkObject,mockStatic) is a design smell; inject a seam instead.
Anti-patterns:
- Mocking
EntityManager,JdbcTemplate, or HTTP clients directly instead of testing against the real thing in integration tests. - Tests that only assert a method was called, not what the system did.
- Verifying exact call sequences; it locks in implementation and breaks on refactors.
Spring test slices
| Annotation | Loads | Use |
| --- | --- | --- |
| @WebMvcTest | MVC layer, no JPA | Controllers, filters, validation, error mapping |
| @WebFluxTest | Reactive web layer | WebFlux controllers |
| @DataJpaTest | JPA + embedded/Testcontainers DB | Repository queries, mappings |
| @JdbcTest | JDBC/DataSource | jOOQ/JdbcTemplate tests (./04-persistence.md) |
| @JsonTest | Jackson | Serialization contracts |
| @SpringBootTest | Full context | Wiring, transactions, end-to-end service behavior |
Rules:
- Prefer the narrowest slice that exercises the behavior; full-context tests are slow and hide coupling.
- Use
@Testcontainersplus@ServiceConnection(Boot 3.1+) so the application context points at real databases/brokers without property plumbing. - Disable auto-configuration you do not need in slices; do not let tests drag the whole application.
@Transactionalon tests rolls back by default; it also masks flush/commit bugs. For persistence behavior you care about, flush explicitly and use non-transactional tests for commit semantics.- Testcontainers singleton pattern (one container per JVM/suite) for speed;
withReuse(true)for local runs only. - Keep a
@TestConfigurationfor shared test beans; never import production-only configuration into tests accidentally.
Anti-patterns:
- One giant
@SpringBootTestclass with a dozen unrelated assertions. - H2 for production-Postgres tests; SQL dialect and locking behavior differ. Use Testcontainers with the same engine.
- Testing
@Transactionalboundaries via mocks; only real transactions prove behavior.
Testcontainers
@Testcontainers
class OrderRepositoryTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> db = new PostgreSQLContainer<>("postgres:17-alpine");
@Test
void findsByStatus() { /* ... */ }
}
Rules:
- Same engine and major version as production (or the closest supported); never substitute H2 for PostgreSQL/Oracle behavior.
- Reuse containers (
withReuse(true)) for local developer loops; keep isolated containers for CI to avoid cross-test state. - Pin image tags/digests;
latestmakes builds non-reproducible. - Use the right module per technology (Kafka, Redis, Vault, LocalStack) and wait strategies instead of sleeps.
- Ryuk cleanup is on by default; in restricted environments configure it rather than disabling it blindly.
- Container startup is a resource cost: budget CPU/memory in CI, and share containers across tests that can share state safely.
- Readiness checks: choose the module's wait strategy, not a fixed sleep; set startup timeouts.
- Seed data via migrations (Flyway/Liquibase) so tests exercise the real schema (
./04-persistence.md).
Anti-patterns:
- Starting a container per test method.
- Tests that depend on leftovers from prior tests in a shared container.
- Random ports assumed stable across restarts; use
getMappedPort.
Architecture tests (ArchUnit / Konsist)
@AnalyzeClasses(packages = "com.example")
class ArchitectureTest {
@ArchTest
static final ArchRule layers = layeredArchitecture()
.consideringAllDependencies()
.layer("Controllers").definedBy("..web..")
.layer("Services").definedBy("..service..")
.layer("Repositories").definedBy("..repo..")
.whereLayer("Controllers").mayNotBeAccessedByAnyLayer()
.whereLayer("Repositories").mayOnlyBeAccessedByLayers("Services");
}
Rules:
- Encode the architecture you intend to keep: layering, package cycles, naming, annotations, no field injection, no direct JDBC in web layer.
- Use
slices().matching("com.example.(*)..").should().beFreeOfCycles()to prevent package tangles. - Frozen violations (
FreezingArchRule) to adopt rules incrementally in legacy code, with a plan to unfreeze. - Konsist offers Kotlin-aware checks (KDoc, visibility, companion objects) where ArchUnit's bytecode view is awkward.
- Run architecture tests in the fast suite; they are cheap and catch drift early.
Anti-patterns:
- Architecture rules so broad they flag generated/framework code; exclude explicitly.
- Freezing violations forever; frozen rules hide new violations of the same kind.
- Documenting architecture only in diagrams while tests allow anything.
Mutation testing (PIT)
- PIT mutates bytecode (boundary changes, negated conditionals, removed calls) and measures whether tests fail. Mutation score is a much better signal than line coverage.
- Scope: run PIT on critical modules (pricing, auth, state machines), not the whole monorepo on every commit.
- Thresholds: start with a baseline, require no regression (incremental analysis), and set module-specific goals.
- Configure
pitest-junit5-pluginand target test classes by package; exclude generated code, DTOs, and trivial mappers. - Use
timestampedReportsand incremental analysis in CI; schedule full runs nightly. - Treat surviving mutants as a backlog: each survivor is either a missing test or dead code to delete.
Anti-patterns:
- Chasing 100% mutation score on code that does not warrant it.
- Running PIT per-commit on a large codebase (CI times explode).
- Chasing score by asserting implementation details instead of behavior.
Async and concurrency testing
- Awaitility for eventual conditions with bounded waits; never
Thread.sleep. - Coroutines:
runTestvirtual time,StandardTestDispatcher, injected dispatchers (./02-kotlin-core.md); Turbine for Flow. - Concurrency tests: stress with
ExecutorServiceand barriers, then assert invariants; keep them in a separate, non-blocking-on-PR suite if slow. - Timeouts on every async test so hangs fail fast instead of blocking CI.
- Avoid asserting exact interleavings; assert invariants and final states.
CI test strategy
- Fail fast: lint, compile, ArchUnit, unit tests first; slices and integration tests after; nightly for mutation and long end-to-end.
- Shard tests across runners by module/package; keep each shard balanced and independently runnable.
- Retries: at most one automatic retry, and always record flaky tests. A retry that passes hides a race.
- Quarantine flaky tests with an owner and expiry date; a permanently quarantined test is deleted.
- Coverage: JaCoCo or Kover for visibility, plus mutation testing on critical modules; never a single global percentage gate.
- Contract tests (Pact) run in CI against provider verification; publish pacts from consumer builds.
- Artifacts: test reports, JFR/heap dumps on failure, container logs; without diagnostics a red build is unactionable.
- Keep the test suite fast enough that developers run it locally; if the unit suite exceeds a few minutes, fix test design before adding runners.
Review checklist
- [ ] Domain logic unit-tested without Spring or containers.
- [ ] Integration tests use the same database engine as production via Testcontainers.
- [ ] No sleeps; Awaitility/virtual time used for async.
- [ ] Mocks only at owned seams; fakes for repositories/gateways where practical.
- [ ] Architecture rules enforced in the fast suite.
- [ ] Mutation testing gates critical modules (nightly), with a no-regression policy.
- [ ] Flaky tests quarantined with owners and expiry; retries tracked.
- [ ] CI publishes failure diagnostics (reports, logs, dumps).
08-android
Android (Kotlin, Jetpack Compose)
Scope: modern Android development: Compose UI, lifecycle/ViewModel architecture, Room, coroutines, performance, testing, and release engineering.
Build baseline
| Item | Guidance |
| --- | --- |
| minSdk | 24+ in practice; raise deliberately, tracking device share/data |
| targetSdk | Track the annual Play requirement; new apps and updates must target the then-current API level |
| compileSdk | Latest stable at release time; required for new APIs |
| Kotlin | 2.x with K2; keep Kotlin, AGP, and KSP versions compatible |
| AGP | Current stable line; verify the compatibility matrix upstream |
| Compose | Compose BOM for aligned versions; do not pin individual androidx.compose.* versions |
| Version catalog | gradle/libs.versions.toml for all versions (./06-build-tooling.md) |
| KSP | Preferred for Room/Hilt over kapt |
Rules:
- Do not guess AGP/Kotlin compatibility; run the compiler/AGP matrix check on every upgrade.
- Keep
compileSdkahead oftargetSdk; downgrading is painful. - Use build types (
debug,release) and product flavors only when they carry real differences; flavor proliferation breaks test matrices. releasebuilds enable R8, resource shrinking, and a strict lint baseline.- Keep the app module thin; feature modules separate concerns and improve build times with configuration cache.
Jetpack Compose
Compose is a declarative UI toolkit: functions describe UI from state; the runtime recomposes when observed state changes.
@Composable
fun UserList(items: List<User>, onSelect: (User) -> Unit) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items, key = { it.id }) { user ->
Text(
text = user.name,
modifier = Modifier
.fillMaxWidth()
.clickable { onSelect(user) }
.padding(16.dp),
)
}
}
}
Core rules:
- UI is a function of state. No UI mutation, no side effects during composition.
- Hoist state: pass values down, events up. Stateful composables are acceptable only as small wrappers around stateless ones.
remembercaches values across recompositions;remember(key)invalidates when the key changes. Never store mutable state in plain fields of a composable.derivedStateOffor expensive computations depending on other state; do not use it for trivial expressions.LaunchedEffect(key)for launching suspend work tied to the composition;DisposableEffectfor cleanup;rememberCoroutineScopefor user-triggered work.rememberUpdatedStatefor lambdas captured by long-lived effects.- Stability: mark data classes
@Immutable/@Stablewhere appropriate; unstable parameters cause unnecessary recomposition. Enable Compose compiler metrics to find offenders. - Strong skipping (default in recent Compose compiler versions) reduces the need for manual stability annotations, but measuring still matters for hot screens.
LazyColumn/LazyRow: always provide stablekeys; never nest scrollables in the same direction; preferitems(list, key = ...)overitemsIndexedwithout keys.- Avoid reading state in high-level composables when only a leaf needs it; narrow reads reduce recomposition scope.
Modifierorder matters. Layout and draw modifiers apply in order; put size/padding/clickable deliberately.- Previews: small, stateless, multiple states (loading/error/empty);
@PreviewParameterfor variant data.
Anti-patterns:
- Running network or database calls directly in composables.
- Using
GlobalScopein UI; userememberCoroutineScopeor ViewModel scope (./02-kotlin-core.md). - Deeply nested composable hierarchies with no extraction; split for readability and skippability.
- Passing
NavControllerdeep into the tree; pass lambdas/events instead. - Using
@Composablegetters for expensive computed values withoutderivedStateOf.
Architecture: lifecycle, ViewModel, state
sealed interface UiState {
data object Loading : UiState
data class Ready(val users: List<User>) : UiState
data class Failed(val message: String) : UiState
}
@HiltViewModel
class UserViewModel @Inject constructor(
private val repo: UserRepository,
) : ViewModel() {
private val state = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = state.asStateFlow()
init { refresh() }
fun refresh() {
viewModelScope.launch {
state.value = UiState.Loading
state.value = runCatching { UiState.Ready(repo.users()) }
.getOrElse { UiState.Failed("Could not load users") }
}
}
}
@Composable
fun UserScreen(vm: UserViewModel = hiltViewModel()) {
val state by vm.uiState.collectAsStateWithLifecycle()
when (val s = state) {
UiState.Loading -> LoadingIndicator()
is UiState.Ready -> UserList(s.users, vm::onUserSelected)
is UiState.Failed -> ErrorMessage(s.message, onRetry = vm::refresh)
}
}
Rules:
collectAsStateWithLifecycle(lifecycle-aware) overcollectAsState; collection stops when the UI is not visible.- ViewModel holds UI state and orchestrates use cases; it must not hold
Context,View, orActivity. - One
StateFlow<UiState>per screen is a clean default; avoid splintering into many flows. - One-off events (navigation, snackbar) via
Channel+receiveAsFlow, or an explicit event state consumed by the UI; neverSharedFlowwith replay for one-shot effects. - Layers: UI → domain (use cases) → data (repositories/sources). Keep Android types out of domain.
- DI with Hilt:
@HiltViewModel,@Injectconstructors, modules for external types; scope bindings deliberately (@Singletonfor repositories, no scoping for stateless things). - Navigation: typed routes, arguments parsed at the boundary, single source of truth for deep links.
- Configuration changes recreate the UI but not the ViewModel; keep process-death restoration in mind (
SavedStateHandlefor critical state). - Saveable state:
rememberSaveablefor transient UI state that must survive process death.
Anti-patterns:
- Android framework types in repositories/domain; use cases should be JVM-testable.
- Business logic in composables or Activities.
- Catching exceptions in UI and showing raw messages; map to user-facing strings in the ViewModel.
- Using
LiveDatafor new code;StateFlowis the modern default. - Leaking
Contextby storing it in singletons; inject@ApplicationContext.
Room
@Entity(tableName = "users")
data class UserEntity(
@PrimaryKey val id: Long,
val name: String,
val syncedAt: Instant?,
)
@Dao
interface UserDao {
@Query("SELECT * FROM users ORDER BY name")
fun observeAll(): Flow<List<UserEntity>>
@Upsert
suspend fun upsert(users: List<UserEntity>)
@Transaction
@Query("SELECT * FROM users WHERE id = :id")
suspend fun findWithOrders(id: Long): UserWithOrders?
}
Rules:
- Entities mirror database rows; keep domain models separate when shapes diverge.
- KSP (
ksp(libs.androidx.room.compiler)) not kapt. - Return
Flowfor observable queries andsuspendfor one-shot operations. - Migrations: prefer explicit
Migrationobjects with@Database(exportSchema = true)and checked-in schemas; auto-migrations for simple additive changes only. - Never use
fallbackToDestructiveMigration()in release builds. @Transactionfor multi-table writes and for queries that must be consistent across relations.- Index foreign keys and columns used in
WHERE/ORDER BY; test query plans for hot queries. - Type converters for
Instant, enums, and value classes; keep them deterministic and null-safe. - Test DAOs against an in-memory database, and also run migrations against a real file-backed database in tests.
- Room KMP exists for sharing data layers; verify feature parity before committing (
./02-kotlin-core.md).
Anti-patterns:
- Doing joins in Kotlin loops instead of SQL; N+1 on device is worse than on server.
- Main-thread database access (Room blocks/throws).
- Storing large blobs in the database; use files and keep paths.
- Ignoring schema export; you lose migration verification in CI.
Coroutines on Android
viewModelScopeandlifecycleScopeare the structured scopes; they cancel with their owner.- Main-safety: suspend functions must move blocking work to
Dispatchers.IOinternally; callers stay onDispatchers.Main. repeatOnLifecycle(Lifecycle.State.STARTED)for collecting flows in Activities/Fragments; Compose usescollectAsStateWithLifecycle.Dispatchers.Defaultfor CPU work; never block it with I/O.- WorkManager for deferrable, guaranteed work (sync, uploads); coroutine workers support suspend functions and constraints.
- Foreground services are for user-visible ongoing work; background restrictions (Doze, App Standby) apply — do not fight them.
- Cancellation is cooperative; long loops in workers/services must check
isActive. - Avoid
runBlockingon the main thread in app code; use it only inmain()and in tests.
Anti-patterns:
- Launching unstructured coroutines in
onCreatewithGlobalScope. - Collecting flows without lifecycle awareness (battery drain, crashes after destruction).
- Doing I/O in
Dispatchers.Main.immediate. - Using
Thread.sleepor busy loops in UI-adjacent code.
Performance
- Baseline profiles: generate with Macrobenchmark and ship in the app to improve startup and jank; refresh per release.
- Macrobenchmark for cold/warm startup, jank, and scroll; Microbenchmark for CPU paths.
- Startup: defer initialization (App Startup library), avoid heavy
Application.onCreatework, lazy-initialize DI singletons where safe. - Compose: check recomposition counts with the layout inspector/compiler metrics; fix unstable types and broad state reads.
- R8 full mode in release; keep rules minimal and justified; map files archived for crash deobfuscation.
- Resource shrinking and image formats (WebP/AVIF), vector drawables, per-density assets.
- Lists: paging library for large data sets, stable keys, prefetch distance tuning only with measurement.
- Network: coalesce requests, cache with HTTP semantics (OkHttp cache, ETag), respect data saver.
- Concurrency: keep database and network off the main thread; batch writes with transactions.
Anti-patterns:
- Optimizing without Macrobenchmark numbers; "looks smoother" is not evidence.
- Blocking startup to preload everything.
- Holding wakelocks/frequent wakeups.
- Huge
remembercomputations on every recomposition instead ofderivedStateOf.
Testing
| Layer | Tooling |
| --- | --- |
| Unit (JVM) | JUnit 5/Kotest, Turbine for flows, fakes for repositories |
| Compose UI | createAndroidComposeRule, semantics-based assertions |
| Screenshot | Paparazzi (JVM) or Roborazzi for host-side/screenshot tests |
| Robolectric | Android APIs on the JVM for fast integration-ish tests |
| Instrumentation | AndroidJUnitRunner, Espresso for legacy views, Hilt test rules |
| Performance | Macrobenchmark module (non-debuggable release-like variant) |
Rules:
- Keep the majority of tests in the JVM suite; UI tests are for UI behavior and integration wiring.
- Compose tests assert on semantics (
onNodeWithText,onNodeWithTag), not internal state. - Use test tags for stable selectors; avoid matching on display strings for critical flows.
- Hilt tests replace modules (
@TestInstallIn,@UninstallModules) instead of service locators. - Turbine for
Flowemission assertions with virtual time inrunTest. - Screenshot tests catch visual regressions; review golden image diffs like code.
- Macrobenchmark requires
profileable/release-like builds; do not benchmark debug builds. - No sleeps; use Compose test clock and idling resources.
Anti-patterns:
- Tests that depend on device animations/locale/network.
- Mocking Android framework classes instead of using Robolectric or fakes.
- Only manual QA for release-critical flows; automate at least startup, login, and checkout.
Release engineering
- Signing: Play App Signing with an upload key; never commit keystores/secrets; rotate and document.
- App Bundle (AAB) for Play; APKs only for side-loaded/enterprise cases.
- R8 mapping files uploaded for deobfuscated crash reports; archive each release's mapping.
- Staged rollouts (1% → 5% → 20% → 100%) with crash/ANR gates from Play vitals or your crash reporter.
- Play Integrity for abuse-sensitive features; treat signals as inputs, not sole gates.
- Privacy: Data safety declarations match actual data collection; permission rationale and minimization; no advertising IDs without consent where required.
- Versioning:
versionCodestrictly increasing,versionNamesemantic; automate from CI. - CI: build AAB, run unit + Robolectric + screenshot tests, lint, upload to internal track, then promote. Keep a release checklist and an emergency rollback/halt plan.
- Observability: crash-free rate, ANR rate, startup and jank metrics per version (
./09-observability-security.md).
Anti-patterns:
- Shipping release builds without R8 due to reflection/keep-rule issues.
- Manual, undocumented release steps.
- Ignoring staged rollout metrics until 100%.
- Permissions requested "just in case".
Review checklist
- [ ] targetSdk tracks the current Play requirement; compileSdk latest stable.
- [ ] Compose state hoisted; no side effects in composition; stable keys in lists.
- [ ] ViewModel owns screen state; framework types kept out of domain/data.
- [ ] Room: KSP, explicit migrations, schema exported, no destructive fallback in release.
- [ ] Coroutines lifecycle-aware; WorkManager for deferrable work.
- [ ] Baseline profiles and Macrobenchmark run each release.
- [ ] JVM-heavy test suite; Compose tests semantics-based; screenshots reviewed.
- [ ] Release automation: signing, mapping upload, staged rollout, rollback plan.
09-observability-security
Observability and Security (JVM)
Scope: production visibility and defense for JVM services: Micrometer and OpenTelemetry, structured logging, authentication/authorization patterns, secrets, supply chain, and hardening.
Telemetry stack
| Concern | Default choice | Notes |
| --- | --- | --- |
| Metrics API | Micrometer (Observation, meters) | Boot-native; bridges to Prometheus/OTLP |
| Tracing API | Micrometer Tracing or OpenTelemetry SDK | Pick one; OTel is the cross-language standard |
| Auto-instrumentation | OTel Java agent | Fastest path to traces for existing apps |
| Manual instrumentation | Observation API / OTel spans | For business-meaningful spans and attributes |
| Logs | JSON to stdout via Logback or Log4j2 | Ship with the platform's log pipeline |
| Profiling | JFR + async-profiler | Continuous JFR in production (./05-jvm-performance.md) |
Rules:
- One correlation story: generate or accept a trace/request ID at the edge, put it in MDC and spans, return it in error responses.
- Instrument business outcomes, not just infrastructure: checkout conversions, payment failures, queue lag per topic.
- Cardinality discipline: never tag metrics with user IDs, order IDs, raw URLs, or unbounded values. Traces can carry these as attributes; metrics cannot.
- Sampling: parent-based head sampling for high-traffic services; tail sampling at the collector when error/latency-biased retention is needed.
- Telemetry data is sensitive: redact PII and secrets from spans, logs, and metric tags. Assume telemetry leaves the trust boundary.
Micrometer
@Service
class CheckoutService {
private final ObservationRegistry registry;
CheckoutService(ObservationRegistry registry) { this.registry = registry; }
CheckoutResult checkout(Cart cart) {
return Observation.createNotStarted("checkout", registry)
.lowCardinalityKeyValue("channel", cart.channel())
.observe(() -> doCheckout(cart));
}
}
Guidance:
- Use
Observation(not separate timer/counter APIs) so metrics and traces are created consistently.@Observed/@Timedannotations for coarse coverage; manual observations for business context. - Meter types: counter (monotonic counts), gauge (sampled values), timer (latency), distribution summary (sizes). Choose based on the question you need to answer.
- Histograms/percentiles: server-side histograms (Prometheus native histograms or client-side SLO buckets) beat per-instance quantiles; never average percentiles across instances.
- Long tasks (
LongTaskTimer) for work that is currently running, useful for concurrency visibility. - Naming: lowercase dot/dash conventions, stable names; treat metric names as API. Renames break dashboards and alerts.
- Tags: bounded dimensions only (status, route template, tenant tier). Use route patterns (
/users/{id}), not raw paths. - JVM binders (memory, GC, threads) are automatic in Boot; add pool and cache binders deliberately.
Anti-patterns:
- Metric explosion from per-user or per-request tags.
- Mixing timer and counter for the same signal with different semantics.
- SLOs defined on averages.
- Alerting on raw resource metrics instead of user-facing symptoms.
OpenTelemetry and tracing
- Agent vs SDK: the Java agent gives zero-code spans for HTTP servers/clients, JDBC, Kafka; the SDK/API gives control. Many teams run the agent plus manual spans; document the mix.
- Semantic conventions: use stable
http.*,db.*,messaging.*attributes; verify the current stability level before depending on newer names. - Context propagation: W3C
traceparentacross services,tracestatefor vendor data. Propagate through HTTP, messaging, and thread-pool boundaries; coroutines need the OTel context integration, not thread locals. - Baggage: keep small and non-sensitive; it propagates everywhere and multiplies cardinality.
- Span kinds and naming: server spans named by route, client spans by target; avoid high-cardinality span names (raw URLs, IDs).
- Sampling: head sampling is cheap and predictable; tail sampling requires a collector and central policy. Document what "not sampled" means for incident response.
- Exporter: OTLP to a collector; batch export with retries; never block business threads on export (use the batch processor).
- Async: for message consumers, create a span per message linked to the producer context, and record receive/process latency separately.
- Coroutines: use the OTel context-aware dispatcher or
Contextpropagation; spans opened inlaunchmust close; considerObservationwithContextpropagation for Java.
Anti-patterns:
- Creating spans for every method call; tracing is for boundaries and meaningful work.
- Forgetting to end spans (leaks) or ending on a different thread without explicit context.
- Injecting trace IDs into logs but not into responses, making support triage blind.
- Treating trace data as compliant-by-default; it is personal data when it carries identifiers.
Structured logging
<!-- logback.xml: JSON console, no file appenders in containers -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>traceId</includeMdcKeyName>
<includeMdcKeyName>requestId</includeMdcKeyName>
</encoder>
</appender>
Rules:
- JSON to stdout in containers; the platform ships and indexes. No file appenders, no size-based rotation inside the app.
- One event per log line with consistent fields: timestamp, level, logger, message, trace/span IDs, service, environment, tenant (hashed where needed).
- Parameterized logging (
log.info("user {} not found", id)) not string concatenation; guard expensive debug logging withisDebugEnabledwhen building payloads. - Levels: ERROR = actionable failure requiring attention; WARN = degraded but handled; INFO = state changes and business events; DEBUG/TRACE off in production (enable per logger temporarily).
- Never log secrets, tokens, passwords, full PANs, or raw request/response bodies. Redaction belongs in a shared utility, not per-caller memory.
- Exceptions: log once at the boundary with stack trace; inner layers either wrap or log, not both.
- Correlate with traces: MDC
traceId/spanIdpopulated by the tracing integration; if absent, generate a request ID at ingress (./03-spring-boot.md). - Audit logs are a separate stream: append-only, immutable retention, access-controlled, and distinct from application logs.
- Beware log amplification: partial failures in fan-out loops can generate thousands of lines per request; rate-limit or aggregate.
Anti-patterns:
System.out.printlnand frameworks that bypass the logging facade.- Logging inside tight loops or per-row batch processing.
- PII in URLs/query strings (logged by access logs by default); redact query parameters.
- Different log formats per service, making cross-service search impossible.
Authentication (authn)
| Pattern | Use when | Notes |
| --- | --- | --- |
| OIDC authorization code + PKCE | Browser/mobile user login | Never implicit flow; PKCE mandatory for public clients |
| Client credentials | Service-to-service with an IdP | Short-lived tokens, audience-restricted |
| mTLS | High-trust service mesh/internal | Strong identity, operational cost; pair with SPIFFE-style identities where available |
| JWT access tokens | Stateless API auth | Validate signature, issuer, audience, expiry, clock skew; cache JWKS with rotation |
| Opaque tokens + introspection | Central revocation required | Adds a network call per validation; cache carefully |
| Session cookies | Server-rendered browser apps | HttpOnly, Secure, SameSite; CSRF protection required |
Rules:
- Validate tokens at every service that consumes them; do not trust an upstream gateway's word unless it is in the same trust boundary (document that assumption).
- Enforce
audandiss; a token for another service is not a token for you. - Clock skew tolerance is small and explicit; do not disable expiry checks.
- Token lifetime: access tokens minutes to an hour; refresh tokens rotated and revocable.
- Store tokens server-side only where necessary; on mobile use the platform keystore (see
./08-android.md). - Log authentication failures with reason codes but never log the token itself.
- Session fixation: rotate session IDs on login; invalidate on logout and privilege change.
Anti-patterns:
- Parsing JWTs without signature verification, or trusting
alg: none. - Long-lived tokens (days) used as API keys.
- Shared credentials between services.
- Custom crypto or token formats.
Authorization (authz)
Patterns:
- RBAC: roles/scopes mapped to permissions; simple, but role explosion is common. Keep roles coarse and permissions fine-grained in code.
- ABAC/policy engines: central policies (OPA/Cedar-style), useful for complex, auditable rules; adds a decision service to the critical path.
- Resource-level checks: every read/write of a user-owned object must verify ownership or explicit sharing. IDOR (insecure direct object reference) is the most common API vulnerability.
- Multi-tenant isolation: tenant ID from the token, never from the request body; enforce at the data layer with tenant-scoped queries or row-level security.
Rules:
- Deny by default; allow lists only.
- Put authorization decisions at the service layer, not only the controller; defense in depth for internal callers.
- Do not rely on UI hiding; the API is the security boundary.
- Centralize policy in one module and test it; ad-hoc checks in controllers drift.
- Return 404 vs 403 deliberately for resource existence leakage.
- Test authorization with property/matrix tests (role × resource × action), not spot checks.
Anti-patterns:
- Role checks using string matching on URLs that can be bypassed with path encoding.
- Tenant ID taken from a header or query parameter without verification.
- Admin endpoints protected only by network location.
- Policy scattered across controllers, filters, and services with no single source of truth.
Secrets management
- Source of truth: a managed store (HashiCorp Vault, cloud secret managers, Kubernetes Secrets backed by CSI/External Secrets). Never in Git, images, or environment defaults committed to the repo.
- Injection at runtime: mounted files, projected volumes, or short-lived credentials fetched by the app at startup with retry.
- Rotation: automated where possible; apps must tolerate credential changes (refresh, reconnect, re-read files) rather than caching forever.
- Least privilege per service and per environment; separate credentials for read replicas, queues, and caches.
- Encryption: TLS in transit everywhere including inside the cluster or mesh; KMS/HSM for at-rest keys; envelope encryption for sensitive columns if required.
- No secrets in logs, crash dumps, JFR recordings, or error reports; scrub heap dumps before sharing (
jhat-style analysis on real dumps is a data exposure risk). - Developers get scoped dev credentials, not production ones; break-glass access is logged and reviewed.
Anti-patterns:
- Secrets in
application.yml, CI variables visible to all jobs, or Docker layers. - One shared credential for all environments and services.
- Long-lived static keys where workload identity is available.
- Secrets in chat/tickets during incidents.
Supply chain
- Lock dependency versions and verify checksums/signatures (
./06-build-tooling.md); builds must fail on unexpected artifacts. - Generate an SBOM (CycloneDX/SPDX) per release and store it with the artifact.
- Scan continuously: dependencies, container images, and IaC. Prioritize reachable, exploitable CVEs over raw counts.
- Sign artifacts (Sigstore/cosign) and attach provenance (SLSA-style build metadata) where the platform supports it.
- Pin CI actions/plugins by digest; treat CI as production infrastructure with least-privilege tokens.
- Base images: minimal, distroless or slim, pinned by digest, rebuilt on a schedule for OS patches.
- Keep an incident process for zero-days: identify affected artifacts via SBOM, patch, rebuild, redeploy, verify.
- Avoid unnecessary dependencies; every library is an attack surface and an upgrade obligation.
Anti-patterns:
curl | bashinstallers in CI for critical tools.- Scanners with no triage process; alert fatigue means real CVEs get ignored.
- Building releases from unverified local builds.
- Forked/abandoned dependencies with no upstream.
JVM and service hardening checklist
- [ ] TLS verified with real trust stores; no
TrustAll/InsecureSkipVerifyequivalents. - [ ] JVM serialization filters set (
jdk.serialFilter) if Java serialization is used at all; prefer not to expose it. - [ ] XML parsers configured against XXE (disable DTDs/external entities).
- [ ] Deserialization of untrusted data avoided or whitelisted (Jackson polymorphic typing disabled/instrumented carefully).
- [ ] Actuator/management endpoints secured or bound to an internal port (
./03-spring-boot.md). - [ ] Security headers (HSTS,
X-Content-Type-Options, CSP where applicable) in place at the edge. - [ ] Rate limiting and request size limits at ingress; timeouts everywhere.
- [ ] Dependency and image scanning in the pipeline; SBOM published.
- [ ] Error responses free of stack traces, SQL, or class names.
- [ ] Logs and traces redacted; audit log stream separate and immutable.
- [ ]
-XX:+HeapDumpOnOutOfMemoryErrorpaths are access-controlled; dumps treated as sensitive. - [ ] Break-glass procedures documented, logged, and reviewed.
Review checklist
- [ ] Metrics, traces, and logs correlated by a single request/trace ID.
- [ ] Cardinality bounded on every metric and tag.
- [ ] Structured JSON logs to stdout; no PII/secrets; audit stream separated.
- [ ] Token validation complete (signature, iss, aud, exp) with JWKS rotation.
- [ ] Authorization denies by default and enforces resource ownership and tenant isolation.
- [ ] Secrets from a managed store, rotated, never logged or committed.
- [ ] SBOM, signature, and provenance produced for releases; scanning triaged.
- [ ] Hardening checklist applied and verified by tests or config review, not memory.
10-migration-modernization
Migration and Modernization
Scope: moving JVM systems forward safely: Java 8/11/17 to 21+, Java-to-Kotlin, Spring Boot upgrades, GraalVM native images, and strangler-style modernization.
Assessment first
Before touching versions, establish the baseline:
| Question | Evidence |
| --- | --- |
| What runs where? | Runtime inventory from deployment manifests, not memory |
| Which JDKs per service? | Build files, container images, base images |
| Which APIs are removed/deprecated? | jdeps --jdk-internals, jdeprscan, compiler warnings |
| Which dependencies are unsupported? | Dependency report plus upstream support windows |
| What is the test safety net? | Coverage of critical paths, integration tests, contract tests |
| What are the blast zones? | Public APIs, persisted data formats, serialized payloads, messaging schemas |
Rules:
- Migrate the smallest valuable increment, behind a seam, with measurable exit criteria.
- Never combine platform upgrade and feature work in one release; keep diffs reviewable.
- Have a rollback path for every step. If you cannot roll back, you cannot move fast.
- Automate the mechanical parts (OpenRewrite-style recipes) and review the rest by hand.
Java 8/11/17 to 21+
Code-level migrations
| Removed/changed | Replacement |
| --- | --- |
| javax.* EE APIs (on Jakarta stacks) | jakarta.* (JPA, Validation, Servlet, Mail, WebSocket) |
| Security manager | Platform sandboxing, container isolation; API is permanently disabled |
| finalize() | Cleaner, try-with-resources, explicit lifecycle |
| sun.misc.Unsafe memory access | VarHandle, MethodHandles, FFM (java.lang.foreign) |
| JAXB / JAX-WS / CORBA modules | Explicit dependencies or modern alternatives (Jackson XML, JAX-WS RI) |
| Nashorn | GraalJS or external JS execution |
| Thread.stop/suspend/resume | Interruption, cooperative cancellation |
| String templates | String.format, builders, templating libraries |
| 32-bit x86 runtime | 64-bit platform only on current releases |
| Non-generational ZGC | Generational ZGC (-XX:+UseZGC only) |
| CMS / old GC flags | G1 (default) or ZGC; strip obsolete flags from scripts |
| --illegal-access=permit | --add-opens/--add-exports per module, each tracked |
Procedure
- Update build tooling first (Gradle/Maven and plugins on a JDK-21-capable line,
./06-build-tooling.md). - Compile with
--release 21(or the target floor) and-Xlint:all; fix warnings before runtime testing. - Run
jdeps --jdk-internals; every internal API reference becomes a task with an owner and replacement plan. - Run the full test suite on the new JDK before changing runtime flags.
- Deploy to staging with production-like JVM flags; compare JFR profiles and GC logs against the old JDK.
- Roll out canary; watch latency, GC pauses, thread counts, and error rates.
- Only then adopt virtual threads, structured concurrency, or new language features — one change at a time.
Adopting virtual threads during upgrade
- Audit for
synchronizedblocks around blocking calls; on JDK 21-23 these pin carriers. Replace withReentrantLockbefore flipping executors. - Remove thread-local request context or migrate to scoped values; measure thread-local usage growth.
- Replace fixed thread pools with virtual-thread-per-task executors, then re-check database pool sizes and downstream limits — the bottleneck moves (
./05-jvm-performance.md). - Verify pinning with JFR (
jdk.VirtualThreadPinned) on the target JDK. - Keep a fallback flag to revert to platform threads for one release.
Common upgrade failures
| Symptom | Cause | Fix |
| --- | --- | --- |
| NoClassDefFoundError: javax/... | Stale dependency or generated code | Update to Jakarta artifacts; regenerate sources |
| Reflection InaccessibleObjectException | Strong encapsulation | Update library; temporary --add-opens with a task |
| Agent crashes / startup failure | Bytecode agent incompatible with new JDK | Update agent before upgrading the JDK |
| GC pauses changed drastically | Collector/flags differ from old defaults | Explicit collector choice and a new GC baseline |
| JIT/startup regression | Class data/AOT behavior changed | Warmup strategy, AOT cache, or native image (./05-jvm-performance.md) |
| Serialization incompatibility | Java serialization across versions | Move to JSON/Protobuf with explicit schemas |
Java to Kotlin migration
Strategy: bidirectional interop means you can migrate module by module without a rewrite.
- Start with new code in Kotlin; migrate existing classes only when you touch them substantially.
- Order: leaf utilities → domain → services → controllers. Avoid mixing both languages in one class.
- Nullability at boundaries: annotate Java APIs with JSpecify (
@NullMarked) so Kotlin sees real nullable types instead of platform types. Until then, treat platform types as nullable and enforce at the boundary (./02-kotlin-core.md). - Convert DTOs/value objects to data classes first; they are mechanical and high-value.
- Replace static utility classes with extension functions or top-level functions; keep Java call sites working with
@JvmStatic/@JvmName. - Keep framework annotations working: Spring opens classes with the Kotlin plugin; JPA entities stay Java if the mapping is complex.
- Checked exceptions: Kotlin has none; Java callers of
suspendfunctions need adapters orrunBlockingonly at edges. - Wire tests at the same time; Kotlin tests can call Java code and vice versa. Use the same test runner where possible.
- Maintain a shared formatting/lint setup so both languages stay consistent.
- Track the K1→K2 compiler migration: remove
-language-version 1.9, re-run builds, fix new diagnostics; K2 changes some inference and variance behavior.
Pitfalls:
!!and platform types hiding nullability debt: addrequireNotNullat Java boundaries with meaningful messages.- Converting entities to
data classbreaks Hibernate; do not. - Kotlin default arguments and overloads produce surprising Java APIs; use
@JvmOverloadsdeliberately. - SAM conversions in Kotlin differ from Java; check lambda-to-interface conversions at boundaries.
- Mixed collections mutability expectations: Kotlin's read-only interfaces do not stop Java mutating the underlying list.
Spring Boot upgrades
2.7 → 3.x (the Jakarta jump)
- Baseline moves to Java 17;
javax.*becomesjakarta.*everywhere (imports, dependencies, generated sources, test fixtures, messaging). - Configuration: migrate
spring.confighandling, remove deprecatedapplication.propertieskeys flagged by the Boot property migrator; enablespring.config.importfor external config. - Security:
WebSecurityConfigurerAdapteris gone; lambda DSL only; adapters andauthorizeRequestsremoved. Rewrite security configuration explicitly. - Actuator: endpoint and property renames; health details defaults changed; expose surface minimized.
- Trailing slash matching default changed; path matching strategy changed. Review controller mappings.
- Observability: Micrometer/OTel integration and observations replace older Sleuth patterns.
- Procedure: upgrade dependencies to Spring Boot 3 line, fix compile errors (mostly imports and config), run tests, then review runtime property deprecations from the migration report.
3.x → 4.x (Framework 7)
- Framework 7 and Jakarta EE 11; JSpecify null-safety at the framework boundary; some modules renamed or split. Read the official migration guide; verify changes upstream.
- Removed/deprecated APIs from the 3.x line are gone; fix deprecation warnings before upgrading (that work is the upgrade).
- HTTP clients:
RestTemplateremains but new code usesRestClient; HTTP interfaces are the default for typed clients. - New capabilities (API versioning, resilience annotations, problem details) are optional; adopt after the core upgrade is green.
- Kotlin support requires a compatible Kotlin 2.x version; align the Spring and Kotlin plugins.
- Testing: slice annotations and
@MockitoBean-style replacements evolve; update test configuration with the same care as production configuration.
Upgrade mechanics
- Use OpenRewrite/automated recipes for the mechanical 80%: imports, property renames, security DSL shapes. Review all changes; recipes are not clairvoyant.
- Upgrade dependencies in waves; keep the dependency-management BOM as the single version source.
- Keep a
spring-boot-properties-migratorclasspath run (3.x) or review the property report to catch silently ignored configuration. - Remove temporary compatibility shims as soon as the build is green; otherwise they become permanent debt.
- Test against the future major line early (a branch in CI) so the eventual upgrade is boring.
GraalVM native and JVM AOT
| Option | Benefit | Cost | | --- | --- | --- | | Standard JVM | Peak throughput, full dynamic behavior | Slow cold start, higher memory | | JVM AOT caches (class-loading and method profiling AOT in recent JDKs) | Faster startup, no closed-world constraints | Build/cache management, JDK-version coupling | | GraalVM native image | Fastest startup, lowest memory for small services/CLI | Closed-world constraints, slower peak, longer builds, more testing |
Choose native when:
- Startup and memory dominate the business case: serverless, scale-to-zero, CLI tools, batch functions, edge.
- The dependency graph is compatible: Spring AOT hints exist, no dynamic agents, no runtime bytecode generation, no JIT-dependent hot loops.
- You can afford to run the full integration suite against the native binary and to maintain the build.
Do not choose native for:
- Long-running CPU-heavy services where peak throughput matters more than startup.
- Applications depending on dynamic proxies, deep reflection, or libraries without hints.
- Teams without capacity to debug native-image build failures.
Procedure:
- Modernize the JVM build first; native is a second pipeline, not a substitute for upgrades.
- Add Spring AOT (
processAot) and native build plugins; fix hint gaps with@RegisterReflectionForBinding,RuntimeHintsRegistrar, or library updates. - Run contract and integration tests against the native binary in CI.
- Measure honestly: cold start, warm throughput, RSS, p99 under load, build time.
- Keep the JVM build as a fallback path; use the same configuration and tests.
Anti-patterns:
- Native image as a rewrite; it is a packaging change.
- Ignoring reflection failures until production; exercise every endpoint in tests.
- Assuming native is universally faster; it is often slower at peak and always slower to build.
Strangler patterns for monoliths and frameworks
| Pattern | Shape | Use when |
| --- | --- | --- |
| Facade/proxy | New code routes to old and new implementations by feature/tenant | Incremental extraction without client changes |
| Branch by abstraction | Introduce an interface; implement old and new behind it; switch at runtime | Replacing a subsystem in place |
| Event interception | Tap domain events to mirror data into the new implementation | Read-side migration with backfill |
| Parallel run | Execute old and new, compare outputs, fail safe | High-risk logic replacement requiring validation |
| Expand/contract | Dual-write, backfill, switch reads, stop writes, remove old | Data/schema migrations (./04-persistence.md) |
Sequencing guidance:
- Define seams by business capability, not by technical layer; extracting a "UserService" while orders and billing share the same tables recreates the monolith.
- Data ownership first: decide which system owns each table before splitting behavior. Shared database writes are the main failure mode.
- Migrate reads before writes; consumers can fall back to the old path while the new one proves itself.
- Feature flags for routing; every flag has an owner and a removal date.
- Dual-write is a temporary hazard: reconcile continuously and have a single cutover authority.
- Monitor the seam: latency, error rate, and divergence between old and new paths.
Anti-patterns:
- Big-bang rewrite with no production feedback until the end.
- Distributed monolith: many services deployed together with synchronous coupling and shared schemas.
- Dual writes without reconciliation or a cutover plan.
- Migrating technology without a business capability boundary.
Rollout, verification, and rollback
- Canary or blue/green for runtime and dependency changes; compare error rate, latency, GC, and saturation against baseline.
- Dark launch new paths (execute but discard results) to validate load behavior before exposing them.
- Feature flags with kill switches; default off for new risky paths.
- Error budgets: stop the migration when the budget is exhausted; continue when the service is healthy.
- Keep the previous artifact runnable and deployable until the new path is proven for a full business cycle.
- Document rollback steps in the release, including data migration reversibility (or forward-fix strategy when not reversible).
Modernization anti-patterns
- Version bumps with no test/observability safety net.
- Combining JDK, framework, and language upgrades in one release.
- Permanent
--add-opens/shim layers with no owner. - Rewriting in Kotlin/another framework to avoid fixing design problems.
- Migrating to microservices before defining data ownership.
- Assuming preview APIs are stable across JDK upgrades.
Migration checklist
- [ ] Inventory and dependency report complete; ownership assigned.
- [ ] Target JDK/framework/language versions pinned and CI matrix updated.
- [ ] Removed API usage (
jdeps,jdeprscan) resolved or explicitly tracked. - [ ]
javaxtojakarta(and other renames) applied mechanically, reviewed manually. - [ ] Test suite green on the new platform; contract tests included.
- [ ] Performance baseline compared (JFR, GC logs, p99 under load) before/after.
- [ ] Each increment behind a seam with a rollback path and canary rollout.
- [ ] Native/AOT decision made with measured startup and throughput data, not ideology.