No — record immutability is shallow. The record itself can never re-bind its components (they're final), but if a component's value is a mutable object, callers can mutate it through their retained reference or through the auto-generated accessor. You need a compact canonical constructor and an accessor override to make it truly immutable.
The leak
public record Team(String name, List<String> members) {}
var ml = new ArrayList<>(List.of("Ada", "Linus"));
var t = new Team("alpha", ml);
ml.add("Mallory"); // 1. external mutation through retained reference
t.members().add("Eve"); // 2. external mutation through accessor
System.out.println(t); // Team[name=alpha, members=[Ada, Linus, Mallory, Eve]]
The record gave us nothing more than final fields. Both ml (the constructor argument) and t.members() (the accessor return) point to the same live ArrayList.
The fix — both ends
Use the compact canonical constructor to copy on the way in, and override the accessor to copy on the way out:
public record Team(String name, List<String> members) {
public Team { // compact canonical ctor
members = List.copyOf(members); // copy IN + unmodifiable
}
@Override
public List<String> members() { // accessor override
return members; // already unmodifiable, safe to return
}
}
List.copyOf does double duty: it snapshots the input (so external mutation of ml no longer affects us) and returns an unmodifiable list (so external mutation through the accessor throws). The accessor override is now strictly redundant — but if you used new ArrayList<>(members) instead, you'd need the override to defensively copy on output too.
Validation goes here too
The compact constructor is also where validation lives, and it runs after the copy so you validate the snapshot, not the caller's original:
public Team {
members = List.copyOf(members);
if (members.isEmpty()) throw new IllegalArgumentException("empty team");
}
Java 16+ caveat — the canonical accessor still exists
A record automatically generates public List<String> members(). Even if you don't override it, you cannot change its visibility or signature — you can only replace the body. That's the override hook you use above.
When to skip records
If most of your components are mutable (legacy types like Date, StringBuilder, or large arrays), the boilerplate of compact constructors plus accessor overrides eats the benefit. A plain final class with an explicit constructor is often clearer.