PrepZone Logo
PrepZone

Records

Immutable data carriers in one line, with validation in a compact constructor when you need it.

Why this matters

  • Data carriers are everywhere — DTOs, map keys, method return values — and writing them by hand means fifty lines where one would do, plus a real risk of an equals that forgets a field.
  • Records are the foundation for sealed hierarchies and record patterns, so they are the entry point to modern data-oriented Java.
  • Knowing exactly what a record does and does not give you is the difference between using them well and reaching for one where a class belongs.

The declaration

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

That single line produces everything below.

Java
// What the compiler generates
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; }          // note: x(), not getX()
    public int y() { return y; }

    @Override public boolean equals(Object o) { /* compares x and y */ }
    @Override public int hashCode() { /* derived from x and y */ }
    @Override public String toString() { return "Point[x=" + x + ", y=" + y + "]"; }
}
Hand-written class
private final fields
Constructor
Getters
equals + hashCode
toString
record Point(int x, int y)
One lineEverything above generated

Fields are final and the class is implicitly final, so a record is immutable by construction.

A record is a transparent carrier for its components. The compiler writes the constructor, accessors, equals, hashCode and toString from the header line.
Java
Point a = new Point(3, 4);
Point b = new Point(3, 4);

System.out.println(a);                    // Point[x=3, y=4]
System.out.println(a.equals(b));           // true — value equality for free
System.out.println(a.x());                 // 3
Set.of(a, b);                              // size 1 — hashCode is consistent

What you get, and what you give up

Guaranteed by the compiler

  • All fields are private final — the record is shallowly immutable.
  • The class is final — it cannot be extended.
  • A canonical constructor taking every component in declaration order.
  • An accessor per component, named exactly like the component with no get prefix.
  • equals and hashCode comparing every component, always consistent with each other.
  • toString listing every component with its name.

What a record cannot do

  • Extend another class. Every record implicitly extends java.lang.Record.
  • Declare instance fields beyond its components. State lives only in the header.
  • Be non-final or abstract, so no record hierarchies.
  • Be mutable. There are no setters and you cannot add them meaningfully.

A record can implement interfaces, declare static fields and methods, add instance methods, define nested types, and carry annotations — so it is a real class, just a constrained one.

Java
public record Money(BigDecimal amount, Currency currency) implements Comparable<Money> {

    public static final Money ZERO_USD = new Money(BigDecimal.ZERO, Currency.getInstance("USD"));

    public Money plus(Money other) {
        require(other.currency.equals(currency), "currency mismatch");
        return new Money(amount.add(other.amount), currency);      // returns a new value
    }

    @Override
    public int compareTo(Money other) {
        return amount.compareTo(other.amount);
    }
}

Validation with a compact constructor

The compact constructor has no parameter list and runs before the fields are assigned. It is the right place for validation and normalisation.

Java
public record Range(int start, int end) {

    public Range {                                   // compact — no parameter list
        if (start > end) {
            throw new IllegalArgumentException("start must not exceed end");
        }
    }
}
Java
public record Email(String value) {

    public Email {
        Objects.requireNonNull(value, "value");
        value = value.trim().toLowerCase(Locale.ROOT);     // reassigning normalises the field
    }
}
Java
// Additional constructors must delegate to the canonical one
public record Range(int start, int end) {

    public Range {
        if (start > end) throw new IllegalArgumentException("start must not exceed end");
    }

    public Range(int end) {
        this(0, end);                                 // delegation is mandatory
    }
}

The shallow immutability trap

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

List<String> members = new ArrayList<>(List.of("ana"));
Team team = new Team("core", members);

members.add("bo");                                // the record's list changed
team.members().add("cy");                          // so did this
System.out.println(team.members());                // [ana, bo, cy]

The members reference is final. The list it points at is not. Defensive copying in the compact constructor fixes it.

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

    public Team {
        members = List.copyOf(members);            // unmodifiable snapshot, rejects nulls
    }
}

When a record is the right choice

AspectUse a recordUse a class
PurposeCarry data — the state is the APIEncapsulate behaviour or hide representation
EqualityTwo instances with the same values are the sameIdentity matters, or equality is partial
MutabilityValues never change after constructionState evolves over its lifetime
InheritanceNone neededMust extend a base class or be extended
FieldsExactly the declared componentsDerived caches, lazy fields, extra state
Typical examplesDTO, map key, coordinates, a query result, an eventService, entity with a lifecycle, builder
  • Purpose

    Use a recordCarry data — the state is the API
    Use a classEncapsulate behaviour or hide representation
  • Equality

    Use a recordTwo instances with the same values are the same
    Use a classIdentity matters, or equality is partial
  • Mutability

    Use a recordValues never change after construction
    Use a classState evolves over its lifetime
  • Inheritance

    Use a recordNone needed
    Use a classMust extend a base class or be extended
  • Fields

    Use a recordExactly the declared components
    Use a classDerived caches, lazy fields, extra state
  • Typical examples

    Use a recordDTO, map key, coordinates, a query result, an event
    Use a classService, entity with a lifecycle, builder

The test is whether exposing every field as a public accessor is acceptable. If not, use a class.

Java
// Records shine as compound map keys — equals and hashCode are guaranteed correct
record CacheKey(String region, String locale, int version) { }

Map<CacheKey, Response> cache = new HashMap<>();
cache.put(new CacheKey("eu", "en", 3), response);
Response hit = cache.get(new CacheKey("eu", "en", 3));      // found

// And as multi-value returns, replacing an out-parameter or an Object[]
record SplitResult(List<String> valid, List<String> rejected) { }

// And as local records inside a method, scoped to where they are needed
void report(List<Order> orders) {
    record Row(String customer, BigDecimal total) { }
    List<Row> rows = orders.stream().map(o -> new Row(o.customer(), o.total())).toList();
}

Records and serialization frameworks

Java
// Jackson needs no annotations since 2.12 — it uses the canonical constructor
record UserDto(String id, String email) { }

// A JPA @Entity cannot be a record — JPA needs a no-arg constructor and mutable fields.
// Records are still ideal for JPA projections:
interface OrderRepository extends Repository<Order, Long> {
    List<OrderSummary> findSummaries();
}
record OrderSummary(Long id, String customer, BigDecimal total) { }

Common misreadings

  • "A record is deeply immutable." Only its references are final. Copy mutable components defensively.
  • "Records have getters." The accessor is x(), not getX(). Some frameworks configured for the bean convention need this pointed out.
  • "A record can extend a class." It already extends java.lang.Record. It can only implement interfaces.
  • "A record can add fields." Instance fields are limited to the components. Static fields are allowed.
  • "The compact constructor runs after assignment." It runs before, which is why reassigning a parameter normalises the stored value.
  • "Records replace classes." They replace data carriers. Anything with behaviour or hidden state stays a class.
  • "Adding a component is a safe change." It changes the canonical constructor signature and equals, so it is a breaking change for callers.

Quick recall

Everything you need if you only revisit this box.

  • A record declares its components in the header; the compiler generates private final fields, a canonical constructor, accessors, equals, hashCode and toString.
  • Accessors are named x(), not getX().
  • Records are implicitly final, extend java.lang.Record, and cannot extend anything else — but they can implement interfaces.
  • No instance fields beyond the components. Static fields, static and instance methods, and nested types are fine.
  • The compact constructor runs before field assignment — validate there, and reassign the parameter to normalise.
  • Immutability is shallow: use List.copyOf or clone() for mutable components.
  • Ideal for DTOs, compound map keys, multi-value returns and local record types. Not for JPA entities.
  • Use a record when the state is the API; use a class when behaviour or encapsulation matters.

Test yourself

Answer these before moving on — recall is what makes it stick.