A record is a class whose job is to carry data. You declare the components; the compiler writes
the constructor, the accessors, equals, hashCode and toString.
One line replaces about sixty.
The sixty lines it replaces
// The old way — and this is the SHORT version, with toString omitted
final class PointOld {
private final int x;
private final int y;
PointOld(int x, int y) {
this.x = x;
this.y = y;
}
int getX() { return x; }
int getY() { return y; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof PointOld other)) return false;
return x == other.x && y == other.y;
}
@Override
public int hashCode() { return Objects.hash(x, y); }
}
// The same thing
record Point(int x, int y) { }
Every one of those hand-written members is a place a bug can hide — an equals that
forgets a field, a hashCode that disagrees with it. The generated versions cannot drift,
because adding a component regenerates all of them.
Using one
record Point(int x, int y) { }
class Demo {
void run() {
Point p = new Point(3, 4);
System.out.println(p.x()); // 3 — accessor is x(), NOT getX()
System.out.println(p); // Point[x=3, y=4] — a real toString
Point same = new Point(3, 4);
System.out.println(p.equals(same)); // true — value equality, generated
System.out.println(p == same); // false — still two objects
Set<Point> points = new HashSet<>();
points.add(p);
System.out.println(points.contains(same)); // true — hashCode agrees with equals
}
}
Note the accessor name: x(), not getX(). Records deliberately break the
JavaBean convention. Most frameworks — Jackson, Spring, JPA to a point — understand records now, but
it is worth knowing when something reflective cannot find your getter.
That HashSet line is the practical payoff. As
Collections showed, a class without equals and
hashCode goes into a hash-based collection and never comes back out. A record cannot have
that bug.
Validation with a compact constructor
Records are not just dumb holders. A compact constructor lets you validate or normalise before the fields are assigned:
Here is a real one, from the console bank app this site uses for examples:
public record User(long id, String email, String password, String fullName, LocalDateTime createdAt) {
public User {
if (email == null || email.isBlank()) {
throw new IllegalArgumentException("A user must have an email");
}
email = email.trim().toLowerCase(); // Normalise once, here, so sign-in never worries about it.
}
public String firstName() {
return fullName.split(" ")[0];
}
public boolean passwordMatches(String attempt) {
return password.equals(attempt);
}
}
Two things to notice. There is no parameter list and no this.email = email; — the
compiler adds the assignments after your block runs. And assigning to the parameter inside the block
changes what actually gets stored, which is how email.trim().toLowerCase() guarantees
every User in the system has a normalised email. Sign-in never has to think about
casing, because no un-normalised User can exist.
Note also that a record is free to have ordinary methods. firstName() and
passwordMatches() are derived behaviour, not stored components — a record is a data
carrier, not a data-only class.
This makes a record a genuinely good domain type: an Order that exists is an
Order whose invariants hold.
What records cannot do
The restrictions are deliberate, and each one follows from "this is a transparent carrier of its components":
- Components are final. There are no setters and there cannot be.
- No extending. A record cannot extend a class and cannot be extended — it is
implicitly
final. It can implement interfaces. - No extra instance fields. Everything a record holds is in its component list. Static fields are allowed.
interface Shape { double area(); }
record Circle(double radius) implements Shape { // interfaces: yes
@Override public double area() { return Math.PI * radius * radius; }
}
record Config(String name) {
static final String DEFAULT = "none"; // static field: fine
// private int cached; // instance field: will not compile
}
Shallow immutability — the trap
A record's components cannot be reassigned. That does not make the objects they point at
immutable, which is the same distinction final has in
static and final:
record Team(String name, List<String> members) { }
class Demo {
void run() {
List<String> members = new ArrayList<>(List.of("Ana"));
Team team = new Team("Ops", members);
members.add("Bo"); // modifying the list from outside
System.out.println(team.members()); // [Ana, Bo] — the record "changed"
}
}
Defend against it in the compact constructor by copying:
record Team(String name, List<String> members) {
Team {
members = List.copyOf(members); // immutable snapshot, taken on construction
}
}
List.copyOf both copies and makes the result unmodifiable, so neither the caller nor
anyone holding the accessor's result can change it afterwards.
When to use one
Use a record for DTOs and API request or response bodies, for value objects
(money, coordinates, an identifier), for a method that needs to return two things, for keys in a
Map or Set, and for test data.
Use a class when the object has mutable state, when it needs to extend something, when it has behaviour rather than data, or when it has to hide its internal representation — a record is transparent by design, and its component list is part of its public API forever.
A short local record declared inside a method is also allowed, and it is often the tidiest way to group intermediate values in a stream pipeline without polluting the package with a type nobody else needs:
class Report {
void summarise(List<String> names) {
record Scored(String name, int length) { } // visible only in this method
names.stream()
.map(n -> new Scored(n, n.length()))
.sorted(Comparator.comparingInt(Scored::length))
.forEach(s -> System.out.println(s.name() + " " + s.length()));
}
}
Next
Records pair with the other Java 17 addition for modelling data. Sealed Classes is next — saying these are the only kinds, and getting the compiler to hold you to it.