Records: what you get, and where they stop helping
The members a record declares for you, why the compact constructor is the only correct place for an invariant, and the three situations where a record is the wrong tool.
A record is a class whose state is its API. One line declares the components, and the compiler writes the rest.
public record Money(long amount, String currency) {}Run it and the generated members are not a claim, they are output:
record Money(long amount, String currency) {}
public class Demo { public static void main(String[] args) { Money m = new Money(1200, "IDR"); System.out.println(m); // toString System.out.println(m.amount() + " " + m.currency()); // accessors System.out.println(m.equals(new Money(1200, "IDR"))); // equals }}That gives you, implicitly: a canonical constructor taking every component in
declaration order, an accessor per component named after it (amount(), not
getAmount()), and equals, hashCode and toString derived from all
components. What it does not give you is setters, a no-arg constructor, or the
ability to extend anything — a record is implicitly final.
The compact constructor is where an invariant belongs
You can validate in a record, and there is exactly one right place for it.
public record Money(long amount, String currency) { public Money { if (amount < 0) { throw new IllegalArgumentException("negative amount: " + amount); } if (currency == null || currency.length() != 3) { throw new IllegalArgumentException("expected ISO 4217, got: " + currency); } currency = currency.toUpperCase(); // normalising is allowed here }}Both halves are visible when you run it — the normalised value, and the object that never came into existence:
record Money(long amount, String currency) { Money { if (amount < 0) throw new IllegalArgumentException("negative amount: " + amount); currency = currency.toUpperCase(); }}
public class Demo { public static void main(String[] args) { System.out.println(new Money(500, "idr")); // normalised on the way in try { new Money(-1, "IDR"); } catch (IllegalArgumentException e) { System.out.println("refused: " + e.getMessage()); } }}That is the compact constructor — no parameter list, no assignments. It runs before the fields are assigned, which is what makes it useful: assigning to the parameter name normalises the value that gets stored, and throwing means the object never comes into existence.
The alternative people reach for is a static factory that validates and then calls the constructor. It works until somebody calls the constructor directly, which they will, because it is public and it is right there. Validation in the compact constructor cannot be bypassed.
Equality comes from the components, and that has teeth
equals and hashCode are generated from every component. That is usually what
you want and occasionally a trap:
record CacheKey(String tenant, List<String> scopes) {}Two CacheKey values with equal lists are equal — good. But if the list is
mutated after the key goes into a HashMap, its hashCode changes and the entry
becomes unreachable: it is still in the map, in a bucket the new hash does not
point at. containsKey says no, iteration still shows it.
The fix is at construction, not at the call site:
record CacheKey(String tenant, List<String> scopes) { CacheKey { scopes = List.copyOf(scopes); // defensive copy, and immutable after }}List.copyOf returns an unmodifiable list and copies, so the caller keeping a
reference to the original cannot reach inside. Note that List.of(...) and
List.copyOf(...) reject nulls and throw UnsupportedOperationException on any
mutation — that is deliberate, not an oversight.
Three places a record is the wrong answer
A JPA entity. JPA needs a no-arg constructor to instantiate a row and mutable fields to write into. A record has neither, and it is final so it cannot be proxied for lazy loading. The pattern that works is records on the outside — request bodies, responses, domain values — and an ordinary class at the persistence boundary. Trying to make records be entities is a fight you lose slowly.
Copy-with-changes at scale. There is no wither in the language, so you write
one:
record Money(long amount, String currency) { Money withAmount(long newAmount) { return new Money(newAmount, currency); }}Fine for two or three components. At twelve, you want a builder — and a record with twelve components is telling you it is really two records.
Anything with identity rather than value. A record says “these components,
together, are the thing”. A User that is the same user after their email
changes has identity, not value equality. Records model values.
The longer treatment, including the wither patterns and the JPA caveats in detail: Java Records in Practice.
Discussion
Loading comments…