Immutability
A value that cannot change is a value you can reason about once. That buys local reasoning and cheap change detection, and it charges copying, allocation and awkwardness in the places that genuinely want to mutate.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
What do I actually get from making a value immutable, and when is the copying not worth it?
A bug report: an order's line items are correct on the confirmation page and wrong in the emailed receipt. Both read the same Order object. Something between them changed it, and four candidate functions all take the order as an argument.
Mutate in place. It is what the language does naturally, it allocates nothing, and every developer already understands it. Objects are for holding state that changes; that is the whole point of an object.
It is fine while one function owns the object. It stops being fine the moment the object is passed somewhere, because now correctness depends on what every recipient does, and nothing in any signature says.
- It is fine while one function owns the object. It stops being fine the moment the object is passed somewhere, because now correctness depends on what every recipient does, and nothing in any signature says.
- As the codebase grows, the question "what is in this order right now" stops having a static answer. Answering it requires knowing the execution path, which is the definition of non-local reasoning and the reason the bug above takes a day.
- Change detection degrades in the same motion. A UI, a cache or a diff that wants to know "did this change" must deep-compare, because the old and new values are the same object (Derived State is the frontend consequence).
- Undo, audit and replay all become special features requiring their own machinery, where with immutable values they are consequences of keeping the old reference.
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The
Orderobject is passed through eleven functions between loading and rendering, and any of them could be the culprit (Local Reasoning). - Orders can contain a few hundred line items in the bulk-ordering flow, so a copy per function call is a real allocation question (Allocation and Copies).
- The codebase is TypeScript:
readonlyis a compile-time annotation with no runtime enforcement, andObject.freezeis runtime with a cost.
- Two readers of the same value see the same thing, regardless of what ran in between.
- A value that has been validated stays valid — nothing can mutate it into an invalid state after the check (Enforcing Invariants).
- Deriving a new value from an old one never disturbs the old one.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- A value type owns being valid at construction and staying that way — which is only enforceable if it cannot be modified afterwards (Value Objects).
- The caller owns deciding what to do with a derived value; the callee owns not touching what it was given.
- One owner per mutable thing, and that owner is a specific module rather than "whoever has a reference" (State Ownership).
- The boundary between the immutable interior and the mutable buffer is owned by whichever function allocates the buffer — it must not escape.
- Immutability is a property of a boundary, not of a codebase. Values crossing module lines should be immutable; a loop inside a function can mutate whatever it allocated (Mutability, Used Deliberately).
- The natural line is "anything a second party can see". A local array being built is nobody's business; the array returned from the function is.
- Persistence is the other boundary: the database row is mutable by definition, so the immutable model is a snapshot with a version, and reconciling the two is where optimistic concurrency lives (Optimistic Concurrency: Versions and If-Match is the backend mechanism).
What the receipt bug actually was
The concrete failure is almost always the same shape: a function that looks like a calculation modifies its argument, and a caller downstream reads a value that is no longer the one it thought it had. It survives review because the mutating line is one word different from the non-mutating one.
The interesting part is the third block. Making the interface immutable does not require making the internals immutable — the function still builds a mutable array, it just does not hand that array back to somewhere that will keep it.
1// the bug: sort mutates in place, and the caller still holds the array2function applyDiscounts(order: Order): Order {3 order.items.sort(byPrice) // <- the confirmation page's array4 order.items[0].price = discounted(order.items[0].price)5 return order6}7 8// immutable interface, mutable internals: fast and safe9function applyDiscounts(order: Order): Order {10 const items = [...order.items].sort(byPrice) // one copy, locally owned11 items[0] = { ...items[0], price: discounted(items[0].price) }12 return { ...order, items } // shares every other field13}14 15// what the type system can say about it16type Order = { readonly id: OrderId; readonly items: readonly LineItem[] }17// TS: erased at runtime, a cast defeats it. Java's final: stops18// reassignment, not mutation. Rust: actually enforced.The middle version allocates exactly two things — one array and one line item — and shares everything else by reference. That is structural sharing done by hand, and for objects of this size it is the whole technique.
What the copying actually costs
The performance objection deserves a concrete answer rather than a reassurance, because it is sometimes correct. What matters is whether a "copy" duplicates the whole structure or only the path to the changed node.
The sketch below is the difference between the two, for a list of four hundred line items with one price changed. It is also the reason the naive reduce with a spread in the accumulator is a genuine bug rather than a style preference: it turns a linear pass into a quadratic one, and it does so silently.
- For a handful of fields, spreading is free and the argument is over (Premature Optimization, Reclaimed).
- For a large collection updated in a loop, full copying is quadratic and is a real defect, not a style question.
- Structural sharing buys back most of it and costs a library or a hand-rolled tree (What a Framework Charges).
- Mutation costs nothing and buys nothing back — the third block is the price the first two are paying to avoid (Local Reasoning).
updating item[2] of 400 FULL COPY (spread the array each time) new: [i0 i1 i2' i3 ... i399] 400 slots allocated old: [i0 i1 i2 i3 ... i399] survives, untouched in a loop over 400 items: 400 x 400 = 160,000 slot writes STRUCTURAL SHARING (persistent vector, branch factor 32) root' |-- branch0 (shared, same object as before) |-- branch1' (copied: 32 slots) | '-- i2' (the one changed value) '-- branch2.. (shared) allocated: ~2 nodes, ~64 slots in a loop over 400 items: 400 x ~64 = ~25,600 slot writes MUTATION items[2].price = x 1 write, 0 allocation and every holder of that array sees it, including the confirmation page that rendered ten milliseconds ago
Scored, with the axis that decides it
The three options below are the ones real teams pick between, and the middle one wins far more often than either extreme. It is worth noticing which axes actually separate them: simplicity and performance, mostly — testability barely moves, which tells you that arguments for immutability made on testability grounds are usually borrowing purity's argument rather than making their own (Purity and Testing).
| Option | Simplicity | Flexibility | Performance | Testability | Migration cost | Note |
|---|---|---|---|---|---|---|
| Mutate freely | Zero allocation and zero ceremony. Correctness depends on every holder of a reference behaving, which is knowable in a small module and not knowable across eleven functions and three years. | |||||
| Immutable at module boundaries, mutable inside | Values crossing a seam are frozen or copied; a function's own buffers are not. Almost all of the reasoning benefit for a small fraction of the copying, and it is the version that survives a deadline. | |||||
| Immutable everywhere, persistent collections | The strongest guarantee, and it brings a library, an unfamiliar API and an interop layer at every boundary with ordinary arrays. Correct for a domain built on history and audit; heavy for one that is not. |
caveat The performance column is the most misleading thing here. Mutation is not uniformly faster: reference-identity change detection can beat the deep comparison that mutation forces on a UI or a cache, and sharing an immutable value avoids the defensive copies a mutable one requires at every boundary. The scores also ignore the language entirely — row three costs almost nothing in Clojure or Rust and a great deal in Java — and they cannot express the thing that actually decides most real cases, which is whether the value is passed across module lines at all.
How to build it
Most important first.
- Make the *interface* immutable first: return copies or frozen views, accept values you promise not to modify. The internals can stay mutable and fast (Information Hiding).
- Prefer construction-time validity to setters. A type with no setters and a validating constructor cannot enter a bad state at all (Making Illegal States Unrepresentable).
- Use structural sharing for large structures — a "copy with one field changed" that shares the untouched ninety-nine percent is the difference between the design being affordable and not.
- Mutate locally, freeze at the exit. Build with a mutable buffer inside a function and hand out an immutable result; this is the pattern that keeps the allocation cost honest.
- Give the compiler what enforcement it can offer —
readonly,final,val,frozendataclasses — and accept that in most languages this is a review-supported convention rather than a guarantee. - Do not freeze deeply on a hot path without measuring.
Object.freezeon a few hundred line items per request is a decision that wants a number, not an opinion (Premature Optimization, Reclaimed).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Before: adding a twelfth function to the order pipeline costs a review of what it does to the object plus, when a bug appears, an audit of all twelve. The cost of understanding grows with the square of the participants, because any of them could have affected any other.
- After: adding a twelfth function costs reading its signature. It takes an order and returns one, and it cannot affect the eleven before it.
- Adding a field is cheap in both designs. Changing how a field is *derived* is dramatically cheaper immutably, because the derivation is a function of a value rather than of a history.
- What stays expensive: a change that genuinely needs in-place mutation for performance — streaming a hundred megabytes through a buffer — now fights the design, and the escape hatch has to be built deliberately rather than being the default (Designing for Cost).
- Copying costs allocation and cache pressure, and in a hot loop over a large structure that cost is not small. Structural sharing reduces it and adds a library or a hand-rolled tree.
- Some algorithms are genuinely awkward immutably — in-place sorts, graph traversals with visited marks, accumulators — and the immutable versions are slower and harder to read, which is a real cost and not a failure of understanding.
- In most mainstream languages the property is a convention. Paying the copying cost while getting a guarantee that a single
as anycan break is a worse deal than it looks on paper.
What can go wrong
- Shallow immutability: the object is frozen, the array inside it is not, and the bug survives the whole refactor in the one place nobody checked.
- Copying in a loop turns an O(n) operation into O(n²) — the classic being a reduce that spreads the accumulator each iteration. This is a real performance failure and not a theoretical one.
- The discipline is enforced by convention, one function reaches through with a cast under deadline, and the guarantee is gone at exactly the site that was rushed.
- The mitigation fails too: adopting a persistent-collection library to fix the copying cost adds a dependency, an unfamiliar API and an interop layer at every boundary with ordinary arrays — which is sometimes worth it and frequently is not (What a Framework Charges).
- Callers depend on the value type and on nothing about its lifecycle, which is what removes the temporal coupling that made the receipt bug possible (Temporal Coupling).
- Change detection gains a dependency on reference identity rather than on deep equality — cheaper, and it silently requires that nobody breaks the discipline.
- A persistence layer gains a dependency on versioning, because two immutable snapshots of the same row need a rule for which one wins.
- "Immutability is about thread safety." That is Concurrency & Parallelism's framing and it is correct there: an immutable value cannot be raced on. This lesson is about something that pays in a strictly single-threaded program too — knowing what a value is without knowing what ran (Shared Mutable State is the concurrency view).
- "Immutable means slow." Sometimes, and sometimes the opposite: reference-identity change detection can be dramatically cheaper than the deep comparison mutation forces, and immutable values can be shared without defensive copies. Measure the specific structure, do not reason from the label (Premature Optimization, Reclaimed).
- "Freeze everything." Deep-freezing hot structures is a measurable cost for a guarantee that only matters at boundaries. Freeze what crosses a boundary; leave the local buffer alone.
- "
readonlymakes it immutable." In TypeScript it is erased at runtime and a cast defeats it; in Javafinalstops reassignment and not mutation of the referent. Know exactly what your language promises, because the design rests on it (Leaky Abstractions).
- temporal-coupling
- shared-state-coupling
Testing it, and how it ages
- Test that a function returns a new value and leaves its argument untouched — assert on the argument after the call, which is the assertion nobody writes and the one that catches the receipt bug.
- Property-test that applying a derivation twice to the same input yields equal outputs, which catches accidental in-place accumulation (Property-Based Testing).
- Benchmark the copy on the largest realistic input before committing to deep freezing; a test that asserts a bound is better than an argument (Cost-Aware Interfaces).
- A lint rule against array mutation methods in the domain package. It is the enforcement mechanism that actually survives, in the absence of language support.
- Immutable domain values age extremely well; the pressure always comes from the edges, where serialization, ORMs and UI frameworks want mutable objects and an adapter has to exist (Boundary Adapters).
- The first real strain is a large collection updated frequently, which is the point to introduce structural sharing rather than to abandon the property.
- It stops being right where the data structure *is* the performance characteristic — an in-memory index, a ring buffer, a game loop's entity table — and mutation with a single clear owner is the correct design (Mutability, Used Deliberately).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- LANGUAGE-SPECIFICRust enforces it through ownership at zero runtime cost and Clojure gives you persistent collections by default; Java has
finalfields and mutable referents; TypeScript'sreadonlydisappears at compile time entirely. The design argument is identical and what you actually get ranges from a compiler guarantee to a naming convention, which should change how much you are willing to pay for it. - PARADIGM-SPECIFICIn a functional idiom immutable values compose with everything and the awkward cases are the ones people have already solved with lenses and zippers. In an OO codebase built around entities with identity and lifecycle, immutability fights the ORM, the framework and the mental model — an immutable
Orderstill has to reconcile with a mutable row, and that adapter is the cost the FP version does not pay. - CONTESTEDThe strongest opposing view: for entities with identity and a long lifecycle, mutation is the accurate model — an order genuinely is one thing that changes over time, and representing it as a stream of snapshots adds versioning, reconciliation and memory for a reasoning benefit you could have had from a single clear owner. Practitioners in large OO systems argue this and are often right; the counter is that "a single clear owner" is exactly what erodes over three years, whereas an immutable type stays immutable without anybody maintaining it.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Programming Languages & Runtime Internals — what
readonly,final,valand ownership actually guarantee at runtime, and what persistent data structures cost in allocation and cache behaviour.