Ch. 7 · Java

Java Records and Shallow Immutability

What Java records generate, why they are only shallowly immutable, how compact constructors copy and validate, and where records do not fit.

~6 min readbeginnerupdated Oct 6, 2026

Since records became final in Java 16, interviewers ask: “What is a record, and is it immutable?” The honest answer is “shallowly”, and the follow-up is where candidates separate themselves: “Then how would you make a record holding a list truly immutable?” They may also ask when not to use a record, to check that you know the boundaries of the feature rather than treating it as a shorter way to write any class.

Records are now everywhere in modern Java code: DTOs in Spring Boot 3 controllers, configuration properties, map keys and messages between services. Knowing exactly what they generate and what they leave to you is practical knowledge, not trivia.

Before you start

You should know how to write a regular Java class with a constructor, getters, equals, hashCode and toString. Understand the equals and hashCode contract and the difference between a final reference and an immutable object. Records require Java 16 or later; examples here compile on Java 17 and 21.

The short answer

A record is a transparent carrier for a fixed set of values. Declaring record Point(int x, int y) gives you private final fields, a canonical constructor, accessors named x() and y(), and equals, hashCode and toString based on all components. Records are implicitly final and cannot extend other classes. They are only shallowly immutable: a List component can still be modified unless you copy it in a compact constructor with List.copyOf.

How it works

This one line:

public record Money(String currency, long cents) {}
java

compiles to roughly the following class:

public final class Money extends java.lang.Record {
    private final String currency;
    private final long cents;

    public Money(String currency, long cents) {
        this.currency = currency;
        this.cents = cents;
    }

    public String currency() { return currency; }
    public long cents() { return cents; }

    @Override public boolean equals(Object o) { /* compares currency and cents */ }
    @Override public int hashCode() { /* combines currency and cents */ }
    @Override public String toString() { return "Money[currency=" + currency + ", cents=" + cents + "]"; }
}
java

The rules that follow from this design:

  • Records can implement interfaces, declare static fields and methods, and add instance methods.
  • They cannot declare additional instance fields beyond the components, and cannot extend a class.
  • Accessors use the component name, so it is money.cents(), not getCents().
  • equals compares reference components with Objects.equals and primitive components with the wrapper’s compare method, so two double components holding NaN are equal.

The compact constructor is a special form of the canonical constructor without the parameter list. It runs before the implicit field assignments, and you can reassign the parameters inside it:

public record Money(String currency, long cents) {
    public Money {
        Objects.requireNonNull(currency, "currency");
        if (currency.length() != 3) {
            throw new IllegalArgumentException("ISO 4217 code expected: " + currency);
        }
        currency = currency.toUpperCase(Locale.ROOT);   // normalised before assignment
    }
}
java

Step-by-step walkthrough

Step 1: Spot the shallow immutability problem

record Order(String id, List<String> items) {}

List<String> items = new ArrayList<>(List.of("book"));
Order order = new Order("o-1", items);
items.add("laptop");                   // changes the record's state
order.items().clear();                 // so does this
java

The items field is final, so it always points to the same list, but the list itself is mutable and shared with the caller.

Step 2: Copy mutable components in the compact constructor

record Order(String id, List<String> items) {
    Order {
        Objects.requireNonNull(id);
        items = List.copyOf(items);    // unmodifiable copy, rejects null elements
    }
}
java

Because the generated accessor returns the field, and the field now holds an unmodifiable list, callers can no longer change the order from either side.

Step 3: Beware array components

record Hash(byte[] bytes) {}

new Hash(new byte[] {1, 2}).equals(new Hash(new byte[] {1, 2}));   // false
java

Arrays use identity equals, so the generated equals compares references. Either override equals, hashCode and toString using Arrays.equals, Arrays.hashCode and Arrays.toString, or store an immutable type such as a List<Byte> or a dedicated value class. Also clone the array in the compact constructor and in an overridden accessor.

Step 4: Add behaviour without breaking transparency

record Money(String currency, long cents) {
    Money plus(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException("currency mismatch");
        }
        return new Money(currency, Math.addExact(cents, other.cents));
    }

    static Money zero(String currency) { return new Money(currency, 0); }
}
java

Records can carry domain logic. Methods that “change” a value return a new record.

Worked scenario

A team migrates its Spring Boot 3 API DTOs and some persistence classes to records. The DTOs work immediately: Jackson 2.12+ deserializes records through their canonical constructor, and Spring’s @ConfigurationProperties binds to records through constructor binding.

Then someone converts a JPA entity:

@Entity
record Customer(@Id Long id, String name) {}   // fails
java

Startup fails. JPA providers need a no-argument constructor, non-final fields they can populate and change, and the ability to subclass entities for lazy-loading proxies. Records offer none of these. The same team had also put a List<Address> component in a CustomerView record, and a caching layer later mutated the list it got from a cached instance, corrupting what other requests saw.

The resolution:

@Entity
class Customer {
    @Id Long id;
    String name;
    protected Customer() {}                       // for JPA
    // getters, domain methods
}

record CustomerView(long id, String name, List<AddressView> addresses) {
    CustomerView { addresses = List.copyOf(addresses); }

    static CustomerView from(Customer c) {
        return new CustomerView(c.id, c.name, c.addresses().stream().map(AddressView::from).toList());
    }
}
java

The entity stays a class; records are used for the read model at the boundary, with deep immutability enforced by copying and by making AddressView a record too. Hibernate 6.2 and later can map records as @Embeddable value types, which is a valid middle ground for small value objects.

Common mistake

  • “Records are immutable.” Only shallowly; mutable components need copies.
  • Expecting getX accessors. Frameworks that require JavaBean getters may not recognise x() without configuration.
  • Using records as JPA entities. They lack the no-arg constructor, mutability and proxying JPA needs.
  • Array components with default equals. Two records holding equal arrays are not equal.
  • Putting secrets in records without overriding toString. The generated toString prints every component, including passwords and tokens, into logs.

Verify the behavior

import static org.junit.jupiter.api.Assertions.*;
import java.util.*;
import org.junit.jupiter.api.Test;

class RecordTest {
    record Shallow(List<String> items) {}
    record Deep(List<String> items) { Deep { items = List.copyOf(items); } }
    record Hash(byte[] bytes) {}

    @Test
    void shallowRecordLeaksMutation() {
        var src = new ArrayList<>(List.of("a"));
        var r = new Shallow(src);
        src.add("b");
        assertEquals(2, r.items().size());
    }

    @Test
    void compactConstructorCopies() {
        var src = new ArrayList<>(List.of("a"));
        var r = new Deep(src);
        src.add("b");
        assertEquals(List.of("a"), r.items());
        assertThrows(UnsupportedOperationException.class, () -> r.items().add("c"));
    }

    @Test
    void arrayComponentsCompareByIdentity() {
        assertNotEquals(new Hash(new byte[] {1}), new Hash(new byte[] {1}));
    }

    @Test
    void equalComponentsAreEqual() {
        assertEquals(new Deep(List.of("a")), new Deep(List.of("a")));
    }
}
java

Follow-up questions

Can a record have a no-arg constructor? Yes, as a non-canonical constructor that delegates to the canonical one, such as Money() { this("EUR", 0); }.

Can records be used with sealed interfaces? Yes, and that combination is the basis of algebraic data types in Java: sealed interface Shape permits Circle, Square with record implementations, deconstructed by record patterns in switch on Java 21.

Are records serializable? If they implement Serializable. Deserialization always goes through the canonical constructor, so validation in the compact constructor also protects deserialized instances, unlike ordinary classes.

Can you override an accessor? Yes, as long as it has the same name, return type and is public. Use this to return a copy of an array component.

Interview exercise

Design a record DateRange(LocalDate start, LocalDate end) that rejects invalid ranges and offers a method to check whether a date falls inside it. What validation belongs where, and is the record deeply immutable?

Answer and reasoning

Put validation in a compact constructor so no invalid instance can exist, including deserialized ones:

record DateRange(LocalDate start, LocalDate end) {
    DateRange {
        Objects.requireNonNull(start, "start");
        Objects.requireNonNull(end, "end");
        if (end.isBefore(start)) {
            throw new IllegalArgumentException("end " + end + " is before start " + start);
        }
    }

    boolean contains(LocalDate d) {
        return !d.isBefore(start) && !d.isAfter(end);   // inclusive bounds
    }
}
java

It is deeply immutable without any copying, because LocalDate is itself immutable. That is the key reasoning: whether a record needs defensive copies depends entirely on the mutability of its component types. State the inclusive or exclusive choice explicitly, since half-open ranges are common in scheduling code.

Continue learning

Practise more with the Java interview questions and the Java MCQs. Related notes: immutable objects and defensive copies and equals and hashCode contracts. The design rationale and rules are in JEP 395: Records.

More in Java

read ✓Java · easy

Java Immutable Objects and Defensive Copies

How to make a class immutable in Java: final fields, no setters, defensive copies of mutable inputs and outputs, and safe publication.

~6 min readread →
esc