Why this matters
- Data carriers are everywhere — DTOs, map keys, method return values — and writing them by hand means fifty lines where one would do, plus a real risk of an
equalsthat forgets a field. - Records are the foundation for sealed hierarchies and record patterns, so they are the entry point to modern data-oriented Java.
- Knowing exactly what a record does and does not give you is the difference between using them well and reaching for one where a class belongs.
The declaration
public record Point(int x, int y) { }
That single line produces everything below.
// What the compiler generates
public final class Point {
private final int x;
private final int y;
public Point(int x, int y) { this.x = x; this.y = y; }
public int x() { return x; } // note: x(), not getX()
public int y() { return y; }
@Override public boolean equals(Object o) { /* compares x and y */ }
@Override public int hashCode() { /* derived from x and y */ }
@Override public String toString() { return "Point[x=" + x + ", y=" + y + "]"; }
}
Fields are final and the class is implicitly final, so a record is immutable by construction.
Point a = new Point(3, 4);
Point b = new Point(3, 4);
System.out.println(a); // Point[x=3, y=4]
System.out.println(a.equals(b)); // true — value equality for free
System.out.println(a.x()); // 3
Set.of(a, b); // size 1 — hashCode is consistent
What you get, and what you give up
Guaranteed by the compiler
- All fields are
private final— the record is shallowly immutable. - The class is
final— it cannot be extended. - A canonical constructor taking every component in declaration order.
- An accessor per component, named exactly like the component with no
getprefix. equalsandhashCodecomparing every component, always consistent with each other.toStringlisting every component with its name.
What a record cannot do
- Extend another class. Every record implicitly extends
java.lang.Record. - Declare instance fields beyond its components. State lives only in the header.
- Be non-final or abstract, so no record hierarchies.
- Be mutable. There are no setters and you cannot add them meaningfully.
A record can implement interfaces, declare static fields and methods, add instance methods, define nested types, and carry annotations — so it is a real class, just a constrained one.
public record Money(BigDecimal amount, Currency currency) implements Comparable<Money> {
public static final Money ZERO_USD = new Money(BigDecimal.ZERO, Currency.getInstance("USD"));
public Money plus(Money other) {
require(other.currency.equals(currency), "currency mismatch");
return new Money(amount.add(other.amount), currency); // returns a new value
}
@Override
public int compareTo(Money other) {
return amount.compareTo(other.amount);
}
}
Validation with a compact constructor
The compact constructor has no parameter list and runs before the fields are assigned. It is the right place for validation and normalisation.
public record Range(int start, int end) {
public Range { // compact — no parameter list
if (start > end) {
throw new IllegalArgumentException("start must not exceed end");
}
}
}
public record Email(String value) {
public Email {
Objects.requireNonNull(value, "value");
value = value.trim().toLowerCase(Locale.ROOT); // reassigning normalises the field
}
}
// Additional constructors must delegate to the canonical one
public record Range(int start, int end) {
public Range {
if (start > end) throw new IllegalArgumentException("start must not exceed end");
}
public Range(int end) {
this(0, end); // delegation is mandatory
}
}
The shallow immutability trap
public record Team(String name, List<String> members) { }
List<String> members = new ArrayList<>(List.of("ana"));
Team team = new Team("core", members);
members.add("bo"); // the record's list changed
team.members().add("cy"); // so did this
System.out.println(team.members()); // [ana, bo, cy]
The members reference is final. The list it points at is not. Defensive copying in the compact
constructor fixes it.
public record Team(String name, List<String> members) {
public Team {
members = List.copyOf(members); // unmodifiable snapshot, rejects nulls
}
}
When a record is the right choice
| Aspect | Use a record | Use a class |
|---|---|---|
| Purpose | Carry data — the state is the API | Encapsulate behaviour or hide representation |
| Equality | Two instances with the same values are the same | Identity matters, or equality is partial |
| Mutability | Values never change after construction | State evolves over its lifetime |
| Inheritance | None needed | Must extend a base class or be extended |
| Fields | Exactly the declared components | Derived caches, lazy fields, extra state |
| Typical examples | DTO, map key, coordinates, a query result, an event | Service, entity with a lifecycle, builder |
Purpose
Use a recordCarry data — the state is the APIUse a classEncapsulate behaviour or hide representationEquality
Use a recordTwo instances with the same values are the sameUse a classIdentity matters, or equality is partialMutability
Use a recordValues never change after constructionUse a classState evolves over its lifetimeInheritance
Use a recordNone neededUse a classMust extend a base class or be extendedFields
Use a recordExactly the declared componentsUse a classDerived caches, lazy fields, extra stateTypical examples
Use a recordDTO, map key, coordinates, a query result, an eventUse a classService, entity with a lifecycle, builder
The test is whether exposing every field as a public accessor is acceptable. If not, use a class.
// Records shine as compound map keys — equals and hashCode are guaranteed correct
record CacheKey(String region, String locale, int version) { }
Map<CacheKey, Response> cache = new HashMap<>();
cache.put(new CacheKey("eu", "en", 3), response);
Response hit = cache.get(new CacheKey("eu", "en", 3)); // found
// And as multi-value returns, replacing an out-parameter or an Object[]
record SplitResult(List<String> valid, List<String> rejected) { }
// And as local records inside a method, scoped to where they are needed
void report(List<Order> orders) {
record Row(String customer, BigDecimal total) { }
List<Row> rows = orders.stream().map(o -> new Row(o.customer(), o.total())).toList();
}
Records and serialization frameworks
// Jackson needs no annotations since 2.12 — it uses the canonical constructor
record UserDto(String id, String email) { }
// A JPA @Entity cannot be a record — JPA needs a no-arg constructor and mutable fields.
// Records are still ideal for JPA projections:
interface OrderRepository extends Repository<Order, Long> {
List<OrderSummary> findSummaries();
}
record OrderSummary(Long id, String customer, BigDecimal total) { }
Common misreadings
- "A record is deeply immutable." Only its references are final. Copy mutable components defensively.
- "Records have getters." The accessor is
x(), notgetX(). Some frameworks configured for the bean convention need this pointed out. - "A record can extend a class." It already extends
java.lang.Record. It can only implement interfaces. - "A record can add fields." Instance fields are limited to the components. Static fields are allowed.
- "The compact constructor runs after assignment." It runs before, which is why reassigning a parameter normalises the stored value.
- "Records replace classes." They replace data carriers. Anything with behaviour or hidden state stays a class.
- "Adding a component is a safe change." It changes the canonical constructor signature and
equals, so it is a breaking change for callers.
Quick recall
Everything you need if you only revisit this box.
- A record declares its components in the header; the compiler generates private final fields, a canonical constructor, accessors,
equals,hashCodeandtoString. - Accessors are named
x(), notgetX(). - Records are implicitly final, extend
java.lang.Record, and cannot extend anything else — but they can implement interfaces. - No instance fields beyond the components. Static fields, static and instance methods, and nested types are fine.
- The compact constructor runs before field assignment — validate there, and reassign the parameter to normalise.
- Immutability is shallow: use
List.copyOforclone()for mutable components. - Ideal for DTOs, compound map keys, multi-value returns and local record types. Not for JPA entities.
- Use a record when the state is the API; use a class when behaviour or encapsulation matters.
Test yourself
Answer these before moving on — recall is what makes it stick.