W3docs

Java Records In Depth

A deeper dive into Java records — canonical and compact constructors, validation, and use cases.

A record is Java's way of declaring a class whose whole job is to carry data. Introduced as a preview in Java 14 and finalized in Java 16, a record collapses the usual boilerplate — private final fields, a constructor, accessors, equals, hashCode, and toString — into a single header line. The earlier records chapter showed the basic syntax; this one goes deeper into how records actually behave: their canonical and compact constructors, how they enforce invariants, what immutability guarantees you get, and where they fit (and where they do not).

What the compiler generates for you

When you write record Point(int x, int y) {}, the compiler emits a final class with two private final fields, a public constructor that takes both, public accessor methods named exactly after the components (x(), y() — no get prefix), and value-based equals, hashCode, and toString.

record Point(int x, int y) {}

// Equivalent to (roughly) hand-writing:
// final class Point {
//   private final int x;
//   private final int y;
//   Point(int x, int y) { this.x = x; this.y = y; }
//   int x() { return x; }
//   int y() { return y; }
//   public boolean equals(Object o) { ... compares x and y ... }
//   public int hashCode() { ... derived from x and y ... }
//   public String toString() { return "Point[x=" + x + ", y=" + y + "]"; }
// }

The x and y in the header are the record's components. The compiler-generated members are derived entirely from them, in declaration order.

Canonical and compact constructors

Every record has a canonical constructor whose parameters match the components. You rarely write it in full — instead you use the compact constructor, which omits the parameter list and the trailing this.field = field assignments. The compiler runs your code first, then assigns the (possibly modified) parameters to the fields. It is the natural home for validation and normalization.

record Range(int low, int high) {
  Range {                                  // compact constructor — no (int low, int high)
    if (low > high) {
      throw new IllegalArgumentException("low must be <= high");
    }
    low = Math.max(low, 0);                // reassigning the parameter normalizes the field
  }
}

If you ever need the explicit canonical form (for example, to defensively copy a mutable component), write the full signature and do the assignments yourself:

record Tags(String name, List<String> values) {
  Tags(String name, List<String> values) {           // explicit canonical constructor
    this.name = name;
    this.values = List.copyOf(values);               // defensive, unmodifiable copy
  }
}

Immutability and what records are not

Record fields are final, so the reference each component holds never changes after construction. That makes records shallowly immutable. But immutability stops at the reference: if a component points to a mutable object (like an ArrayList), callers who share that object can still mutate its contents. Defensive copies in the canonical constructor close that gap.

PropertyRecordsRegular classes
Fieldsalways private finalyour choice
Classimplicitly finalextendable unless final
Superclassalways java.lang.Recordany (default Object)
Accessorsauto-generated, no get prefixhand-written
equals/hashCodevalue-based, generatedidentity by default
Settersnone — immutableallowed

Because a record always extends java.lang.Record, it cannot extend another class. It can still implement interfaces, declare static members, and add instance methods.

Adding behavior, statics, and factories

A record is still a class. You can give it extra methods, static factory methods, static fields, and even nested types. The components define the state; everything else is ordinary Java.

record Money(String currency, long cents) {
  static Money of(String currency, long cents) {     // static factory
    return new Money(currency, cents);
  }
  Money plus(Money other) {                          // derived behavior
    if (!currency.equals(other.currency)) {
      throw new IllegalArgumentException("currency mismatch");
    }
    return new Money(currency, cents + other.cents); // returns a new value
  }
}

Records also pair naturally with sealed types and pattern matching, modelling closed sets of data shapes — the backbone of algebraic-style data design in modern Java. A sealed interface fixes the set of permitted record implementations, and a switch over those records can deconstruct each one by its components in a single expression.

A worked example: records end to end

This program exercises a record's generated members, proves the immutability and class properties via reflection, enforces an invariant in a compact constructor, lists the record components in declaration order, and shows records working with collections and added behavior.

java— editable, runs on the server

What to take from the run:

  • The Point you never wrote a body for still printed Point[x=3, y=4], answered a.x(), and reported equals by value: true with matching hash codes — the compiler generated value-based toString, accessors, equals, and hashCode from the two components alone.
  • Reflection confirmed the contract the language guarantees: is final class : true (records cannot be subclassed) and is a record : true (every record extends java.lang.Record), which is why there are no setters and the fields are immutable.
  • The Range(9, 2) call was rejected with low must be <= high. The compact constructor ran before the fields were assigned, so a record is never constructed in an invalid state — validation belongs there, not in a separate factory check.
  • getRecordComponents() returned the components in declaration order as low:int high:int, showing that a record's structure is reflectively introspectable — the basis for serialization libraries and frameworks that map records automatically.
  • Money.of("USD", 500).plus(Money.of("USD", 250)) produced USD 750, and distinct() collapsed two identical Point(0,0) values to leave 2 — records behave like proper values everywhere, including streams and sets, precisely because their equals/hashCode compare contents.

When to use a record (and when not to)

Reach for a record when the type is defined by its data and that data does not change after construction:

  • DTOs and API request/response payloads.
  • Map keys and set elements (value-based equals/hashCode come for free).
  • Return types that bundle several values, replacing throwaway tuples or out-parameters.
  • The "leaves" of a sealed hierarchy you destructure with pattern matching.

Prefer a regular class when:

  • The object has mutable state or a lifecycle (entities, builders, services).
  • You need to extend another class — records can only implement interfaces.
  • The identity of the object matters more than its contents (you want reference equality).

A common pitfall: a record's accessor returns the stored reference as-is. If a component is a mutable type (a List, an array, a Date), copy it defensively in the canonical constructor — as the Tags example above does with List.copyOf — otherwise callers can mutate the record's "immutable" state through the reference they passed in.

Practice

Practice
What does a record's compact constructor (for example 'Range { ... }') let you do that an ordinary explicit constructor body would otherwise require more code for?
What does a record's compact constructor (for example 'Range { ... }') let you do that an ordinary explicit constructor body would otherwise require more code for?
Was this page helpful?