Records in Java

A record declares an immutable data carrier, and the compiler generates the constructor, accessors, equals, hashCode and toString.

Definition

A record is a class whose purpose is to carry data. You declare the components, and the compiler supplies the rest. Records were finalised in Java 16.

public record Point(int x, int y) { }

What the compiler generates

  • A private final field per component.
  • A canonical constructor taking every component in order.
  • An accessor per component, named after it: x(), not getX().
  • equals and hashCode built from all components.
  • toString listing every component.
Point a = new Point(3, 4);
Point b = new Point(3, 4);

System.out.println(a.x());        // 3
System.out.println(a);            // Point[x=3, y=4]
System.out.println(a.equals(b));  // true
System.out.println(a.hashCode() == b.hashCode());   // true

The same thing without a record

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) {
        return o instanceof Point p && p.x == x && p.y == y;
    }
    @Override public int hashCode() { return Objects.hash(x, y); }
    @Override public String toString() { return "Point[x=" + x + ", y=" + y + "]"; }
}

Around thirty lines replaced by one, with no chance of the two equality methods drifting apart.

Validation with a compact constructor

public record Money(String currency, long minorUnits) {

    public Money {                       // compact: no parameter list, no assignments
        Objects.requireNonNull(currency, "currency is required");
        if (minorUnits < 0) {
            throw new IllegalArgumentException("Amount must not be negative");
        }
        currency = currency.toUpperCase();    // normalisation is allowed
    }
}

The compact form runs before the fields are assigned, so reassigning a parameter normalises the stored value. This is where every validation rule belongs.

Adding behaviour

public record Rectangle(double width, double height) {

    public double area() {
        return width * height;
    }

    public Rectangle scaled(double factor) {
        return new Rectangle(width * factor, height * factor);   // return a new one
    }

    public static Rectangle square(double side) {
        return new Rectangle(side, side);
    }
}

A record may declare methods, static members and additional constructors. It simply may not add instance fields beyond its components.

Rules and restrictions

RuleReason
Implicitly finalValue semantics must not be undermined by a subclass
Cannot extend a classIt already extends java.lang.Record
May implement interfacesContracts are still useful
No extra instance fieldsThe state is exactly the components
Components are finalRecords are shallowly immutable

Shallow immutability

public record Team(String name, List<String> members) {

    public Team {
        members = List.copyOf(members);      // defensive copy on the way in
    }
}

Without the copy, a caller keeps a reference to the list and can still modify it after construction. A record freezes the reference, not the object behind it, exactly as final does.

Records with pattern matching

sealed interface Shape permits Circle, Rectangle { }
record Circle(double radius) implements Shape { }
record Rectangle(double width, double height) implements Shape { }

static double area(Shape shape) {
    return switch (shape) {
        case Circle(double r)              -> Math.PI * r * r;
        case Rectangle(double w, double h) -> w * h;
    };
}

Records, sealed types and pattern matching were designed together. A sealed hierarchy of records gives the compiler enough information to check that every case is handled.

Local and nested records

public List<String> topNotes(List<Note> notes) {

    record Scored(Note note, double score) { }   // local to this method

    return notes.stream()
            .map(note -> new Scored(note, rank(note)))
            .sorted(Comparator.comparingDouble(Scored::score).reversed())
            .map(scored -> scored.note().title())
            .toList();
}

When not to use a record

  • The object needs mutable state.
  • The identity matters more than the values, as with a database entity.
  • The fields are an implementation detail that should stay hidden; a record exposes all of them.
  • You need to extend a class.

Common mistakes

  • Expecting getX(). The accessor is x().
  • Assuming deep immutability and storing a mutable collection without copying.
  • Writing the full canonical constructor and then repeating assignments the compact form would have handled.
  • Using a record for an entity whose identity is a database key rather than its contents.

Best practices

  • Use records for DTOs, value objects, method results and map keys.
  • Put validation and normalisation in the compact constructor.
  • Copy mutable components in and out.
  • Combine records with sealed interfaces to model a closed set of alternatives.
  • Give a record behaviour when the behaviour belongs to the data.

Practice

  1. Convert a class with four fields, getters, equals, hashCode and toString into a record.
  2. Add validation to a Percentage record that rejects values outside zero to one hundred.
  3. Show that a record holding a List is not deeply immutable, then fix it.
  4. Write a sealed interface with two record implementations and an exhaustive pattern switch.
  5. Give one situation where a record would be the wrong choice, and explain why.

Conclusion

A record states that a type is its data. The compiler then writes the boilerplate correctly and consistently, leaving you to add validation and any behaviour that genuinely belongs with the values.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Java notes →

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.