Java Records: Stop Writing Boilerplate for Simple Data Carriers
Java records (finalized in JDK 16) eliminate the constructor/getter/equals/hashCode/toString ceremony for data carriers. A worked Point2D example, bytecode inspection, and honest limitations.
29 Oct 2025, 01:40 UTC

If you've ever written a Java class just to hold two or three values, you know the drill: declare private final fields, write a constructor, write getters, then generate equals, hashCode, and toString from your IDE. Thirty lines later, you have a class whose entire purpose was "an x and a y." The boilerplate doesn't just waste time — it buries intent. A reader has to scan all that ceremony to confirm the class really is just data.
Java records, finalized in JDK 16 (JEP 395), solve this directly. Declare the components, and the compiler generates the constructor, accessors, equals, hashCode, and toString for you. This post shows what records actually give you, walks through a working example, and covers where they don't fit.
Declaring a record is one line
A record declaration names the type and its components — the pieces of data it carries:
public record Point2D(double x, double y) { }
From that single line, the compiler synthesizes a final class with:
- private final fields
xandy - a canonical constructor taking both components in declared order
- accessor methods named
x()andy()— note: notgetX() equals,hashCode, andtoStringbased on all components
The accessor naming trips people up coming from JavaBeans conventions. Records deliberately drop the get prefix, which reads nicely in pipelines but means frameworks that rely on JavaBeans introspection may need configuration or updated versions to work with records.
A worked example: points through a stream
Here's a complete, compilable program. Save it as Point2D.java and run it with JDK 16 or later:
import java.util.List;
public record Point2D(double x, double y) {
// You can still add your own methods.
double distanceFromOrigin() {
return Math.hypot(x, y);
}
public static void main(String[] args) {
List<Point2D> points = List.of(
new Point2D(1.0, 2.0),
new Point2D(0.5, 0.5),
new Point2D(3.0, 4.0)
);
points.stream()
.filter(p -> p.distanceFromOrigin() > 1.0)
.forEach(System.out::println);
}
}
Compile and run from the directory containing the file (no special permissions needed):
javac Point2D.java
java Point2D
You should see the auto-generated toString output, formatted like Point2D[x=1.0, y=2.0], for the two points whose distance from the origin exceeds 1.0. Nothing here is magic — the stream and filter work exactly as they would with a hand-written class, but the class definition was one line plus one method you actually cared about.
To confirm what the compiler built, inspect the bytecode:
javap -p Point2D
You'll see the private final fields, the canonical constructor, and the accessors — useful proof that the record declaration is doing real work rather than hiding a library call.
Records play well with modern Java
Because records are shallowly immutable and have value-based equality, they work naturally as map keys, in sets, and in pattern matching. With JDK 16+, instanceof patterns combine cleanly:
if (obj instanceof Point2D p) {
System.out.println(p.x() + ", " + p.y());
}
On JDK 21 and later, record patterns go further and destructure in place:
if (obj instanceof Point2D(double x, double y)) {
System.out.println(x + ", " + y);
}
You can also customize behavior without losing the generated members. A compact constructor lets you validate without re-declaring assignments:
public record Point2D(double x, double y) {
public Point2D {
if (Double.isNaN(x) || Double.isNaN(y)) {
throw new IllegalArgumentException("coordinates must be numbers");
}
}
}
The trade-offs you should know before committing
Records are not a general replacement for classes:
- They are implicitly final and cannot extend another class (they can implement interfaces). Inheritance-heavy designs are out.
- They are shallowly immutable. The fields are final, but if a component is a mutable object like an
ArrayList, callers can still mutate that list. Records don't defensively copy for you. - No additional instance fields. All state must live in the component list. You can add static fields and methods, but not per-instance state outside the components.
- Framework compatibility varies. Modern versions of Jackson, Hibernate (as embeddables or DTO projections), and Spring handle records well, but older tooling that expects a no-arg constructor and setters will not. Check your stack before converting DTOs wholesale.
If you need mutable state, lazy computation backed by hidden fields, or a class hierarchy, write a regular class. That isn't a failure of records — the restriction is what makes the generated semantics trustworthy.
Where to start
Pick one obvious data carrier in your codebase — a coordinate, a range, an API response DTO — and convert it to a record. Compile, run your tests, and use javap -p if you want to see exactly what the compiler produced. If your tests pass and the call sites read better with x() instead of getX(), you've found the right use case. Records won't redesign your architecture, but they'll delete hundreds of lines that were never saying anything anyway.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.