Promptheus/rules53 rule sets · CC0Promptheus hub ↗

Language · Java 25 LTS · Records · Sealed types · Streams · JUnit 6

Java

Records, sealed types, streams and Optional — modern Java.

javarecordsstreams

Updated 5 Jul 2026 · CC0

AGENTS.mdrepo root

You 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 (or palantir-java-format); do not hand-format.
  • Logging: SLF4J 2.x API with a parameterized message (log.info("id={}", id)) — never string concatenation, never System.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 packages com.fasterxml.jackson.*tools.jackson.* — only jackson-annotations stays com.fasterxml.jackson.annotation. Build one immutable mapper via JsonMapper.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, ...helpers are smells). Keep types package-private unless a caller in another package needs them.
  • Naming: PascalCase types, camelCase members, UPPER_SNAKE constants. No Hungarian, no I prefix on interfaces, no Impl suffix 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 record for every immutable data carrier (DTOs, value objects, event payloads, multi-value returns). Never write a hand-rolled class with fields + getters + equals/hashCode/toString for 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); use List<T> + List.copyOf.
  • Records are final and cannot extend classes — model shared shape with a sealed interface, not inheritance.

Sealed types + pattern matching (sum types)

  • Model closed hierarchies (states, results, AST nodes, commands) as a sealed interface permitting 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 switch expression using record deconstruction patterns — no default branch, 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 nested if inside a case.
  • Handle null explicitly with case null -> when the input can be null; otherwise let the switch NPE loudly rather than adding a silent default.
  • Do not add default to a switch over a sealed type — it defeats exhaustiveness checking.

Immutability

  • Fields private final by 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 call add/put on them.
  • Never expose a public static mutable field (mutable global state). Constants are public static final and deeply immutable.
  • Prefer Instant/LocalDate/Duration (java.time, immutable) over java.util.Date/Calendar.

Optional for absence

  • Return Optional<T> (or OptionalInt/OptionalLong) from a public method that can legitimately find nothing. Public methods must never return null.
  • Never use Optional for a field, constructor parameter, method parameter, or collection element. For "maybe absent" input, overload the method or accept @Nullable + requireNonNull where required.
  • Never call .get(); use orElseThrow(), orElse, orElseGet, map, flatMap, filter, ifPresentOrElse, or .stream() to flatten in a pipeline.
  • An empty collection means "empty", not Optional.empty() — return List.of(), never a null list and never Optional<List<T>>.

Streams + collectors

  • Reach for a stream when it reads more clearly than a loop; keep it. Use a plain for/for-each loop 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 use forEach to accumulate — collect instead. Reserve forEach for genuine terminal effects (logging, publishing).
  • Terminal collection: .toList() (JDK 16+, unmodifiable) instead of .collect(Collectors.toList()). Use Collectors.groupingBy, partitioningBy, teeing, toUnmodifiableMap.
  • Use mapMulti for 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 T for producers you read from, ? super T for 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 bare Exception/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/Throwable to "be safe", and never catch InterruptedException without re-interrupting (Thread.currentThread().interrupt()).
  • Use try-with-resources for everything AutoCloseable (streams, JDBC, HTTP clients, ExecutorService in JDK 19+); never hand-roll finally { close() }.
  • Validate inputs at public boundaries with Objects.requireNonNull and 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 var when the initializer makes the type obvious (var orders = new ArrayList<Order>();, var line = reader.readLine();) — it cuts noise.
  • Do not use var when 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 var for 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+ synchronized no longer pins carrier threads (JEP 491), but still prefer ReentrantLock around 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 JUnit assertEquals, and richer diffs.
  • Name tests by behavior; add @DisplayName. Group with @Nested. Parameterize with @ParameterizedTest + @ValueSource/@CsvSource/@MethodSource instead of copy-pasted cases.
  • Assert exceptions with assertThatThrownBy(() -> ...).isInstanceOf(X.class).hasMessageContaining("..."); never a try/fail/catch block.
  • Use AssertJ collection assertions (containsExactly, containsExactlyInAnyOrder, extracting, usingRecursiveComparison) rather than looping asserts. Group independent soft assertions in an assertSoftly/SoftAssertions block.
  • 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 Clock for time; never mock Instant.now.
  • Use @TempDir for 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 with String.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 strict ObjectInputFilter allowlist.
  • XML/XXE: disable external entities on every parser — factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) and XMLConstants.FEATURE_SECURE_PROCESSING, disable external general/parameter entities.
  • Randomness: SecureRandom for tokens, salts, keys, session IDs. Use RandomGenerator/ThreadLocalRandom only for non-security. Never java.util.Random or Math.random() for secrets.
  • Crypto: use javax.crypto with 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 ProcessBuilder with an argument list and no shell; never pass user input to a shell string. Validate/allowlist the command.
  • Path traversal: resolve then normalize() and verify resolved.startsWith(baseDir) before any file access.
  • Validate all boundary inputs (Jakarta Bean Validation @Valid/@NotNull/@Size or 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 Optional for absence at public boundaries; return List.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>). Legacy Vector/Hashtable/Stack/EnumerationArrayList/HashMap/ArrayDeque (+ List.copyOf for immutability, ConcurrentHashMap for concurrency).
  • Returning null from a public method → Optional<T> or an empty collection. Optional fields/params → @Nullable or overloads.
  • Empty catch {} and catch (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/hashCoderecord. instanceof-chains / visitor pattern → sealed + switch.
  • .collect(Collectors.toList()) when you want unmodifiable → .toList(). Optional.get()orElseThrow(). Mutating state inside map/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/SimpleDateFormatjava.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 (or mvn -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.

Back to top ↑