For twenty years, a Java class that "only holds data" came with about thirty lines of ceremony: private final fields, a constructor, getters, and then equals, hashCode and toString, written by hand or generated by the IDE. Since Java 16, Java records replace all of that ceremony with a single declaration, and since Java 17, sealed lets us close the set of subtypes. Combine the two with the pattern-matching switch of Java 21 and you get a sum type that the compiler can check: add a variant and forget to handle it, and the code does not compile. This article walks through Java records and the other two on code that actually runs, shows where Java records are "immutable" only in a shallow sense, and uses them to model payments and order states.
This is the last article in the Java & Design Patterns series. The previous one is Refactoring to design patterns: a case study from real code, where record appeared as the tool for wrapping CouponEffect, and Visitor pattern in the Java 21 era: sealed interfaces and pattern matching, where sealed types replaced the Visitor. All the code lives in code/s6-36 and runs on JDK 21.0.12 on an Apple M4 with 16 GB of RAM; Jackson 2.17.2 comes from the local Maven repository (the same version Spring Boot 3.3.5 uses).
What a Java record is and is not
According to JEP 395 (Java records were previewed in JDK 14 and 15 and became final in JDK 16), the goal of Java records is a concise construct for representing a simple aggregate of values, modeling immutable data rather than extensible behavior. The JEP says explicitly that less boilerplate is only a side effect, not the goal. For each component, the compiler gives you a private final field and a public accessor with the same name, a canonical constructor matching the header, and equals, hashCode and toString derived from the components. Two Java records are equal when they have the same type and equal components.
The limits of Java records matter just as much: no extends clause (the superclass is always java.lang.Record), implicitly final, and no instance fields beyond the components in the header. Java records may still have methods, static members, nested types, generics and implements clauses. The Oracle Java 21 Language Updates add that an explicit accessor must be public with the same return type, and that a custom equals/hashCode/toString must keep the semantics of java.lang.Record.
Reflection shows that Java records have a single final field, the superclass java.lang.Record, and the methods generated by the compiler. A Sku Value Object written as Java records takes 9 lines, while the hand-written pre-Java 16 version (classic/SkuClassic.java) takes 36 lines for the same behavior.
superclass = java.lang.Record
fields:
private final java.math.BigDecimal demo.Values$Money.amountrecord Sku (declared in Values.java, lines 12-20): 9 lines
SkuClassic.java (same behavior, hand-written): 36 linesThe compact constructor: how Java records protect themselves
What makes Java records useful for domain modeling is the compact constructor: a declaration with no parameter list that runs before the fields are assigned and is used for validation or normalization. JEP 395 stresses that inside a compact constructor body you cannot assign the fields directly; the fields are assigned from the parameters at the end. To normalize, reassign the parameter, as Email below lowercases its value, while Money rounds to whole units and rejects negative amounts:
public record Email(String value) {
public Email {
value = value.strip().toLowerCase(); // normalize: reassign the parameter, not this.value
if (!value.contains("@")) {
throw new IllegalArgumentException("Invalid email: " + value);
}
}
}
public record Money(BigDecimal amount) {
public Money {
amount = amount.setScale(0, RoundingMode.HALF_UP);
if (amount.signum() < 0) throw new IllegalArgumentException("Negative amount: " + amount);
}Checking with Demo1Records, parts 1 and 2:
=== 1. Generated equals, hashCode, toString ===
a = Sku[value=ABC-1234]
a.equals(b) = true, same hashCode = true, a == b: false
Set with 2 equal-valued elements -> size = 1
=== 2. Compact constructor: validation and normalization ===
Email[value=an.nguyen@example.test]
true
Rejected: Invalid SKU: abc-12
Rejected: Negative amount: -5
Rejected: Quantity must be > 0
Money[amount=1001] (rounded to whole units)Two Java record results stand out. First, two Sku instances with different references but the same value are correctly equals, share a hashCode, and collapse to one element in a HashSet. Second, new Email("AN@x.test").equals(new Email(" an@X.TEST ")) is true because normalization runs before comparison: the invariant is established at creation time, so a malformed Sku or a negative Money cannot exist. This is exactly the Value Object idea from DDD in Java: Entity, Value Object and Aggregate, but now it costs very little code.
Shallow immutability: the biggest trap of Java records
Oracle describes the fields of Java records as final because the class is designed as a simple "data carrier"; that does not mean everything Java records point to is immutable. If a component is a mutable List, whoever holds a reference to the original list can still change the Java record's contents from outside. ShallowCart in Values.java deliberately does not defend itself, while SafeCart uses List.copyOf in the compact constructor (which also throws on null). Remember too that Java records have no setters, so to "change" it you create a new instance, for example with a withLine method:
/** The correct version: List.copyOf in the compact constructor (also rejects null). */
public record SafeCart(String id, List<OrderLine> lines) {
public SafeCart {
lines = List.copyOf(lines);
}
public Money total() { return lines.stream().map(OrderLine::total).reduce(new Money(BigDecimal.ZERO), Money::plus); }
/** "Wither": a record has no setters, so to change it you create a new one. */
public SafeCart withLine(OrderLine line) {
var next = new java.util.ArrayList<>(lines);
next.add(line);
return new SafeCart(id, next);
}
}Part 3 of Demo1Records builds two carts from the same list, then modifies the original list after they were created:
=== 3. Shallow immutability: a record holding a List ===
ShallowCart sees 2 lines (changed from outside)
SafeCart sees 1 line, total 100000 VND
safe.lines().add(...) -> UnsupportedOperationException
withLine creates a new instance: original 1 line, new one 2 linesShallowCart "sees" 2 lines: the second line was added from outside after the cart was created, which means a cart that is immutable on paper was changed, total included. SafeCart still has 1 line, adding to lines() throws UnsupportedOperationException, and withLine returns a new instance without touching the original. The rule: collection components must be defensively copied, and so must other mutable components (Date, arrays).
The second trap of Java records is arrays. Part 4 of the demo:
=== 4. Array component: equals compares references ===
same array contents, equals = false
toString = WithArray[name=n, data=[I@<hash>]Two Java records holding arrays with the same contents are not equal, because a Java record's equals compares each component using that component's own equals, and arrays compare only by reference; toString prints a meaningless hash like [I@.... With Java records, the fix is to use a List<Integer> or a Value Object, or to override equals, hashCode and toString yourself with Arrays.equals, Arrays.hashCode and Arrays.toString (JEP 395 allows this as long as the semantics are preserved).
Sealed types: closing the set of subtypes
JEP 409 (final in JDK 17, after two previews in JDK 15 and 16) lets a class or interface restrict who may extend it with sealed and a permits clause. Each permitted subclass must use exactly one of three modifiers: final (no further inheritance), sealed (controlled inheritance continues) or non-sealed (open again to everyone). The subclasses must be in the same module (or the same package, in an unnamed module); if they are all in the same source file, you can omit permits and the compiler infers it. JEP 409 notes that Java records are implicitly final, so they make natural subclasses, and combining sealed types with Java records gives us algebraic data types. This is the payment method model:
public sealed interface PaymentMethod {
record Card(String last4, int installments) implements PaymentMethod {
public Card {
if (!last4.matches("\\d{4}")) throw new IllegalArgumentException("last4 must be exactly 4 digits");
if (installments < 1 || installments > 12) throw new IllegalArgumentException("Installments must be 1-12");
}
}
record BankTransfer(String bank, String reference) implements PaymentMethod {}
record Wallet(String provider, Money balance) implements PaymentMethod {}
record CashOnDelivery() implements PaymentMethod {}
}Each variant is a Java record carrying exactly its own data: a card has its last four digits and the number of installments (validated by a compact constructor), a bank transfer has the bank and a reference, a wallet has a balance, and cash on delivery carries nothing. There is no longer a single Payment class with seven nullable fields where you must remember which field is used for which type.
Pattern matching switch: exhaustiveness checked at compile time
The real power appears when you use switch. According to JEP 441 (finalized in Java 21 after four previews), a switch that uses patterns must be exhaustive, and with a sealed hierarchy the compiler verifies coverage from the permits clause, so you usually do not need a default. The JEP also states a clear preference for an exhaustive switch without a match-all branch over one with default, because when a new subtype is added, default silently swallows it. Also included are the when guard (only patterns can have guards) and the dominance rule: a label that is already covered by an earlier label is a compile error. JEP 440 adds record patterns for Java records, which let you deconstruct components right in the case, nest them, use var, and note that null matches no record pattern:
public static long fee(PaymentMethod m, long amount) {
return switch (m) {
case Card c when c.installments() > 1 -> amount * 3 / 100; // installments: 3%
case Card c -> amount * 2 / 100; // single payment: 2%
case BankTransfer b -> 0;
case Wallet w -> amount * 1 / 100;
case CashOnDelivery cod -> 15_000;
}; public static String describe(PaymentMethod m) {
return switch (m) {
case Card(var last4, var n) when n > 1 -> "Card ****" + last4 + " in " + n + " installments";
case Card(var last4, var n) -> "Card ****" + last4;
case BankTransfer(var bank, var ref) -> "Bank transfer " + bank + ", reference " + ref;
case Wallet(var provider, var balance) -> "Wallet " + provider + ", balance " + balance.amount();
case CashOnDelivery() -> "Cash on delivery";
};Running Demo2Sealed:
=== 1. Exhaustive switch on a sealed interface ===
Card ****4242 fee on 2,000,000 = 40,000
Card ****4242 in 6 installments fee on 2,000,000 = 60,000
Bank transfer VCB, reference DH001 fee on 2,000,000 = 0
Wallet MoMo, balance 250000 fee on 2,000,000 = 20,000
Cash on delivery fee on 2,000,000 = 15,000
=== 2. Reflection on a sealed type ===
isSealed = true
permits Card
permits BankTransfer
permits Wallet
permits CashOnDeliveryTwo details are worth noticing in the code. First, the two Card branches follow the "guarded first, unguarded after" order that JEP 441 recommends; reversing the order is a compile error because the unguarded branch dominates the guarded one. Second, neither switch has a default. The describe method uses case Card(var last4, var n) to extract components without calling accessors by hand. Reflection confirms the structure: isSealed = true and getPermittedSubclasses() lists the four variants in declaration order.
Adding a variant and forgetting to handle it: a compile error, not a production bug
This is the most practical benefit. Demo4Compile.java deliberately adds a Qr method that the switch has no branch for:
static long fee(Pay p) {
return switch (p) {
case Card c -> 2;
case Cod c -> 15_000;
// forgot the Qr branch
};
}Compiling with javac (JDK 21.0.12) gives the real result:
src/demo/Demo4Compile.java:11: error: the switch expression does not cover all possible input values
return switch (p) {
^
1 errorCompared with an enum plus a switch with default, or a class tree with virtual methods, sealed types give us a middle ground: data and operations are separated (adding a new operation does not touch the Java records), but the set of variants is closed (adding a variant forces you to update every switch). This is exactly the trade-off analyzed in the article on the Visitor pattern.
Making invalid states unrepresentable
The state machine in State pattern and an order state machine uses state classes with behavior. With sealed types and Java records we have a different, data-oriented approach: each state carries only the data that belongs to it. Draft has no receipt, so a "draft order with a receipt" cannot exist; Shipped requires a trackingNo, so a "shipped order without a tracking number" cannot exist:
static OrderState ship(OrderState s, String trackingNo) {
return switch (s) {
case Paid(var at, var receipt) -> new Shipped(at, receipt, trackingNo);
case Draft d -> throw new IllegalStateException("A draft order is unpaid and cannot be shipped");
case Shipped sh -> throw new IllegalStateException("Order already shipped with tracking number " + sh.trackingNo());
case Cancelled c -> throw new IllegalStateException("Order cancelled: " + c.reason());
};
}Parts 3 and 4 of Demo2Sealed run the state transitions and the rejected cases:
=== 3. State machine with sealed records ===
Shipped[paidAt=2026-10-01T03:00:00Z, receipt=RC-77, trackingNo=VN123456]
Rejected: A draft order is unpaid and cannot be shipped
Rejected: Order already shipped with tracking number VN123456
Rejected: Order cancelled: customer changed their mind
=== 4. Invalid components are rejected on creation ===
Rejected: last4 must be exactly 4 digits
Rejected: Installments must be 1-12There is only one valid path (Paid to Shipped) and the other three throw clear errors; add a Returned state and the ship method will not compile until we decide on its behavior. This is the spirit of data-oriented programming as Brian Goetz describes it: immutable data represented as sum types and product types (sealed types and Java records), with logic living in functions that use pattern matching.
Java records and the ecosystem: JSON, serialization, JPA
Real data has to cross boundaries: JSON over HTTP, bytes through a queue, rows in a database. The question is whether the invariants of Java records survive the crossing. Demo3Json tries it with Jackson 2.17.2 (the version Spring Boot 3.3.5 uses; Jackson has supported records since 2.12):
=== 1. Jackson + record: serialize and read back ===
{"sku":{"value":"ABC-0001"},"quantity":2,"unitPrice":{"amount":150000}}
read back equal: true
=== 2. Bad data coming through JSON is still blocked by the compact constructor ===
ValueInstantiationException <- IllegalArgumentException: Invalid SKU: wrong
=== 3. Polymorphic sealed interface in JSON ===
{"type":"card","last4":"4242"} | {"type":"cod"}
read back: Card[last4=4242]
=== 4. Java serialization: a record is rebuilt through the canonical constructor ===
deserialized: Note[text=hello]Four takeaways. One, Java records serialize and read back with Jackson without any annotations, and the result is equals to the original.
Two, bad data coming through JSON is still blocked by the compact constructor: Jackson calls the canonical constructor, so the error Invalid SKU: wrong is wrapped in a ValueInstantiationException; the invariant lives in the domain rather than in the controller class, following the principle from the DDD article. Three, a polymorphic sealed interface needs @JsonTypeInfo and @JsonSubTypes to write the type name into the JSON (the type field); without them, Jackson does not know which variant to build when reading. Four, per JEP 395, standard Java serialization uses the canonical constructor on deserialization, so a Java record with validation cannot be "bypassed" with tampered bytes, unlike a regular class where fields are assigned directly; part 4 of the demo confirms that a Note is rebuilt correctly through ObjectInputStream.
JPA is different: an @Entity needs a no-argument constructor and mutable fields, so do not use Java records as entities. The reasonable places in the persistence layer are embedded value types and DTOs/projections for read queries.
When not to use Java records and sealed types
- Objects with identity and a lifecycle: entities compare by id, so the generated
equalsof Java records has the wrong semantics here. - Inheritance or mutable state needed: a Java record is implicitly
finaland has no instance fields beyond the header. - Open models: sealed closes the set of variants; if third parties need to add types (plugins, SPI), use a regular interface or a
non-sealedbranch. - Domains that change quickly along the variant axis: every new variant forces you to update every
switch; that is worth it for stable domains (payment methods, order states) and expensive for volatile ones. - Long-lived public APIs: changing the components of Java records changes the constructor, accessors,
equalsand serialized form all at once.
Common mistakes with Java records and sealed types
- Assuming Java records are deeply immutable: always use
List.copyOfandMap.copyOfin the compact constructor. - Array components:
equalscompares references; use aListor override by hand. - Assigning
this.fieldin a compact constructor: does not compile; normalize by reassigning the parameter. - Using
defaultin a switch over a sealed type: a new variant falls intodefaultwith no warning. - An unguarded branch before a guarded one: a compile error because of dominance.
- Using Java records as an
@Entity: Hibernate needs a default constructor and the ability to assign fields.
Checklist
- Plain data, compared by value, no inheritance needed: use Java records; validate and normalize in the compact constructor.
- Collection or mutable components: copy defensively; avoid array components.
- A closed set of variants: a
sealed interfacewith Java records, each variant carrying only its own data. - Pattern switches without
default; guarded branches first, unguarded after. - JPA entities use regular classes; Java records for embedded values and DTOs.
In short, Java records give us a nearly free Value Object, sealed types give us a closed set of variants, and pattern matching joins the two with compile-time checking. They do not replace every class, but for plain data and domain states, this is the shortest way to turn "business rules in a document" into "business rules supervised by the compiler".
Series wrap-up
The thirty-six articles in this series move from principles to practice: the foundation in SOLID principles in Java with practical examples; creational, structural and behavioral patterns such as Builder, Decorator, Strategy and State; enterprise architecture with Dependency Injection, DDD, Hexagonal Architecture and Saga; and modern Java with virtual threads and this article. The two articles near the end, Anti-patterns in Java and Testing patterns, remind us that a pattern is only valuable when used in the right place and protected by tests.
Three ideas run through the whole series. One, patterns are a shared language and a destination for refactoring, not a goal: start from a concrete pain. Two, every new Java feature (lambdas, Java records, sealed types, pattern matching) lightens many classic patterns, so today's implementations are much simpler than those in the 1994 book. Three, every claim in the series comes with runnable code and real output; run it again, change it, and if the result on your machine differs, that is the part most worth learning from. Thank you for reading to the end of the series.



