Java Immutability
Purpose
Make objects that cannot change after construction actually unable to change — and know
when not to bother. Two failure modes to prevent: the shallowly immutable object (final
fields, mutable contents) that changes under a caller who believed it could not; and
dogmatic immutability forced onto hot paths, entity frameworks and accumulators, where it
fights the tools without a measurement to justify it.
Workflow
- Inspect the compatibility and ownership contract. Check compiler release/toolchains,
framework/binder versions, equality/hash behavior, null/order requirements and who can mutate
each input. Examples use Java 17-compatible features: records and type-pattern
instanceof
need Java 16+ without preview, copyOf needs Java 10+, and Stream.toList() needs Java 16+.
Keep the project baseline; use ordinary classes/private-copy wrappers on older targets.
- Classify every field or record component. Primitive and known deeply immutable values are
safe. Collections, arrays, legacy dates, buffers and custom types require proof of both
container and element immutability; “no setters” is not proof.
- Close the inbound route. Copy in the constructor:
List.copyOf, Map.copyOf,
Set.copyOf, clone() for arrays. These are shallow copies; mutable elements/keys/values need
their own immutable representation or deep-copy policy.
- Close the outbound route. Accessors can return deeply immutable values directly.
Owned mutable representations still need isolation on output: arrays, dates and buffers
need defensive access even after an inbound copy. An unmodifiable container does not
protect mutable elements.
- Check publication. Fields final,
this not escaping the constructor. Final-field
initialization safety protects observed constructed state, but a happens-before publication
mechanism is still preferable for reference visibility, lifecycle and later mutable state.
- Verify with a hostile test. Mutate the constructor container and a nested mutable element
after construction; attempt mutation through each accessor; publish across threads using the
intended handoff. The documented state must remain unchanged and visible.
Rules
- A record guarantees final component references, not immutable referents. A mutable input
must be isolated by copying or a proven ownership transfer; already immutable values can be
reused. Check the reachable state, not merely whether the component type is
List or Map.
- Never ship a record with an array component and generated
equals/hashCode — arrays
compare by identity, so two records with equal contents are not equal. Copy in and out
and override both, or use a List instead.
- Prefer
List.copyOf when its null contract fits. An unmodifiable wrapper around a privately
owned copy is also a valid snapshot; a wrapper around aliased mutable backing is a live view
requiring a documented concurrency/lifecycle protocol. copyOf remains shallow; never call
re-copying “free” without measuring a material path.
- Evolve immutable state with hand-written
withX methods that return a new instance.
Java has no wither syntax and records have no generated with — do not claim or wait
for one.
- Do not assert or deny an allocation cost without a measurement. Escape analysis may
eliminate an allocation; it never guarantees it.
- State what immutability includes: object fields only, reachable graph, external resources, and
cached/derived state. An immutable wrapper around a mutable entity/client/file is not deeply
immutable merely because its reference is final.
- Copying is not an atomic snapshot of concurrently changing input. Require confinement,
synchronization or a collection-specific snapshot contract before copying; final fields
cannot repair a mixed or raced constructor read. Preserve null acceptance, iteration order
and equality when replacing an API, or explicitly identify the contract change.
Deliverable
Name the immutability boundary and remaining aliases, construction/access strategy and any
API behavior changed. Report mutation tests and the happens-before argument for the actual
handoff; a passing thread test alone does not prove JMM correctness. Keep allocation benefits
and framework round-trip safety conditional until measured or exercised.
References
- Records and defensive copies — read when writing or
reviewing a record, a constructor/accessor pair, or a wither; includes the worked
example and the false positives (builders, cached derived fields).
- Safe publication and the JMM — read when the object
crosses threads, when a field cannot be final, or when reviewing lazy caching of a
derived value.
- Costs and when not to apply — read before making an
existing mutable class immutable, and whenever performance is the argument for or
against immutability.
1---2name: java-immutability3description: Immutable objects in modern Java: records in depth, defensive copies, immutable collection factories versus unmodifiable views, deep versus shallow immutability, final-field semantics and safe publication (JMM), and the withers pattern. Use when designing a value object, when a record has a List, Map or array component, when an accessor returns internal mutable state, when an "immutable" object is observed changing, or when deciding whether immutability is worth its allocation cost. Does not cover null validation in constructors (java-null-safety) or Optional usage (java-optional).4---56# Java Immutability78## Purpose910Make objects that cannot change after construction actually unable to change — and know11when not to bother. Two failure modes to prevent: the shallowly immutable object (final12fields, mutable contents) that changes under a caller who believed it could not; and13dogmatic immutability forced onto hot paths, entity frameworks and accumulators, where it14fights the tools without a measurement to justify it.1516## Workflow17180. **Inspect the compatibility and ownership contract.** Check compiler release/toolchains,19 framework/binder versions, equality/hash behavior, null/order requirements and who can mutate20 each input. Examples use Java 17-compatible features: records and type-pattern `instanceof`21 need Java 16+ without preview, `copyOf` needs Java 10+, and `Stream.toList()` needs Java 16+.22 Keep the project baseline; use ordinary classes/private-copy wrappers on older targets.231. **Classify every field or record component.** Primitive and known deeply immutable values are24 safe. Collections, arrays, legacy dates, buffers and custom types require proof of both25 container and element immutability; “no setters” is not proof.262. **Close the inbound route.** Copy in the constructor: `List.copyOf`, `Map.copyOf`,27 `Set.copyOf`, `clone()` for arrays. These are shallow copies; mutable elements/keys/values need28 their own immutable representation or deep-copy policy.293. **Close the outbound route.** Accessors can return deeply immutable values directly.30 Owned mutable representations still need isolation on output: arrays, dates and buffers31 need defensive access even after an inbound copy. An unmodifiable container does not32 protect mutable elements.334. **Check publication.** Fields final, `this` not escaping the constructor. Final-field34 initialization safety protects observed constructed state, but a happens-before publication35 mechanism is still preferable for reference visibility, lifecycle and later mutable state.365. **Verify with a hostile test.** Mutate the constructor container and a nested mutable element37 after construction; attempt mutation through each accessor; publish across threads using the38 intended handoff. The documented state must remain unchanged and visible.3940## Rules4142- A record guarantees final component references, not immutable referents. A mutable input43 must be isolated by copying or a proven ownership transfer; already immutable values can be44 reused. Check the reachable state, not merely whether the component type is `List` or `Map`.45- Never ship a record with an array component and generated `equals`/`hashCode` — arrays46 compare by identity, so two records with equal contents are not equal. Copy in and out47 and override both, or use a `List` instead.48- Prefer `List.copyOf` when its null contract fits. An unmodifiable wrapper around a privately49 owned copy is also a valid snapshot; a wrapper around aliased mutable backing is a live view50 requiring a documented concurrency/lifecycle protocol. `copyOf` remains shallow; never call51 re-copying “free” without measuring a material path.52- Evolve immutable state with hand-written `withX` methods that return a new instance.53 Java has no wither syntax and records have no generated `with` — do not claim or wait54 for one.55- Do not assert or deny an allocation cost without a measurement. Escape analysis may56 eliminate an allocation; it never guarantees it.57- State what immutability includes: object fields only, reachable graph, external resources, and58 cached/derived state. An immutable wrapper around a mutable entity/client/file is not deeply59 immutable merely because its reference is final.60- Copying is not an atomic snapshot of concurrently changing input. Require confinement,61 synchronization or a collection-specific snapshot contract before copying; final fields62 cannot repair a mixed or raced constructor read. Preserve null acceptance, iteration order63 and equality when replacing an API, or explicitly identify the contract change.6465## Deliverable6667Name the immutability boundary and remaining aliases, construction/access strategy and any68API behavior changed. Report mutation tests and the happens-before argument for the actual69handoff; a passing thread test alone does not prove JMM correctness. Keep allocation benefits70and framework round-trip safety conditional until measured or exercised.7172## References7374- [Records and defensive copies](references/records-and-copies.md) — read when writing or75 reviewing a record, a constructor/accessor pair, or a wither; includes the worked76 example and the false positives (builders, cached derived fields).77- [Safe publication and the JMM](references/safe-publication.md) — read when the object78 crosses threads, when a field cannot be final, or when reviewing lazy caching of a79 derived value.80- [Costs and when not to apply](references/costs-and-when-not.md) — read before making an81 existing mutable class immutable, and whenever performance is the argument for or82 against immutability.