Records in Java: Immutable Data Carriers Done Right
Executive Summary
A record is an immutable data carrier: one line declares the components. Then, the compiler generates the canonical constructor, private final fields, accessors, plus equals, hashCode, and toString derived from every component. The compact constructor validates and normalizes values without boilerplate, running before the implicit field assignment. Records are implicitly final, extend java.lang.Record, cannot extend classes, and may implement interfaces, which pairs them naturally with the sealed hierarchies in the next article. Immutability is shallow: final fields do not make a contained List immutable, so defensive copies of mutable components are your job. Use records for value objects, DTOs, map keys, and multi-value returns. However, avoid them for JPA entities, mutable state, and anything needing inheritance. If a class is mostly accessors, it was trying to be a record all along.
The Record Declaration and What It Generates
Here is the one-line version of the Money class from the Object article, this time complete and copy-pasteable:
record Money(long cents, String currency) { }
// usage
Money price = new Money(999, "USD");
Money same = new Money(999, "USD");
System.out.println(price.cents()); // 999: accessor, no get prefix
System.out.println(price.equals(same)); // true: generated correctly
System.out.println(price); // Money[cents=999, currency=USD]
That single line generates five things, all shown in the records lesson and the Record superclass spec:
| Generated member | Generated as |
|---|---|
| Canonical constructor | Assigns every component to its field |
| Fields | private final, one per component |
| Accessors | cents() and currency(), named after components |
| equals and hashCode | Derived from all components, contract-correct |
| toString | Money[cents=999, currency=USD] |
Handwritten, that table is roughly sixty lines for two fields. Also, the equals and hashCode pair is exactly the code the Object article taught you to write. The generated versions follow the same contract, so records are immediately valid map keys and set elements, with the pair rule satisfied by construction.
Compact Constructors: Validation Without Boilerplate
Records without rules are half a solution, so Java offers the compact constructor: no parameter list, no field assignments, just the code that validates and normalizes before the compiler assigns fields for you.
record Money(long cents, String currency) {
Money {
if (cents < 0) {
throw new IllegalArgumentException("negative amount: " + cents);
}
currency = currency.toUpperCase(); // normalize the parameter
if (currency.length() != 3) {
throw new IllegalArgumentException("bad currency: " + currency);
}
}
}
Money ok = new Money(999, "usd"); // stored as USD
// new Money(-1, "usd"); // throws at birth, never later
// new Money(100, "dollars"); // throws: not three letters
The parameter reassignment inside the compact constructor is the whole trick: normalize the parameters. Then, the compiler’s implicit assignment at the end stores the normalized values. Validation lives at birth, exactly where the classes article argued it belongs, with none of the ceremony.
Records can also declare extra convenience constructors. However, every one must delegate to the canonical one, so no path around your validation exists. You cannot assign fields directly in a compact constructor. Also, you cannot add mutable fields beyond the components: the record state is its components, full stop.
Shallow Immutability: The Trap Inside the Good Idea
Records are immutable, and that sentence needs an asterisk. The fields are final. However, if a field holds a mutable object, the record’s state still changes when that object changes. Wrong code first:
// WRONG: "immutable" record wrapping a mutable list
record Tags(String id, java.util.ArrayList<String> tags) { }
var tags = new Tags("p1", new java.util.ArrayList<>(java.util.List.of("a")));
tags.tags().add("b"); // the record's state just changed
// RIGHT: copy defensively in the compact constructor
record Tags(String id, java.util.List<String> tags) {
Tags {
tags = java.util.List.copyOf(tags); // unmodifiable, defensive copy
}
}
var tags = new Tags("p1", java.util.List.of("a"));
// tags.tags().add("b"); // throws UnsupportedOperationException
The delta: List.copyOf stores an unmodifiable copy, so neither the caller nor later code can mutate the record through the component. The Collections articles in Part 3 cover these views properly. Still, the rule survives regardless: every mutable component needs a defensive copy, and every accessor returning mutable internals needs the same care the encapsulation article demanded of arrays.
Records, Inheritance, and Pattern Matching
Records sit in a fixed corner of the inheritance model, which is a feature. A record is implicitly final: no class extends it, so no subclass can break its equals or add mutable state. A record cannot extend any class, because it already extends java.lang.Record. However, records implement interfaces freely, which is how they join the contract world from the inheritance article without inheriting state.
Pattern matching is where records and the next article meet. Because a record’s components are its structure, Java 21 can destructure one directly, via record patterns:
Object obj = new Money(999, "USD");
if (obj instanceof Money(var cents, var currency)) {
System.out.println("amount: " + cents + " " + currency);
}
That one instanceof tests the type, casts it, and binds both components in a single step. The sealed classes article next shows how sealed hierarchies plus records plus pattern matching turn data modeling into algebra: closed sets of immutable shapes the compiler can check exhaustively.
How Real Systems Do This
Records have become the default for data crossing boundaries in production Java. Service-to-service messages, configuration slices, and API payloads are records because their identity is content and their lifetime is short. JSON libraries deserialize records directly, which matters when the HTTP and JSON article in Part 9 arrives. Also, validation lives in the compact constructor, not scattered across handlers.
Records also solve two classic annoyances. Multi-value returns: instead of an array of Object or a hand-built Pair, methods return local records declared inside the method body, which is legal and idiomatic. And map keys: because equals and hashCode are correct by construction, records are the safest key types you will ever write.
In my experience, the most convincing migration was an audit of a service with about thirty hand-written DTOs: two had equals/hashCode mismatches, and one had a toString leaking an email address into logs. Converting all thirty to records removed roughly two thousand lines and deleted the entire bug class, because the compiler does not have review fatigue.
Decision Framework
- Is the class a carrier whose identity is its content? Record. The compiler writes the trio correctly.
- Does the value need validation or normalization? Compact constructor, in the same declaration.
- Does the object have a lifecycle with mutable state, like an Account balance? Class, with behavior guarding mutation.
- Does it need inheritance or extension? Class; records are final by design.
- Is it a persistence entity? Class; ORM frameworks need mutation and proxies, and the persistence articles explain why.
- Is it an intermediate result inside one method? Local record, declared where it is used.
When NOT to Use This
- Do not model stateful domain objects as records. A BankAccount with a changing balance wants a class whose methods enforce the rules. In contrast, a record would force rebuilding a new account value on every deposit, which erases identity.
- Do not use records as JPA entities. The persistence layer in Part 8 needs mutable instances, a no-arg constructor, and lazy proxies, all of which fight the record model.
- Do not wrap mutable components without defensive copies. The record stays shallowly immutable, and every caller with the old reference can still mutate your “immutable” value.
Common Mistakes
- Hunting for setters: records have none, by design. To change a value, build a new record: new Money(price.cents() + 100, price.currency()).
- Mutating a record through a mutable component: final fields do not protect shared Lists; copy defensively in the compact constructor.
- Trying to extend a record or add non-component instance fields: the compiler rejects both, because both would break value semantics.
- Writing a normal constructor that bypasses the canonical one: every extra constructor must delegate to the canonical form, so validation stays on every path.
- Stuffing services, I/O, or orchestration into a record: validation and derived values belong there. However, behavior with dependencies belongs in a class you can test and wire.
- Assuming records are magic value objects on the stack: they are heap objects like any class, with no hidden performance fairy dust, just less code.
Key Takeaways
- A record is a one-line immutable data carrier. The compiler generates the canonical constructor, final fields, accessors, equals, hashCode, and toString.
- The compact constructor validates and normalizes before the implicit assignment, so invalid values cannot exist.
- Immutability is shallow: defensive-copy mutable components in the compact constructor.
- Records are implicitly final, extend java.lang.Record, cannot extend classes, and implement interfaces freely.
- Record patterns (Java 21) destructure components in a single instanceof, the foundation of the next article.
- Use records for value objects, DTOs, map keys, and multi-value returns; use classes for mutable state and JPA entities.
- “Change” means a new record: new Money(price.cents() + 100, price.currency()). No setters exist.
FAQ
What is a record in Java?
An immutable data carrier declared in one line: the components define the fields, and the compiler generates the constructor, accessors, equals, hashCode, and toString. Standardized in Java 16 through JEP 395.
Can Java records have methods?
Yes: static methods, instance methods, extra constructors that delegate to the canonical one, and static fields are all allowed. What is not allowed is non-component instance fields, a mutable shape, or a subclass.
Why can’t records be extended?
They are implicitly final so value semantics survive: a subclass adding mutable state or overriding equals would break the guarantee that equal content means equal records. Records can still implement interfaces.
Can a record contain a mutable field like a List?
It can hold one, but it should not keep it mutable. Copy the component defensively with List.copyOf in the compact constructor, otherwise the record’s state changes whenever the list does.
When should I not use a record?
For objects with mutable lifecycles and behavior around state, for ORM entities that frameworks must mutate and proxy, and for anything that needs inheritance. Those want classes.
Conclusion
Records close the data-class chapter of Java: one line replaces sixty, correctness arrives by construction, and validation lives where values are born. Combined with enums for fixed variants and classes for stateful behavior, the modeling toolkit for Part 2 is now complete.
The next article assembles the final piece of modern Java modeling: sealed classes and interfaces, which close hierarchies so the compiler can verify you handled every variant.
If a class is mostly accessors, it was trying to be a record all along. Let it become one.
Last updated on 23 September 2026.
