Skip to content
xscriptor-ai

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.

seniorv2.0.0senior/java-kotlin
View source on GitHub

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

  1. Constructor injection only. No field injection, no @Autowired on fields, no service locator. It keeps dependencies explicit, immutable, and testable.
  2. Immutability by default. Records, Kotlin val, data class, sealed hierarchies, immutable collections. Mutability is a deliberate, documented choice.
  3. Validate at boundaries, trust internals. Jakarta Validation on controllers, messaging consumers, and @ConfigurationProperties; domain logic assumes already-valid input.
  4. 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).
  5. 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).
  6. Production schemas change only through versioned migrations. ddl-auto=update is forbidden outside tests (./references/04-persistence.md).
  7. Every remote call has a timeout, every pool has a bound. HTTP clients, DB pools, executors, and queues without limits are outage generators.
  8. 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).
  9. Virtual threads remove thread scarcity, not backpressure. Pool what is genuinely scarce: DB connections, file handles, downstream quotas (./references/05-jvm-performance.md).
  10. Measure before tuning. Percentiles over averages, JFR and async-profiler over intuition, JMH for microbenchmarks, load tests for macro behavior.
  11. Pin and lock everything. Toolchains, dependency versions, and checksums; builds must be reproducible and reviewable (./references/06-build-tooling.md).
  12. Tests are deterministic. No Thread.sleep, no order dependence, no shared mutable fixtures; use Testcontainers for real dependencies (./references/07-testing.md).
  13. Secrets and PII never touch logs, dumps, or artifacts. Secrets come from a managed store with rotation (./references/09-observability-security.md).
  14. 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).
  15. 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

  1. Read this file, then load only the references the task needs.
  2. Follow cross-links inside references for adjacent topics; every link is relative and local to this pack.
  3. Treat version ranges as floors and ranges, not pins. Confirm the current releases and support windows upstream before locking a version line.
  4. Prefer the checklists at the end of each reference as review gates.

Port

  • Port id — skill://senior/java-kotlin (version in metadata.port-version).
  • Kind — read-only reference pack; no side effects, no tools required.
  • Entrypoint — this SKILL.md; depth lives in references/.
  • Load modes
    1. Installed agent: skill({ name: "java-kotlin" }) in OpenCode; Claude Code reads <skills-dir>/java-kotlin/SKILL.md.
    2. Orchestrator: read SKILL.md, then load only the references the task needs.
    3. Not installed: consumers MUST degrade gracefully using their own guidance and report the degraded mode. Never block on the pack.
  • Consumer contract — metadata.consumers lists the agents that may load it; consumers reference it as load skill java-kotlin (optional).
  • Stability — stable; breaking changes bump port-version major.

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 21 even 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-preview at 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 List still exposes a mutable list. Defensive-copy collections in the compact constructor when the collection is not immutable.
  • Records get equals, hashCode, and toString. 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.
  • record components are final fields; accessor names are the component names (amount(), not getAmount()). 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 setAccessible tricks; 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); permits can be omitted when subtypes are in the same source file.
  • Subtypes must be final, sealed, or non-sealed. Marking non-sealed reopens 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; null is still a separate case unless explicitly matched (case null -> ...).
  • Order matters. Put specific cases before broad type patterns; a case Object o later makes some later cases unreachable (compile error).
  • Guarded patterns via when clauses 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.sleep are exactly the workloads virtual threads improve.
  • Before JDK 24, synchronized blocks pinned the carrier. JDK 24 removes that limitation; on 21-23, replace hot synchronized with ReentrantLock around blocking calls.
  • Native/FFM calls and class initializers can still pin; verify pinning with JFR events (jdk.VirtualThreadPinned) on your runtime.
  • ThreadLocal on a per-task virtual thread is fine but multiplies with thread count; prefer ScopedValue (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 newVirtualThreadPerTaskExecutor are AutoCloseable; 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 ShutdownOnFailure for fail-fast fan-out, ShutdownOnSuccess for 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, CompletableFuture with 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 no set.
  • 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/isBound when context is optional; throwing NoSuchElementException for normal control flow is a smell.
  • On JDK 21-24, ThreadLocal plus 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, SortedMap all expose the sequenced views.
  • Deque gained addFirst/addLast, getFirst/getLast, removeFirst/removeLast in the sequenced naming; prefer these for intent.
  • Collections.unmodifiableSequencedCollection(...) and friends preserve sequencing in wrappers.
  • Use getFirst()/getLast() instead of get(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-internals and jdeprscan on every artifact as part of upgrades.
  • Compile with -Xlint:all -Werror in CI so new deprecation warnings fail the build before they accumulate.
  • Use --add-opens/--add-exports only as a temporary bridge; each one is a tracked task with an owner.
  • Test with the strongest encapsulation (--illegal-access=deny is 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 synchronized around blocking calls on JDK 21-23 virtual-thread paths.
  • [ ] No records as JPA entities or as long-lived mutable state.
  • [ ] No ThreadLocal request context without cleanup and without a documented upgrade path to scoped values.
  • [ ] No new use of Unsafe, finalize, or removed JDK modules.
  • [ ] Exhaustive switch over sealed types has no unnecessary default branch 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.md and ./10-migration-modernization.md).
  • Keep kotlin-stdlib aligned 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, let are the daily tools. Prefer ?: error(...)/requireNotNull over !!.
  • !! 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 in Objects.requireNonNull.
  • lateinit is for framework-injected mutable fields only; not for constructor-optional state. Delegates.notNull() is for primitives.
  • Prefer sealed results or Result over nullable plus exceptions for business outcomes; use exceptions for programming errors.
  • Smart casts work after is checks and null checks, but fail across mutable var captured in closures or custom getters; use local val copies.
  • Collection nullability: List<String?> versus List<String>? are different; be deliberate in APIs.
  • firstOrNull, singleOrNull, associateByNotNull avoid nullable-losing conversions; measure first { } usage in hot paths (throws).

Anti-patterns:

  • !! sprinkled to silence the compiler; it converts compile-time safety into runtime crashes.
  • Using lateinit to avoid constructor parameters in tests; inject dependencies instead.
  • Returning null where an empty collection or a sealed NotFound is 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 class gives equals/hashCode/copy/toString/componentN. Properties declared outside the primary constructor are excluded from equals; do not rely on them for identity.
  • Never put mutable arrays or mutable collections in a data class without defensive copies; equals will surprise you.
  • data object (stable since 1.9) for singletons with value equality; do not use object with manual equals.
  • Sealed interfaces are preferred over sealed classes for hierarchies that need multiple inheritance shapes.
  • Exhaustive when over a sealed hierarchy replaces else; 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 else branch; you lose exhaustiveness.
  • Data classes for JPA/Hibernate entities: generated equals/hashCode and mutability fight the persistence model (see ./04-persistence.md).
  • Copying large data classes in hot loops; copy is shallow but still allocates and re-validates init blocks.

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 @JvmName to control the generated static name and avoid clashes; @JvmStatic on companion functions for Java ergonomics.
  • inline + reified enables 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/apply pyramids are a readability failure.
  • Do not use also for mutation that changes meaning; side effects should be obvious.
  • run on a nullable receiver (user?.run { ... }) is idiomatic for "if present, do several things".
  • with for reads, apply for writes.

Anti-patterns:

  • x?.let { ... } ?: throw ... where requireNotNull reads better.
  • Using apply on objects that have no builder semantics; it hides the real construction logic.
  • Mixing it and this lambdas 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:

  • suspend functions must be main-safe: switch to Dispatchers.IO (or a dedicated dispatcher) around blocking work; never block the caller's dispatcher.
  • runBlocking is only for main() 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: CoroutineExceptionHandler on root launches, or catch inside launch (an uncaught exception in a child cancels the parent).
  • CancellationException must 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.Default runs on a specific thread (use newSingleThreadContext only when truly needed, and close it).
  • Dispatchers.IO has a bounded parallelism (tens of threads by default); for large blocking fan-outs prefer a virtual-thread dispatcher via Executors.newVirtualThreadPerTaskExecutor().asCoroutineDispatcher() (see ./01-java-core.md, ./05-jvm-performance.md).
  • Coroutine context elements propagate; avoid storing mutable state in contexts. Use ThreadLocal only around blocking library calls that require it.
  • Cancellation is cooperative: tight CPU loops must check ensureActive()/isActive; blocking calls must be interruptible.

Testing coroutines:

  • runTest with StandardTestDispatcher/TestScope virtual time; never runBlocking in tests.
  • Inject dispatchers; never hardcode Dispatchers.IO in classes under test.
  • Use Turbine for Flow assertions (see ./07-testing.md).

Anti-patterns:

  • GlobalScope.launch anywhere: unstructured, untracked work.
  • async on the same dispatcher as an immediate await, gaining no concurrency.
  • Catching Throwable and swallowing CancellationException.
  • 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 { } and channelFlow { } for concurrent emission; keep them side-effect free until collected.
  • flowOn changes the upstream context; operators before it run on the new dispatcher. Do not use flowOn on SharedFlow/StateFlow.
  • Context preservation: you cannot emit from a different context inside flow { }; use channelFlow/flowOn/withContext correctly.
  • flatMapLatest cancels superseded work, ideal for search/autocomplete. flatMapMerge for fan-out, flatMapConcat for ordering.
  • Hot sharing: stateIn for UI state, shareIn for shared side-effect streams. Understand SharingStarted.Eagerly/Lazily/WhileSubscribed lifetime semantics.
  • StateFlow conflates and always has a value; it is not an event bus. For one-shot events use Channel with receiveAsFlow or an explicit event queue.
  • Backpressure: buffer, conflate, collectLatest are different tools; conflate drops intermediate values, collectLatest cancels slow collectors.
  • Exceptions in flows terminate collection; place catch before terminal operators and consider retryWhen for transient upstream failures.
  • Never collect a cold flow multiple times expecting shared work; convert with shareIn/stateIn.

Anti-patterns:

  • MutableStateFlow exposed publicly; expose StateFlow and keep mutation private.
  • Using Flow for request/response when a simple suspend function suffices.
  • collect inside launch on 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. UNLIMITED is a memory leak waiting for a busy producer.
  • Channel is single-consumer by default; fan-out distributes elements across consumers. Use BroadcastChannel-style sharing via shareIn for multiple collectors.
  • Close channels in finally; iterate with for (e in channel) so closure ends the loop.
  • select/onReceive for multiplexing; produce for 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 commonTest and 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 internal for security; it maps to public with name mangling.
  • Using @JvmOverloads on Android View subclasses 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.
  • [ ] CancellationException always 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 allWarningsAsErrors on 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 @Autowired needed with a single constructor (Java and Kotlin).
  • @Component for classes you own; @Bean methods for third-party types and explicit wiring. Keep @Configuration classes grouped by concern.
  • Prefer ApplicationContext-free domain code: no @Autowired fields in domain objects, no BeanFactory lookups in logic.
  • @Qualifier beats @Primary when 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 @Lazy without a plan to break the cycle.
  • @ConfigurationProperties beans should be records or immutable classes; validate at startup.
  • In Kotlin, class bodies are final by default. The Spring plugin opens @Component/@Configuration classes; use @ConfigurationProperties data classes with @ConstructorBinding semantics (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.
  • @ComponentScan on 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; use spring.config.import for 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; @Value is for one-off, non-domain values only.
  • Relaxed binding maps APP_PAYMENTS_BASE_URL to app.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.yml short; if it grows past a page, split by concern with spring.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 boundedElastic only 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) or WebClient (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 HttpServiceProxyFactory or 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 WebClient fan-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: @Valid on request bodies and query parameters, @Validated on configuration properties and beans with method validation.
  • Use RFC 9457 ProblemDetail for error responses; consistent problem types and machine-readable type URIs.
  • 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:

  • @Valid on the controller class instead of parameters.
  • Catching Exception in controllers and returning 500 for everything.
  • Custom error format that changes shape per endpoint.
  • Throwing exceptions from equals/hashCode or 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. WebSecurityConfigurerAdapter is 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,prometheus by default; everything else through the management port, bound to the internal network.
  • Health groups: liveness must be independent of dependencies (DB down must not kill the pod); readiness may include critical dependencies.
  • management.endpoint.health.show-details=when-authorized at 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=true with ApplicationStartup tracking 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/loggers to 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, ClassPathScanningCandidateComponentProvider behavior 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 @Conditional graphs 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, @SpringBootTest and @ServiceConnection are 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 final classes (proxying), no Lombok @Data (generated equals/hashCode breaks proxy semantics and lazy loading).
  • Identity: use generated numeric IDs; implement equals/hashCode on a business key or database ID with a null-safe, stable contract. Never use all fields (mutability) and never use the default Object identity (proxy and set operations fail).
  • Association defaults: ManyToOne/OneToOne LAZY; never rely on EAGER. @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.
  • @Version optimistic locking on aggregates modified by concurrent users; handle OptimisticLockException with retry or user-visible conflict.
  • Use Instant/LocalDate/OffsetDateTime; avoid java.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=validate at most). Hibernate schema generation is for tests only.
  • Prefer @Column lengths/precision explicit; never let the database pick types silently for money (NUMERIC, not FLOAT).

Anti-patterns:

  • Lazy loading in view/templating layers after the transaction closed.
  • @Data/@EqualsAndHashCode on entities.
  • Storing large collections of entities as @ElementCollection without 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=true and inspect query counts in tests; assert query counts on critical paths.
  • Datasource proxies (p6spy, datasource-proxy) or Hibernate StatementInspector in 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 EAGER to "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 extra count(*) — 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.
  • @Transactional on self-invocation does nothing (proxy bypass). Extract to another bean or use programmatic TransactionTemplate.
  • Rollback defaults: unchecked exceptions roll back; checked exceptions do not. Set rollbackFor explicitly when needed.
  • readOnly = true on 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:

  • @Transactional on 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 LOCKED via 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 (DSLContext backed 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=validate in 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 TABLE rewriting a large table during peak hours without a plan (NOT VALID constraints, 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.
  • maximumPoolSize is per application instance: total connections = instances × pool size. Budget globally.
  • connectionTimeout short (e.g., seconds) so requests fail fast instead of queueing forever; validationTimeout, idleTimeout, maxLifetime shorter 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 maximumPoolSize to hundreds because "requests are slow".
  • Holding connections open across user think time.
  • Sharing one pool across services with different latency profiles without isolation (readOnly replica 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) and order_inserts/order_updates; verify with SQL logs that batching actually happens (identity generation disables insert batching in some configurations).
  • saveAll does not guarantee batching by itself; flush/clear periodically in loops.
  • StatelessSession or 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/count queries instead of loading collections to check emptiness at scale.

Review checklist

  • [ ] Fetch plans explicit per query; N+1 verified with statement counting tests.
  • [ ] @Version on 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

  1. Define the objective: latency SLO (p50/p95/p99), throughput, memory footprint, startup time, or cost.
  2. Establish a baseline with production-like load. Numbers without a load model are noise.
  3. Measure with the right tool: JMH for microbenchmarks, JFR for production, load generators (k6, Gatling, wrk) for macro.
  4. Change one variable at a time; keep a written record of flag, expectation, and result.
  5. 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:+PrintCompilation and -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining for 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.
  • MaxGCPauseMillis is 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 ActiveProcessorCount reflects 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 -Xms equal to -Xmx in 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 -Xmx in 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 MaxMetaspaceSize to surface classloader leaks as OutOfMemoryError: Metaspace instead 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=summary in staging (not production) and jcmd <pid> VM.native_memory summary to 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 -Xmx with 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 jfr CLI (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.html for a 60-second incident capture; attach to a PID without restarting.

Workflow:

  1. Reproduce with a load model matching production traffic mix.
  2. Capture JFR + async-profiler wall/CPU for the affected window.
  3. Classify: CPU-bound, allocation/GC-bound, lock/park-bound, I/O-bound, or downstream-bound.
  4. Fix the dominant term; re-measure.
  5. For leaks: enable heap dumps, take two dumps separated by time, diff dominator trees in Eclipse MAT or jcmd GC.class_histogram for 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()) or ForkJoinPool); 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 synchronized blocking 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); no apply plugin: or buildscript classpath unless unavoidable.
  • Enable configuration cache (org.gradle.configuration-cache=true) and build cache; treat cache misses/incompatibilities as bugs.
  • Avoid doFirst/doLast with 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:

  • clean as a standard build step; it hides incremental build bugs and slows CI.
  • --no-daemon everywhere (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 bundles for 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, not v1).

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.languageVersion and kotlin { 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; Kotlin jvmTarget should match (e.g., target 17 bytecode while building on 21).
  • Kotlin/Java mixed modules: align jvmTarget and Java release, enable -Xjdk-release for 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-logic included build over buildSrc for convention plugins: faster, cacheable, testable.
  • settings.gradle.kts declares rootProject.name and include(...); keep a flat, explicit module list.
  • Dependencies: api only when a type is exposed in public signatures; otherwise implementation to 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.md for 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.
  • buildSrc used 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-locks only 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 flatten for 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:

  • force in resolution strategy without resolving upstream causes.
  • Ignoring dependencyUpdates reports 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; api vs implementation deliberate.
  • [ ] 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 assertAll assertions 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:

  • @Nested for scenario grouping; @DisplayName for readable reports; @Tag for suite selection.
  • Lifecycle: @BeforeEach/@AfterEach per test; @BeforeAll/@AfterAll static or @TestInstance(PER_CLASS). Prefer per-test isolation.
  • @TempDir for filesystem tests; @Timeout for hang detection; assertTimeoutPreemptively only when necessary (it runs in another thread and can leak state).
  • Extensions (@ExtendWith): manage resources, parameter resolution, and lifecycle. @RegisterExtension for per-test setup.
  • Conditional execution: @EnabledIfEnvironmentVariable, @EnabledOnOs; avoid silent skips in CI.
  • Dynamic tests and @TestFactory for 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; use assertAll to see all failures.

Anti-patterns:

  • @Disabled without a linked issue and owner.
  • Depending on execution order between tests.
  • Thread.sleep for 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-property for 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/eventually where 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 = true in 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 @Testcontainers plus @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.
  • @Transactional on 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 @TestConfiguration for shared test beans; never import production-only configuration into tests accidentally.

Anti-patterns:

  • One giant @SpringBootTest class with a dozen unrelated assertions.
  • H2 for production-Postgres tests; SQL dialect and locking behavior differ. Use Testcontainers with the same engine.
  • Testing @Transactional boundaries 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; latest makes 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-plugin and target test classes by package; exclude generated code, DTOs, and trivial mappers.
  • Use timestampedReports and 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: runTest virtual time, StandardTestDispatcher, injected dispatchers (./02-kotlin-core.md); Turbine for Flow.
  • Concurrency tests: stress with ExecutorService and 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 compileSdk ahead of targetSdk; downgrading is painful.
  • Use build types (debug, release) and product flavors only when they carry real differences; flavor proliferation breaks test matrices.
  • release builds 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.
  • remember caches values across recompositions; remember(key) invalidates when the key changes. Never store mutable state in plain fields of a composable.
  • derivedStateOf for expensive computations depending on other state; do not use it for trivial expressions.
  • LaunchedEffect(key) for launching suspend work tied to the composition; DisposableEffect for cleanup; rememberCoroutineScope for user-triggered work.
  • rememberUpdatedState for lambdas captured by long-lived effects.
  • Stability: mark data classes @Immutable/@Stable where 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 stable keys; never nest scrollables in the same direction; prefer items(list, key = ...) over itemsIndexed without keys.
  • Avoid reading state in high-level composables when only a leaf needs it; narrow reads reduce recomposition scope.
  • Modifier order matters. Layout and draw modifiers apply in order; put size/padding/clickable deliberately.
  • Previews: small, stateless, multiple states (loading/error/empty); @PreviewParameter for variant data.

Anti-patterns:

  • Running network or database calls directly in composables.
  • Using GlobalScope in UI; use rememberCoroutineScope or ViewModel scope (./02-kotlin-core.md).
  • Deeply nested composable hierarchies with no extraction; split for readability and skippability.
  • Passing NavController deep into the tree; pass lambdas/events instead.
  • Using @Composable getters for expensive computed values without derivedStateOf.

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) over collectAsState; collection stops when the UI is not visible.
  • ViewModel holds UI state and orchestrates use cases; it must not hold Context, View, or Activity.
  • 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; never SharedFlow with replay for one-shot effects.
  • Layers: UI → domain (use cases) → data (repositories/sources). Keep Android types out of domain.
  • DI with Hilt: @HiltViewModel, @Inject constructors, modules for external types; scope bindings deliberately (@Singleton for 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 (SavedStateHandle for critical state).
  • Saveable state: rememberSaveable for 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 LiveData for new code; StateFlow is the modern default.
  • Leaking Context by 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 Flow for observable queries and suspend for one-shot operations.
  • Migrations: prefer explicit Migration objects with @Database(exportSchema = true) and checked-in schemas; auto-migrations for simple additive changes only.
  • Never use fallbackToDestructiveMigration() in release builds.
  • @Transaction for 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

  • viewModelScope and lifecycleScope are the structured scopes; they cancel with their owner.
  • Main-safety: suspend functions must move blocking work to Dispatchers.IO internally; callers stay on Dispatchers.Main.
  • repeatOnLifecycle(Lifecycle.State.STARTED) for collecting flows in Activities/Fragments; Compose uses collectAsStateWithLifecycle.
  • Dispatchers.Default for 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 runBlocking on the main thread in app code; use it only in main() and in tests.

Anti-patterns:

  • Launching unstructured coroutines in onCreate with GlobalScope.
  • Collecting flows without lifecycle awareness (battery drain, crashes after destruction).
  • Doing I/O in Dispatchers.Main.immediate.
  • Using Thread.sleep or 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.onCreate work, 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 remember computations on every recomposition instead of derivedStateOf.

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 Flow emission assertions with virtual time in runTest.
  • 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: versionCode strictly increasing, versionName semantic; 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/@Timed annotations 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 traceparent across services, tracestate for 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 Context propagation; spans opened in launch must close; consider Observation with Context propagation 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 with isDebugEnabled when 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/spanId populated 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.println and 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 aud and iss; 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 | bash installers 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/InsecureSkipVerify equivalents.
  • [ ] 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:+HeapDumpOnOutOfMemoryError paths 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

  1. Update build tooling first (Gradle/Maven and plugins on a JDK-21-capable line, ./06-build-tooling.md).
  2. Compile with --release 21 (or the target floor) and -Xlint:all; fix warnings before runtime testing.
  3. Run jdeps --jdk-internals; every internal API reference becomes a task with an owner and replacement plan.
  4. Run the full test suite on the new JDK before changing runtime flags.
  5. Deploy to staging with production-like JVM flags; compare JFR profiles and GC logs against the old JDK.
  6. Roll out canary; watch latency, GC pauses, thread counts, and error rates.
  7. Only then adopt virtual threads, structured concurrency, or new language features — one change at a time.

Adopting virtual threads during upgrade

  • Audit for synchronized blocks around blocking calls; on JDK 21-23 these pin carriers. Replace with ReentrantLock before 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 suspend functions need adapters or runBlocking only 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: add requireNotNull at Java boundaries with meaningful messages.
  • Converting entities to data class breaks Hibernate; do not.
  • Kotlin default arguments and overloads produce surprising Java APIs; use @JvmOverloads deliberately.
  • 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.* becomes jakarta.* everywhere (imports, dependencies, generated sources, test fixtures, messaging).
  • Configuration: migrate spring.config handling, remove deprecated application.properties keys flagged by the Boot property migrator; enable spring.config.import for external config.
  • Security: WebSecurityConfigurerAdapter is gone; lambda DSL only; adapters and authorizeRequests removed. 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: RestTemplate remains but new code uses RestClient; 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-migrator classpath 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:

  1. Modernize the JVM build first; native is a second pipeline, not a substitute for upgrades.
  2. Add Spring AOT (processAot) and native build plugins; fix hint gaps with @RegisterReflectionForBinding, RuntimeHintsRegistrar, or library updates.
  3. Run contract and integration tests against the native binary in CI.
  4. Measure honestly: cold start, warm throughput, RSS, p99 under load, build time.
  5. 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.
  • [ ] javax to jakarta (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.