4.2 Records And Sealed Classes

Records and sealed types help you model domains precisely and concisely. Records provide immutable data carriers with generated accessors, equals, hashCode, and toString. Sealed hierarchies explicitly constrain which types can implement/extend an API, enabling exhaustive reasoning in switch.

When to Use

  • Use record for immutable data aggregates with value semantics (e.g., DTOs, events, identifiers). Add validation in a compact constructor.
  • Use sealed to limit extensibility and promote exhaustive handling; combine with final and non-sealed appropriately.

Domain Modeling Example

Consider a payments domain; we represent methods as a sealed hierarchy and keep data immutable via records.

sealed interface PaymentMethod permits Card, Upi, NetBanking {}

record Card(String maskedPan, YearMonth expiry, String network) implements PaymentMethod {
    public Card {
        Objects.requireNonNull(maskedPan);
        Objects.requireNonNull(expiry);
        Objects.requireNonNull(network);
        if (!maskedPan.matches("\\d{6}\\*{6}\\d{4}")) {
            throw new IllegalArgumentException("maskedPan must look like 411111******1111");
        }
    }
}

record Upi(String handle) implements PaymentMethod {
    public Upi {
        Objects.requireNonNull(handle);
        if (!handle.contains("@")) throw new IllegalArgumentException("Invalid UPI handle");
    }
}

record NetBanking(String bankCode, String customerId) implements PaymentMethod {}

record Money(BigDecimal amount, Currency currency) {
    public Money {
        Objects.requireNonNull(amount);
        Objects.requireNonNull(currency);
        if (amount.signum() < 0) throw new IllegalArgumentException("amount >= 0");
        amount = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.HALF_EVEN);
    }
}

record OrderId(UUID value) {}

record CustomerId(UUID value) {}

record Order(OrderId id, CustomerId customer, Money total, PaymentMethod method) {}

Exhaustive Handling via switch

When combined with pattern matching, sealed hierarchies enable exhaustive switch without fragile default clauses:

String describe(PaymentMethod pm) {
    return switch (pm) {
        case Card(String masked, var expiry, var network) ->
            "Card %s (%s)".formatted(masked, network);
        case Upi(var handle) -> "UPI %s".formatted(handle);
        case NetBanking(var bank, var cid) -> "NetBanking %s/%s".formatted(bank, cid);
    };
}

Evolving Hierarchies Safely

  • Start sealed, and explicitly list permitted subclasses. New kinds must be consciously added and handled.
  • Use non-sealed at a leaf to allow open extension for that branch.

Guidance

  • Prefer records for data; avoid mutable setters and Lombok-like boilerplate.
  • Validate invariants in compact constructors; throw domain-specific exceptions when applicable.
  • Combine sealed hierarchies with switch to make illegal states unrepresentable and handling exhaustive.

Records: Deep Dive

Components, Accessors, and Constructors

  • A record declares its components in the header; the compiler generates accessors, equals, hashCode, and toString.
  • You can define:
    • The canonical constructor with all components.
    • A compact constructor (public RecordName { ... }) to validate and normalize without parameter declarations.
  • Prefer normalization in the compact constructor to enforce invariants close to data.
record User(String name, List<String> roles) {
    public User {
        Objects.requireNonNull(name);
        roles = List.copyOf(Objects.requireNonNull(roles)); // defensive copy
    }
}

Immutability Boundaries

  • Records are shallowly immutable: component references cannot be reassigned, but the referenced objects may still be mutable.
  • Use List.copyOf, Set.copyOf, Map.copyOf, or immutable types to guarantee deep immutability where required.
  • Avoid arrays as components; use immutable collections or value types. If unavoidable, copy defensively and consider exposing List views.

Value Semantics and Equality

  • Record equals/hashCode are component-wise. Be careful with floating-point components and scale-sensitive types like BigDecimal.
  • Arrays in records compare by reference by default; prefer collections to get value semantics.
record Amount(BigDecimal value, Currency currency) {
    public Amount {
        value = value.stripTrailingZeros();
    }
}

Methods, Factories, and Withers

  • Records can define static factories for clarity and validation.
  • For the “wither” pattern (copy with changes), add helper methods that construct a new record.
record Person(String first, String last, int age) {
    Person withAge(int newAge) { return new Person(first, last, newAge); }
    static Person of(String first, String last) { return new Person(first, last, 0); }
}

Records in Frameworks

  • JSON: Jackson (2.12+) supports records; annotate constructors if needed (@JsonCreator when ambiguity arises).
  • Spring Boot binds configuration and request bodies to records naturally.
  • JPA Entities should not be records due to mutability and proxy requirements; use records for DTOs and projections.

Local and Nested Records

  • Local records inside methods are excellent for structuring intermediate results.
  • Nested records group closely related types; keep them small and focused.
static List<String> labels(Path root) {
    record Entry(String name, long size) {}
    try (var stream = Files.list(root)) {
        return stream.map(p -> new Entry(p.getFileName().toString(), p.toFile().length()))
                                 .map(e -> e.name() + " (" + e.size() + ")")
                                 .toList();
    }
}

Sealed Types: Deep Dive

Modifiers and permits

  • A sealed class or interface restricts which classes can extend/implement it via permits.
  • Each permitted subclass must be final, sealed, or non-sealed.
  • Use final for closed leaves, non-sealed to open a branch, and nested sealed for structured hierarchies.
public sealed interface Shape permits Circle, Rectangle, Polygon {}
public record Circle(double radius) implements Shape {}
public record Rectangle(double width, double height) implements Shape {}
public non-sealed interface Polygon extends Shape {}
public record Triangle(double a, double b, double c) implements Polygon {}

Packaging and Modules

  • In the same module/package, permits can omit explicit listing if subclasses are nested.
  • Across modules, ensure visibility and exports allow subclasses; sealed constraints are checked at runtime and compile time.

Exhaustive Reasoning with switch

  • Sealed hierarchies enable the compiler to enforce exhaustiveness in switches over the sealed supertype.
  • Combine with record patterns and guards (when) for expressive, safe control flow.
String render(Shape s) {
    return switch (s) {
        case Circle(var r) -> "Circle r=" + r;
        case Rectangle(var w, var h) -> "Rect " + w + "x" + h;
        case Polygon p when p instanceof Triangle(var a, var b, var c) -> "Tri:" + a + "," + b + "," + c;
        case Polygon p -> "Polygon:" + p.getClass().getSimpleName();
    };
}

Pattern Matching with Records

  • Record patterns destructure directly in switch/instanceof.
  • Nested and guarded patterns express complex matching succinctly.
record Auth(String user, String token) {}
record Req(String path, Auth auth) {}

String who(Req req) {
    return switch (req) {
        case Req("/health", _) -> "system";
        case Req(var p, Auth(var user, var token)) when token != null -> user;
        case Req(_, Auth(var user, null)) -> user + " (no token)";
    };
}

Migration Strategy

  1. Replace POJOs + Lombok boilerplate with records for DTOs, events, commands.
  2. Introduce sealed interfaces for domain variants where exhaustiveness matters.
  3. Convert instanceof/casts to pattern matching; prefer switch expressions over chains of if-else.
  4. Normalize data in compact constructors and enforce immutability with defensive copies.
  5. Keep entities (JPA) as classes; use records at the boundaries (API, messaging, views).

Common Pitfalls and How to Avoid Them

  • Mutable components: Use copyOf/immutable types; avoid exposing internal state.
  • Arrays in records: Prefer collections; arrays break value semantics in equals.
  • Overusing non-sealed: Only open branches where extension is intentional and documented.
  • Ambiguous JSON constructors: Add @JsonCreator/@JsonProperty as needed.
  • Null handling in patterns: Match null explicitly or avoid nullable components.
  • Performance surprises: Records are regular classes; avoid giant records and hot allocation paths unless escape analysis helps.

Best Practices

  • Use record for value types, DTOs, and messages; keep behavior minimal and pure.
  • Validate invariants in constructors; fail fast with meaningful exceptions.
  • Favor sealed hierarchies where the set of variants is known; combine with exhaustive switch.
  • Prefer expression-oriented handling with pattern matching; avoid imperative type checks.
  • Document permitted subclasses; treat non-sealed as a conscious design decision.

Classes vs Records (Quick Comparison)

Aspect Class Record
Mutability Often mutable via setters Shallowly immutable by design
Boilerplate Requires fields, accessors, equals, hashCode, toString Generated accessors and value semantics out of the box
Equality Identity or custom value semantics Component-wise value equality
Constructors Full control; default no-arg possible Canonical and compact constructors; no default no-arg
Inheritance Can extend classes Cannot extend classes; can implement interfaces
Framework fit Entities, heavy lifecycle/proxies DTOs, API payloads, events, projections
Typical uses Stateful services, domain objects with behavior Data carriers, messages, configuration snapshots

Design guidance:

  • Prefer records for value types that represent data with minimal behavior.
  • Keep classes for lifecycled objects (e.g., JPA entities, services) or where mutation and inheritance are essential.

When Not to Use Records

  • JPA/Hibernate entities: require mutability, proxies, and lazy initialization incompatible with record constraints.
  • Frameworks that depend on setters/field injection: records do not provide setters.
  • Long-lived mutable state (caches, accumulators): records are intended for immutable snapshots, not in-place updates.
  • Deep immutability required: records are shallowly immutable; ensure component types are themselves immutable or copied defensively.
  • Default no-arg construction required: records mandate component initialization; use classes instead.

Rule of thumb: use records at the boundaries (API, messaging, views) and classes for lifecycled, stateful domain entities.

Quick Reference

  • Records: immutable headers, compact constructors, generated value semantics.
  • Sealed: permits + leaf modifiers (final, sealed, non-sealed).
  • Pattern matching: destructuring via record patterns; guarded when clauses.
  • Migration: POJOs → records; open hierarchies → sealed; casts → patterns.