final fields feel like they should guarantee immutability, and that belief is exactly how mutable state sneaks into classes their authors believed were safe to share across threads without synchronization. Real immutability is a property of the entire object graph reachable from an instance, not a keyword on its fields, and it's worth being precise about what that actually requires.
The Four Conditions
A class is genuinely immutable only when all of the following hold:
- All fields are
final. - The class itself is
final, or its constructors are otherwise not overridable in a way that breaks invariants. - No method mutates state after construction — no setters, no in-place
add/remove. - Any mutable object referenced by a field is either never exposed, or exposed only as a defensive copy.
Condition 4 is where most "immutable" classes actually fail:
public final class Itinerary {
private final List<String> stops;
public Itinerary(List<String> stops) {
this.stops = stops; // bug: stores the caller's live reference
}
public List<String> stops() {
return stops; // bug: hands out the internal mutable list directly
}
}List<String> mutable = new ArrayList<>(List.of("NYC", "LON"));
Itinerary trip = new Itinerary(mutable);
mutable.add("TOK"); // mutates the itinerary from outside, unnoticed
trip.stops().add("PARIS"); // mutates it again, through the "immutable" object itselfBoth bugs come from the same root cause: never having made a copy. The fix is defensive copying at both boundaries — in and out.
public final class Itinerary {
private final List<String> stops;
public Itinerary(List<String> stops) {
this.stops = List.copyOf(stops); // unmodifiable, independent copy
}
public List<String> stops() {
return stops; // already unmodifiable — safe to return directly
}
}List.copyOf both copies and wraps the result in an unmodifiable view, so any attempt to mutate it later — from either direction — throws UnsupportedOperationException immediately instead of corrupting state silently.
Records Don't Automatically Solve This
Records generate accessors that return the field directly, with no defensive copying. A record holding a List has exactly the same leak unless you add it yourself, in the compact constructor:
public record Itinerary(List<String> stops) {
public Itinerary {
stops = List.copyOf(stops); // now genuinely immutable
}
}Without that compact constructor, new Itinerary(mutableList).stops() returns the same mutable list the caller passed in, and the record's immutability is cosmetic — the fields can't be reassigned, but the object graph underneath one of them still can be mutated freely.
Why This Is Worth the Effort
| Benefit | Mechanism |
|---|---|
| Thread-safe without synchronization | No mutable state means no data race is possible |
| Safe to use as a Map/Set key | Hash code can't change after insertion |
| Safe to cache and share freely | No caller can corrupt a shared instance |
| Simpler reasoning | Object's state is fully known at construction, forever |
The thread-safety benefit alone is usually the strongest argument in a service handling concurrent requests: a genuinely immutable object can be freely shared across threads, cached, and passed around without a single synchronized block, because there is no mutable state left for two threads to race over.
A Practical Default
For value objects — configuration, DTOs, domain value types — default to immutable and require a specific reason to introduce mutability, rather than the other way around. Use List.copyOf, Map.copyOf, and Set.copyOf at construction boundaries as a habit, not an afterthought reached for only after a bug report. The cost is a handful of copy calls; the payoff is an entire category of concurrency and aliasing bugs that simply can't happen.