โ˜•
Backend

Java Spring Boot 21 โ€” Features I Use Daily in Production

AK
Arun Kataria
๐Ÿ“… November 28, 2024โฑ 8 min read
โ† Back to all posts

Java 21 is the most exciting Java release in years. Combined with Spring Boot 3.2+, it brings features that genuinely change how I write microservices โ€” not just syntax sugar, but fundamental performance and design improvements.

Here are the features I actually use every day and why they matter.

โœ… What you'll learnVirtual threads and how they transformed our API throughput, pattern matching for cleaner domain logic, records for DTOs, sealed classes for error modelling, and structured concurrency for parallel LLM calls.

1. Virtual Threads (Project Loom)

This is the biggest one. Before virtual threads, a Spring Boot app under high load would exhaust its thread pool waiting on I/O (DB calls, HTTP requests). Virtual threads are lightweight โ€” you can create millions of them without the overhead of platform threads.

Enabling them in Spring Boot 3.2 is just one config line:

# application.properties
spring.threads.virtual.enabled=true

The result in our loan eligibility API at Coforge: the same service handling 800 req/s scaled to 4,200 req/s without any code changes. Just virtual threads.

๐Ÿ’ก When NOT to use virtual threadsCPU-bound tasks (heavy computation) don't benefit from virtual threads โ€” they benefit from parallelism. Virtual threads shine for I/O-bound work: DB calls, HTTP calls, file reads.

2. Pattern Matching for Switch

Gone are the days of verbose instanceof chains. Pattern matching makes domain logic dramatically cleaner:

// Old way
String describe(Object obj) {
  if (obj instanceof String s) return "String: " + s.length();
  else if (obj instanceof Integer i) return "Int: " + i;
  else return "Unknown";
}

// Java 21 โ€” pattern matching switch
String describe(Object obj) {
  return switch (obj) {
    case String s  -> "String of length " + s.length();
    case Integer i -> "Integer: " + i;
    case null      -> "null value";
    default        -> "Unknown: " + obj.getClass().getSimpleName();
  };
}

I use this constantly when handling different LLM response types, different event types in our event bus, and different error shapes from external APIs.

3. Records for DTOs

Records eliminate the boilerplate of DTO classes. Instead of a 40-line class with getters, setters, equals, hashCode, and toString, you get:

// A complete, immutable DTO in one line
public record TaxFilingRequest(
  String userId,
  int taxYear,
  List<String> documentIds,
  FilingStrategy strategy
) {}

// Works seamlessly with Jackson (Spring Boot)
// Works with @Valid Bean Validation
// Works with Spring Data projections

4. Sealed Classes for Error Modelling

Sealed classes let you define a closed hierarchy of types โ€” perfect for modelling API results and errors:

public sealed interface FilingResult
  permits FilingResult.Success, FilingResult.Failure, FilingResult.PendingReview {}

public record Success(String confirmationId, LocalDate filedDate)
  implements FilingResult {}

public record Failure(String errorCode, String message)
  implements FilingResult {}

public record PendingReview(String reviewId, String reason)
  implements FilingResult {}

// At the call site โ€” exhaustive switch, no default needed:
String message = switch (result) {
  case Success s       -> "Filed! Confirmation: " + s.confirmationId();
  case Failure f       -> "Error: " + f.message();
  case PendingReview p -> "Under review: " + p.reason();
};

5. Structured Concurrency for Parallel LLM Calls

When building the AI pipeline at Intuit, we needed to call multiple services in parallel and combine results. Structured concurrency (preview in Java 21) makes this clean and safe:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
  Future<UserDocuments> docs    = scope.fork(() -> documentService.fetch(userId));
  Future<TaxHistory>   history  = scope.fork(() -> taxService.getHistory(userId));
  Future<RiskProfile>  risk     = scope.fork(() -> riskEngine.evaluate(userId));

  scope.join().throwIfFailed();  // waits for all; cancels all if one fails

  return new TaxContext(docs.get(), history.get(), risk.get());
}

Compare this to manually managing CompletableFuture chains โ€” structured concurrency is both safer (automatic cancellation on failure) and more readable.

Performance Impact Summary

FeatureImpactUse Case
Virtual Threads5ร— throughput gainI/O-bound microservices
Pattern Matching~40% less boilerplateDomain logic, event handling
Records~80% less DTO codeAPI request/response DTOs
Sealed ClassesCompile-time exhaustivenessError modelling, result types
Structured ConcurrencySimpler parallel codeMulti-service aggregation
โš ๏ธ Upgrade noteVirtual threads and structured concurrency require Java 21. Make sure your Docker base image and CI environment are updated: eclipse-temurin:21-jre-alpine is a good starting point.

Found this useful?

Share with your Java team ๐Ÿ‘‡