Java 21 is a landmark release with stable virtual threads (Project Loom), records, sealed
classes, pattern matching for switch, and sequenced collections. Combined with Spring Boot 3
and modern reactive patterns, Java is more expressive and performant than ever. This skill
covers the idioms that define production-grade Java today.
Core Mental Model
Modern Java embraces immutability first (records, sealed hierarchies), structural pattern
matching over instanceof chains, and non-blocking concurrency via virtual threads rather than
reactive programming for most use cases. The type system is your first line of defence —
use it to make illegal states unrepresentable. Prefer composition over inheritance, and let
the Stream API replace imperative loops wherever the intent becomes clearer.
Java 21+ Language Features
Records — immutable data carriers
// Records: concise immutable data, auto-generates constructor, getters, equals, hashCode, toString
public record AgentProfile(
String agentId,
String displayName,
List<String> capabilities,
Instant registeredAt
) {
// Compact constructor for validation
public AgentProfile {
Objects.requireNonNull(agentId, "agentId required");
if (agentId.isBlank()) throw new IllegalArgumentException("agentId blank");
capabilities = List.copyOf(capabilities); // defensive copy → immutable
}
// Custom method on record
public boolean hasCapability(String cap) {
return capabilities.contains(cap);
}
}
Sealed classes for exhaustive hierarchies
public sealed interface AgentEvent
permits AgentEvent.Registered, AgentEvent.MessageSent, AgentEvent.Deactivated {
record Registered(String agentId, Instant at) implements AgentEvent {}
record MessageSent(String agentId, String messageId, int bytes) implements AgentEvent {}
record Deactivated(String agentId, String reason) implements AgentEvent {}
}
// Pattern matching for switch — compiler enforces exhaustiveness
String describe(AgentEvent event) {
return switch (event) {
case AgentEvent.Registered r -> "New agent: " + r.agentId();
case AgentEvent.MessageSent m -> "Sent %d bytes".formatted(m.bytes());
case AgentEvent.Deactivated d -> "Deactivated: " + d.reason();
};
}
Virtual Threads (Project Loom)
// Virtual threads: millions of lightweight threads, blocking I/O doesn't waste OS threads
import java.util.concurrent.Executors;
// Use virtual thread executor for I/O-bound work
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<AgentProfile>> futures = agentIds.stream()
.map(id -> executor.submit(() -> fetchAgentFromDB(id))) // blocks here cheaply
.toList();
List<AgentProfile> profiles = futures.stream()
.map(f -> { try { return f.get(); } catch (Exception e) { throw new RuntimeException(e); } })
.toList();
}
// Spring Boot 3.2+: enable virtual threads in application.properties
// spring.threads.virtual.enabled=true
Pattern matching instanceof
// Old way
if (obj instanceof String) {
String s = (String) obj;
return s.toUpperCase();
}
// Java 16+ pattern matching
if (obj instanceof String s && !s.isBlank()) {
return s.toUpperCase();
}
// Pattern matching in switch (Java 21 stable)
Object process(Object input) {
return switch (input) {
case Integer i when i > 0 -> "positive: " + i;
case Integer i -> "non-positive: " + i;
case String s -> s.strip();
case null -> "null";
default -> input.toString();
};
}
Stream API — Beyond the Basics
import java.util.stream.*;
import java.util.function.*;
// Collectors.groupingBy + downstream collector
Map<String, Long> capabilityCount = agents.stream()
.flatMap(a -> a.capabilities().stream())
.collect(Collectors.groupingBy(Function.identity(), Collectors.counting()));
// teeing collector: two operations in one pass
record Stats(long count, OptionalDouble avg) {}
Stats stats = messages.stream()
.collect(Collectors.teeing(
Collectors.counting(),
Collectors.averagingInt(Message::byteSize),
(count, avg) -> new Stats(count, OptionalDouble.of(avg))
));
// flatMap for nested collections
List<String> allTags = agents.stream()
.flatMap(agent -> agent.tags().stream())
.distinct()
.sorted()
.toList(); // Java 16+ .toList() → unmodifiable
// Parallel stream (use only for CPU-bound, large datasets, no shared mutable state)
long count = largeList.parallelStream()
.filter(Agent::isActive)
.count();
Optional — Correct Usage
// ✅ Use Optional as return type for "might not exist"
Optional<Agent> findById(String id) {
return Optional.ofNullable(cache.get(id));
}
// ✅ Chain operations without null checks
String displayName = findById(id)
.filter(Agent::isActive)
.map(Agent::displayName)
.orElse("Unknown Agent");
// ✅ orElseGet for expensive defaults (lazy)
Agent agent = findById(id)
.orElseGet(() -> fetchFromDatabase(id));
// ✅ orElseThrow with specific exception
Agent agent = findById(id)
.orElseThrow(() -> new AgentNotFoundException("No agent: " + id));
// ❌ NEVER do this
Optional<Agent> opt = findById(id);
if (opt.isPresent()) {
return opt.get().displayName(); // defeats the purpose
}
// ❌ Optional as field type (serialization nightmares)
class Dto { Optional<String> name; } // wrong
CompletableFuture Pipelines
import java.util.concurrent.CompletableFuture;
// Chain async operations
CompletableFuture<AgentProfile> enrichedProfile(String agentId) {
return CompletableFuture
.supplyAsync(() -> fetchAgent(agentId)) // stage 1: fetch
.thenApplyAsync(agent -> enrich(agent)) // stage 2: transform (async pool)
.thenCombineAsync( // stage 3: combine with another future
fetchCapabilities(agentId),
(agent, caps) -> agent.withCapabilities(caps)
)
.exceptionally(ex -> AgentProfile.fallback(agentId)); // error recovery
// Run two things in parallel, combine results
CompletableFuture<Summary> summary(String agentId) {
var profileFuture = fetchProfile(agentId);
var statsFuture = fetchStats(agentId);
return profileFuture.thenCombine(statsFuture, Summary::new);
}
// allOf: wait for all, handle each
CompletableFuture<List<Agent>> fetchAll(List<String> ids) {
List<CompletableFuture<Agent>> futures = ids.stream()
.map(this::fetchAgent)
.toList();
return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.thenApply(_ -> futures.stream().map(CompletableFuture::join).toList());
}
Spring Boot 3 — REST Controller
@RestController
@RequestMapping("/api/agents")
@RequiredArgsConstructor
@Validated
public class AgentController {
private final AgentService agentService;
@GetMapping("/{agentId}")
public ResponseEntity<AgentProfileDto> getAgent(
@PathVariable @Pattern(regexp = "[a-z0-9-]+") String agentId) {
return agentService.findById(agentId)
.map(ResponseEntity::ok)
.orElseThrow(() -> new AgentNotFoundException(agentId));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public AgentProfileDto registerAgent(@Valid @RequestBody RegisterAgentRequest req) {
return agentService.register(req);
}
@ExceptionHandler(AgentNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
ProblemDetail handleNotFound(AgentNotFoundException ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
pd.setTitle("Agent Not Found");
pd.setDetail(ex.getMessage());
return pd;
}
}
JPA / Hibernate — Preventing N+1
// N+1 problem: loading agents and then agents.skills triggers N additional queries
// ❌ Bad: triggers N additional queries
List<Agent> agents = agentRepo.findAll();
agents.forEach(a -> a.getSkills().size()); // N queries
// ✅ Fix 1: @EntityGraph for specific query
@EntityGraph(attributePaths = {"skills", "settings"})
List<Agent> findAllWithSkills();
// ✅ Fix 2: JPQL JOIN FETCH
@Query("SELECT a FROM Agent a LEFT JOIN FETCH a.skills WHERE a.active = true")
List<Agent> findActiveWithSkills();
// ✅ Fix 3: @BatchSize for lazy collections you can't always eagerly load
@OneToMany(mappedBy = "agent", fetch = FetchType.LAZY)
@BatchSize(size = 20) // loads in batches of 20 instead of 1
private Set<Skill> skills;
// ✅ Projections for read-only DTOs (faster than loading full entities)
interface AgentSummary {
String getAgentId();
String getDisplayName();
long getSkillCount();
}
@Query("SELECT a.agentId as agentId, a.displayName as displayName, SIZE(a.skills) as skillCount FROM Agent a")
List<AgentSummary> findAllSummaries();
Exception Hierarchy
// Custom exception hierarchy
public abstract sealed class AppException extends RuntimeException
permits AgentNotFoundException, ValidationException, ExternalServiceException {
private final String errorCode;
protected AppException(String message, String errorCode) {
super(message);
this.errorCode = errorCode;
}
public String errorCode() { return errorCode; }
}
public final class AgentNotFoundException extends AppException {
public AgentNotFoundException(String agentId) {
super("Agent not found: " + agentId, "AGENT_NOT_FOUND");
}
}
// Global exception handler with @ControllerAdvice
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(AppException.class)
ResponseEntity<ProblemDetail> handleAppException(AppException ex, HttpServletRequest req) {
HttpStatus status = switch (ex) {
case AgentNotFoundException _ -> HttpStatus.NOT_FOUND;
case ValidationException _ -> HttpStatus.UNPROCESSABLE_ENTITY;
default -> HttpStatus.INTERNAL_SERVER_ERROR;
};
ProblemDetail pd = ProblemDetail.forStatusAndDetail(status, ex.getMessage());
pd.setProperty("errorCode", ex.errorCode());
return ResponseEntity.of(pd).build();
}
}
JVM Tuning
# G1GC (default Java 9+) — good for most services
-XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=16m
# ZGC — ultra-low pause, good for latency-sensitive services (Java 21 generational ZGC)
-XX:+UseZGC -XX:+ZGenerational
# Heap sizing (avoid setting Xmx too large — leave room for off-heap)
-Xms512m -Xmx2g
# Useful diagnostics
-XX:+PrintGCDetails -Xlog:gc*:file=gc.log:time,uptime:filecount=5,filesize=20m
# Container-aware (important in Docker/K8s)
-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0
# Startup: class data sharing
-XX:+UseAppCDS -XX:SharedArchiveFile=app-cds.jsa
Anti-Patterns
// ❌ Optional.get() without isPresent (throws NoSuchElementException)
return opt.get();
// ✅ Always use orElse/orElseGet/orElseThrow
// ❌ Catching Exception broadly
catch (Exception e) { log.error("error"); }
// ✅ Catch specific, log with context
catch (AgentNotFoundException e) { log.warn("Agent not found: {}", id, e); throw e; }
// ❌ String concatenation in loops
String result = "";
for (String s : list) result += s; // O(n²)
// ✅
String result = String.join("", list);
// or StringBuilder for dynamic building
// ❌ Static mutable state (thread-safety nightmare)
public class Config { public static Map<String, String> settings = new HashMap<>(); }
// ✅ Inject via Spring @ConfigurationProperties
// ❌ @Transactional on private methods (Spring AOP won't intercept)
@Transactional private void save() {}
// ✅ @Transactional on public methods only
// ❌ Lazy loading outside transaction (LazyInitializationException)
Agent agent = agentRepo.findById(id).get();
// ... transaction ends ...
agent.getSkills().size(); // ❌ throws LazyInitializationException
Quick Reference
Records: immutable data carriers, compact constructor for validation
Sealed: exhaustive type hierarchies, enables exhaustive switch
Virtual threads: blocking I/O is fine, replace reactive for most cases
Optional: return type only, chain with map/filter/orElse, never .get()
Streams: groupingBy, teeing, flatMap, .toList() (Java 16+)
CompletableFuture: thenApplyAsync → thenCombine → exceptionally
JPA N+1: @EntityGraph or JOIN FETCH, @BatchSize for lazy
Exceptions: sealed hierarchy + @RestControllerAdvice + ProblemDetail (RFC 7807)
JVM: ZGC for low latency, MaxRAMPercentage for containers