Records and sealed classes
Data as data, hierarchies the compiler can check exhaustively, and pattern matching that turns a visitor into a switch.
Two features, added in Java 16 and 17, that together change how domain code is written. A record is a class that is transparently its data. A sealed type is a hierarchy the compiler knows is complete. Put them together with pattern matching and you get algebraic data types: a closed set of shapes, each carrying exactly its fields, handled exhaustively in a switch — the visitor pattern without the visitor. This lesson is both features, what the compiler and JVM generate for them, the run-time check that catches a subtype nobody permitted, and a visitor replaced step by step.
Records
public record Money(long minor, Currency currency) {}That line declares a final class with two final fields, a canonical constructor, accessors minor() and currency(), and equals, hashCode and toString over both components. The class cannot declare instance fields beyond its components, cannot be extended, and cannot extend anything (it implicitly extends Record). It can implement interfaces, declare static members, and add instance methods.
Validation goes in the compact constructor, which runs before the fields are assigned:
public record Money(long minor, Currency currency) {
public Money {
Objects.requireNonNull(currency, "currency");
if (minor < 0) throw new IllegalArgumentException("negative amount: " + minor);
}
public Money plus(Money o) {
if (!o.currency.equals(currency)) throw new IllegalArgumentException("currency mismatch");
return new Money(minor + o.minor, currency);
}
public static Money zero(Currency c) { return new Money(0, c); }
}You can also normalise: this.name = name.strip(); is not allowed (fields are assigned after), but reassigning the parameter name = name.strip(); in a compact constructor is exactly how it is done.
Mutable components need copying. A record Order(List<Line> lines) shares whatever list it is given; add lines = List.copyOf(lines); in the compact constructor. Records give you value semantics for free only when the components have them.
Under the hood: what a record and a sealed type compile to
A record's class file carries a Record attribute listing its components, which is what Class.getRecordComponents() reads and what serialisation frameworks use to know the canonical constructor. The three generated methods are not written out by javac: equals, hashCode and toString are each an invokedynamic to java.lang.runtime.ObjectMethods, a bootstrap that builds the implementation at first call from the component list, with the right semantics per type (Double.compare for doubles, Arrays-free identity for arrays, the component's own equals otherwise). The fields are trusted finals: reflection cannot write them, Unsafe is refused, and the JIT may constant-fold a read. Java serialisation of a record calls the canonical constructor with the deserialised components, so validation in the compact constructor runs on the way in, which is the security fix classic serialisation never had.
A sealed type's class file carries a PermittedSubclasses attribute. It is enforced twice. The compiler refuses class Freight implements Shipment outside the permitted list. And the JVM checks again at class load: a class that names a sealed supertype it is not permitted by, say one compiled against an older version of the interface, fails to load with IncompatibleClassChangeError. Sealing is a promise the runtime keeps, not just a compile-time hint.
Exhaustiveness is a compile-time check with a run-time backstop. For a switch over a sealed type with no default, javac verifies every permitted subtype is covered and still emits a hidden default arm that throws MatchException. It fires only when the sealed hierarchy was recompiled with a new member and this switch was not, which is the separate-compilation case the load-time check cannot catch (the new subtype is permitted; this class just never heard of it). The dispatch itself is the SwitchBootstraps.typeSwitch invokedynamic from the Control Flow lesson, linked once and specialised by the JVM.
Sealed types
public sealed interface Shipment permits Parcel, Pallet, Digital {}
public record Parcel(double kg, Dimensions box) implements Shipment {}
public record Pallet(int units, double kg) implements Shipment {}
public record Digital(String downloadUrl) implements Shipment {}sealed says: these are the only implementations, and the compiler will check that. A permitted subtype must be final, sealed (with its own permitted set) or non-sealed (open again). Permitted types in the same file can omit the permits clause.
What this buys you is exhaustiveness:
Money shippingCost(Shipment s) {
return switch (s) {
case Parcel p -> parcelRate(p.kg(), p.box());
case Pallet p -> palletRate(p.units());
case Digital d -> Money.zero(EUR);
}; // no default — the compiler knows these are all of them
}Add record Freight(...) implements Shipment and every such switch fails to compile until it handles Freight. That is the compiler enumerating every place in the codebase that needs to know about the new case. With an open hierarchy and a default branch, the new case would silently take the default.
Deconstruction
Record patterns pull components out in the pattern itself, nested as deep as you like:
return switch (s) {
case Parcel(double kg, Dimensions(var w, var h, var d)) when w * h * d > 0.5 -> oversizeRate(kg);
case Parcel(double kg, var box) -> parcelRate(kg, box);
case Pallet(int units, var kg) -> palletRate(units);
case Digital d -> Money.zero(EUR);
};A record pattern calls the accessors, not the fields, so a record that overrides an accessor (rarely wise) is deconstructed through the override. Nested patterns are checked for exhaustiveness too: if Dimensions were sealed with two records, a switch on Parcel(double, Dimensions) would need both.
Walkthrough: replacing the visitor
An expression evaluator with Expr implemented by Num, Add, Mul, and three operations: eval, print, simplify. In the visitor pattern that is an ExprVisitor<R> with three visit methods, an accept on each Expr, and three visitor classes; adding Neg means editing the interface, three visitors and every accept. Convert it:
Seal the hierarchy.
sealed interface Expr permits Num, Add, Mul {}withrecord Num(double v),record Add(Expr l, Expr r),record Mul(Expr l, Expr r). Theacceptmethods are deleted.Each operation is a method with a switch.
printandsimplifyfollow the same shape. Nodefaultanywhere.java double eval(Expr e) { return switch (e) { case Num(var v) -> v; case Add(var l, var r) -> eval(l) + eval(r); case Mul(var l, var r) -> eval(l) * eval(r); }; }Delete the visitors. Three classes, one interface and three
acceptmethods gone; three plain methods remain, each readable in isolation.Add
Neg.record Neg(Expr e) implements Expr {}and one morepermitsentry. The build fails ineval,printandsimplifywith "the switch expression does not cover all possible input values", which is the list of places to edit. Add a case to each; done. Under the visitor, the compiler would have found the same three places, but through an interface method nobody wanted to write.Simplify becomes readable.
case Mul(Num(var a), var r) when a == 1 -> simplify(r);says "one times anything is the thing" in one line, where the visitor version needed a type check and two casts.
The visitor existed to get exhaustiveness and per-type dispatch without instanceof chains. Sealed types give both directly, and the operation's code lives in the operation instead of being scattered through accept calls.
Where sealed types fit in a service
- Results that can be several things:
sealed interface Outcome permits Success, ValidationFailure, Conflict, returned from a service method and switched on in the controller to choose a status code. Cleaner than exceptions for expected outcomes. - Events:
sealed interface OrderEvent permits Placed, Paid, Shipped, Cancelled, consumed with one exhaustive switch. - Commands and states in a state machine.
- Domain values with a closed set of variants: payment methods, shipment types, pricing rules.
Records are the right type for DTOs, API request and response bodies, events, map keys, tuples returned from a method, and configuration. Jackson, Spring's @ConfigurationProperties and JPA projections all understand them. They are the wrong type for JPA entities, which need mutable state and identity semantics.
Try it yourself
Will it compile, will it load?
Module A defines sealed interface Shape permits Circle, Square. Module B, compiled against an older A where Shape was not sealed, contains class Triangle implements Shape. What happens when B's Triangle is loaded against the new A?
Answer
It compiled, because B was built against the unsealed version. At load time the JVM checks Triangle's superinterface's PermittedSubclasses attribute, finds Triangle absent, and throws IncompatibleClassChangeError: class Triangle cannot implement sealed interface Shape. Sealing is enforced by the runtime, so an old jar cannot smuggle a subtype in. A switch in A over Shape would never see a Triangle.
Where does MatchException come from?
A switch over a sealed type with no default has been compiled and shipped. The team adds a fourth permitted record to the interface, recompiles the interface's module, and deploys it without recompiling the module with the switch. What happens at run time, and why did the compiler not prevent it?
Answer
The first time a fourth-type value reaches that switch it throws MatchException, from the hidden default arm javac emitted. The compiler checked exhaustiveness against the hierarchy it could see at compile time; the new record was permitted later, so the load-time check passes, and only the stale switch is wrong. A loud exception is the right outcome for a binary-incompatible deploy; the fix is to rebuild everything that switches on the type, which the build failing on recompile would have listed.
Record or class?
Decide for each: an Order entity persisted by JPA with a mutable status; a Coordinates(double lat, double lon) value; an Event published to Kafka with a List<String> tags; a Cache holding a ConcurrentHashMap.
Answer
Order: a class; JPA needs identity semantics, a no-arg constructor and mutable state, none of which a record has. Coordinates: a record, with a compact constructor validating ranges. Event: a record, with tags = List.copyOf(tags) in the compact constructor so the value is actually immutable. Cache: a class; it has behaviour over mutable state, and a record's generated equals over a ConcurrentHashMap component would be meaningless and expensive.
Misconceptions
- "A record is a class with less boilerplate." It is a class with a contract: transparent, immutable, its equality defined by its components, its fields trusted by the JIT, its deserialisation through the constructor. The boilerplate saving is the least of it.
- "
equalsandhashCodein a record are generated code." They areinvokedynamiccalls to a bootstrap that builds them at first use from theRecordattribute; there is no method body to read injavap. - "Sealed is a compile-time hint." The JVM enforces
PermittedSubclassesat class load and refuses an unlisted subtype with an error. - "A
defaultbranch is safer than an exhaustive switch." It is the opposite: it turns a new subtype into a silent wrong branch. The exhaustive switch turns it into a compile error, and intoMatchExceptionif you skipped the recompile. - "Records replace entities." A record has no identity and no mutable state; an entity is both. Records are for values, results, events and DTOs.
Going deeper
- JEP 395 (Records), JEP 409 (Sealed Classes), JEP 440 (Record Patterns), JEP 441 (Pattern Matching for switch).
- JVMS §4.7.30 (the
Recordattribute) and §4.7.31 (PermittedSubclasses), and §5.3.5 for the load-time sealing check. java.lang.runtime.ObjectMethodsandSwitchBootstrapsJavadoc, the two bootstraps behind records and pattern switches.- Brian Goetz, "Data Oriented Programming in Java", for the algebraic-data-type way of thinking these features enable.
- JLS §14.11.1.1, the exhaustiveness rules, including nested record patterns.