Records (JEP 395): Immutable Data Classes Without the Boilerplate
Finalized in Java 16 (JEP 395). Available in all Java 16+ releases, including Java 17. Previous previews: Java 14 (JEP 359) and Java 15 (JEP 384).
The Problem: Data Classes in Java
Writing a simple immutable data class in Java 11 requires significant boilerplate:
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)) return false;
Point point = (Point) o;
return x == point.x && y == point.y;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
@Override
public String toString() {
return "Point[x=" + x + ", y=" + y + "]";
}
}
That is 30 lines for two int fields. IDEs can generate this, but generated code still needs to be maintained — any time you add a field, you must regenerate or manually update equals, hashCode, toString, and the constructor.
Records eliminate all of this.
What Is a Record?
A record is a concise declaration of an immutable data carrier. Its state is fully described by its components (parameters in the header).
public record Point(int x, int y) {}
This single line generates:
- A
private final int xfield - A
private final int yfield - A canonical constructor
Point(int x, int y)that assigns both fields - Accessor methods
x()andy()(notgetX()/getY()) equals()that compares all componentshashCode()that hashes all componentstoString()that returns"Point[x=3, y=4]"
The generated implementations are correct, consistent, and immune to “forgot to update after adding a field” bugs.
Record Basics
Creating and Using Records
record Point(int x, int y) {}
var p1 = new Point(3, 4);
var p2 = new Point(3, 4);
var p3 = new Point(5, 6);
System.out.println(p1); // Point[x=3, y=4]
System.out.println(p1.x()); // 3
System.out.println(p1.y()); // 4
System.out.println(p1.equals(p2)); // true
System.out.println(p1.equals(p3)); // false
System.out.println(p1.hashCode() == p2.hashCode()); // true
Records Are Immutable
Records have no setters. The fields are private final and can only be set via the constructor:
record User(String name, String email) {}
var user = new User("alice", "alice@example.com");
// user.name = "bob"; // does not compile — no setter
// user.setName("bob"); // does not exist
To “modify” a record, create a new instance:
var updated = new User("bob", user.email()); // copy with new name
Customizing Records
Compact Constructors
A compact constructor runs before the canonical constructor’s field assignments. Use it to validate, normalize, or transform inputs:
record Range(int min, int max) {
Range {
if (min > max) throw new IllegalArgumentException(
"min (%d) must be <= max (%d)".formatted(min, max));
}
}
// Usage
new Range(1, 10); // ok
new Range(10, 1); // throws IllegalArgumentException
Note: In a compact constructor, this.min and this.max are assigned after the body runs. You can reassign the components before assignment:
record Name(String first, String last) {
Name {
first = first.strip(); // normalize
last = last.strip();
}
}
Explicit Canonical Constructor
If you need the full constructor syntax (e.g., to call this(...) delegation), use the explicit form:
record Interval(double start, double end) {
Interval(double start, double end) {
if (start > end) throw new IllegalArgumentException();
this.start = start;
this.end = end;
}
}
You cannot have both a compact and an explicit canonical constructor.
Custom Accessors
You can override the generated accessor to add logic:
record Temperature(double celsius) {
public double fahrenheit() {
return celsius * 9.0 / 5.0 + 32;
}
@Override
public double celsius() {
return Math.round(celsius * 10.0) / 10.0; // round to 1 decimal
}
}
var t = new Temperature(36.666);
System.out.println(t.celsius()); // 36.7 (rounded)
System.out.println(t.fahrenheit()); // 98.0
Additional Constructors
Records can have additional constructors, but they must delegate to the canonical constructor:
record Color(int r, int g, int b) {
// Convenience constructor: parse "#RRGGBB"
Color(String hex) {
this(
Integer.parseInt(hex.substring(1, 3), 16),
Integer.parseInt(hex.substring(3, 5), 16),
Integer.parseInt(hex.substring(5, 7), 16)
);
}
}
var red = new Color("#FF0000");
System.out.println(red); // Color[r=255, g=0, b=0]
Instance Methods
Records can have any number of instance methods:
record Vector2D(double x, double y) {
double magnitude() {
return Math.sqrt(x * x + y * y);
}
Vector2D add(Vector2D other) {
return new Vector2D(x + other.x, y + other.y);
}
Vector2D normalize() {
double mag = magnitude();
return new Vector2D(x / mag, y / mag);
}
}
Static Members
Records can have static fields and methods:
record Point(int x, int y) {
static final Point ORIGIN = new Point(0, 0);
static Point fromPolar(double r, double theta) {
return new Point((int)(r * Math.cos(theta)), (int)(r * Math.sin(theta)));
}
}
Records and Interfaces
Records can implement interfaces:
interface Printable {
String formatted();
}
record Invoice(String id, double amount) implements Printable {
@Override
public String formatted() {
return "Invoice %s: $%.2f".formatted(id, amount);
}
}
Records can implement Comparable:
record Version(int major, int minor, int patch) implements Comparable<Version> {
@Override
public int compareTo(Version other) {
int c = Integer.compare(this.major, other.major);
if (c != 0) return c;
c = Integer.compare(this.minor, other.minor);
if (c != 0) return c;
return Integer.compare(this.patch, other.patch);
}
}
var versions = List.of(
new Version(2, 1, 0),
new Version(1, 5, 3),
new Version(2, 0, 1)
);
Collections.sort(versions);
System.out.println(versions);
// [Version[major=1, minor=5, patch=3], Version[major=2, minor=0, patch=1], Version[major=2, minor=1, patch=0]]
Records Restrictions
Records have explicit constraints enforced by the compiler:
| Restriction | Reason |
|---|---|
Cannot extend another class | Records implicitly extend java.lang.Record |
All fields are private final | Immutability guarantee |
| Cannot declare instance fields beyond components | All state is in the header |
Cannot be abstract | Records are concrete data holders |
Can be final (always implicitly final) | No subclassing |
// All of these fail to compile:
record Bad1(int x) extends SomeClass {} // cannot extend
abstract record Bad2(int x) {} // cannot be abstract
record Bad3(int x) {
private int y; // cannot add non-static fields outside components
}
Records with Generics
Records support generic type parameters:
record Pair<A, B>(A first, B second) {
Pair<B, A> swapped() {
return new Pair<>(second, first);
}
}
var pair = new Pair<>("hello", 42);
System.out.println(pair); // Pair[first=hello, second=42]
System.out.println(pair.swapped()); // Pair[first=42, second=hello]
record Result<T>(T value, String error) {
static <T> Result<T> ok(T value) {
return new Result<>(value, null);
}
static <T> Result<T> fail(String error) {
return new Result<>(null, error);
}
boolean isOk() { return error == null; }
}
Result<Integer> r = Result.ok(42);
Result<Integer> e = Result.fail("not found");
Records as API Return Types
Records are ideal for methods that return multiple values without requiring a dedicated class:
record PagedResult<T>(List<T> items, int totalCount, int page, int pageSize) {
int totalPages() {
return (int) Math.ceil((double) totalCount / pageSize);
}
boolean hasNext() {
return page < totalPages() - 1;
}
}
// Usage
PagedResult<User> result = userService.findAll(page = 0, pageSize = 20);
System.out.println("Page 1 of " + result.totalPages());
Records and Serialization
Records support standard Java serialization:
record Point(int x, int y) implements Serializable {}
Records serialize and deserialize correctly — the deserialization process calls the canonical constructor, which means compact constructor validation runs on deserialization too. This is a safety improvement over regular classes, where deserialized objects bypass constructor logic.
JSON serialization with Jackson (requires Jackson 2.12+):
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(new Point(3, 4));
// {"x":3,"y":4}
Point p = mapper.readValue(json, Point.class);
// Point[x=3, y=4]
Jackson recognizes records and uses their canonical constructor for deserialization.
Records vs Lombok @Data and @Value
Teams using Lombok for boilerplate elimination often ask whether records replace Lombok. The answer depends on your needs:
| Feature | Records | Lombok @Data | Lombok @Value |
|---|---|---|---|
| Mutable | No | Yes | No |
| Immutable | Yes | No | Yes |
| Inheritance | No | Yes | No |
| Custom equals | Override | Override | Override |
| Boilerplate | None | Plugin-based | Plugin-based |
| IDE dependency | None | Lombok plugin | Lombok plugin |
| Compile-time | JDK feature | Annotation processing | Annotation processing |
| Generic support | Yes | Yes | Yes |
Use records when:
- You need an immutable data carrier
- The type represents a value: a coordinate, a range, a result, a request/response DTO
- You want zero annotation processor dependencies
Keep Lombok when:
- You need mutable objects with setters
- You need to extend other classes
- You have a large existing Lombok codebase and cannot migrate incrementally
Records and Pattern Matching (Preview in Java 17, Final in Java 21)
Records work beautifully with pattern matching. In Java 21, Record Patterns (JEP 440) allow deconstruction in a single expression:
// Java 21 — record patterns
if (obj instanceof Point(int x, int y)) {
System.out.println("x=" + x + ", y=" + y);
}
In Java 17, you still need the intermediate variable:
// Java 17 — pattern matching for instanceof
if (obj instanceof Point p) {
System.out.println("x=" + p.x() + ", y=" + p.y());
}
Records and sealed classes together (covered in Article 7) form the foundation of algebraic data type modeling in Java.
Common Patterns
Request/Response DTOs
record CreateUserRequest(String username, String email, String role) {}
record CreateUserResponse(String id, String username, Instant createdAt) {}
Domain Value Objects
record Money(BigDecimal amount, Currency currency) {
Money {
Objects.requireNonNull(amount);
Objects.requireNonNull(currency);
if (amount.scale() > 2) throw new IllegalArgumentException("scale > 2");
}
Money add(Money other) {
if (!this.currency.equals(other.currency))
throw new IllegalArgumentException("Currency mismatch");
return new Money(this.amount.add(other.amount), this.currency);
}
}
Event Sourcing Events
sealed interface OrderEvent permits OrderPlaced, OrderShipped, OrderCancelled {}
record OrderPlaced(String orderId, List<String> items, Instant at) implements OrderEvent {}
record OrderShipped(String orderId, String trackingNumber, Instant at) implements OrderEvent {}
record OrderCancelled(String orderId, String reason, Instant at) implements OrderEvent {}
Summary
| Feature | Behavior |
|---|---|
| Declaration | record Point(int x, int y) {} |
| Fields | Auto-generated private final from components |
| Constructor | Auto-generated canonical constructor |
| Accessors | x(), y() — not getX() |
equals | Compares all components |
hashCode | Hashes all components |
toString | "Point[x=3, y=4]" format |
| Compact constructor | Validates/transforms before field assignment |
| Interface implementation | Fully supported |
| Generics | Fully supported |
| Serialization | Calls canonical constructor on deserialization |
| Inheritance | Cannot extend (implicitly extends Record) |
| Mutability | Immutable — no setters |
What’s Next
Article 5: Pattern Matching for instanceof (JEP 394) covers how to eliminate the redundant cast that follows every instanceof check — and how it combines with records for clean, safe type handling.