Part 4 of 14

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 x field
  • A private final int y field
  • A canonical constructor Point(int x, int y) that assigns both fields
  • Accessor methods x() and y() (not getX() / getY())
  • equals() that compares all components
  • hashCode() that hashes all components
  • toString() 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:

RestrictionReason
Cannot extend another classRecords implicitly extend java.lang.Record
All fields are private finalImmutability guarantee
Cannot declare instance fields beyond componentsAll state is in the header
Cannot be abstractRecords 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:

FeatureRecordsLombok @DataLombok @Value
MutableNoYesNo
ImmutableYesNoYes
InheritanceNoYesNo
Custom equalsOverrideOverrideOverride
BoilerplateNonePlugin-basedPlugin-based
IDE dependencyNoneLombok pluginLombok plugin
Compile-timeJDK featureAnnotation processingAnnotation processing
Generic supportYesYesYes

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

FeatureBehavior
Declarationrecord Point(int x, int y) {}
FieldsAuto-generated private final from components
ConstructorAuto-generated canonical constructor
Accessorsx(), y() — not getX()
equalsCompares all components
hashCodeHashes all components
toString"Point[x=3, y=4]" format
Compact constructorValidates/transforms before field assignment
Interface implementationFully supported
GenericsFully supported
SerializationCalls canonical constructor on deserialization
InheritanceCannot extend (implicitly extends Record)
MutabilityImmutable — 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.