Java Records (Java 16+)

Records, introduced in Java 16 (JEP 395), are a special kind of class designed to be a transparent carrier for immutable data. A single-line record declaration auto-generates a canonical constructor, accessor methods, equals(), hashCode(), and toString(), eliminating the boilerplate that makes simple data classes painful to write and maintain.

Contents

Declaration and Auto-generated Members

A record is declared with the record keyword followed by a list of components in parentheses. Each component becomes a private final field plus a public accessor method with the same name (not getX(), just x()).

Java
// Declaration — replaces ~50 lines of boilerplate
record Point(int x, int y) {}

// What the compiler auto-generates:
//  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; }
//  public boolean equals(Object o) { ... }
//  public int hashCode() { ... }
//  public String toString() { return "Point[x=1, y=2]"; }

Point p = new Point(3, 7);
System.out.println(p.x());        // 3
System.out.println(p.y());        // 7
System.out.println(p);            // Point[x=3, y=7]

// equals() compares all components by value
Point p2 = new Point(3, 7);
System.out.println(p.equals(p2)); // true

// Records work perfectly in collections and as map keys
Set<Point> set = new HashSet<>();
set.add(new Point(1, 2));
set.add(new Point(1, 2));         // duplicate — not added
System.out.println(set.size());   // 1
Record fields are always private final and there are no setters. Records are inherently immutable: this makes them thread-safe and safe to use as map keys.

Compact Constructors

A compact constructor lets you add validation or normalization logic without repeating the parameter assignments: the compiler inserts the assignments automatically at the end.

Java
record Range(int min, int max) {
    // Compact constructor — no parameter list, no field assignments needed
    Range {
        if (min > max)
            throw new IllegalArgumentException(
                "min (%d) must be <= max (%d)".formatted(min, max));
        // Compiler inserts: this.min = min; this.max = max;
    }
}

record Person(String name, int age) {
    Person {
        Objects.requireNonNull(name, "name must not be null");
        name = name.strip();  // normalize before assignment
        if (age < 0) throw new IllegalArgumentException("age must be >= 0");
    }
}

Person p = new Person("  Alice  ", 30);
System.out.println(p.name()); // "Alice" — stripped

You can also override the canonical constructor explicitly, but compact constructors are cleaner for validation:

Java
record Coordinate(double lat, double lon) {
    // Explicit canonical constructor (less common — prefer compact)
    Coordinate(double lat, double lon) {
        if (lat < -90 || lat > 90)  throw new IllegalArgumentException("Invalid lat");
        if (lon < -180 || lon > 180) throw new IllegalArgumentException("Invalid lon");
        this.lat = lat;
        this.lon = lon;
    }
}

Adding Custom Methods

Records can have instance methods, static methods, and static fields. They cannot have instance fields beyond their components.

Java
record Money(long cents, String currency) {

    // Static factory method
    public static Money of(double amount, String currency) {
        return new Money(Math.round(amount * 100), currency);
    }

    // Instance method
    public Money add(Money other) {
        if (!this.currency.equals(other.currency))
            throw new IllegalArgumentException("Currency mismatch");
        return new Money(this.cents + other.cents, this.currency);
    }

    // Custom toString (overrides auto-generated)
    @Override
    public String toString() {
        return String.format("%s %.2f", currency, cents / 100.0);
    }

    // Static constant — allowed
    public static final Money ZERO_USD = new Money(0, "USD");
}

Money price = Money.of(19.99, "USD");
Money tax   = Money.of(1.60, "USD");
System.out.println(price.add(tax)); // USD 21.59

Implementing Interfaces

Records can implement interfaces but cannot extend classes (they implicitly extend java.lang.Record). This makes records excellent for implementing value-object interfaces.

Java
interface Shape {
    double area();
    double perimeter();
}

record Circle(double radius) implements Shape {
    public double area()      { return Math.PI * radius * radius; }
    public double perimeter() { return 2 * Math.PI * radius; }
}

record Rectangle(double width, double height) implements Shape {
    public double area()      { return width * height; }
    public double perimeter() { return 2 * (width + height); }
}

// Works great with sealed interfaces for exhaustive pattern matching
List<Shape> shapes = List.of(new Circle(5), new Rectangle(3, 4));
shapes.forEach(s -> System.out.printf("Area=%.2f%n", s.area()));

Records also work seamlessly with Comparable and other standard interfaces:

Java
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);
        return c != 0 ? c : Integer.compare(this.patch, other.patch);
    }
}

var versions = List.of(
    new Version(2, 1, 0),
    new Version(1, 9, 3),
    new Version(2, 0, 1)
);
versions.stream().sorted().forEach(System.out::println);
// Version[major=1, minor=9, patch=3]
// Version[major=2, minor=0, patch=1]
// Version[major=2, minor=1, patch=0]

Records vs Regular Classes

  • Use a record when the class is a pure data carrier: DTOs, value objects, result types, event objects, coordinates, ranges.
  • Use a regular class when you need mutable state, inheritance, lazy initialization, or complex construction logic.
  • Records cannot extend other classes and cannot be abstract.
  • Records are not suitable as JPA/Hibernate entities (no no-arg constructor, no setters).
  • Records are Serializable if you implement the interface, but custom serialization logic (readObject/writeObject) is not allowed.
Java
// Classic DTO use case — a record replaces 30+ lines of boilerplate
record UserDto(long id, String username, String email) {}

// API response wrapper
record ApiResponse<T>(T data, String status, String message) {
    public static <T> ApiResponse<T> ok(T data) {
        return new ApiResponse<>(data, "OK", null);
    }
    public static <T> ApiResponse<T> error(String message) {
        return new ApiResponse<>(null, "ERROR", message);
    }
}

ApiResponse<UserDto> resp = ApiResponse.ok(new UserDto(1, "alice", "alice@example.com"));
System.out.println(resp);
// ApiResponse[data=UserDto[id=1, username=alice, email=alice@example.com], status=OK, message=null]
Records cannot have instance fields beyond their components. Any state beyond what is declared in the component list is not allowed.