Overview
Idiomatic, production-grade Java 21+ (25 is the current LTS): records, sealed hierarchies, pattern matching, streams, virtual threads, the Maven and Gradle builds, JUnit 5 and Spring Boot. Assumes the reader knows the language. The content is decision rules and the traps that survive review: == on boxed values, Collectors.toMap on a duplicate key, @Transactional on a self-call, a pinned virtual thread, a test task that runs zero JUnit 5 tests and reports success.
Tooling
Use the wrapper the repository ships (./gradlew, ./mvnw); it pins the build tool version. Keep the existing build tool: a Maven-to-Gradle migration is a separate, requested change.
| Task | Gradle | Maven |
|---|---|---|
| Full gate | ./gradlew check |
./mvnw -q verify |
| Compile main and tests | ./gradlew testClasses |
./mvnw -q test-compile |
| Unit tests | ./gradlew test |
./mvnw -q test |
| One test | ./gradlew test --tests 'com.acme.FooTest.bar' |
./mvnw test -Dtest='FooTest#bar' |
| Dependency tree | ./gradlew dependencies --configuration runtimeClasspath |
./mvnw dependency:tree |
| Why is X on the classpath | ./gradlew dependencyInsight --dependency jackson-databind |
./mvnw dependency:tree -Dincludes=com.fasterxml.jackson.core |
| Format | ./gradlew spotlessApply |
./mvnw spotless:apply |
- Gradle:
checkrunstestplus every verification task a plugin attaches (Spotless'sspotlessCheck, Checkstyle, SpotBugs); Error Prone runs insidecompileJava.buildischeckplusassemble. Atesttask whose inputs have not changed is skipped asUP-TO-DATE;--rerun(Gradle 7.6+) forces it. - Maven: Surefire runs unit tests in the
testphase (default includes**/Test*.java,**/*Test.java,**/*Tests.java,**/*TestCase.java); Failsafe runs*IT.javainintegration-test/verify. Pinmaven-surefire-plugin3.x in<pluginManagement>: older Maven super-POMs bind Surefire 2.12.4, which runs no JUnit 5 tests and reports success. - Versions: Gradle keeps them in the version catalog (
gradle/libs.versions.toml) and imports BOMs withplatform(...); Maven imports BOMs in<dependencyManagement>with<scope>import</scope>. Conflict resolution differs: Gradle picks the highest requested version, Maven picks the nearest declaration in the tree, which can silently downgrade a transitive dependency.maven-enforcer-plugin'sdependencyConvergencerule turns that into a build error. - Static analysis:
javac -Xlint:all -Werror; Error Prone (javac plugin, compile-time bug patterns); NullAway (an Error Prone check) with JSpecify@NullMarked/@Nullable; SpotBugs on bytecode; Checkstyle or PMD for style rules. Format with Spotless running google-java-format or palantir-java-format.
// build.gradle.kts for a Java project
plugins {
java
alias(libs.plugins.spotless)
alias(libs.plugins.errorprone)
}
java {
toolchain { languageVersion = JavaLanguageVersion.of(21) }
}
dependencies {
testImplementation(platform(libs.junit.bom))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testImplementation(libs.assertj.core)
errorprone(libs.errorprone.core)
}
tasks.withType<JavaCompile>().configureEach {
options.compilerArgs.addAll(listOf("-Xlint:all", "-Werror"))
}
tasks.test {
useJUnitPlatform() // without it Gradle runs the JUnit 4 engine, which does not see Jupiter tests
}
Records, Sealed Types and Pattern Matching
Model data as records and closed sets of alternatives as sealed interfaces; switch then checks exhaustiveness (Java 21).
public sealed interface Command {
record Upload(Path source, String target) implements Command {
public Upload { // compact constructor: runs on every construction
Objects.requireNonNull(source, "source");
if (target == null || target.isBlank()) throw new IllegalArgumentException("target is blank");
}
}
record Delete(String target) implements Command {}
record Batch(List<Command> commands) implements Command {
public Batch { commands = List.copyOf(commands); } // defensive copy; rejects null elements
}
}
static String describe(Command command) {
return switch (command) { // exhaustive over the sealed type: no default
case Command.Upload(Path source, var target) -> "upload " + source + " -> " + target;
case Command.Delete(String target) -> "delete " + target;
case Command.Batch(var commands) when commands.isEmpty() -> "empty batch";
case Command.Batch(var commands) -> "batch of " + commands.size();
};
}
- Records nested in the sealed interface need no
permitsclause. Jackson (2.12+) deserializes records through the canonical constructor, so compact-constructor validation also guards parsed input. - Records are shallowly immutable. Copy mutable components (
List.copyOf,Map.copyOf) in the compact constructor. An array component keeps reference equality:record Blob(byte[] data)has anequalsthat compares array identity; overrideequals/hashCodewithArrays.equals/Arrays.hashCodeor hold an immutable type. - Leave
defaultout of aswitchover a sealed type. A new subtype then fails compilation at every switch that must handle it; adefaultbranch absorbs it silently. instanceofpatterns bind and narrow in one step:if (obj instanceof String s && !s.isBlank()). The unnamed pattern_(Java 22) ignores record components you do not use.- Enums for fixed sets without per-instance data;
EnumMap/EnumSetare array-backed and faster than hash collections keyed by enums. varfor locals whose type the right-hand side states (var users = new ArrayList<User>()); spell the type when the initializer is a call whose return type the reader cannot see.
Errors and Null Handling
public Config load(Path path) throws ConfigException {
try (var reader = Files.newBufferedReader(path)) { // closed on every exit path
return parse(reader);
} catch (NoSuchFileException e) { // specific before general
throw new ConfigException("config not found: " + path, e);
} catch (IOException e) {
throw new ConfigException("reading config " + path, e); // always pass the cause
}
}
- Checked exceptions for conditions a caller can act on (missing file, refused connection); unchecked (
IllegalArgumentException,IllegalStateException) for bugs and violated preconditions. Checked exceptions do not pass through lambdas: wrap inUncheckedIOExceptioninside a stream and unwrap at the boundary. - Pass the cause to every wrapping exception.
throw new X(msg)inside acatchdiscards the original stack trace. - try-with-resources closes resources in reverse order; an exception thrown by
close()is attached to the primary one as suppressed (getSuppressed()). InterruptedException: restore the flag withThread.currentThread().interrupt()before returning or rethrowing unchecked. Swallowing it breaks cancellation for every caller up the stack.Optionalis a return type. Keep it out of fields, parameters and collections.orElse(compute())always evaluatescompute();orElseGet(this::compute)is lazy. PreferorElseThrow()toget().- Null:
Objects.requireNonNull(x, "x")at public boundaries; JSpecify@NullMarkedon the package plus@Nullableon the exceptions, checked at compile time by NullAway. Return empty collections instead ofnull.
Streams and Collections
Map<String, Integer> totals = orders.stream()
.collect(Collectors.toMap(Order::customer, Order::amount, Integer::sum)); // merge: duplicates add up
List<String> names = users.stream()
.filter(User::active)
.map(User::name)
.toList(); // unmodifiable (Java 16+)
Map<Status, List<Order>> byStatus = orders.stream()
.collect(Collectors.groupingBy(Order::status, () -> new EnumMap<>(Status.class), Collectors.toList()));
Collectors.toMapwithout a merge function throwsIllegalStateExceptionon a duplicate key, andNullPointerExceptionon a null value.Stream.toList()returns an unmodifiable list that allows nulls;Collectors.toList()makes no promise about mutability;Collectors.toUnmodifiableList()rejects nulls.List.of,Set.of,Map.ofare immutable and reject nulls;Set.of/Map.ofthrow on duplicates at construction, and their iteration order is unspecified and changes between JVM runs. Sort, or useLinkedHashMap/TreeMap, wherever order is observable (JSON output, snapshots, log lines under test).Arrays.asListis fixed-size and writes through to the backing array.- Sequenced collections (21):
getFirst(),getLast(),reversed()on lists, deques,LinkedHashSetandLinkedHashMap. - Streams are lazy and single-use. Side effects in
peekormapmay never run: since Java 9,count()skips the pipeline when the size is known from the source. parallelStream()runs on the JVM-wide commonForkJoinPool. Blocking IO inside it starves every other parallel stream and everyCompletableFuture.supplyAsyncwithout an executor. Use it only for CPU-bound work on large inputs, measured.- Stream gatherers (final in 24):
stream.gather(Gatherers.windowFixed(3)),windowSliding, andGatherers.mapConcurrent(n, fn)for bounded concurrent mapping on virtual threads.
Concurrency
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> pages = urls.stream()
.map(url -> executor.submit(() -> fetch(url))) // one virtual thread per blocking call
.toList();
for (Future<String> page : pages) {
consume(page.get()); // ExecutionException wraps the task's failure
}
} // close() waits for every submitted task
- Virtual threads (21) are for blocking IO at high concurrency. Create one per task and never pool them. The executor has no size limit, so bound calls to a downstream service with a
Semaphore. - Pinning: before JDK 24, blocking inside
synchronizedpinned the carrier thread; JEP 491 (JDK 24) removed that forsynchronized. Native frames (JNI, some drivers) still pin. On 21, guard blocking calls withReentrantLockand watch thejdk.VirtualThreadPinnedJFR event. ThreadLocalworks on virtual threads, but each thread gets its own copy: a per-thread cache of heavy objects multiplies by the thread count.ScopedValue(final in 25) passes request context down a call tree immutably.- CPU-bound work belongs on a platform pool sized to the cores (
Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors())) or aForkJoinPool. CompletableFuture.supplyAsync(fn)without an executor runs on the common pool; pass one.join()throws uncheckedCompletionException,get()throws checkedExecutionException. Attachexceptionally/handleat the end of a chain; a failed future nobody observes fails silently.StructuredTaskScopeis still a preview API in 25 and its shape changed between releases; keep it out of code that must build without--enable-preview.- Shared state:
ConcurrentHashMap.merge/computefor atomic read-modify-write (agetfollowed byputraces);LongAdderfor contended counters,AtomicLongotherwise;volatilefor a flag with one writer. Immutable records cross threads without locks. - Spring Boot 3.2+:
spring.threads.virtual.enabled=trueserves requests on virtual threads.
Packages and Modules
- Standard layout:
src/main/java,src/main/resources,src/test/java,src/test/resources; the directory path matches the package. - Package-private (no modifier) is the default for implementation classes; mark
publiconly the API. Tests in the same package reach package-private members without widening them. - JPMS (
module-info.java):exportsthe API packages,requires transitivefor types your API exposes,opensfor packages that reflective frameworks (Jackson, Hibernate, Spring) must read. A missingopenssurfaces at runtime asInaccessibleObjectException. Applications that publish no library usually stay on the classpath.
Spring Boot Essentials
@ConfigurationProperties(prefix = "spool")
@Validated
public record SpoolProperties(@NotBlank String directory, @Positive int maxFiles) {}
@Service
class SpoolService {
private final SpoolRepository repository;
SpoolService(SpoolRepository repository) { // single constructor: injected without @Autowired
this.repository = repository;
}
@Transactional
public void archive(long id) {
var entry = repository.findById(id).orElseThrow(() -> new EntryNotFoundException(id));
entry.markArchived(); // dirty checking writes the change at commit
}
}
- Register properties with
@ConfigurationPropertiesScanor@EnableConfigurationProperties(SpoolProperties.class). Records bind through the constructor;@Validatedfails startup on invalid values. - Proxy-based annotations (
@Transactional,@Async,@Cacheable) do nothing on a self-call:this.archive(id)from the same bean bypasses the proxy. Move the method to another bean. - Rollback happens by default only for unchecked exceptions and
Error; a checked exception commits. Use@Transactional(rollbackFor = Exception.class)where checked exceptions signal failure. - Set
spring.jpa.open-in-view=false. The default keeps the persistence context open through response rendering, hides lazy-loading N+1 queries and holds a connection for the whole request (Boot logs a warning about it). Load what a use case needs withJOIN FETCHor@EntityGraph. - JPA entities are classes with a no-arg constructor. Records cannot be entities. Base
equals/hashCodeon the id or a business key, and keep Lombok@Dataoff entities: itstoString/hashCodewalk lazy associations. - Actuator: expose
healthandinfo; keepenv,heapdump,loggersand the rest behind authentication. - Boot 3+ uses the
jakarta.*namespace (jakarta.persistence,jakarta.validation).javax.*imports mark Boot 2 era code.
Testing
class SpoolWriterTest {
@TempDir Path root;
@Test
void writesEntryUnderRoot() throws IOException {
new SpoolWriter(root).write("a.json", "{}");
assertThat(root.resolve("a.json")).hasContent("{}");
}
@ParameterizedTest
@ValueSource(strings = {"../escape.json", "/etc/passwd", "a/../../b"})
void rejectsPathsOutsideRoot(String name) {
var writer = new SpoolWriter(root);
assertThatThrownBy(() -> writer.write(name, "{}"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("outside spool root");
}
}
- JUnit Jupiter (JUnit 5; JUnit 6 keeps the
org.junit.jupiter.apiannotations):@Test,@ParameterizedTestwith@ValueSource/@CsvSource/@MethodSource,@TempDir,@Nested,@BeforeEach. Test classes and methods can be package-private. - AssertJ (
assertThat(...),assertThatThrownBy) produces better failure messages thanassertEquals. JUnit'sassertThrowsreturns the exception for further checks. - Mockito with
@ExtendWith(MockitoExtension.class)uses strict stubs: an unused stub fails the test. Mock roles you own; build value objects for real. - Integration tests: Testcontainers with the production database engine. H2 in a compatibility mode differs in SQL dialect and locking.
- Spring slices (
@WebMvcTest,@DataJpaTest,@JsonTest) load a narrow context;@SpringBootTestloads all of it. Each distinct context configuration (another set of mocked beans, other properties) starts a new context, the usual cause of slow Spring suites.@MockitoBean(Spring Framework 6.2) replaces the deprecated@MockBean. - Asynchronous outcomes: Awaitility (
await().atMost(Duration.ofSeconds(5)).until(...)) instead ofThread.sleep. - Zero-test traps: Gradle without
useJUnitPlatform()and Maven with Surefire older than 2.22 both run no Jupiter tests. Check the report's test count after changing the build.
Loom Test Runner Adapter
Adapter. Java uses gradle or maven. loom project detect prints the choice per package. A directory with pom.xml, build.gradle or settings.gradle is kind java (build.gradle.kts or settings.gradle.kts make it kind kotlin, which follows the same rule), and the runner is gradle when a build.gradle* exists there, else maven. Bazel and Ant builds carry none of these markers and get no Java adapter. A contract's optional runner: field names the adapter when detection picks the wrong one.
service kinds=java runner=maven skills=loom-java
Single-test command. loom runs it with the package directory as the working directory:
# gradle, when gradlew exists in the package directory
./gradlew test --tests '{test}'
# gradle, without a wrapper there
gradle test --tests '{test}'
# maven
mvn -q test -Dtest='{test}'
The commands are fixed. The maven adapter runs mvn from PATH even when the project ships ./mvnw, and the subprojects of a Gradle multi-project build hold no gradlew, so loom runs gradle from PATH there. Install a matching Maven or Gradle on the host when either applies. When rerunning a command by hand, drop -q: quiet mode hides Surefire's Tests run: line on a passing run.
The test field.
| Adapter | test value |
Example |
|---|---|---|
gradle |
package.Class.method |
com.acme.spool.SpoolWriterTest.rejectsSymlinkedSpool |
maven |
Class#method |
SpoolWriterTest#rejectsSymlinkedSpool |
Both runners filter on the Java class and method names; @DisplayName plays no part in selection. Maven's Class#method uses the simple class name, so two SpoolWriterTest classes in different packages both run: keep contract test class names unique within the module.
No-match behaviour. D5 marks both runners "documented": no fixture was captured, and loom's gradle and maven parsers were written from the runners' documented output. Gradle fails the task with No tests found for given includes; Surefire fails with No tests matching pattern ... were executed. Both exit non-zero, like a failing test, so the exit code cannot tell a misspelled name from a red test. loom reads the runner's summary instead: a name that selects nothing classifies as NotSelected, which fails the freeze ("the runner did not select the test") and fails the completion check ("contract test not selected").
Writing contract tests. Test sources live under src/test/java/ in the directory of their package, for example src/test/java/com/acme/spool/SpoolWriterTest.java. Name classes *Test so Surefire's default includes pick them up; *IT classes belong to Failsafe, which mvn test does not run. For the adapter to select exactly one test:
- one contract per plain
@Testmethod in a top-level class; a@Nestedclass changes the name the filter sees (Outer$Inner); - a camelCase method name, unique in its class, with no overloads;
- the
testvalue copied from the source, with the package forgradle.
contracts:
- id: rejects-symlinked-spool
file: src/test/java/com/acme/spool/SpoolWriterTest.java
test: com.acme.spool.SpoolWriterTest.rejectsSymlinkedSpool
scenario: the spool directory is a symlink to a directory outside the data root
rejects: a writer that opens the spool path without LinkOption.NOFOLLOW_LINKS and writes through the link
In a Maven package the same contract reads test: SpoolWriterTest#rejectsSymlinkedSpool.
Build failures. A contract test that does not compile yet, because it calls a class or method the stage has not written, counts as red at freeze time: loom classifies the compiler failure as BuildFailed. javac compiles the whole test source set together, so one uncompilable contract file makes every test in that module BuildFailed until the implementation exists. Freeze and completion run each command under a 300-second limit: run the single-test command once before freezing so the daemon start and dependency download happen outside the timed run. If a contract needs a new test dependency, the build file it edits must match one of the stage's harness globs; the freeze accepts only contract files and harness matches.
Anti-Patterns
// Boxed comparison: == compares references, and Integer caches only -128..127
Integer a = 1000, b = 1000;
boolean same = a == b; // BAD: false
boolean equal = a.equals(b); // GOOD
// Ternary unboxing: the expression type is int, so a null from the map throws NPE
Integer count = found ? cache.get(key) : 0; // BAD
Integer count = found ? cache.get(key) : Integer.valueOf(0); // GOOD
// Removing while iterating
for (String s : list) if (s.isBlank()) list.remove(s); // BAD: ConcurrentModificationException
list.removeIf(String::isBlank); // GOOD
// BigDecimal
new BigDecimal(0.1); // BAD: 0.1000000000000000055511151231257827...
new BigDecimal("0.1"); // GOOD (or BigDecimal.valueOf(0.1))
new BigDecimal("1.0").equals(new BigDecimal("1.00")); // false: equals compares scale
new BigDecimal("1.0").compareTo(new BigDecimal("1.00")) == 0; // true
Quick swaps: field @Autowired → constructor injection with final fields; Date/SimpleDateFormat (mutable, not thread-safe) → java.time (Instant, LocalDate, DateTimeFormatter); e.printStackTrace() → a logger call with the exception as the last argument; raw List → List<String>; empty catch → handle, log with context, or rethrow; return null for "no results" → an empty collection; += on a String in a loop → StringBuilder or Collectors.joining; synchronized (this) in a public class → a private final lock object; equals without hashCode → both, or a record; list.remove(1) on a List<Integer> removes index 1 → list.remove(Integer.valueOf(1)) to remove the value.
Expert Practices
Equality and Hashing
equalsandhashCodemust agree. Records generate both over all components; hand-written classes useObjects.equalsandObjects.hash(which allocates a varargs array, so hand-roll it in hot paths).- A mutable object used as a
HashMapkey orHashSetelement, then mutated, lands in the wrong bucket:containsreturns false and the entry leaks. Key maps by immutable values. switchwith adefaultover a sealed type hides new subtypes (see Records above).
Strings, Locales and Charsets
toLowerCase()/toUpperCase()use the default locale: under a Turkish locale"TITLE".toLowerCase()yields a dotlessı. PassLocale.ROOTfor identifiers, protocol keywords, header names and file extensions.String.formatandformattedalso use the default locale (%.2fprints1,50underde_DE). UseLocale.ROOTfor machine-readable output.- The default charset is UTF-8 since JDK 18 (JEP 400); older runtimes used the platform encoding. Pass
StandardCharsets.UTF_8explicitly in code that still runs on 17. String.splittakes a regex and drops trailing empty strings:"a.b".split(".")returns an empty array. Usesplit(Pattern.quote("."), -1)to split on a literal and keep empties.
Language Gotchas
int/longarithmetic wraps silently on overflow.Math.addExact,multiplyExactandtoIntExactthrowArithmeticExceptioninstead;Math.abs(Integer.MIN_VALUE)is still negative.Collections.unmodifiableList(list)is a read-only view: later changes tolistshow through it.List.copyOftakes a snapshot.Arrays.asList(new int[] {1, 2})is aList<int[]>with one element. UseList.of(1, 2)orIntStream.of(...).boxed().- Enum and
Objecthash codes are identity-based and differ between runs, so aHashSetof enums iterates in a different order each run.EnumSet/EnumMapiterate in declaration order. - Static fields initialize in textual order. A static read that runs before the field's initializer (a circular class initialization, or a static method called from an earlier initializer) sees
nullor0. - Money in
doubleaccumulates rounding error. UseBigDecimalwith an explicitRoundingMode, or integer minor units.
Security
- Never call
ObjectInputStream.readObjecton untrusted bytes: gadget chains in the classpath turn it into remote code execution. Where it cannot be removed, install anObjectInputFilterallow-list. - Jackson:
activateDefaultTypingor@JsonTypeInfo(use = Id.CLASS)on untrusted input lets the payload pick the class to instantiate. UseId.NAMEwith registered subtypes. - XML parsers resolve external entities by default (XXE). On
DocumentBuilderFactory,SAXParserFactoryandXMLInputFactory, disable DTDs (http://apache.org/xml/features/disallow-doctype-decl) and enableXMLConstants.FEATURE_SECURE_PROCESSING. - SQL:
PreparedStatementparameters and JPA named parameters. String concatenation into JPQL ("... where name = '" + name + "'") is as injectable as SQL. - Paths:
base.resolve(input).normalize()followed by astartsWith(base)check; symlinks needtoRealPath()orLinkOption.NOFOLLOW_LINKSon top. - Tokens come from
SecureRandom;RandomandThreadLocalRandomare predictable. Compare secrets withMessageDigest.isEqual(constant time). Runtime.exec(String)splits on whitespace. UseProcessBuilderwith an argument list and keep user input out ofsh -c.
Performance
- Measure with JMH; a hand-written timing loop measures JIT warm-up and dead-code elimination. Profile real workloads with JFR (
-XX:StartFlightRecording) and JDK Mission Control or async-profiler. - Autoboxing in hot loops (
Map<Long, Long>counters,Stream<Integer>) allocates per element. UseIntStream/LongStream, primitive arrays or primitive collections. - Presize collections when the count is known:
HashMap.newHashMap(n)(Java 19) accounts for the load factor;new HashMap<>(n)does not. - In containers the JVM reads cgroup limits. Size the heap as a share of the limit (
-XX:MaxRAMPercentage=75); a fixed-Xmxhas to be kept in step with the container limit by hand. - SLF4J placeholders (
log.debug("loaded {}", id)) skip formatting when the level is off; string concatenation in the call always runs. finalizeis deprecated for removal. Release resources with try-with-resources, orCleaneras a safety net.
Verification Checklists
Before marking Java work done:
-
./gradlew checkor./mvnw -q verifypasses, with-Xlint:all -Werrorand the project's static analysis - Formatter applied (
spotlessApplyor the project equivalent) - New behaviour has tests; the test report shows a non-zero count for the new classes
- No
==on boxed numbers or strings;BigDecimalcompared withcompareTo - Every wrapping exception passes its cause; no empty
catch;InterruptedExceptionrestores the flag -
Optionalonly as a return type; public boundaries reject null or are@Nullable-annotated -
Collectors.toMapcalls have a merge function where keys can repeat - No iteration-order assumptions on
Map.of/Set.of/HashMapin output or tests - Untrusted input never reaches
ObjectInputStream, default typing, a DTD-enabled XML parser or concatenated SQL/JPQL
Concurrency and Spring review:
- Blocking IO on virtual threads, CPU work on a bounded platform pool; downstream concurrency bounded by a
Semaphore - Every
CompletableFuturechain has an executor and an error handler - Shared mutable state uses
ConcurrentHashMap.merge/compute, atomics or locks; no check-then-act across two calls - No
@Transactional/@Async/@Cacheablemethod reached through a self-call; rollback rules cover checked exceptions -
spring.jpa.open-in-view=false; no N+1 queries in new repository calls; entities keep Lombok@Dataoff