Immutable data carriers without boilerplate — records replace POJOs in most scenarios.
Published September 21, 2026
A record (final in Java 16) is a special kind of class for one job: carrying data. You declare the fields once, in the header, and the compiler writes everything else. That means a constructor, accessor methods, equals(), hashCode() and toString(). The result is a class that is immutable, compared by value, and a fraction of the size of the equivalent hand-written class.
// A traditional value class: fields, constructor, accessors, equals, hashCode, toString (~40 lines)
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; }
public int y() { return y; }
@Override public boolean equals(Object o) { /* compare x and y */ }
@Override public int hashCode() { return Objects.hash(x, y); }
@Override public String toString() { return "Point[x=" + x + ", y=" + y + "]"; }
}
// The same thing as a record
public record Point(int x, int y) {}
For record Point(int x, int y) you get:
| Generated member | Behaviour |
|---|---|
private final int x, y | One field per component (the names in the header); always private final |
Canonical constructor Point(int x, int y) | Assigns every field |
Accessors x() and y() | Named after the component, not getX() |
equals(Object) | True when the other object is a Point with equal components |
hashCode() | Derived from all components |
toString() | Point[x=3, y=4] |
The class is implicitly final (no subclasses) and extends java.lang.Record. Because equality is based on the components, two records holding the same data are equal. That's exactly what you want for DTOs, map keys and value objects.
Point a = new Point(3, 4);
Point b = new Point(3, 4);
a == b; // false — different objects
a.equals(b); // true — same data
Set.of(a).contains(b); // true — works as a key because hashCode matches
You'll often want to reject bad data or normalize it. A compact constructor is a constructor without a parameter list. It runs before the fields are assigned, and you can reassign the parameters:
public record Email(String value) {
public Email { // compact constructor
Objects.requireNonNull(value, "email is required");
value = value.trim().toLowerCase(); // normalize before assignment
if (!value.contains("@")) throw new IllegalArgumentException("invalid email: " + value);
}
}
This is a big part of why records are useful: once an Email exists, it's guaranteed valid. Code that receives an Email never has to re-check it.
public record Money(BigDecimal amount, String currency) implements Comparable<Money> {
public static final Money ZERO_USD = new Money(BigDecimal.ZERO, "USD"); // static fields: allowed
public static Money usd(String amount) { // static factories: allowed
return new Money(new BigDecimal(amount), "USD");
}
public Money plus(Money other) { // instance methods: allowed
if (!currency.equals(other.currency)) throw new IllegalArgumentException("currency mismatch");
return new Money(amount.add(other.amount), currency); // "change" = return a new record
}
@Override public int compareTo(Money o) { return amount.compareTo(o.amount); } // interfaces: allowed
}
What records cannot have:
Record, although they can implement any interfaces.final, and there are no setters.A record's fields are final, but the objects they point to may not be:
public record Order(String id, List<String> items) {}
List<String> list = new ArrayList<>(List.of("book"));
Order order = new Order("o1", list);
list.add("pen"); // the "immutable" order now has 2 items!
order.items().add("mug"); // and callers can modify it through the accessor too
The fix is a defensive copy in the compact constructor:
public record Order(String id, List<String> items) {
public Order {
items = List.copyOf(items); // unmodifiable copy; also rejects null elements
}
}
Arrays have a second problem. The generated equals() compares array components by reference, not content, so two records holding equal arrays are not equal. Prefer List over arrays in records.
public record CreateUserRequest(@NotBlank String name, @Email @NotBlank String email) {}
Money, Email, DateRange. Validated once, compared by value.record Cell(int row, int col) as a HashMap key in grid/graph problems. It's correct equals/hashCode for free.Pair class: record MinMax(int min, int max).instanceof and switch, e.g. if (shape instanceof Circle(var r)). See the Pattern Matching lesson.ShoppingCart that items are added to). A record models values, not things that change over time.@ValueLombok's @Value produces a similar immutable class, but through an annotation processor and with getX()-style getters. Records are part of the language, so they need no library or IDE plugin, and they work with pattern matching. Lombok still offers extras such as builders (@Builder), which records don't have. For plain data carriers on Java 16+, records are the default choice.
Q: Are records truly immutable?
A: Only shallowly. The fields are final and there are no setters, but a component that refers to a mutable object (a List, an array, a Date) can still be changed through that reference. Make them deeply immutable with defensive copies in the compact constructor (List.copyOf) and immutable component types.
Q: Can you override the generated accessor, equals or toString?
A: Yes. Declare a method with the same signature and yours replaces the generated one. That's useful, for example, to mask a password in toString(). Keep the contract, though: an accessor should return the component's value, and equals/hashCode should stay consistent with each other.
Q: Why can't a record extend another class?
A: A record implicitly extends java.lang.Record, and Java has single inheritance. The deeper reason is that a record's whole state must be described by its header, and inherited fields from a superclass would break that. Records can implement interfaces, which is how you share behaviour between them.
Q: Can a record be used as a Spring @ConfigurationProperties class?
A: Yes, since Spring Boot 2.6. Properties are bound through the canonical constructor. That makes configuration immutable and validated at startup, which is a nice fit.
Q: What's the difference between the canonical constructor and a compact constructor? A: The canonical constructor has the full parameter list and assigns every field. You can write it explicitly if you need to. The compact constructor is shorthand for it with no parameter list: you write only the validation and normalization logic, and the compiler adds the field assignments at the end.