Pavan Rangani

HomeBlogJava Records and Sealed Classes for Domain Modeling: A Complete Guide

Java Records and Sealed Classes for Domain Modeling: A Complete Guide

By Pavan Rangani · March 21, 2026 · Java & Spring

Java Records and Sealed Classes for Domain Modeling

Java records sealed classes together form the foundation of modern domain modeling in Java. Records provide immutable data carriers with automatic equals, hashCode, and toString. Sealed classes, meanwhile, restrict which types can extend a base class, creating closed type hierarchies. Combined with pattern matching, they enable algebraic data types — a powerful modeling technique previously limited to functional languages like Kotlin, Scala, and Haskell. Consequently, Java developers can now express domain rules in the type system rather than scattering them across runtime validation code.

This guide shows how to use records and sealed classes to build expressive, type-safe domain models that eliminate entire categories of bugs. You will learn patterns for modeling business events, command results, value objects, and state machines — all enforced by the compiler rather than runtime checks. Throughout, the emphasis is on practical patterns that production teams actually reach for, not academic exercises.

Records as Value Objects

Records are ideal for value objects — domain concepts defined by their attributes rather than identity. An email address, money amount, or date range are all value objects that benefit from records’ built-in immutability and structural equality. Because two records with identical components are equal by default, you avoid the boilerplate and bugs that hand-written equals methods routinely introduce.

The compact constructor is where records become genuinely powerful for domain work. It runs before the canonical assignment, so you can both validate and normalize input in one place. As a result, an invalid value object simply cannot exist — there is no way to construct one that bypasses the rules. This is the core idea behind “parse, don’t validate”: once you hold an EmailAddress, every downstream method can trust it without re-checking.

// Value objects with validation
public record EmailAddress(String value) {
    public EmailAddress {
        if (value == null || !value.matches("^[\w.-]+@[\w.-]+\.[a-zA-Z]{2,}$")) {
            throw new IllegalArgumentException("Invalid email: " + value);
        }
        value = value.toLowerCase().trim();
    }
}

public record Money(BigDecimal amount, Currency currency) {
    public Money {
        Objects.requireNonNull(amount, "Amount required");
        Objects.requireNonNull(currency, "Currency required");
        if (amount.scale() > currency.getDefaultFractionDigits()) {
            throw new IllegalArgumentException(
                "Too many decimal places for " + currency);
        }
    }

    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new CurrencyMismatchException(this.currency, other.currency);
        }
        return new Money(this.amount.add(other.amount), this.currency);
    }

    public Money multiply(int quantity) {
        return new Money(this.amount.multiply(BigDecimal.valueOf(quantity)), this.currency);
    }

    public static Money usd(String amount) {
        return new Money(new BigDecimal(amount), Currency.getInstance("USD"));
    }
}

public record DateRange(LocalDate start, LocalDate end) {
    public DateRange {
        if (end.isBefore(start)) {
            throw new IllegalArgumentException("End before start");
        }
    }

    public boolean contains(LocalDate date) {
        return !date.isBefore(start) && !date.isAfter(end);
    }

    public long days() {
        return ChronoUnit.DAYS.between(start, end);
    }
}
Java domain modeling with records and sealed classes
Building type-safe domain models with Java records and compact constructors

Guarding Against the Mutable Component Trap

One edge case catches many teams off guard: records are only shallowly immutable. If a record holds a List or array, callers can still mutate the contents through the accessor. Therefore, defensive copying in the compact constructor is essential whenever a component is a mutable collection. The pattern below makes the record genuinely immutable rather than merely final.

public record LineItems(List<LineItem> items) {
    public LineItems {
        // Defensive copy on the way in — caller's list cannot leak through
        items = List.copyOf(items);
    }
}
// List.copyOf returns an unmodifiable list, so the accessor is safe too.
// Without this, someone could call lineItems.items().add(...) and corrupt state.

In addition, watch out for NaN and floating-point components. Because records derive equals from each field’s own semantics, a record containing a double inherits the quirk that NaN != NaN in primitive comparison but Double.NaN.equals(Double.NaN) is true. For money and quantities, prefer BigDecimal or long minor units, which sidesteps the issue entirely.

Sealed Classes for Algebraic Data Types

Sealed classes create closed type hierarchies where the compiler knows every possible subtype. This enables exhaustive pattern matching — the compiler verifies you handle every case, catching missing logic at compile time instead of runtime. When you later add a new subtype, every switch that lacks a branch for it fails to compile, which turns a silent production gap into an immediate build error.

// Sealed command result — every outcome is explicit
public sealed interface PaymentResult {
    record Success(String transactionId, Money amount, Instant processedAt)
        implements PaymentResult {}

    record Declined(String reason, String errorCode)
        implements PaymentResult {}

    record RequiresAuthentication(String redirectUrl, String challengeId)
        implements PaymentResult {}

    record NetworkError(Exception cause, int retryAfterSeconds)
        implements PaymentResult {}
}

// Exhaustive pattern matching (Java 21+)
public String handlePaymentResult(PaymentResult result) {
    return switch (result) {
        case PaymentResult.Success s ->
            "Payment %s processed: %s".formatted(s.transactionId(), s.amount());

        case PaymentResult.Declined d ->
            "Payment declined: %s (code: %s)".formatted(d.reason(), d.errorCode());

        case PaymentResult.RequiresAuthentication auth ->
            "Redirect to: " + auth.redirectUrl();

        case PaymentResult.NetworkError err ->
            "Retry after %d seconds".formatted(err.retryAfterSeconds());
        // No default needed — compiler ensures all cases are handled
    };
}

This PaymentResult pattern deserves emphasis because it replaces a common anti-pattern: returning a single object with nullable fields and a status enum, then forcing callers to guess which fields are populated. With a sealed result, the data and the outcome travel together. A Success carries a transaction id; a Declined carries a reason. There is no way to read a transaction id off a declined payment, so an entire class of NullPointerException simply disappears.

Modeling Domain Events

Event-driven systems benefit enormously from sealed hierarchies. Each event type carries exactly the data relevant to that fact, and the sealed parent gives you one place to enumerate the entire event vocabulary of an aggregate. Notably, this pairs cleanly with event sourcing, where state is rebuilt by folding a stream of past events.

// Sealed hierarchy for order domain events
public sealed interface OrderEvent {
    OrderId orderId();
    Instant occurredAt();

    record OrderPlaced(OrderId orderId, CustomerId customerId,
                       List<LineItem> items, Money total,
                       Instant occurredAt) implements OrderEvent {}

    record OrderConfirmed(OrderId orderId, Instant estimatedDelivery,
                          Instant occurredAt) implements OrderEvent {}

    record OrderShipped(OrderId orderId, String trackingNumber,
                        String carrier, Instant occurredAt) implements OrderEvent {}

    record OrderDelivered(OrderId orderId, Instant deliveredAt,
                          String signedBy, Instant occurredAt) implements OrderEvent {}

    record OrderCancelled(OrderId orderId, String reason,
                          Money refundAmount, Instant occurredAt) implements OrderEvent {}
}

// Event sourcing with sealed events
public class OrderAggregate {
    private OrderStatus status = OrderStatus.PENDING;
    private final List<OrderEvent> changes = new ArrayList<>();

    public void apply(OrderEvent event) {
        // Compiler enforces handling every event type
        switch (event) {
            case OrderEvent.OrderPlaced e -> this.status = OrderStatus.PLACED;
            case OrderEvent.OrderConfirmed e -> this.status = OrderStatus.CONFIRMED;
            case OrderEvent.OrderShipped e -> this.status = OrderStatus.SHIPPED;
            case OrderEvent.OrderDelivered e -> this.status = OrderStatus.DELIVERED;
            case OrderEvent.OrderCancelled e -> this.status = OrderStatus.CANCELLED;
        }
        changes.add(event);
    }
}

Record Patterns and Deconstruction

Java 21 added record patterns, which let you destructure a record directly inside a switch or instanceof. This goes a step beyond simply matching the type — you bind the components to local variables in the same expression. As a result, business logic reads almost like the domain language itself, and nested data structures unfold without a cascade of accessor calls.

// Nested record patterns with guards (Java 21+)
public String describe(OrderEvent event) {
    return switch (event) {
        case OrderEvent.OrderShipped(var id, var tracking, var carrier, var when)
            when carrier.equals("EXPRESS") ->
            "Priority shipment %s via %s".formatted(tracking, carrier);

        case OrderEvent.OrderShipped(var id, var tracking, var carrier, var when) ->
            "Standard shipment %s via %s".formatted(tracking, carrier);

        case OrderEvent.OrderCancelled(var id, var reason, var refund, var when) ->
            "Cancelled: %s, refunding %s".formatted(reason, refund);

        default -> "Order event: " + event.orderId();
    };
}

The when guard shown above is the idiomatic way to branch on a condition without abandoning exhaustiveness. Because a guarded case is not considered total on its own, the compiler still expects a fallback, which keeps the safety net intact.

Java Records Sealed Classes as State Machines

Moreover, Java records sealed classes excel at modeling state machines where transitions between states are strictly controlled. The key insight is that each state exposes only the operations valid for that state. Consequently, the compiler prevents invalid state transitions at compile time — you cannot publish a draft or reject an already-approved document, because those methods simply do not exist on the wrong type.

// Type-safe state machine for document workflow
public sealed interface DocumentState {

    record Draft(DocumentId id, String content, AuthorId author)
        implements DocumentState {
        public InReview submitForReview(ReviewerId reviewer) {
            return new InReview(id, content, author, reviewer, Instant.now());
        }
    }

    record InReview(DocumentId id, String content, AuthorId author,
                    ReviewerId reviewer, Instant submittedAt)
        implements DocumentState {
        public Approved approve(String comments) {
            return new Approved(id, content, author, reviewer,
                               comments, Instant.now());
        }
        public Draft reject(String reason) {
            return new Draft(id, content + "\n// Rejected: " + reason, author);
        }
    }

    record Approved(DocumentId id, String content, AuthorId author,
                    ReviewerId reviewer, String comments, Instant approvedAt)
        implements DocumentState {
        public Published publish() {
            return new Published(id, content, author, Instant.now());
        }
    }

    record Published(DocumentId id, String content, AuthorId author,
                     Instant publishedAt) implements DocumentState {
        public Archived archive() {
            return new Archived(id, content, author, publishedAt, Instant.now());
        }
    }

    record Archived(DocumentId id, String content, AuthorId author,
                    Instant publishedAt, Instant archivedAt)
        implements DocumentState {}
}

// Usage — invalid transitions are compile errors
var draft = new DocumentState.Draft(docId, "Hello", authorId);
var review = draft.submitForReview(reviewerId);
var approved = review.approve("Looks good");
// approved.reject() — COMPILE ERROR: reject() not available on Approved
var published = approved.publish();

Because each transition returns a brand-new immutable state object, you also get a free audit trail: hold onto the previous values and you have a record of exactly how the document moved through its lifecycle. This composes naturally with the event-sourcing approach shown earlier, and it is far harder to misuse than a single mutable entity with a setStatus method that any caller can flip arbitrarily.

Type-safe programming with modern Java
Implementing compile-time safety with sealed class state machines

Records with Spring Boot

Records integrate smoothly with Spring Boot, particularly as request and response DTOs. Jackson deserializes JSON into records out of the box on recent versions, and Bean Validation annotations work on record components. Therefore, an immutable, self-validating request object costs almost no boilerplate. The example below also shows records as JPA query projections, which is one of the cleanest ways to fetch exactly the columns you need.

// Records as DTOs in Spring Boot REST APIs
public record CreateOrderRequest(
    @NotNull CustomerId customerId,
    @NotEmpty List<@Valid LineItemRequest> items,
    @NotNull ShippingAddress shippingAddress
) {}

public record LineItemRequest(
    @NotNull ProductId productId,
    @Positive int quantity
) {}

// Spring Data projections with records
public interface OrderRepository extends JpaRepository<OrderEntity, Long> {
    @Query("SELECT new com.app.OrderSummary(o.id, o.status, o.total) " +
           "FROM OrderEntity o WHERE o.customerId = :customerId")
    List<OrderSummary> findSummariesByCustomer(CustomerId customerId);
}

public record OrderSummary(Long id, OrderStatus status, Money total) {}

A common pattern in production teams is to keep records strictly at the boundary — as DTOs and projections — while the persistence layer uses mutable entities. This cleanly separates the wire and database shapes from the domain model, and it sidesteps the JPA limitations discussed next.

When NOT to Use Records and Sealed Classes

Records are not suitable for JPA entities because they are immutable and final — JPA requires mutable proxies for lazy loading and a no-args constructor it can populate via reflection. Additionally, records cannot extend other classes, so deep inheritance hierarchies (which you should avoid anyway) are not possible with records. If your domain object has mutable state that changes over its lifecycle, a traditional class with private setters is more appropriate. Likewise, a record with a dozen components is usually a smell; consider grouping related fields into nested records or rethinking the boundary.

Furthermore, sealed classes add compile-time constraints that can slow down rapid prototyping. If your domain model is still evolving and new subtypes are added frequently, unsealed interfaces provide more flexibility. There is also a serialization caveat: adding a new permitted subtype to a sealed hierarchy is a breaking change for any consumer doing exhaustive matching, so coordinate such changes carefully across services and library boundaries. Consequently, use sealed classes when the type hierarchy is stable and well-understood, not during early exploration phases. The documentation recommends sealing precisely because the set of subtypes is meant to be a deliberate, reviewed decision rather than an accident of who happened to implement the interface.

Modern Java development and best practices
Applying modern Java features to create maintainable domain models

Key Takeaways

Records and sealed classes together bring algebraic data types to Java, enabling domain models that are validated by the compiler rather than runtime checks. Use records for value objects and DTOs, sealed classes for closed type hierarchies and state machines, and pattern matching for exhaustive business logic. The result is domain code that is more concise, safer, and easier to maintain than traditional class hierarchies with abstract methods. Above all, the payoff compounds over time: when the model changes, the compiler points you at every place that needs to adapt.

For more Java topics, explore our guide on Java 21 pattern matching and Spring Boot production best practices. The JEP 395 Records specification and JEP 409 Sealed Classes specification provide the definitive language references.

In conclusion, Java Records Sealed Classes is an essential topic for modern software development. By applying the patterns and practices covered in this guide, you can build more robust, scalable, and maintainable systems. Start with the fundamentals, iterate on your implementation, and continuously measure results to ensure you are getting the most value from these approaches.

← Back to all articles