- name
- loom-java
- description
- Java language expertise for idiomatic, production-quality code.
- triggers
- ["java","jdk","jvm","javac","maven","mvn","pom.xml","surefire","gradle","gradlew","junit","jupiter","assertj","mockito","testcontainers","spring","spring boot","jpa","hibernate","jakarta","record","sealed","virtual threads","completablefuture","optional"]
# Java Language Expertise
## 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:** `check` runs `test` plus every verification task a plugin attaches (Spotless's `spotlessCheck`, Checkstyle, SpotBugs); Error Prone runs inside `compileJava`. `build` is `check` plus `assemble`. A `test` task whose inputs have not changed is skipped as `UP-TO-DATE`; `--rerun` (Gradle 7.6+) forces it.
- **Maven:** Surefire runs unit tests in the `test` phase (default includes `**/Test*.java`, `**/*Test.java`, `**/*Tests.java`, `**/*TestCase.java`); Failsafe runs `*IT.java` in `integration-test`/`verify`. Pin `maven-surefire-plugin` 3.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 with `platform(...)`; 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`'s `dependencyConvergence` rule 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.
```kotlin
// 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).
```java
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 `permits` clause. 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 an `equals` that compares array identity; override `equals`/`hashCode` with `Arrays.equals`/`Arrays.hashCode` or hold an immutable type.
- Leave `default` out of a `switch` over a sealed type. A new subtype then fails compilation at every switch that must handle it; a `default` branch absorbs it silently.
- `instanceof` patterns 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`/`EnumSet` are array-backed and faster than hash collections keyed by enums.
- `var` for 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
```java
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 in `UncheckedIOException` inside a stream and unwrap at the boundary.
- Pass the cause to every wrapping exception. `throw new X(msg)` inside a `catch` discards 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 with `Thread.currentThread().interrupt()` before returning or rethrowing unchecked. Swallowing it breaks cancellation for every caller up the stack.
- `Optional` is a return type. Keep it out of fields, parameters and collections. `orElse(compute())` always evaluates `compute()`; `orElseGet(this::compute)` is lazy. Prefer `orElseThrow()` to `get()`.
- Null: `Objects.requireNonNull(x, "x")` at public boundaries; JSpecify `@NullMarked` on the package plus `@Nullable` on the exceptions, checked at compile time by NullAway. Return empty collections instead of `null`.
## Streams and Collections
```java
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.toMap` without a merge function throws `IllegalStateException` on a duplicate key, and `NullPointerException` on 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.of` are immutable and reject nulls; `Set.of`/`Map.of` throw on duplicates at construction, and their iteration order is unspecified and changes between JVM runs. Sort, or use `LinkedHashMap`/`TreeMap`, wherever order is observable (JSON output, snapshots, log lines under test).
- `Arrays.asList` is fixed-size and writes through to the backing array.
- Sequenced collections (21): `getFirst()`, `getLast()`, `reversed()` on lists, deques, `LinkedHashSet` and `LinkedHashMap`.
- Streams are lazy and single-use. Side effects in `peek` or `map` may never run: since Java 9, `count()` skips the pipeline when the size is known from the source.
- `parallelStream()` runs on the JVM-wide common `ForkJoinPool`. Blocking IO inside it starves every other parallel stream and every `CompletableFuture.supplyAsync` without an executor. Use it only for CPU-bound work on large inputs, measured.
- Stream gatherers (final in 24): `stream.gather(Gatherers.windowFixed(3))`, `windowSliding`, and `Gatherers.mapConcurrent(n, fn)` for bounded concurrent mapping on virtual threads.
## Concurrency
```java
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 `synchronized` pinned the carrier thread; JEP 491 (JDK 24) removed that for `synchronized`. Native frames (JNI, some drivers) still pin. On 21, guard blocking calls with `ReentrantLock` and watch the `jdk.VirtualThreadPinned` JFR event.
- `ThreadLocal` works 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 a `ForkJoinPool`.
- `CompletableFuture.supplyAsync(fn)` without an executor runs on the common pool; pass one. `join()` throws unchecked `CompletionException`, `get()` throws checked `ExecutionException`. Attach `exceptionally`/`handle` at the end of a chain; a failed future nobody observes fails silently.
- `StructuredTaskScope` is 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`/`compute` for atomic read-modify-write (a `get` followed by `put` races); `LongAdder` for contended counters, `AtomicLong` otherwise; `volatile` for a flag with one writer. Immutable records cross threads without locks.
- Spring Boot 3.2+: `spring.threads.virtual.enabled=true` serves 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 `public` only the API. Tests in the same package reach package-private members without widening them.
- JPMS (`module-info.java`): `exports` the API packages, `requires transitive` for types your API exposes, `opens` for packages that reflective frameworks (Jackson, Hibernate, Spring) must read. A missing `opens` surfaces at runtime as `InaccessibleObjectException`. Applications that publish no library usually stay on the classpath.
## Spring Boot Essentials
```java
@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 `@ConfigurationPropertiesScan` or `@EnableConfigurationProperties(SpoolProperties.class)`. Records bind through the constructor; `@Validated` fails 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 with `JOIN FETCH` or `@EntityGraph`.
- JPA entities are classes with a no-arg constructor. Records cannot be entities. Base `equals`/`hashCode` on the id or a business key, and keep Lombok `@Data` off entities: its `toString`/`hashCode` walk lazy associations.
- Actuator: expose `health` and `info`; keep `env`, `heapdump`, `loggers` and the rest behind authentication.
- Boot 3+ uses the `jakarta.*` namespace (`jakarta.persistence`, `jakarta.validation`). `javax.*` imports mark Boot 2 era code.
## Testing
```java
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.api` annotations): `@Test`, `@ParameterizedTest` with `@ValueSource`/`@CsvSource`/`@MethodSource`, `@TempDir`, `@Nested`, `@BeforeEach`. Test classes and methods can be package-private.
- AssertJ (`assertThat(...)`, `assertThatThrownBy`) produces better failure messages than `assertEquals`. JUnit's `assertThrows` returns 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; `@SpringBootTest` loads 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`.
Auf GitHub ansehen