Java Records, Sealed Classes & Pattern Matching in Production: Modern Java 21+ Guide

Java 21 modern features records sealed classes pattern matching

Java 21 is the most significant Java release since Java 8 — and unlike Java 8's lambda revolution, Java 21's improvements are not just syntactic sugar. Records, sealed classes, pattern matching for switch, and virtual threads fundamentally change how you model domains, handle data flows, and build concurrent systems. This guide focuses on the features that matter most in production Spring Boot microservices.

Why Java 21 Features Matter for Production Code

Java's evolution from version 8 to 21 has been gradual but transformative. The modern Java features — records (JEP 395, finalized in Java 16), sealed classes (JEP 409, finalized in Java 17), pattern matching for instanceof (JEP 394, finalized in Java 16), and pattern matching for switch (JEP 441, finalized in Java 21) — work together as a cohesive system for expressive, type-safe domain modeling. They replace patterns that Java developers have cargo-culted from older idioms for decades: mutable POJOs, class hierarchies with runtime type checks, and verbose data transfer objects.

Beyond modeling improvements, these features reduce boilerplate significantly. A typical Java 8 value object — a class with private final fields, constructor, getters, equals, hashCode, and toString — requires 40–60 lines. The equivalent record requires 1 line. Across a microservice with dozens of value objects, DTOs, and command objects, this dramatically reduces the code surface area that can contain bugs.

This guide uses Java 21 with Spring Boot 3.2+. All features shown are stable, production-ready, and supported by current IDEs, build tools, and cloud JVM runtimes.

Records: Immutable Data Carriers Done Right

A Java record is a restricted form of class designed to model immutable data transparently. When you declare a record, the compiler automatically generates the canonical constructor, accessors (named after the components, not getters), equals, hashCode, and toString. The resulting class is final, all fields are implicitly final, and the class extends java.lang.Record.

// Pre-Java 16: verbose, error-prone POJO
public final class CreateOrderRequest {
    private final String customerId;
    private final List<OrderItem> items;
    private final String shippingAddress;
    
    public CreateOrderRequest(String customerId, List<OrderItem> items, String shippingAddress) {
        this.customerId = Objects.requireNonNull(customerId);
        this.items = List.copyOf(items); // defensive copy
        this.shippingAddress = Objects.requireNonNull(shippingAddress);
    }
    
    public String getCustomerId() { return customerId; }
    public List<OrderItem> getItems() { return items; }
    public String getShippingAddress() { return shippingAddress; }
    
    @Override public boolean equals(Object o) { /* 10 more lines */ }
    @Override public int hashCode() { /* 5 more lines */ }
    @Override public String toString() { /* 5 more lines */ }
}

// Java 21: compact, correct, complete
public record CreateOrderRequest(
    String customerId,
    List<OrderItem> items,
    String shippingAddress
) {
    // Compact constructor for validation
    public CreateOrderRequest {
        Objects.requireNonNull(customerId, "customerId is required");
        if (customerId.isBlank()) throw new IllegalArgumentException("customerId cannot be blank");
        items = List.copyOf(Objects.requireNonNull(items, "items is required"));
        if (items.isEmpty()) throw new IllegalArgumentException("Order must have at least one item");
        Objects.requireNonNull(shippingAddress, "shippingAddress is required");
    }
}

The compact constructor syntax (the block after the class declaration with no parameter list) runs before the implicit assignment of fields. This is where you place validation logic and defensive copies. The fields are assigned automatically after the compact constructor completes — you cannot assign them inside it, only reassign via the implicit parameter names.

Records in Spring Boot REST APIs: Records work seamlessly as request/response DTOs with Jackson, Spring MVC's @RequestBody, and Bean Validation annotations:

public record CreateProductRequest(
    @NotBlank String name,
    @NotBlank String sku,
    @DecimalMin("0.01") @NotNull BigDecimal price,
    @Min(0) int stockQuantity,
    @NotBlank String categoryId
) {}

public record ProductResponse(
    String id,
    String name,
    String sku,
    BigDecimal price,
    int stockQuantity,
    String categoryId,
    Instant createdAt
) {
    // Factory method — keeps construction logic centralized
    public static ProductResponse from(Product product) {
        return new ProductResponse(
            product.getId().value(),
            product.getName(),
            product.getSku(),
            product.getPrice().amount(),
            product.getStockQuantity(),
            product.getCategoryId().value(),
            product.getCreatedAt()
        );
    }
}

@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
    
    @PostMapping
    public ResponseEntity<ProductResponse> create(
            @Valid @RequestBody CreateProductRequest request) {
        Product product = productService.create(request);
        return ResponseEntity.status(201).body(ProductResponse.from(product));
    }
}

Note that Jackson requires no special configuration to serialize/deserialize records in Spring Boot 3.x. Records serialize to JSON objects with field names matching the record component names (lowercase, camelCase). If you need custom names, use @JsonProperty on the component.

Sealed Classes: Controlled Type Hierarchies

Sealed classes (and interfaces) restrict which classes can extend or implement them. This sounds limiting — and intentionally so. A sealed hierarchy is an exhaustive enumeration of variants known at compile time, which enables the compiler and the type system to enforce exhaustive handling of all cases, eliminating entire categories of runtime bugs.

// Sealed class hierarchy modeling payment outcomes
public sealed interface PaymentResult
    permits PaymentResult.Success, PaymentResult.Failed, PaymentResult.Pending {
    
    record Success(
        String transactionId,
        BigDecimal amount,
        Instant processedAt
    ) implements PaymentResult {}
    
    record Failed(
        String errorCode,
        String errorMessage,
        boolean retryable
    ) implements PaymentResult {}
    
    record Pending(
        String paymentIntentId,
        String redirectUrl,
        Instant expiresAt
    ) implements PaymentResult {}
}

// Modeling order states as a sealed hierarchy
public sealed interface OrderState
    permits OrderState.Draft, OrderState.Submitted, OrderState.Confirmed,
            OrderState.Shipped, OrderState.Delivered, OrderState.Cancelled {
    
    record Draft(Instant createdAt) implements OrderState {}
    record Submitted(Instant submittedAt, String confirmationCode) implements OrderState {}
    record Confirmed(Instant confirmedAt, String warehouseId) implements OrderState {}
    record Shipped(Instant shippedAt, String trackingNumber, String carrier) implements OrderState {}
    record Delivered(Instant deliveredAt, String proofOfDelivery) implements OrderState {}
    record Cancelled(Instant cancelledAt, String reason) implements OrderState {}
}

The permits clause explicitly lists all permitted subclasses. Any attempt to extend a sealed class in a class that is not in the permits list results in a compile error — enforcing the closed-world assumption at compile time rather than runtime.

Pattern Matching for Switch: Exhaustive Case Handling

Pattern matching for switch, finalized in Java 21, is where sealed classes deliver their most compelling value. When you switch on a sealed type, the compiler verifies that all permitted subtypes are covered — and warns (or errors, in some configurations) if you add a new subtype without updating all switch expressions that cover it.

// Exhaustive switch on sealed PaymentResult — compiler verifies all cases covered
public String generateReceipt(PaymentResult result) {
    return switch (result) {
        case PaymentResult.Success s ->
            "Payment of $%s processed. Transaction ID: %s at %s"
                .formatted(s.amount(), s.transactionId(), s.processedAt());
        
        case PaymentResult.Failed f when f.retryable() ->
            "Payment failed (retryable): %s. Please try again.".formatted(f.errorMessage());
        
        case PaymentResult.Failed f ->
            "Payment declined: %s. Contact your bank.".formatted(f.errorMessage());
        
        case PaymentResult.Pending p ->
            "Additional verification required. Complete at: %s (expires %s)"
                .formatted(p.redirectUrl(), p.expiresAt());
    };
    // No default needed — compiler knows all cases are covered!
}

// Pattern matching for instanceof — eliminates explicit casts
public void processOrderEvent(Object event) {
    if (event instanceof OrderSubmittedEvent e) {
        // 'e' is already typed as OrderSubmittedEvent here
        initiatePayment(e.orderId(), e.totalAmount());
    } else if (event instanceof OrderCancelledEvent e) {
        refundPayment(e.orderId(), e.refundAmount());
    } else if (event instanceof OrderShippedEvent e && e.trackingNumber() != null) {
        sendTrackingNotification(e.orderId(), e.trackingNumber());
    }
}

The when clause in switch pattern matching (called a "guard") enables conditional patterns. The case PaymentResult.Failed f when f.retryable() pattern matches only Failed results where retryable() returns true — the next case PaymentResult.Failed f catches all remaining Failed results. Guards must be placed in order from most specific to most general.

The compiler's exhaustiveness check is the killer feature. If you add PaymentResult.Disputed to the sealed hierarchy later, every switch expression over PaymentResult becomes a compile error until you handle the new case. This is the type system enforcing that new states are handled everywhere they need to be — something no amount of unit tests can guarantee as reliably.

Text Blocks for Cleaner Templates

Text blocks (finalized in Java 15) eliminate the escape-sequence noise that plagues multi-line strings in Java — particularly important for SQL, JSON, YAML, and HTML templates embedded in application code.

// SQL query as a text block — readable, maintainable
public List<OrderSummary> findOrdersByCustomer(String customerId, OrderStatus status) {
    String sql = """
            SELECT o.id, o.created_at, o.total_amount, o.status,
                   COUNT(ol.id) as item_count
            FROM orders o
            JOIN order_lines ol ON ol.order_id = o.id
            WHERE o.customer_id = :customerId
              AND o.status = :status
              AND o.created_at > NOW() - INTERVAL '90 days'
            GROUP BY o.id, o.created_at, o.total_amount, o.status
            ORDER BY o.created_at DESC
            LIMIT 100
            """;
    
    return jdbcTemplate.query(sql,
        Map.of("customerId", customerId, "status", status.name()),
        orderSummaryRowMapper);
}

// JSON template for webhook payloads
public String buildWebhookPayload(OrderShippedEvent event) {
    return """
            {
              "event_type": "order.shipped",
              "order_id": "%s",
              "tracking_number": "%s",
              "carrier": "%s",
              "shipped_at": "%s"
            }
            """.formatted(
                event.orderId(),
                event.trackingNumber(),
                event.carrier(),
                event.shippedAt()
            );
}

The indentation of a text block is determined by the position of the closing """. The incidental whitespace — the leading spaces that result from code indentation — is stripped automatically based on the minimum common indent. This means your SQL or JSON is correctly indented in both the source code and the resulting string.

Unnamed Patterns and Variables: Cleaner Deconstruction

Java 21 introduces unnamed patterns (_) as a preview feature (finalized in Java 22) that allows you to ignore components in pattern matching and enhanced for loops when you don't need them. This produces significantly cleaner code when working with records in switch expressions:

// Deconstruct records in switch — extract only needed components
public BigDecimal calculateRefund(PaymentResult result) {
    return switch (result) {
        case PaymentResult.Success(var txId, var amount, var _) -> amount; // ignore processedAt
        case PaymentResult.Failed(var _, var _, var retryable) -> BigDecimal.ZERO;
        case PaymentResult.Pending _ -> BigDecimal.ZERO;
    };
}

// Unnamed variable in enhanced for loop
for (var _ : irrelevantList) {
    counter++; // We only care about the count, not the elements
}

// Unnamed catch variable
try {
    return parseJson(input);
} catch (JsonParseException _) {
    return Optional.empty(); // Suppress known parse failures
}

The unnamed variable convention signals clearly to readers: "I know this value exists, but I intentionally am not using it." This is far more expressive than naming it ignored or unused and more honest than simply omitting the component from a deconstruction pattern.

Records with Custom Serialization in Spring Boot Microservices

Records integrate smoothly into Spring Boot microservices, but there are production patterns worth knowing. Kafka message serialization with Avro or Protocol Buffers requires care — records are final and cannot be extended to implement generated interfaces. Use a conversion layer between the record domain model and the serialization format:

// Domain record — pure Java, no serialization concerns
public record OrderCreatedEvent(
    String orderId,
    String customerId,
    BigDecimal totalAmount,
    Instant occurredAt
) {}

// Kafka producer — converts domain record to Avro schema
@Component
public class OrderEventProducer {
    
    private final KafkaTemplate<String, SpecificRecord> kafkaTemplate;
    
    public void publishOrderCreated(OrderCreatedEvent event) {
        // Convert domain record to Avro generated class
        var avroEvent = com.example.avro.OrderCreatedEvent.newBuilder()
            .setOrderId(event.orderId())
            .setCustomerId(event.customerId())
            .setTotalAmount(event.totalAmount().toPlainString())
            .setOccurredAt(event.occurredAt().toEpochMilli())
            .build();
        
        kafkaTemplate.send("order-events", event.orderId(), avroEvent);
    }
}

For database persistence, Spring Data JPA requires a @Entity annotation and a no-argument constructor — records support neither. The solution is to use records exclusively in the domain and application layers, and map to JPA entity classes in the infrastructure layer. This separation is not just a technical workaround; it enforces the DDD principle that the domain model should be independent of persistence technology.

Key Takeaways

  • Use records for all data carriers: DTOs, commands, events, value objects, query results. The elimination of boilerplate and guaranteed immutability reduce bugs significantly.
  • Use compact constructors for validation: Centralize validation in the compact constructor so invariants are enforced at construction time, not scattered across the codebase.
  • Model domain variants with sealed classes: Sealed hierarchies combined with exhaustive switch expressions give you compile-time proof that all cases are handled — invaluable as domain models evolve.
  • Use pattern matching switch, not instanceof chains: Switch expressions with patterns are more readable, more exhaustive, and enable guard clauses that instanceof chains cannot express cleanly.
  • Records do not replace entities: Records are for immutable data; JPA entities require mutability and identity. Use records in domain/application layers, JPA entities in infrastructure.
  • Text blocks for embedded strings: SQL, JSON, YAML, and configuration templates as text blocks are dramatically more maintainable than escaped single-line strings.

Related Posts

Discussion / Comments

Join the conversation — your comment goes directly to my inbox.

← Back to Blog