Java Coding Notes
Notes on Java, the JVM and writing software that lasts

Java records are not structs, and the difference will bite you

ยท 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.

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.