Use when writing, reviewing or refactoring modern Java (21+, 25 LTS) — records and sealed interfaces as algebraic data types, exhaustive pattern-matching switch over instanceof ladders, virtual-thread concurrency, structured concurrency and ScopedValue replacing ThreadLocal, Stream and Optional pipelines, compact-constructor validation, parametrized JDBC, and the Maven/Gradle build surface. NOT Spring framework wiring (that is `spring-boot`).
npx skills add https://github.com/ericrisco/rsc-harness --skill java
Write, review, and refactor Java the way the language is shaped today, not the way it
was in 2014.
Targets Java 21+ with Java 25 as the current LTS (released 2025-09-16, supported to
2033; Java 26 is the current non-LTS, 2026-03-17). "Modern" here means: records and sealed
interfaces instead of JavaBeans and type hierarchies, exhaustive pattern-matching switch
instead of instanceof+cast ladders, virtual threads instead of hand-tuned pools, and
ScopedValue instead of ThreadLocal. If you are reaching for a telescoping constructor,
a getter/setter bean, or a manual ExecutorService sized to a core count, stop.
Use when:
.java file.instanceof+cast chains or visitor boilerplate with a pattern-matching switch.virtual threads, fan-out with structured concurrency, ThreadLocal -> ScopedValue.
pom.xml / build.gradle(.kts) for a current JDK (toolchain,--release, preview flags, JUnit 5).
When NOT to use (delegate):
@Transactional -> spring-boot.This skill is the plain-JDK layer underneath; framework wiring is out of scope by topic.
kotlin-android. Java and Kotlin interop butare different languages; coroutines and Kotlin null-safety belong there.
secure-coding. This skill keepsonly Java-specific controls (parametrized JDBC, compact-constructor validation, sane deser).
deployment. This skill keeps only ajlink/jpackage note and the JDK base-image choice.
postgresdb. This skill covers JDBCparametrization from the Java side only.
harness (see "Project grounding").Records/sealed/pattern-matching design, virtual-thread concurrency, and the build surface
live here — there is no separate "java-concurrency" or "java-build" skill.
Apply these on every Java edit:
record; itgets equals/hashCode/toString and is immutable for free. Class only when you need
mutable identity or inheritance.
switch over instanceof ladders. A closed set of cases is asealed interface; the compiler then proves your switch is total. An instanceof+cast
ladder is an unchecked manual version of this.
final by default. Fields final, collections wrapped/copied; mutateonly where you have measured a reason. Shared mutable state is the bug you debug at 3am.
Optional is a return type, never a field or parameter. It signals "maybe absent" to acaller; an Optional field just adds a null-of-null. For absent collections, return empty.
spend their life parked on sockets/JDBC; it does nothing for a tight CPU loop.
pooling them re-introduces the limit they exist to remove.
record's invariants belong in its compactconstructor so an invalid instance cannot exist — not in a separate validate() you can forget.
PreparedStatement with ?, bind values; neverconcatenate user input into SQL.
map/filter/collect pipeline isclear; a forEach that mutates external state is a loop wearing a costume — write the loop.
10. Treat compiler warnings as failures. Build with -Xlint:all -Werror; an ignored
unchecked or deprecation warning is a deferred bug.
A record is the default data carrier — immutable, no setters, validated in its compact
constructor.
// Good: invalid Money cannot exist; value semantics for free.
public record Money(long cents, Currency currency) {
public Money { // compact constructor
if (cents < 0) throw new IllegalArgumentException("cents < 0: " + 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);
}
}
// Bad: telescoping constructors, mutable identity, hand-written equals you will get wrong.
public final class Money {
private long cents; private Currency currency;
public Money() {}
public Money(long cents) { this.cents = cents; }
public Money(long cents, Currency currency) { this.cents = cents; this.currency = currency; }
public void setCents(long c) { this.cents = c; } // now any caller can break invariants
public long getCents() { return cents; }
// ... 40 lines of getters/setters/equals/hashCode ...
}
A sealed interface + record cases is an algebraic data type — a closed set the compiler
knows about:
public sealed interface PaymentResult permits Captured, Declined, Pending {}
public record Captured(String id, Money amount) implements PaymentResult {}
public record Declined(String reason, int code) implements PaymentResult {}
public record Pending(String id, Instant retryAfter) implements PaymentResult {}
Enum vs sealed: use an enum when cases are a fixed set of *constants with no per-case
data* (Status.ACTIVE); use a sealed interface when each case *carries different fields*
(above). Reaching for an enum plus a parallel "payload" map means you wanted a sealed type.
instanceof binds the pattern variable directly — no cast:
if (event instanceof Captured c) { // Good: c is in scope, already typed
ledger.record(c.id(), c.amount());
}
An exhaustive switch over a sealed type with record deconstruction and when guards
replaces the whole ladder — and omit default when the sealed set is covered so adding a
new case becomes a compile error you must handle:
// Good: total over the sealed type; deconstruction patterns; guard; null branch.
String describe(PaymentResult r) {
return switch (r) {
case Captured(var id, var amt) when amt.cents() == 0 -> "free capture " + id;
case Captured(var id, var amt) -> "captured " + amt.cents() + "c " + id;
case Declined(var reason, var code) -> "declined(" + code + "): " + reason;
case Pending(var id, var __) -> "pending " + id;
case null -> "no result";
// no default: compiler proves exhaustiveness; a new permits-case won't compile until handled
};
}
// Bad: instanceof + cast ladder. No exhaustiveness check; a new case silently falls through.
String describe(PaymentResult r) {
if (r instanceof Captured) { Captured c = (Captured) r; return "captured " + c.amount(); }
else if (r instanceof Declined) { Declined d = (Declined) r; return "declined " + d.reason(); }
else return "?"; // silently wrong when Pending is added
}
Java 25 adds primitive type patterns in instanceof and switch (e.g. case int i),
extending exhaustiveness to primitives.
For blocking I/O, run one virtual thread per task inside a per-task executor closed with
try-with-resources (the close call blocks until every submitted task finishes):
// Good: 500 blocking fetches, one virtual thread each. No pool sizing, no reactive ceremony.
List<String> fetchAll(List<URI> urls, HttpClient http) throws InterruptedException {
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = urls.stream()
.map(u -> exec.submit(() -> http.send(
HttpRequest.newBuilder(u).build(), BodyHandlers.ofString()).body()))
.toList();
return futures.stream().map(f -> {
try { return f.get(); } catch (Exception e) { throw new CompletionException(e); }
}).toList();
} // close() joins all tasks
}
// Bad: a fixed platform-thread pool caps you at N concurrent blocking calls and starves.
var exec = Executors.newFixedThreadPool(16); // 484 of 500 fetches wait on a thread
JEP 491 (JDK 24) removed synchronized pinning — a virtual thread inside a synchronized
block no longer pins its carrier, so legacy synchronized code is safe on Loom now. For *long*
holds prefer ReentrantLock anyway (fairness, tryLock, interruptibility).
Structured concurrency (StructuredTaskScope) ties subtasks to a scope so one failure
cancels the siblings — but it is still preview in Java 25 (JEP 505, 5th preview), so it
needs --enable-preview. ScopedValue is FINAL in Java 25 (JEP 506) and is the immutable
replacement for ThreadLocal when sharing context across virtual threads:
// Good: immutable, explicitly scoped, propagates into child tasks; nothing to clean up.
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
ScopedValue.where(REQUEST_ID, id).run(() -> handle(req)); // REQUEST_ID.get() inside
A ThreadLocal set on the caller does not appear in the tasks you submit to an executor,
and on millions of virtual threads its mutable per-thread copies leak memory — that symptom is
exactly what ScopedValue fixes. Deep dive (scopes, joiners, pinning diagnosis, when platform
threads still win) -> references/concurrency.md.
Use stream().toList() (Java 16+, immutable) for the common case; Collectors.groupingBy
and teeing cover most aggregation:
Map<Currency, Long> totalByCcy = payments.stream()
.collect(Collectors.groupingBy(Payment::currency,
Collectors.summingLong(p -> p.amount().cents())));
Optional discipline: chain map/filter/orElseGet/orElseThrow; never .get() without a
prior isPresent, never an Optional field. Do not stream when a loop is clearer — a
forEach with side-effects, an early break/return, or index-coupled logic is a loop.
Full Collector catalog, custom collectors, mapMulti, Gatherers (Java 24+), and parallel-stream
caveats -> references/streams.md.
Pin the language level with the compiler plugin's <release>, run JUnit 5 via Surefire.
Maven 4 is not yet GA (4.0.0-rc-5, Nov 2025) — use Maven 3.9.x for production:
<properties><maven.compiler.release>25</maven.compiler.release></properties>
<build><plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration><release>25</release>
<compilerArgs><arg>-Xlint:all</arg><arg>-Werror</arg></compilerArgs></configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId>
<version>3.5.2</version>
</plugin>
</plugins></build>
Gradle 9.x (current 9.5.1, requires Java 17+ to run, prefers the configuration cache) uses a
toolchain block so the build picks the right JDK regardless of the runner's default:
plugins { java }
java { toolchain { languageVersion = JavaLanguageVersion.of(25) } }
tasks.test { useJUnitPlatform() }
Full pom.xml / build.gradle.kts skeletons, multi-module, preview-flag wiring, JUnit 5 +
AssertJ + Mockito, jlink/jpackage -> references/builds.md.
null. Return Optional<T> for a maybe-value, an empty collection for "none".AutoCloseable; do not write a finally { close() } by hand.throw new ServiceException("loading user " + id, cause) — so thestack trace keeps the root. Swallowing (catch (Exception e) {}) destroys the evidence.
extends Exception) for recoverable, expected conditionsthe caller must handle; unchecked (extends RuntimeException) for programming bugs / invariant
violations. Do not wrap everything in a checked exception "to be safe" — it just gets rethrown.
| Rationalization | Reality / Do instead |
| --- | --- |
| "I'll add setters to the record for convenience" | Then it isn't immutable. Build a new instance; validate in the compact constructor. |
| "instanceof+cast is fine, it's only three cases" | No exhaustiveness; case four silently falls through. Sealed + switch. |
| "pool the virtual threads to be safe" | Pooling re-adds the limit Loom removes. One virtual thread per task. |
| "ThreadLocal carries my request context" | It doesn't cross submit() and leaks on millions of vthreads. ScopedValue. |
| "Optional field so callers handle absence" | Optional is a return type. A field is null-of-null; use the value or a default. |
| "raw List is shorter than List<Payment>" | Raw types disable generics checks. Always parametrize. |
| "catch and ignore, it can't happen here" | Then assert it; a swallowed exception erases the bug. Log + rethrow or handle. |
| "fmt the id into the SQL string, it's trusted" | Trust nothing at the boundary. PreparedStatement with ?. |
| "wrap it in a checked exception to force handling" | Most callers just rethrow. Unchecked for bugs; checked only when recovery is real. |
| "new Thread(r).start() per request" | On platform threads that's a few thousand max. Virtual-thread-per-task executor. |
| "default branch keeps the switch safe" | A default hides the new case. Omit it on a sealed type so it won't compile. |
| "warnings are just noise" | unchecked/deprecation are deferred bugs. -Xlint:all -Werror. |
| Task | Idiom / command |
| --- | --- |
| Build + test (Maven) | ./mvnw -q verify (or mvn -q verify) |
| Build + test (Gradle) | ./gradlew check |
| Data carrier | record Money(long cents, Currency ccy) { Money { ... } } |
| Closed case set | sealed interface R permits A, B {} + record A(...) implements R {} |
| Exhaustive match | switch (r) { case A(var x) -> ...; case B b -> ...; } (no default) |
| Concurrent blocking I/O | try (var e = Executors.newVirtualThreadPerTaskExecutor()) { ... } |
| Context across threads | ScopedValue.where(KEY, val).run(() -> ...) |
| Run a single source file | java App.java (compact source + instance main, JEP 512, final in 25) |
| Local gate | ./scripts/verify.sh (run in your project root) |
When this skill runs in a project with a 02-DOCS/ layer (the
harness Karpathy wiki), record this project's Java decisions there
and index them from the root CLAUDE.md, so the next agent inherits them instead of re-deriving.
02-DOCS/wiki/stack/java.md, indexed in 02-DOCS/wiki/index.md (theKnowledge map index; root CLAUDE.md points to it).
Maven vs Gradle, the domain-modeling conventions (records/sealed), the concurrency model
(virtual threads, structured-concurrency preview on/off), and the error/null conventions —
then index it in 02-DOCS/wiki/index.md (the Knowledge map; root CLAUDE.md keeps only a
short pointer to it).
article (bump its Updated date) in the same change.
No 02-DOCS/ layer? Skip silently (optionally suggest harness). Technical conventions are
*recorded, not gated* — never block the task on this.
Sibling skills (link only those present under skills/):
spring-boot - the Spring framework surface (controllers, beans, autoconfiguration); thisskill is the plain-JDK/language layer underneath it.
secure-coding - threat modeling and language-agnosticauthz/abuse/OWASP review (this skill keeps the Java-specific controls).
deployment - Docker, CI, and shipping (this skill keeps only thejlink/jpackage note and base-image choice).
postgresdb - SQL schema/index/query tuning (this skill covers JDBCparametrization from the Java side only).
harness - the 02-DOCS/ workspace wiki where per-project Javaconventions are recorded (see "Project grounding").
Local references (read when):
references/concurrency.md - virtual-thread internals, structured concurrency, ScopedValue,pinning diagnosis, when platform threads still win.
references/streams.md - full Collectors catalog, custom collectors, Gatherers, parallel-streamcaveats, Stream-vs-loop decision table.
references/builds.md - full pom.xml + build.gradle.kts skeletons, multi-module, preview flags,JUnit 5 + AssertJ + Mockito, jlink/jpackage, dependency hygiene.
Take ericrisco/java from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.