Skip to main content
Java

Records, Sealed Types, and Pattern Matching as a Domain Language

Ravinder··7 min read
JavaRecordsSealed ClassesPattern MatchingDDD
Share:
Records, Sealed Types, and Pattern Matching as a Domain Language

The Lombok era did not end because Lombok was bad. It ended because Java caught up. Records handle value semantics. Sealed classes handle closed hierarchies. Pattern matching handles structural dispatch. Together, they let you model a domain with the precision of a type system and the clarity of readable code — no annotation processor required.

This post is about using these features as a coherent modeling tool, not as isolated curiosities.

The Problem With Class Hierarchies as Domain Models

Before Java 17, a typical payment domain model might look like:

// The old way — open, verbose, and dangerous
public abstract class PaymentMethod {
    public abstract String type();
}
 
public class CreditCard extends PaymentMethod {
    private final String last4;
    private final String network;
    // constructor, getters, equals, hashCode, toString — all manual
}
 
public class BankTransfer extends PaymentMethod { ... }
public class Crypto extends PaymentMethod { ... }

The problems:

  1. Any code outside the package can extend PaymentMethod. Your dispatch logic cannot be exhaustive.
  2. Every class is 30 lines of boilerplate for 3 fields.
  3. instanceof chains are the only dispatch mechanism, and the compiler won't tell you when you've missed a case.

Sealed Classes: Closing the Hierarchy

sealed declares that you know all the subtypes. The compiler enforces it. This is the algebraic data type (ADT) pattern from functional languages, finally in Java.

public sealed interface PaymentMethod
    permits CreditCard, BankTransfer, CryptoCurrency {}
 
public record CreditCard(String last4, String network, int expiryYear) 
    implements PaymentMethod {}
 
public record BankTransfer(String iban, String bic) 
    implements PaymentMethod {}
 
public record CryptoCurrency(String ticker, String walletAddress) 
    implements PaymentMethod {}

Eight lines. Full value semantics. Closed hierarchy. The permits clause is not just documentation — it prevents extension from outside the module. If you add WireTransfer to the domain, the compiler will force every pattern-match switch over PaymentMethod to handle it.

Pattern Matching: Structural Dispatch

The real payoff comes when you need to handle different cases differently. The old approach was instanceof chains:

// Obsolete — don't write this
String describe(PaymentMethod pm) {
    if (pm instanceof CreditCard cc) {
        return "Card ending " + cc.last4();
    } else if (pm instanceof BankTransfer bt) {
        return "Bank transfer via " + bt.bic();
    }
    throw new IllegalStateException("Unknown type"); // runtime bomb
}

The new approach is exhaustive pattern matching in switch:

String describe(PaymentMethod pm) {
    return switch (pm) {
        case CreditCard cc  -> "Card ending " + cc.last4() + " (" + cc.network() + ")";
        case BankTransfer bt -> "Bank transfer via " + bt.bic();
        case CryptoCurrency c -> c.ticker() + " wallet " + c.walletAddress().substring(0, 8) + "...";
    };
}

No default. No throw. The compiler verifies all cases of the sealed hierarchy are covered. Add a new permits variant and every switch over PaymentMethod becomes a compile error until you handle it. That is the exhaustiveness guarantee, and it changes how safe refactoring feels.

The Full Domain Model Architecture

classDiagram class PaymentMethod { <> } class CreditCard { <> +String last4 +String network +int expiryYear } class BankTransfer { <> +String iban +String bic } class CryptoCurrency { <> +String ticker +String walletAddress } class PaymentResult { <> } class Success { <> +String transactionId +Instant processedAt } class Declined { <> +String reason +DeclineCode code } class RequiresAction { <> +String redirectUrl +ActionType actionType } PaymentMethod <|.. CreditCard PaymentMethod <|.. BankTransfer PaymentMethod <|.. CryptoCurrency PaymentResult <|.. Success PaymentResult <|.. Declined PaymentResult <|.. RequiresAction

Nested Sealed Hierarchies

Sealed hierarchies compose. A payment result has its own type structure:

public sealed interface PaymentResult
    permits PaymentResult.Success, PaymentResult.Declined, PaymentResult.RequiresAction {
 
    record Success(String transactionId, Instant processedAt) implements PaymentResult {}
    record Declined(String reason, DeclineCode code) implements PaymentResult {}
    record RequiresAction(String redirectUrl, ActionType actionType) implements PaymentResult {}
}

Nesting the records inside the sealed interface is clean for small hierarchies. The usage reads naturally:

PaymentResult result = paymentService.process(payment);
 
String userMessage = switch (result) {
    case PaymentResult.Success s   -> "Payment confirmed: " + s.transactionId();
    case PaymentResult.Declined d  -> "Payment declined: " + d.reason();
    case PaymentResult.RequiresAction ra -> "Please complete: " + ra.redirectUrl();
};

Guarded Patterns and Deconstruction

Pattern matching in switches supports guards — conditions evaluated after the pattern matches:

BigDecimal fee(PaymentMethod pm, BigDecimal amount) {
    return switch (pm) {
        case CreditCard cc when cc.network().equals("AMEX") -> amount.multiply(new BigDecimal("0.035"));
        case CreditCard cc                                  -> amount.multiply(new BigDecimal("0.025"));
        case BankTransfer bt when amount.compareTo(TEN_THOUSAND) > 0 -> FLAT_WIRE_FEE;
        case BankTransfer bt                                -> BigDecimal.ZERO;
        case CryptoCurrency c                               -> amount.multiply(new BigDecimal("0.01"));
    };
}

The when clause is evaluated only when the pattern has already matched. Ordering matters — more specific guards come first.

Records as Value Objects in DDD

Records are ideal for value objects — immutable, equality by value, no identity. They replace a common Lombok pattern:

// Pre-record (with Lombok)
@Value
public class Money {
    BigDecimal amount;
    Currency currency;
}
 
// Post-record (zero dependencies)
public record Money(BigDecimal amount, Currency currency) {
    // Compact constructor for validation
    public Money {
        Objects.requireNonNull(amount, "amount");
        Objects.requireNonNull(currency, "currency");
        if (amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Amount cannot be negative");
        }
        amount = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.HALF_EVEN);
    }
 
    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("Currency mismatch");
        }
        return new Money(this.amount.add(other.amount), this.currency);
    }
}

The compact constructor (the {} block after the parameter list) runs after canonical construction. You can validate, normalize, and reject invalid state without a separate builder. The record components are final by definition.

Putting It Together: A Payment Processing Pipeline

public class PaymentProcessor {
 
    public PaymentResult process(Order order, PaymentMethod method) {
        var validation = validate(order, method);
 
        return switch (validation) {
            case Validation.Invalid v -> new PaymentResult.Declined(v.reason(), DeclineCode.VALIDATION_FAILED);
            case Validation.Valid v   -> executePayment(order, method);
        };
    }
 
    private PaymentResult executePayment(Order order, PaymentMethod method) {
        return switch (method) {
            case CreditCard cc  -> cardGateway.charge(cc, order.total());
            case BankTransfer bt -> bankGateway.initiate(bt, order.total());
            case CryptoCurrency c -> cryptoGateway.broadcast(c, order.total());
        };
    }
 
    private sealed interface Validation permits Validation.Valid, Validation.Invalid {
        record Valid() implements Validation {}
        record Invalid(String reason) implements Validation {}
    }
 
    private Validation validate(Order order, PaymentMethod method) {
        if (order.total().amount().compareTo(BigDecimal.ZERO) <= 0) {
            return new Validation.Invalid("Order total must be positive");
        }
        return switch (method) {
            case CreditCard cc when cc.expiryYear() < LocalDate.now().getYear() ->
                new Validation.Invalid("Card expired");
            case CryptoCurrency c when c.walletAddress().isBlank() ->
                new Validation.Invalid("Wallet address required");
            default -> new Validation.Valid();
        };
    }
}

This code has no null returns, no unchecked casts, no runtime surprises from missing cases. Every path through the logic is explicit and compiler-verified.

Serialization Considerations

Records serialize well with Jackson 2.12+ and the jackson-module-parameter-names module:

<dependency>
    <groupId>com.fasterxml.jackson.module</groupId>
    <artifactId>jackson-module-parameter-names</artifactId>
</dependency>
ObjectMapper mapper = new ObjectMapper()
    .registerModule(new ParameterNamesModule())
    .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
 
// Records serialize/deserialize without @JsonProperty annotations
// as long as -parameters compiler flag is set

For sealed interfaces with Jackson, you need a type discriminator:

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
    @JsonSubTypes.Type(value = PaymentResult.Success.class, name = "success"),
    @JsonSubTypes.Type(value = PaymentResult.Declined.class, name = "declined"),
    @JsonSubTypes.Type(value = PaymentResult.RequiresAction.class, name = "requires_action")
})
public sealed interface PaymentResult permits ...

Not as elegant as the code, but manageable. Kotlin's @Polymorphic with kotlinx.serialization handles this more cleanly — that's an honest tradeoff to acknowledge.

Key Takeaways

  • Sealed interfaces with record implementations are the Java equivalent of algebraic data types — they close hierarchies, enforce exhaustiveness, and eliminate boilerplate simultaneously.
  • Exhaustive pattern matching in switch is a compile-time guarantee: add a new sealed variant and every switch statement over that type becomes a compile error until handled.
  • Records replace value objects cleanly — compact constructors handle validation, normalization, and rejection without builders or extra annotations.
  • Guarded patterns (when clauses) let you express complex dispatch logic in a single switch expression rather than nested if-else chains.
  • Lombok is not wrong, but it is now redundant for the common cases of immutable value objects and closed type hierarchies; evaluate whether the annotation processor dependency is still earning its keep.
  • Jackson integration requires a type discriminator annotation on sealed interfaces used in JSON APIs — plan for this before committing the type hierarchy shape to an external contract.