How do you keep a Java record truly immutable when one of its components is a mutable type like a List or a Date?
answer
- final = reference fixed, object not frozen
- Inbound copy in the constructor
- Outbound copy in the accessor
- List.copyOf solves both for lists
- Mutable component breaks equals/hashCode as a key
basics
~20 sA record's fields are final but a mutable component (like a List) can still be changed through the caller's reference. Make a defensive copy in the canonical constructor on the way in, and return a copy from the accessor on the way out.
solid answer
~50 sRecords give you `final` fields, but `final` only means the reference can't be reassigned — it does not make the referenced object immutable. If a component is a mutable type (`List`, `Date`, an array), the caller can mutate it after construction, or read it from the accessor and mutate it, breaking the record's value semantics. To fix this you copy on both boundaries: in the canonical constructor (idiomatically the compact form) make a defensive copy of the incoming argument — e.g. `tags = List.copyOf(tags);` — so external changes don't leak in; and override the accessor to return a copy or an unmodifiable view so callers can't mutate the internal state. For truly fixed-size mutable types like `Date` or arrays you clone/copy them in both places. The cleanest approach is to use immutable component types (`List.copyOf`, `Instant` instead of `Date`) so the problem disappears, since `equals`/`hashCode` are derived from these components and must stay stable.
code
java · 14 linesimport java.util.List;
record Team(String name, List<String> members) {
Team {
members = List.copyOf(members); // inbound copy -> unmodifiable & independent
}
// No accessor override needed: List.copyOf is unmodifiable,
// so members() can't be mutated by callers either.
}
var src = new java.util.ArrayList<>(List.of("Ann"));
var t = new Team("A", src);
src.add("Bob"); // does NOT affect t
// t.members().add("Eve"); // throws UnsupportedOperationExceptiongo deeper
Knows records are 'immutable' and that a List inside one is a special case to be careful with.
Can add a defensive copy in the compact constructor with List.copyOf and explain the inbound leak.
Handles both inbound and outbound leaks, overrides accessors for Date/array components, and links mutation to equals/hashCode/key stability.
Sets a team convention to prefer immutable component types, weighs copy cost vs. safety, and reasons about thread-safety/publication guarantees of immutable records.
## Why records aren't automatically deeply immutable A **record** stores each component in a `private final` field. The keyword **`final`** on a field means the *reference* cannot be reassigned after construction — it does **not** mean the *object the reference points to* is frozen. This distinction is the heart of the problem. Consider: ```java record Team(String name, List<String> members) {} List<String> m = new ArrayList<>(List.of("Ann")); Team t = new Team("A", m); m.add("Bob"); // mutates the SAME list the record holds! System.out.println(t.members()); // [Ann, Bob] -- the record changed ``` Two leak channels exist: 1. **Inbound leak:** the constructor stored the caller's list directly, so the caller still holds a reference and can mutate it. 2. **Outbound leak:** the accessor `members()` returns the internal list directly, so anyone can call `t.members().add(...)`. Because a record's auto-generated `equals` and `hashCode` are **derived from the component values**, a mutated component silently changes the record's identity in a `HashSet`/`HashMap` key — the same key-mutation hazard as any hash collection. ## Fix 1 — defensive copy inbound (in the canonical constructor) Use the compact canonical constructor to copy the argument so the stored field is independent of the caller's reference: ```java record Team(String name, List<String> members) { Team { members = List.copyOf(members); // immutable, independent copy (also null-checks) } } ``` `List.copyOf` returns an **unmodifiable** list, which also conveniently solves the outbound leak for `List` (the returned reference can't be mutated). For arrays or `Date`, use `members.clone()` / `new Date(d.getTime())`. ## Fix 2 — defensive copy outbound (override the accessor) If the stored field is still mutable (e.g. you kept a mutable `ArrayList` or a `Date`), override the **accessor** to hand back a copy or an unmodifiable view: ```java record Event(Date when) { Event { when = new Date(when.getTime()); } // inbound copy public Date when() { return new Date(when.getTime()); } // outbound copy } ``` Note the accessor must be named exactly after the component (`when()`), and you typically copy in **both** directions for a non-immutable type like `Date`. ## Fix 3 (preferred) — use immutable component types The least error-prone option is to choose component types that are already immutable: `List.copyOf(...)`/`Map.copyOf(...)`, `Instant`/`LocalDate` instead of `Date`, a wrapped value instead of a raw array. Then both leaks vanish and you don't have to remember to override accessors. ## Summary of the boundary discipline - **Inbound** copy in the canonical constructor → external mutation can't reach the field. - **Outbound** copy in the accessor → callers can't mutate the field through the returned reference. - For `List`/`Map`/`Set`, `List.copyOf` etc. handle both at once by being unmodifiable. - Keeping components immutable keeps the derived `equals`/`hashCode` stable, which is required for safe use as map keys.
- Does declaring the record's field final make a List component immutable?No. final fixes the reference so it can't be reassigned, but the List object it points to can still be mutated. You need a defensive copy (e.g. List.copyOf) to get true immutability.
- Why does a mutable component endanger using the record as a HashMap key?equals/hashCode are derived from the components. If a component mutates after the record is used as a key, its hashCode changes and the entry becomes unretrievable in the hash bucket it was placed in.
Lending a record a mutable list is like giving someone a photocopy vs. your original document. If you hand over the original (no defensive copy), they can scribble on it and your record changes. Copy on the way in and on the way out so everyone scribbles only on their own photocopy.
saying these in an interview costs you the question
- Assuming records are automatically deeply immutable because fields are final.
- Copying only inbound and returning the raw mutable field from the accessor (outbound leak).
- Using java.util.Date or arrays as components without copying both ways.