Java Patterns
Intro
Modern Java leans on records, sealed types, pattern matching, and streams. Prefer immutable data carriers, exhaustive switches, and constructor injection over the Java-EE-era boilerplate.
Overview
Language features (17+)
Use record for immutable data carriers — you get equals,
hashCode, toString for free. Use sealed interfaces to restrict
hierarchies and unlock exhaustive switch. Use pattern matching
with instanceof to skip casts. Use text blocks ("""...""") for
multi-line strings. Prefer var for locals when the right-hand
side makes the type obvious.
Records and sealed classes
Records replace boilerplate DTOs. Validate in the compact constructor:
record Age(int value) {
Age {
if (value < 0) throw new IllegalArgumentException();
}
}
Sealed hierarchies plus records create algebraic data types:
sealed interface Result<T> permits Success, Failure {}
record Success<T>(T value) implements Result<T> {}
record Failure<T>(String error) implements Result<T> {}
Combined with pattern-matching switch, the compiler enforces
exhaustiveness — adding a variant breaks the build at every
consumer.
Stream API
Use streams for transformations, not side effects. Prefer toList()
(Java 16+) over Collectors.toList(). The common pipeline is
filter → map → collect. Use flatMap to flatten nested
collections and groupingBy/partitioningBy for aggregation. Drop
back to a for loop when it reads more clearly than a stream.
Optional
Return Optional<T> from methods that may not produce a result.
Never use Optional as a field or parameter type. Chain with map,
flatMap, and filter rather than isPresent() + get(). Use
orElseThrow(() -> new NotFoundException(...)) for required values
and Optional.empty() instead of returning null.
Spring Boot conventions
Use constructor injection, not field injection with @Autowired.
Keep controllers thin — validate input, delegate to service, return
a response. Put @Transactional on the service layer, not the
controller. Configure via application.yml with typed
@ConfigurationProperties classes and environment-specific
profiles. Return ResponseEntity<T> for explicit status codes and
centralize error handling in @RestControllerAdvice.
Testing
Use JUnit 5 with @DisplayName for readable output. Use
@ParameterizedTest with @CsvSource or @MethodSource for
data-driven cases. Mock with @Mock and @ExtendWith(MockitoExtension.class)
rather than the legacy runner. Use AssertJ's fluent assertions
(assertThat(result).isEqualTo(expected)). Use Spring test slices
— @WebMvcTest for controllers, @DataJpaTest for repositories —
to keep tests fast.
Gotchas
Agent-specific failure modes — provider-neutral pause-and-self-check items:
- Field injection with
@Autowiredon fields. Field injection hides dependencies, prevents immutability, and requires Spring's reflection to construct the object — making unit tests without Spring painful. Use constructor injection for all mandatory dependencies. Optional.get()without checking presence. Calling.get()on an emptyOptionalthrowsNoSuchElementException— the same crash you were trying to avoid with null. Use.orElseThrow(() -> new NotFoundException(...))or.ifPresent(...)instead.- Streams for trivial iteration. A stream over a single-step
forloop adds indirection and hurts readability. Use aforloop when it's clearer; reach for streams only when the pipeline —filter → map → collect— adds genuine expressiveness. - Wide
@Transactionalon controllers. Transactions should span a single unit of work inside the service layer. Annotating a controller method@Transactionalcreates a transaction that outlives the database interaction and crosses HTTP concern boundaries. - Deep inheritance hierarchies over sealed types. Adding a new subclass to an open hierarchy requires updating every consumer that switches on type. Model closed sets with sealed interfaces and pattern-matching
switch; the compiler enforces exhaustiveness. Collectors.toList()on Java 16+. TheCollectors.toList()method returns a mutable list;Stream.toList()(Java 16+) returns an unmodifiable list and is shorter to write. PrefertoList()unless you need mutability.Optionalas a field or method parameter.Optionalis designed for return types only. Using it as a field adds boxing overhead and breaks serialization; using it as a parameter forces callers to wrap values unnecessarily. Use overloads or nullable with explicit documentation for parameters.
Full reference
Dependency injection
Prefer constructor injection — it makes dependencies explicit and
enables testing without Spring. Inject interfaces, not concrete
implementations. Use @Qualifier or custom annotations to
disambiguate multiple beans. If a constructor has more than ~5
parameters, the class is probably doing too much — split it.
Code organization
Package by feature (com.app.order, com.app.user), not by layer
(com.app.controllers, com.app.services). Keep one primary
responsibility per class. Mark classes and fields final when you
mean them to be immutable. Prefer composition and interfaces over
deep inheritance. For larger systems, use module-info.java to
enforce explicit module boundaries.
Pattern matching switch
String describe(Shape s) {
return switch (s) {
case Circle c -> "circle r=" + c.radius();
case Rectangle r -> "rect " + r.width() + "x" + r.height();
};
}
With a sealed hierarchy, the compiler requires every permitted
type to be handled — no default branch needed.
Anti-patterns
- Field injection with
@Autowired— hides dependencies and requires reflection to test. Optionalparameters or fields — use overloads or nullable with documentation instead..get()onOptionalwithout checking presence — useorElseThrow.- Streams for trivial iteration where a
forloop is clearer. - Deep inheritance hierarchies of abstract classes — prefer composition and interfaces.
- Wide
@Transactionalannotations on controllers that cross business operations. Collectors.toList()on Java 16+ — usetoList().
Common migrations
- POJO with getters/setters and equals/hashCode →
record. - Visitor pattern over an open hierarchy → sealed interface + pattern-matching switch.
if/else ifinstanceof chains → pattern matching instanceof.- Manual null-checks →
Optionalchains. - Reflection-based DTO mappers between layers → records with explicit conversion methods.