The boilerplate problem records solve
Before Java 16, a plain data-holding class required dozens of lines of repetitive code:
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) {
if (this == o) return true;
if (!(o instanceof Point other)) return false;
return x == other.x && y == other.y;
}
@Override public int hashCode() { return Objects.hash(x, y); }
@Override public String toString() { return "Point[x=" + x + ", y=" + y + "]"; }
}
With records (finalized in Java 16, JEP 395):
record Point(int x, int y) {}
One line. The compiler generates everything above automatically.
What the compiler generates
The identifiers in the record header are components. For each component the compiler creates:
- A
private finalfield. - A public accessor method named after the component (
x(), notgetX()). - A canonical constructor that accepts all components.
equals(),hashCode(), andtoString()based on all components.
record Person(String name, int age) {}
Person p = new Person("Alice", 30);
p.name(); // "Alice"
p.age(); // 30
p.toString(); // "Person[name=Alice, age=30]"
p.equals(new Person("Alice", 30)); // true
Immutability — shallow, not deep
Record fields are private final, so a reference to a component cannot be reassigned
after construction. But if a component's type is mutable, the object it refers to can
still change:
record Scores(List<Integer> values) {}
var s = new Scores(new ArrayList<>(List.of(1, 2)));
s.values().add(99); // mutates the list — perfectly legal
For true deep immutability, defensively copy mutable components in the canonical constructor:
record Scores(List<Integer> values) {
Scores {
values = List.copyOf(values); // unmodifiable snapshot
}
}
Rule of thumb: records give you shallow immutability by default. Wrap or copy mutable components yourself when you need a value object that can't be modified from outside.
The canonical and compact constructor
The canonical constructor has the same parameter list as the header. You can override
it for validation or normalisation. The compact form (no parameter list, no explicit
field assignments) is the preferred style — the compiler appends this.x = x; this.y = y;
automatically after your body:
record Range(int min, int max) {
Range { // compact canonical constructor
if (min > max)
throw new IllegalArgumentException("min=%d > max=%d".formatted(min, max));
// compiler inserts: this.min = min; this.max = max;
}
}
You can also add alternative constructors as long as they delegate to the canonical one as their first statement:
record Point(double x, double y) {
Point() { this(0.0, 0.0); }
}
A key safety property: deserialization always invokes the canonical constructor, so
validation in the compact constructor is enforced even when a Point is deserialized from
bytes — unlike regular classes where Java deserialization bypasses constructors entirely.
Records vs regular classes — restrictions
Records are implicitly final (cannot be subclassed) and implicitly extend
java.lang.Record. Beyond the generated members, you cannot add:
- Additional non-static instance fields (only the components exist).
- Superclass declarations (records can't extend any class).
abstractmodifier.
You can add:
- Static fields and static methods.
- Additional instance methods.
- Interface implementations (
implements Comparable<T>,implements Serializable, etc.). - Annotations on components.
record Temperature(double celsius) implements Comparable<Temperature> {
static final double ABS_ZERO = -273.15;
static Temperature ofFahrenheit(double f) {
return new Temperature((f - 32) * 5.0 / 9.0);
}
double toFahrenheit() { return celsius * 9.0 / 5.0 + 32; }
@Override
public int compareTo(Temperature other) {
return Double.compare(celsius, other.celsius);
}
}
Records vs Lombok
Lombok's @Value and @Data were the pre-records workaround. The key differences:
| Aspect | Java Record | Lombok @Value |
|---|---|---|
| Accessor style | x() | getX() (JavaBean) |
| Inheritance | Cannot extend or be subclassed | Normal |
| Build tool | None — JDK feature | Requires annotation processor |
| Jackson compat | Native since Jackson 2.12 | Works with any version |
| Builders | No built-in builder | @Builder available |
When to prefer records: new code on Java 16+, no need for getX() naming, no need
for mutable builders.
When to prefer Lombok: legacy codebase with frameworks expecting getX(), need for
@Builder, or targeting Java < 16.
Jackson integration
Jackson supports records natively since 2.12. Serialization uses the component accessors; deserialization uses the canonical constructor:
record User(String name, int age) {}
var mapper = new ObjectMapper();
String json = mapper.writeValueAsString(new User("Alice", 30));
// {"name":"Alice","age":30}
User u = mapper.readValue(json, User.class);
// User[name=Alice, age=30]
For older Jackson, add -parameters to the compiler flags so parameter names are
preserved in bytecode, and include jackson-module-parameter-names.
Generic records
Type parameters work exactly as on regular classes:
record Pair<A, B>(A first, B second) {}
record Result<T>(T value, String message) {}
var p = new Pair<String, Integer>("hello", 42);
p.first(); // "hello"
p.second(); // 42
Generic records replace generic tuple classes with a concise, self-documenting declaration.
Records as Map keys and Set elements
The generated equals() and hashCode() are based on all components, making records
correct HashMap/HashSet keys immediately:
record Point(int x, int y) {}
var labels = new HashMap<Point, String>();
labels.put(new Point(0, 0), "origin");
System.out.println(labels.get(new Point(0, 0))); // "origin"
With a regular class you'd have to implement equals()/hashCode() manually or rely on
IDE generation (which can drift from the fields over time).
Local records
Records can be declared inside a method — scoped to that method, invisible outside:
List<String> topEmails(List<User> users) {
record Ranked(User user, int score) {} // local record
return users.stream()
.map(u -> new Ranked(u, score(u)))
.sorted(Comparator.comparingInt(Ranked::score).reversed())
.limit(10)
.map(r -> r.user().email())
.toList();
}
Local records are implicitly static. They're ideal for naming intermediate pipeline
values without polluting the top-level namespace.
Records with sealed interfaces and pattern matching
Records compose naturally with sealed interfaces (Java 17) and switch pattern matching (Java 21) to model algebraic data types:
sealed interface Shape permits Circle, Rectangle, Triangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double w, double h) implements Shape {}
record Triangle(double base, double h) implements Shape {}
double area(Shape s) {
return switch (s) {
case Circle(double r) -> Math.PI * r * r;
case Rectangle(double w, double h) -> w * h;
case Triangle(double b, double h) -> 0.5 * b * h;
};
}
The switch is exhaustive (compiler-checked because Shape is sealed) and the
components are destructured inline — no instanceof casts, no accessor calls.
This pattern (sealed interface + record variants + exhaustive switch) is the modern Java replacement for the Visitor design pattern.
Recap
Records (Java 16) eliminate data-class boilerplate by generating equals(),
hashCode(), toString(), and component accessors from a one-line header. Fields are
private final giving shallow immutability — copy mutable components in the compact
constructor for deep immutability. Validation in the compact constructor is enforced on
deserialization too, unlike regular classes. Records are final, can't extend
classes, and can't add instance fields beyond components, but they freely implement
interfaces and carry static members and instance methods. They work with Jackson 2.12+
out of the box, make safe Map keys without manual equals()/hashCode(), and compose
with sealed interfaces and switch pattern matching as a clean alternative to the
Visitor pattern.