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 final is visible correctly to any thread, without synchronization, provided this did 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 not final — 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 #

java
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:

kotlin
val updated = order.copy(status = SHIPPED)   // new object; the old one is untouched

The 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 O(n)O(n) 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 — O(log⁡32n)O(\log_{32} n), 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 + copyPersistent structureMutable
Thread safetyfreefreemanual
Single small updateone allocation~log n nodesin place
Bulk update in a loopO(n) per stepamortizedin place
Cache localitycontiguouspointer-chasingcontiguous
Reasoning costlocallocalglobal

Sources

  1. docsJEP 395: Records (final in Java 16)
  2. docsJava Language Specification — final field semantics (§17.5, safe publication)
  3. paperOkasaki, Purely Functional Data Structures (CMU thesis, 1996)
  4. bookDomain-Driven Design: Tackling Complexity in the Heart of Software
  5. bookEffective Java, 3rd Edition

Searches titles, aliases, headings, summaries, and full text.↑↓ move ↵ open esc close