Records
Declare immutable data carriers in one line and know where records fit in a service: DTOs, keys, results, but not JPA entities.
record Name(Type first, Type second) {
// optional: static fields, methods, extra constructors
}A record is a class whose whole job is to carry a fixed set of values. You list the components in the header and the compiler writes the rest: a private final field per component, a constructor that takes them all (the canonical constructor), an accessor per component, and equals, hashCode and toString based on every component.
record User(long id, String email) {}
That one line replaces around forty lines of a hand-written class. Accessors are named after the component, so you call user.email(), not getEmail(). toString prints User[id=1, [email protected]], and two records with equal components are equal, which makes them safe as map keys and easy to compare in tests.
What a record cannot do
- It is implicitly
finaland already extendsjava.lang.Record, so it cannot extend another class or be extended. - It cannot declare extra instance fields. Static fields, static and instance methods, nested types and extra constructors are all allowed.
- It can implement interfaces, which is how records join a sealed hierarchy later in this stage.
Where records fit in a back end
Use them for data that crosses a boundary: request and response bodies, rows read from a query, messages on a queue, configuration, composite cache keys, and methods that need to return two or three values together. Jackson has supported records since 2.12 and maps JSON properties to components by name. A record can also be declared locally inside a method, which is handy for an intermediate result in a stream pipeline.
They are not a fit for JPA entities. Jakarta Persistence needs a no-argument constructor and a non-final class it can subclass for lazy-loading proxies, which a final, immutable record cannot offer. Keep entities as classes and map them to records at the edge.
Mistakes people make
Records are only shallowly immutable. The field holding a List cannot be reassigned, but if the caller passed an ArrayList they can still add to it after the record is built. Copy collections in a compact constructor (next lesson). Arrays are worse: the generated equals compares array references, not contents, so two records holding equal byte[] values are not equal. Prefer a List, or override equals and hashCode yourself.
A record also exposes every component through its accessor and its toString. Do not put a password hash or an API token in a record that may end up in a log line, or override toString to mask it.
Example
When to use it
- Model a JSON request body such as CreateUserRequest(String email, String name) that Jackson binds by component name.
- Return a row from a query as a read-only DTO instead of exposing a mutable entity to the web layer.
- Use a record of tenant and user id as the key for an in-memory cache.
More examples
Static factories, methods and a wither
Records can carry behaviour as long as it only reads the components. Because the fields are final, every change returns a new record, so the original price is untouched after plus and withPence.
record Money(long pence, String currency) {
static Money gbp(long pence) {
return new Money(pence, "GBP");
}
Money plus(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("currency mismatch");
}
return new Money(pence + other.pence, currency);
}
// A "wither" returns a copy with one component changed.
Money withPence(long newPence) {
return new Money(newPence, currency);
}
String display() {
return "%s %d.%02d".formatted(currency, pence / 100, pence % 100);
}
}
void main() {
Money price = Money.gbp(1999);
Money total = price.plus(Money.gbp(501));
IO.println(total.display());
IO.println(price.withPence(0));
IO.println(price.display());
}
// Output:
// GBP 25.00
// Money[pence=0, currency=GBP]
// GBP 19.99A composite cache key
The generated equals and hashCode use every component, so a record is a correct map key with no extra code. Concatenating strings such as tenant + ":" + id works too, but is easy to get subtly wrong when a value contains the separator.
record CacheKey(String tenant, long userId) {}
void main() {
Map<CacheKey, String> cache = new HashMap<>();
cache.put(new CacheKey("acme", 42), "Ada Lovelace");
// A new instance with the same components finds the same entry.
IO.println(cache.get(new CacheKey("acme", 42)));
IO.println(cache.get(new CacheKey("globex", 42)));
IO.println(cache.containsKey(new CacheKey("acme", 42)));
}
// Output:
// Ada Lovelace
// null
// trueShallow immutability and arrays
Arrays use reference equality, so records holding them break equality and map lookups. The Tags record changed after construction because it kept the caller's mutable list; a compact constructor with List.copyOf fixes that.
record Payload(byte[] data) {}
record Tags(List<String> values) {}
void main() {
var a = new Payload(new byte[] {1, 2, 3});
var b = new Payload(new byte[] {1, 2, 3});
IO.println("payloads equal: " + a.equals(b));
IO.println("contents equal: " + Arrays.equals(a.data(), b.data()));
List<String> source = new ArrayList<>(List.of("java"));
var tags = new Tags(source);
source.add("sql");
IO.println(tags.values());
}
// Output:
// payloads equal: false
// contents equal: true
// [java, sql]
Discussion