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
recordfor immutable data aggregates with value semantics (e.g., DTOs, events, identifiers). Add validation in a compact constructor. - Use
sealedto limit extensibility and promote exhaustive handling; combine withfinalandnon-sealedappropriately.
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-sealedat 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
switchto make illegal states unrepresentable and handling exhaustive.
Records: Deep Dive
Components, Accessors, and Constructors
- A
recorddeclares its components in the header; the compiler generates accessors,equals,hashCode, andtoString. - 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
Listviews.
Value Semantics and Equality
- Record
equals/hashCodeare component-wise. Be careful with floating-point components and scale-sensitive types likeBigDecimal. - 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 (
@JsonCreatorwhen 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
sealedclass or interface restricts which classes can extend/implement it viapermits. - Each permitted subclass must be
final,sealed, ornon-sealed. - Use
finalfor closed leaves,non-sealedto open a branch, and nestedsealedfor 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,
permitscan 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
- Replace POJOs + Lombok boilerplate with records for DTOs, events, commands.
- Introduce sealed interfaces for domain variants where exhaustiveness matters.
- Convert
instanceof/casts to pattern matching; preferswitchexpressions over chains ofif-else. - Normalize data in compact constructors and enforce immutability with defensive copies.
- 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/@JsonPropertyas needed. - Null handling in patterns: Match
nullexplicitly or avoid nullable components. - Performance surprises: Records are regular classes; avoid giant records and hot allocation paths unless escape analysis helps.
Best Practices
- Use
recordfor 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-sealedas 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
whenclauses. - Migration: POJOs → records; open hierarchies → sealed; casts → patterns.