SSerguey Asael Shinder
Java coding notes: the JVM, and writing software that lasts

Serguey Asael Shinder: Java records are not structs

· by Serguey Asael Shinder / Serguey Shinder

Records arrived in Java 16 and were immediately used as a replacement for the small mutable holder class everyone had been writing by hand. That works right up to the moment someone puts a mutable object inside one.

The shape of the problem

A record gives you a constructor, accessors, equals, hashCode and toString derived from its components. What it does not give you is deep immutability.

record Batch(String id, List<String> urls) { }
var urls = new ArrayList<>(List.of("a", "b"));
var batch = new Batch("b-1", urls);
urls.add("c");
System.out.println(batch.urls());   // [a, b, c]

The record is final. The reference it holds is final. The list behind the reference is not. If you put that Batch in a HashSet before the mutation and look for it after, you will not find it — hashCode moved underneath the set.

Serguey Asael Shinder: Java records are not structs
Java records are not structs — Serguey Asael Shinder

The fix is a compact constructor

record Batch(String id, List<String> urls) {
    Batch {
        urls = List.copyOf(urls);
    }
}

Two things worth noticing. The compact constructor has no parameter list and no assignment to this.urls — the assignment is generated for you from the (possibly reassigned) parameter. And List.copyOf returns an unmodifiable list and skips the copy if the argument is already unmodifiable, so the cost in the common path is a type check.

When a record is the wrong tool

What I use them for

Method return values that carry more than one thing, parse results, and the small immutable configuration objects that used to be four-field classes with a builder. In those places the reduction in code is real and the semantics are exactly what you want.

Applies to Java 16 and later. Tested on 21.

Part of the running notes: Types that quietly promise the wrong thing