Immutability & Value Objects
What immutability actually guarantees — free thread-safety, safe sharing, valid hash keys — what it costs in allocation and how persistent structures and copy-on-write reduce that cost, and why a domain value object beats a primitive for money, identifiers and quantities.
An immutable object cannot change after construction, and almost every property people want from a type follows from that one restriction rather than having to be engineered separately. It is the cheapest correctness mechanism available in most languages, and the reason to be deliberate about it is that the cost — allocation on every "change" — is real and localized, so it can be reasoned about, unlike the cost of shared mutable state, which is not local to anything.
What it buys, mechanically #
- Thread safety with no synchronization. There is no write to race with. This is not "safer"; it is a proof. In Java the guarantee is precise: a properly constructed object whose fields are all
finalis visible correctly to any thread, without synchronization, providedthisdid not escape the constructor (JLS §17.5). Non-final fields get no such guarantee, which is why "immutable by convention" — private fields, no setters, but notfinal— is not safely publishable. - Safe sharing without copies. No defensive copy on the way in or out, because there is nothing to defend against (aliasing).
- Valid hash keys. The hash cannot go stale, so an entry cannot become unreachable in its own map.
- Free temporal reasoning. A reference passed to a method is the same value when the method returns. Caching, memoization, and structural sharing all become trivially correct.
- A meaningful
==. Structural equality is the only sensible equality for a value, and it is stable.
Value object versus entity #
The distinction that makes the design decision easy:
| Value object | Entity | |
|---|---|---|
| Identity | none — defined entirely by its attributes | a stable id, independent of attributes |
| Equality | structural | by id |
| Two with equal fields | are the same value | are two different things |
| Mutability | immutable | mutable state over a lifecycle |
| Examples | Money, Instant, EmailAddress, Coordinate, OrderId |
Order, Account, User |
Two €5 notes are interchangeable; two accounts with the same balance are not. When a type has no identity, giving it one — a mutable object whose changes others observe — is inventing a problem.
The language support is now good #
record Money(long minorUnits, Currency currency) {
Money { // compact constructor — validation
if (currency == null) throw new IllegalArgumentException("currency");
}
Money plus(Money other) {
if (!currency.equals(other.currency)) throw new IllegalArgumentException("mixed currency");
return new Money(minorUnits + other.minorUnits, currency);
}
}Java records (JEP 395, final in Java 16) generate the constructor, accessors, equals, hashCode and toString, and the fields are final. Kotlin has data class plus copy(), C# has record and record struct with with-expressions, Swift has value-semantic struct, Python has frozen dataclasses. In every case the "modify" operation is a copy with one field replaced:
val updated = order.copy(status = SHIPPED) // new object; the old one is untouchedThe cost, and how to keep it small #
Every update allocates. For a small object that is a bump-pointer allocation and a short-lived object that dies in the nursery — on the JVM and .NET, generational collectors make this genuinely cheap (GC vs manual). For a large collection, copying the whole thing per update is and unacceptable in a loop.
Two standard answers:
Persistent data structures share the unchanged parts. Okasaki's work is the reference; the practical form is the hash array mapped trie behind Clojure's vectors and maps, Scala's immutable collections, and Immutable.js: a 32-way branching tree where an update copies only the path from root to leaf — , which is at most about 7 nodes for a billion elements. "Effectively constant" is a fair description at real sizes, but it is not the same constant as a mutable array write, and the indirection costs cache locality.
Copy-on-write keeps a single mutable representation and copies only when a second holder exists. Swift's standard library does this behind value semantics; CopyOnWriteArrayList does it for read-mostly listener lists, where writes are rare and reads must never lock.
Builders are the third answer, and the most common in practice: mutate freely inside a local scope, freeze once at the end. StringBuilder is exactly this — the string is immutable, the construction is not, and the mutable phase is confined to code that owns it exclusively.
| Immutable + copy | Persistent structure | Mutable | |
|---|---|---|---|
| Thread safety | free | free | manual |
| Single small update | one allocation | ~log n nodes | in place |
| Bulk update in a loop | O(n) per step | amortized | in place |
| Cache locality | contiguous | pointer-chasing | contiguous |
| Reasoning cost | local | local | global |