Modern Java skill for Claude Code
Records, sealed types, and virtual threads done right. Claude writes Java that uses what the language finally offers: immutable records with compact-constructor validation, sealed hierarchies matched by exhaustive switch with record patterns and no default branch, and virtual threads for blocking I/O with semaphores instead of pools. It upgrades legacy 8, 11 and 17 codebases in a safe order, build tooling first.
- name
java-modern - size191 lines · ≈2,100 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Writes and modernizes Java 21+ code — records for data, sealed interfaces with exhaustive pattern-matching switch, record patterns, virtual threads for blocking I/O, sequenced collections, Optional and stream discipline, and Maven/Gradle toolchain setup for Java 21 and 25 LTS. Use when upgrading from Java 8/11/17, replacing Lombok or visitor patterns, adopting virtual threads, reviewing Java code style, or when a switch over types or instanceof chains appears. Try asking
- Upgrade this service from Java 11 to 21 and modernize the DTOs
- Replace this visitor pattern with a sealed interface and switch
- Should we turn on virtual threads in our Spring Boot app, and what will break?
Use it when
- Upgrading a Java codebase to 21 or 25 LTS
- Replacing Lombok, visitors, or instanceof chains
- Adopting virtual threads for blocking I/O
- Reviewing Java for Optional, null, and stream misuse
Skip it when
- Wiring Spring Boot configuration, beans and web layers — use spring-boot
- Writing Kotlin on the JVM — use kotlin-idioms
How to check the result
The build passes with --release set to the target LTS and -Xlint:all, tests pass on the new JDK, and a JFR recording under load shows no virtual-thread pinning.
The common mistake it prevents
Adding a default branch to a switch over a sealed type, which compiles today and silently ignores the next subtype someone adds.
Done well means: data is immutable records, closed hierarchies are sealed and matched
exhaustively without a default, blocking I/O runs on virtual threads without pinning,
nulls do not cross public APIs, and the build compiles with --release 21 (or 25) and
-Xlint:all -Werror on new modules.
Ground rules
- Target an LTS: 21 is the baseline, 25 (Sept 2025) is the current LTS. Never use preview
features in production code without the team agreeing to
--enable-preview. - Records for value carriers; classes for things with identity and lifecycle.
- Sealed +
switchpattern matching replaces visitor patterns andinstanceofladders. - Prefer composition and small interfaces; final by default.
- Do not add Lombok to new code: records, compact constructors, and IDE generation cover most of it.
Inspect before changing
java -version; mvn -v 2>/dev/null | head -3; ./gradlew --version 2>/dev/null | grep -E "Gradle|JVM"
grep -nE "<release>|<maven.compiler|languageVersion|sourceCompatibility" pom.xml build.gradle* 2>/dev/null
grep -rn "lombok" pom.xml build.gradle* 2>/dev/null
Set the level once:
<!-- Maven -->
<properties><maven.compiler.release>21</maven.compiler.release></properties>
// Gradle (Kotlin DSL)
java { toolchain { languageVersion = JavaLanguageVersion.of(21) } }
tasks.withType<JavaCompile> { options.compilerArgs.addAll(listOf("-Xlint:all", "-Werror")) }
Feature map by version (finalized, no preview flag)
| Feature | Since | Use for |
|---|---|---|
var locals | 10 | Obvious types on the right-hand side only |
Text blocks """ | 15 | SQL, JSON, multi-line messages |
| Records | 16 | DTOs, value objects, compound map keys |
| Sealed classes/interfaces | 17 | Closed domain hierarchies, result types |
Pattern matching instanceof | 16 | if (o instanceof Order o2 && o2.total() > 0) |
| Switch patterns + record patterns | 21 | Exhaustive dispatch with deconstruction |
| Virtual threads | 21 | Thread-per-request blocking I/O |
| Sequenced collections | 21 | getFirst(), getLast(), reversed() |
Unnamed variables _ | 22 | Unused lambda params, catch vars, patterns |
| FFM API | 22 | Native interop instead of JNI |
| Stream gatherers | 24 | Custom intermediate ops (windowFixed, fold) |
No pinning on synchronized | 24 | Virtual threads may block inside synchronized |
| Scoped values, flexible constructor bodies, module imports, compact source files | 25 | Context passing, validation before super() |
Structured concurrency (StructuredTaskScope) is still preview through 25; check the
current JDK before relying on it.
Records
public record Money(long cents, Currency currency) {
public Money { // compact constructor: validate
if (cents < 0) throw new IllegalArgumentException("negative amount: " + cents);
Objects.requireNonNull(currency, "currency");
}
public Money plus(Money other) {
if (!currency.equals(other.currency)) throw new IllegalArgumentException("currency mismatch");
return new Money(cents + other.cents, currency);
}
}
- Defensive-copy mutable components by reassigning the parameter in the compact
constructor:
items = List.copyOf(items);(you cannot assignthis.itemsthere). - Records are fine as JPA projections and Jackson DTOs (Jackson 2.12+); not as JPA entities (need no-arg constructor and mutability).
- Static factory methods for alternative construction (
Money.ofDollars(12)).
Sealed types and exhaustive switch
public sealed interface PaymentResult permits Approved, Declined, NeedsReview {}
public record Approved(String txId) implements PaymentResult {}
public record Declined(String reason, boolean retryable) implements PaymentResult {}
public record NeedsReview(String caseId) implements PaymentResult {}
String describe(PaymentResult r) {
return switch (r) { // no default: compiler enforces exhaustiveness
case Approved(var txId) -> "approved " + txId;
case Declined(var reason, var retry) when retry -> "retry: " + reason;
case Declined(var reason, _) -> "declined: " + reason; // `_` needs 22+
case NeedsReview nr -> "review " + nr.caseId();
};
}
Leaving out default is the point: adding a fourth subtype breaks every switch that
must handle it. A default branch silently swallows new cases.
Virtual threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Quote>> futures = suppliers.stream()
.map(s -> executor.submit(() -> s.fetchQuote(request))) // blocking HTTP is fine
.toList();
for (var f : futures) quotes.add(f.get());
}
| Situation | Decision |
|---|---|
| Many concurrent blocking calls (HTTP, JDBC, files) | Virtual threads |
| CPU-bound work | Platform thread pool sized to cores (ForkJoinPool, parallel streams) |
| Spring Boot 3.2+ web app | spring.threads.virtual.enabled=true |
| Need to cap load on a downstream (DB pool of 20) | Semaphore around the call; never pool virtual threads |
Gotchas:
- Never pool virtual threads or reuse them; create one per task.
- On JDK 21–23, blocking inside
synchronizedpins the carrier thread; switch toReentrantLockor upgrade to 24+. Diagnose with the JFR eventjdk.VirtualThreadPinned. ThreadLocalcaches (e.g., per-thread formatters, buffers) multiply memory with millions of threads; use scoped values or plain objects.- The DB connection pool, not thread count, becomes the limit; size HikariCP deliberately.
Nulls, Optional, streams
Optionalonly as a return type for “may be absent”. Never as a field, parameter, or collection element. Never call.get(); useorElseThrow(),map,orElse.- Collections: return empty, never null.
List.of(),Map.of()are immutable and reject nulls. - Annotate nullness at boundaries (JSpecify
@Nullable/@NullMarked) and let NullAway or IDE inspections enforce it if the project uses them. - Streams for transformations; loops when there are side effects, checked exceptions,
or early exits.
.toList()(16+) returns an unmodifiable list. - Do not use parallel streams for I/O or small collections.
Map<Status, List<Order>> byStatus = orders.stream()
.collect(Collectors.groupingBy(Order::status));
var lastThree = orders.reversed().stream().limit(3).toList(); // sequenced collections
Modernization checklist (8/11/17 to 21+)
- [ ] Upgrade build plugins first (maven-compiler-plugin 3.13+, surefire 3.x, Gradle 8.5+)
- [ ] Bump release level; fix compile errors and removed APIs (jdeps --jdk-internals target/*.jar)
- [ ] Run the full test suite on the new JDK before changing any code style
- [ ] Replace DTO classes and Lombok @Value with records
- [ ] Replace instanceof chains and visitors with sealed types + switch
- [ ] Replace thread pools around blocking I/O with virtual threads (after load testing)
- [ ] Replace Date/Calendar with java.time
- [ ] Enable -Xlint:all and fix warnings module by module
Gotchas
recordequalsuses all components; arrays inside records compare by reference. UseListinstead of arrays.switchwith patterns on a non-sealed type requiresdefault; that is a signal to seal the hierarchy if you own it.varwith diamond (var list = new ArrayList<>()) infersArrayList<Object>.List.of(...).contains(null)throws NPE.Stream.toList()is unmodifiable;Collectors.toList()is not guaranteed mutable either — useCollectors.toCollection(ArrayList::new)when you must mutate.String.formatted()and text blocks strip incidental indentation; trailing spaces need\s.- Serialization with sealed hierarchies in Jackson needs
@JsonTypeInfo+@JsonSubTypes(or@JsonTypeInfo(use = Id.DEDUCTION)).
Verification
mvn -q verify # or ./gradlew build
jdeps --multi-release 21 --print-module-deps target/*.jar # module deps after upgrade
- Compiler warnings: zero new ones with
-Xlint:all. - For virtual thread changes: run a load test, record a JFR (
-XX:StartFlightRecording), and checkjdk.VirtualThreadPinnedevents and DB pool wait times. - Static analysis if configured (Error Prone, SpotBugs, PMD) passes.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.