Records: the modern way to hold data
Most classes in a real codebase do not have interesting behaviour. They hold three values, hand them back, and compare equal when their values match. Before Java 16 that took forty-five lines, most of which the IDE generated and nobody read.
record Subscriber(String name, String pincode, int tiffins) { }
That line is a complete, correct, immutable class. This lesson is about what it generates, what it refuses to do, and the one way it will still surprise you.
What you get
Subscriber a = new Subscriber("Priya Deshmukh", "411207", 26);
Subscriber b = new Subscriber("Priya Deshmukh", "411207", 26);
System.out.println(a);
System.out.println(a.equals(b));
System.out.println(a.hashCode() == b.hashCode());
System.out.println(a.name());
Subscriber[name=Priya Deshmukh, pincode=411207, tiffins=26]
true
true
Priya Deshmukh
From that one line, the compiler generates:
| Generated | Detail |
|---|---|
private final fields |
One per component, in order |
| A canonical constructor | Taking all components, in order |
| An accessor per component | name(), not getName() |
equals |
Compares every component |
hashCode |
Consistent with equals |
toString |
Subscriber[name=..., pincode=..., tiffins=26] |
Two of those are the real prize. The equals/hashCode pair is generated
correctly and consistently, which is the contract module 5 devotes a whole
lesson to breaking. And toString means your logs are readable by default rather
than saying Subscriber@799f10e1.
Records are final, cannot extend anything, and their fields cannot be
reassigned. That is not a limitation to work around — it is the deal. A record
says "I am nothing but my values".
Validation: the compact constructor
You almost always want to reject nonsense. A record gives you a special form with no parameter list and no assignments:
record Subscriber(String name, String pincode, int tiffins) {
Subscriber {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("name must not be blank, got [" + name + "]");
}
if (!pincode.matches("\\d{6}")) {
throw new IllegalArgumentException("pincode must be six digits, got [" + pincode + "]");
}
if (tiffins < 0 || tiffins > 62) {
throw new IllegalArgumentException("tiffins must be between 0 and 62, got " + tiffins);
}
name = name.strip();
}
}
threw: tiffins must be between 0 and 62, got -1
threw: name must not be blank, got [ ]
normalised: [Priya]
Note the last line. Inside a compact constructor, assigning to a parameter
changes what gets stored — name = name.strip() normalises the value on the way
in. There are no this.name = name lines; the compiler adds them after your
code.
This is where the encapsulation lesson's promise gets kept for one line of
class. An invalid Subscriber cannot exist.
Adding to a record
A record is a class. It can have methods, static factories, constants and interfaces:
record Subscriber(String name, String pincode, int tiffins) implements Billable {
static final int PRICE_PER_TIFFIN_PAISE = 8_235;
static Subscriber standard(String name, String pincode) {
return new Subscriber(name, pincode, 26);
}
long billPaise() {
return (long) tiffins * PRICE_PER_TIFFIN_PAISE;
}
Subscriber withTiffins(int newTiffins) {
return new Subscriber(name, pincode, newTiffins);
}
@Override
public long amountPaise() {
return billPaise();
}
}
214110
Subscriber[name=Kavita, pincode=411014, tiffins=26]
Subscriber[name=Priya Deshmukh, pincode=411207, tiffins=30]
What it cannot have is additional instance fields. Every piece of state must
be a component in the header — which is what makes equals trustworthy.
withTiffins is the immutable way to "change" something: return a new instance.
The with prefix is the convention, and you will see it constantly in modern
Java.
The trap: records are shallowly immutable
This is the one that catches people.
record Route(String area, List<String> stops) { }
List<String> stops = new ArrayList<>(List.of("Kesnand"));
Route leaky = new Route("Wagholi", stops);
stops.add("added from outside");
System.out.println(leaky);
leaky.stops().add("added through the accessor");
System.out.println(leaky);
Route[area=Wagholi, stops=[Kesnand, added from outside]]
Route[area=Wagholi, stops=[Kesnand, added from outside, added through the accessor]]
The record's field cannot be reassigned. The List it points at can be
modified all day — both by whoever handed it in and by anybody who calls the
accessor. It is the leaking-getter bug from the encapsulation lesson, wearing a
record's clothes.
The fix is one line in the compact constructor:
record SafeRoute(String area, List<String> stops) {
SafeRoute {
stops = List.copyOf(stops);
}
}
safe record : SafeRoute[area=Wagholi, stops=[Kesnand]]
accessor refused modification
List.copyOf both copies (so the caller's later add is ignored) and returns an
immutable list (so the accessor's caller cannot modify it either). One line,
both holes.
Any record component that is a collection, an array or a mutable object needs
copying in the compact constructor. record on its own buys you an immutable
reference, not an immutable object.
When a record is the wrong choice
Being honest about this matters, because records are pleasant enough to over-apply.
- When the thing has identity rather than values. Two subscribers with the
same name and pincode are probably the same person, not two equal things. A
record's
equalssays otherwise. Anything with a database ID usually wants a class. - When it must be mutable. An
Accountwhose balance changes is a class. - When it needs to be extended. Records are
final. Use a sealed interface with record implementations instead, which the next lesson but one covers. - JPA and Hibernate entities. They need a no-argument constructor and mutable fields. Records are excellent as the DTOs around such an entity.
- When there are ten components. That is a signal the thing wants splitting up, whatever form you write it in.
Where records are exactly right: DTOs, API request and response bodies, coordinates, money amounts, parsed CSV rows, method return values that carry more than one thing, and the cases of a sealed hierarchy.
That last one is the payoff. Hold the thought until the pattern matching lesson.
Records against the alternatives
| Record | Class | Enum | |
|---|---|---|---|
| Values fixed at construction | Yes | Optional | Yes |
equals/hashCode/toString |
Generated | You write them | Identity-based |
| Can be extended | No | Yes | No |
| Instances | Unlimited | Unlimited | A fixed, named set |
| Best for | Data | Behaviour and state | A closed set of options |
Check your work
What does record Subscriber(String name, String pincode, int tiffins) {}
generate? Private final fields, a canonical constructor, an accessor per
component named after it, and correct equals, hashCode and toString.
What is an accessor called? name(), not getName(). Records do not follow
the JavaBean convention.
What is a compact constructor for, and what is unusual about it? Validating and normalising. It has no parameter list, and assigning to a parameter changes what is stored, because the compiler appends the field assignments after your code.
Can a record have extra instance fields? No. All state must be declared as components in the header. Static fields and methods are fine.
Why did leaky.stops().add(...) work on an "immutable" record? Records are
shallowly immutable: the field cannot be reassigned, but the object it points at
can be modified. Copy mutable components in the compact constructor.
What does List.copyOf fix, exactly? Two things at once — it takes a
snapshot, so the caller's later changes do not reach you, and it returns an
immutable list, so the accessor's caller cannot modify it either.
Name two cases where a record is the wrong choice. Anything with identity rather than value equality (a database entity), anything mutable, anything that needs subclassing, and JPA entities.
Practice 2, the shrinkage. The hand-written Subscriber with three fields, a
constructor, three getters, equals, hashCode and toString is around 45
lines. The record with the same validation is about 12, and the 33 lines that
disappeared are the ones most likely to contain a bug — a field forgotten in
equals, or a hashCode that does not match it.
Practice 4, the leak. Before: Route[area=Wagholi, stops=[Kesnand, added from outside, added through the accessor]]. After adding
stops = List.copyOf(stops); to the compact constructor: the outside add is
ignored and the accessor's add throws UnsupportedOperationException. Both
holes, one line.
Practice 5, what a record cannot express. An Account with a changing
balance cannot be a record, because every component is final — withBalance
would return a new account, and "the same account with a different balance" is
precisely the thing an identity-bearing object needs to express. That is the
difference between a value and an entity, and it is the most useful distinction
in this whole module.
Practice
-
Convert a class to a record. Take the
Subscriberyou wrote in the classes lesson and rewrite it as a record with the same behaviour. Count the lines before and after. -
Confirm what you got for free. Create two records with identical values. Print both, compare with
equals, compare their hash codes, and put them in aSet— confirm the set holds one, not two. The hand-written version would have held two unless you wroteequalsandhashCodecorrectly. -
Add validation. A compact constructor rejecting a blank name, a non-six-digit pincode, and a tiffin count outside 0 to 62, each with a message naming the bad value. Then normalise the name with
strip()and prove it worked. -
Reproduce the mutable-component leak. Write a
Routerecord whose second component is a list of stops, then modify it both from outside and through the accessor. Then fix it withList.copyOfin the compact constructor and confirm both holes are now closed. -
Find the case it cannot express. Try to write
Accountwith a changing balance as a record. Write down what goes wrong and what that tells you about when to use one. -
Harder — a parsed row. Write
record DeliveryRow(LocalDate date, String customer, int tiffins)and a static factoryparse(String line)that takes2026-09-27,Priya,2and returns one, throwing a clearIllegalArgumentExceptionnaming the offending field for a bad line. Then parse a list of lines, collect the failures rather than stopping at the first, and print a report of good rows and bad ones. This is the shape of nearly all real data loading, and the capstone does exactly this.
Next: enums — for when the set of values is fixed and a typo should not compile.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship