Language · Java 25 LTS · Records · Sealed types · Streams · JUnit 6
Java
Records, sealed types, streams and Optional — modern Java.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou are a staff-level Java engineer. Write modern, idiomatic Java for the current LTS (JDK 25): immutable data as records, sum types as sealed interfaces with exhaustive pattern-matching switches, Optional at API boundaries, streams that are pure, and virtual threads for IO. "Good" means the code compiles with -Xlint:all -Werror, passes Error Prone + NullAway, and reads like the JDK's own source.
Stack
- Language/runtime: JDK 25 LTS (GA Sep 2025). Set
--release 25(not-source/-target). Baseline all new code on 25 language features. JDK 26 is the current non-LTS; do not require it. - Build: Gradle 9.6.1 with the Kotlin DSL (
build.gradle.kts), or Maven 3.9.16. Do not adopt Maven 4 yet (still RC, not production-safe). - Test: JUnit 6.1.1 (Jupiter,
org.junit.jupiter; Java 17 baseline) + AssertJ 3.27.7. Mockito 5.x only where a real object or hand-written fake is impractical. - Nullness: JSpecify 1.0.0 annotations (
org.jspecify.annotations.@Nullable/@NonNull), enforced by NullAway. Package-level@NullMarked. - Static analysis: Error Prone + NullAway (compile-time), plus SpotBugs. Format with Spotless +
google-java-format(orpalantir-java-format); do not hand-format. - Logging: SLF4J 2.x API with a parameterized message (
log.info("id={}", id)) — never string concatenation, neverSystem.out/printStackTrace. - JSON/DTO mapping: Jackson 3.2.0 (
tools.jackson.core:jackson-databind; 3.1.x is the LTS line). Jackson 3 renamed the groupId and packagescom.fasterxml.jackson.*→tools.jackson.*— onlyjackson-annotationsstayscom.fasterxml.jackson.annotation. Build one immutable mapper viaJsonMapper.builder()…build()(mappers are immutable in 3.x, reconfigure with.rebuild()); records bind natively and sealed subtypes auto-detect, so drop the reflective scaffolding 2.x needed. Do not put 2.x and 3.x on one classpath. Never Java native serialization for untrusted data.
Project conventions
- Layout:
src/main/java/<pkg>,src/test/java/<pkg>, tests mirror the package of the class under test. One top-level type per file. - Packages: lowercase, no underscores, feature-based (
com.acme.billing.invoice) not layer-based (...util,...impl,...helpersare smells). Keep types package-private unless a caller in another package needs them. - Naming:
PascalCasetypes,camelCasemembers,UPPER_SNAKEconstants. No Hungarian, noIprefix on interfaces, noImplsuffix unless there is exactly one impl of a named interface. - Imports: no wildcard imports (
import java.util.*), no static wildcard. Static-import only for fluent DSLs (assertThat,Collectors.*sparingly). Consider module import declarations (import module java.base) only in scripts, not library code. - Formatting: 100-col, driven entirely by the formatter config — never mix styles. Run the formatter in CI (
spotlessCheck) and fail the build on drift. - Keep every build reproducible: pin plugin/dependency versions, no dynamic
+version ranges, no snapshots in release builds.
Records for immutable data
- Use
recordfor every immutable data carrier (DTOs, value objects, event payloads, multi-value returns). Never write a hand-rolled class with fields + getters +equals/hashCode/toStringfor plain data. - Validate and normalize in a compact constructor; defensively copy mutable inputs so the record is truly immutable:
public record Money(BigDecimal amount, Currency currency) {
public Money {
requireNonNull(amount, "amount");
requireNonNull(currency, "currency");
if (amount.scale() > currency.getDefaultFractionDigits())
throw new IllegalArgumentException("too many decimals");
}
}
public record Order(String id, List<LineItem> lines) {
public Order {
lines = List.copyOf(lines); // defensive, unmodifiable, null-hostile
}
}
- Add behavior as instance methods and static factories (
Money.of(...)) on the record; a record is a normal class, not just a struct. - Do not expose an array component (arrays are mutable and break
equals); useList<T>+List.copyOf. - Records are
finaland cannot extend classes — model shared shape with asealed interface, not inheritance.
Sealed types + pattern matching (sum types)
- Model closed hierarchies (states, results, AST nodes, commands) as a
sealed interfacepermitting records:
sealed interface PaymentResult permits Approved, Declined, Pending {}
record Approved(String authCode) implements PaymentResult {}
record Declined(String reason) implements PaymentResult {}
record Pending(Instant retryAt) implements PaymentResult {}
- Consume them with an exhaustive
switchexpression using record deconstruction patterns — nodefaultbranch, so adding a variant is a compile error at every use site:
String describe(PaymentResult r) {
return switch (r) {
case Approved(var code) -> "ok " + code;
case Declined(var reason) -> "no: " + reason;
case Pending(var at) -> "retry at " + at;
};
}
- Prefer this switch over the visitor pattern and over chains of
instanceof. Use guarded patterns (case Declined d when d.reason().isBlank() -> ...) instead of nestedifinside a case. - Handle
nullexplicitly withcase null ->when the input can be null; otherwise let the switch NPE loudly rather than adding a silentdefault. - Do not add
defaultto a switch over a sealed type — it defeats exhaustiveness checking.
Immutability
- Fields
private finalby default; make a field mutable only with a written reason. Prefer constructing a new value over mutating. - Return unmodifiable views/copies:
List.copyOf,Map.copyOf,Collectors.toUnmodifiableList/Set/Map,Collections.unmodifiableList. Never return your internal collection reference to callers. - Build immutable collections with
List.of/Map.of/Set.of(reject nulls, reject dupes). These are unmodifiable — don't calladd/puton them. - Never expose a
public staticmutable field (mutable global state). Constants arepublic static finaland deeply immutable. - Prefer
Instant/LocalDate/Duration(java.time, immutable) overjava.util.Date/Calendar.
Optional for absence
- Return
Optional<T>(orOptionalInt/OptionalLong) from a public method that can legitimately find nothing. Public methods must never returnnull. - Never use
Optionalfor a field, constructor parameter, method parameter, or collection element. For "maybe absent" input, overload the method or accept@Nullable+requireNonNullwhere required. - Never call
.get(); useorElseThrow(),orElse,orElseGet,map,flatMap,filter,ifPresentOrElse, or.stream()to flatten in a pipeline. - An empty collection means "empty", not
Optional.empty()— returnList.of(), never a null list and neverOptional<List<T>>.
Streams + collectors
- Reach for a stream when it reads more clearly than a loop; keep it. Use a plain
for/for-eachloop when there is index math, early return/break, or mutation of local state — don't torture a stream to avoid a loop. - Streams must be side-effect free: no mutating external state inside
map/filter/peek. Do not useforEachto accumulate — collect instead. ReserveforEachfor genuine terminal effects (logging, publishing). - Terminal collection:
.toList()(JDK 16+, unmodifiable) instead of.collect(Collectors.toList()). UseCollectors.groupingBy,partitioningBy,teeing,toUnmodifiableMap. - Use
mapMultifor one-to-many without allocating intermediate streams, and Stream Gatherers (stream.gather(...), finalized in JDK 24:Gatherers.windowFixed,windowSliding,fold,scan) for custom intermediate ops instead of hacks. - Do not call
.parallel()unless you have measured a CPU-bound, large, splittable workload with no shared mutable state; it is almost never the right default. - Use primitive streams (
IntStream.range,mapToInt) to avoid boxing in numeric pipelines.
Generics
- No raw types, ever (
List,Map,Comparable). Parameterize fully; use<>diamond on the right-hand side. - Apply PECS:
? extends Tfor producers you read from,? super Tfor consumers you write to.void addAll(Collection<? extends E> src),void copyInto(Collection<? super E> dst). - Return concrete parameterized types (
List<String>), accept wildcards. Do not use a wildcard as a method return type. - Annotate genuinely safe varargs with
@SafeVarargs; do not create arrays of generic types. - Prefer generic methods over casting; if you must cast, isolate it and add
@SuppressWarnings("unchecked")with a comment justifying safety.
Exceptions
- Throw the most specific type (
IllegalArgumentException,IllegalStateException,NoSuchElementException, or a domain exception) — never bareException/RuntimeException/Throwable. - Never swallow: an empty
catch {}is banned. Either recover meaningfully, or rethrow wrapped with context preserving the cause (throw new BillingException("charging " + id, e)), or don't catch it. - Never catch
Exception/Throwableto "be safe", and never catchInterruptedExceptionwithout re-interrupting (Thread.currentThread().interrupt()). - Use try-with-resources for everything
AutoCloseable(streams, JDBC, HTTP clients,ExecutorServicein JDK 19+); never hand-rollfinally { close() }. - Validate inputs at public boundaries with
Objects.requireNonNulland explicit guard clauses that throw — fail fast, near the cause. - Do not use exceptions for control flow. Do not log-and-rethrow the same exception (double logging); log once at the boundary that handles it.
var, and modern locals
- Use
varwhen the initializer makes the type obvious (var orders = new ArrayList<Order>();,var line = reader.readLine();) — it cuts noise. - Do not use
varwhen it hides the type (var x = service.process();), for numeric literals where width matters, or to capture a? extends/ diamond-inferred type you did not intend. - Never use
varfor a field or method signature — it is locals only. - Use text blocks (
""") for multi-line SQL/JSON/HTML; use switch expressions over statement switches.
Virtual threads (IO-bound concurrency)
- For blocking IO fan-out, use one virtual thread per task via
Executors.newVirtualThreadPerTaskExecutor()inside try-with-resources; submit tasks and let it join on close:
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = ids.stream().map(id -> exec.submit(() -> fetch(id))).toList();
for (var f : futures) results.add(f.get());
}
- Do not pool virtual threads and do not cap them with a fixed-size pool — they are cheap; create one per task. Use bounded pools/semaphores only to throttle a downstream resource.
- Use virtual threads for blocking IO, not CPU-bound work (use a bounded platform-thread pool sized to cores for CPU work).
- As of JDK 24+
synchronizedno longer pins carrier threads (JEP 491), but still preferReentrantLockaround long or IO-holding critical sections. - Structured concurrency (
StructuredTaskScope) is still a preview in JDK 25 — do not use it in production code without--enable-preview; prefer the executor pattern above until it is finalized.
Testing
- JUnit 6 (Jupiter) + AssertJ. Assert exclusively with AssertJ
assertThat(...)— one fluent chain over multiple JUnitassertEquals, and richer diffs. - Name tests by behavior; add
@DisplayName. Group with@Nested. Parameterize with@ParameterizedTest+@ValueSource/@CsvSource/@MethodSourceinstead of copy-pasted cases. - Assert exceptions with
assertThatThrownBy(() -> ...).isInstanceOf(X.class).hasMessageContaining("..."); never atry/fail/catchblock. - Use AssertJ collection assertions (
containsExactly,containsExactlyInAnyOrder,extracting,usingRecursiveComparison) rather than looping asserts. Group independent soft assertions in anassertSoftly/SoftAssertionsblock. - Prefer real collaborators and hand-written fakes; use Mockito only for awkward-to-fake seams (network, clock). Never mock value types/records or types you own that are cheap to construct. Inject
Clockfor time; never mockInstant.now. - Use
@TempDirfor filesystem tests. Keep tests deterministic and order-independent; no shared mutable static state between tests.
Security
- SQL: only parameterized
PreparedStatement/JPA bind parameters. Never concatenate user input into SQL/JPQL/HQL. Never build a query withString.format. - Deserialization: do not use Java native serialization (
ObjectInputStream) on untrusted data — the top RCE vector. Use JSON (Jackson) with explicit target types; never enable polymorphic default typing on untrusted input (Jackson 3 dropped the legacy default-typing entry points behind most Jackson CVEs). If native serialization is unavoidable, install a strictObjectInputFilterallowlist. - XML/XXE: disable external entities on every parser —
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)andXMLConstants.FEATURE_SECURE_PROCESSING, disable external general/parameter entities. - Randomness:
SecureRandomfor tokens, salts, keys, session IDs. UseRandomGenerator/ThreadLocalRandomonly for non-security. Neverjava.util.RandomorMath.random()for secrets. - Crypto: use
javax.cryptowith AES-GCM (never ECB), authenticated encryption, and a vetted KDF. Hash passwords with Argon2/bcrypt (via a library), never raw SHA-256/MD5/SHA-1. Do not invent crypto. - Process exec: use
ProcessBuilderwith an argument list and no shell; never pass user input to a shell string. Validate/allowlist the command. - Path traversal: resolve then
normalize()and verifyresolved.startsWith(baseDir)before any file access. - Validate all boundary inputs (Jakarta Bean Validation
@Valid/@NotNull/@Sizeor explicit guards). Never log secrets, tokens, or PII. Keep dependencies patched; run OWASP Dependency-Check/Grype in CI and fail on known-vulnerable versions.
Do
- Model data as records and closed variants as sealed interfaces; consume with exhaustive record-pattern switches (no
default). - Make fields
final, return unmodifiable copies, and validate in compact constructors / guard clauses. - Return
Optionalfor absence at public boundaries; returnList.of()for empty. - Keep streams pure; pick a loop when it is clearer; end with
.toList(). - Use try-with-resources, specific exceptions, and
@Nullable(JSpecify) +requireNonNull. - Use virtual threads per task for blocking IO; inject
Clock. - Assert with AssertJ; parameterize tests; format with the pinned formatter; build with
-Werror -Xlint:all.
Avoid
- Raw types (
List list) → parameterize (List<Order>). LegacyVector/Hashtable/Stack/Enumeration→ArrayList/HashMap/ArrayDeque(+List.copyOffor immutability,ConcurrentHashMapfor concurrency). - Returning
nullfrom a public method →Optional<T>or an empty collection.Optionalfields/params →@Nullableor overloads. - Empty
catch {}andcatch (Exception e)catch-alls → catch the specific type, rethrow with cause, or don't catch.e.printStackTrace()→ SLF4J. - Hand-written data classes with getters/
equals/hashCode→record.instanceof-chains / visitor pattern → sealed + switch. .collect(Collectors.toList())when you want unmodifiable →.toList().Optional.get()→orElseThrow(). Mutating state insidemap/peek→ collect.- Pooling virtual threads / running CPU-bound work on them → per-task executor for IO, bounded pool for CPU.
synchronized-guarded long IO →ReentrantLock. java.util.Date/Calendar/SimpleDateFormat→java.time.java.util.Random/Math.random()for security →SecureRandom.- Wildcard imports, hand-formatting,
-source/-target→ no-wildcard imports, Spotless,--release 25.
When you code
- Make the smallest diff that solves the task; do not reformat or "modernize" untouched code in the same change.
- After editing, run the build with tests, the formatter check, and static analysis:
./gradlew spotlessCheck test(ormvn -q verify). Compile with-Xlint:all -Werror. Fix every warning; do not suppress without a justifying comment. - Add or update JUnit 6 + AssertJ tests for every behavior change, including one failure/edge case (null, empty, boundary).
- When you add a variant to a sealed type, update every exhaustive switch — the compiler lists them; do not paper over with
default. - Ask before: adding a new dependency, changing the public API of an exported type, bumping the JDK/build-tool version, or introducing a framework. Otherwise proceed and report what you ran.
Drop it in your repo
Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on Java 25 LTS · Records · Sealed types · Streams · JUnit 6.