EffectsLANGUAGE-SPECIFICSCALE-SPECIFICCONTESTED

Mutability, Used Deliberately

Mutation is not the problem. Mutation of something with no clear owner is. Local ownership, a stated lifecycle and a boundary it does not cross make it the right design surprisingly often.

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.

The question

When is mutable state the correct design, and what has to be true for it to stay correct?

The requirement

A code review rejects a hand-written parser because it mutates a position index and a token buffer. The reviewer cites the team's immutability guideline. The author points out that the immutable version allocates a new state object per character and runs four times slower on a two-megabyte file.

The obvious build

Adopt a blanket rule. "Prefer immutability" becomes "never mutate", which is easy to review, easy to teach and requires no judgement at the point of use.

Why it breaks

The rule was written to prevent shared mutable state, and it is being applied to a local buffer that is shared with nobody. The situation the rule protects against is absent, and the cost is being paid anyway.

How it breaks as requirements change
  • The rule was written to prevent shared mutable state, and it is being applied to a local buffer that is shared with nobody. The situation the rule protects against is absent, and the cost is being paid anyway.
  • As the codebase grows, the rule accumulates exceptions that are argued case by case in review, which is more expensive than the judgement it replaced and produces less consistent outcomes (Review as Design Feedback — and Why It Arrives Too Late).
  • People route around it. An "immutable" API with an internal mutable cache appears, undeclared, and now there is hidden mutation in a codebase whose reviewers have stopped looking for it.
  • The rule cannot distinguish the parser from the cache, which means it also cannot teach anyone the difference — and the difference is the entire skill.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

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.

Constraints
  • The parser is on a request path with a p99 budget, so the four-times figure is a product constraint rather than a benchmark curiosity (Designing for Cost).
  • The team guideline exists because of a real prior incident involving a shared mutable cache, and nobody wants to reopen that.
  • The parser is one file, called from one place, and returns a finished syntax tree.
Invariants
  • The mutable state never escapes the function that owns it — no reference to the buffer is visible after the call returns.
  • Every mutation happens on a value with exactly one owner at that moment (State Ownership).
  • The value handed to a caller is finished: it will not change again for any reason.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • The owner of a mutable value owns its whole lifecycle: allocation, every mutation, and the moment it stops being mutable.
  • The owner also owns preventing escape. Returning a mutable buffer is transferring ownership, and it must be deliberate and documented (Designing a Module Interface).
  • The type owns saying which phase it is in, where the language allows it — a builder that becomes a value on build() encodes the lifecycle rather than describing it (Making Illegal States Unrepresentable).
  • Nobody owns state reachable through a module-level variable, which is why that case is different in kind rather than in degree (Hidden Global State).
Boundaries
  • The boundary is escape. Mutation confined inside one function, or inside one object that hands out only copies, is invisible to the rest of the system and costs nothing in reasoning.
  • The second boundary is time: a value that is mutable during construction and frozen afterwards has two phases, and the transition is the interesting moment (Explicit State).
  • The third is concurrency, and it is a hard one. A mutable value reachable from two threads is a different problem with a different literature (Shared Mutable State in Concurrency & Parallelism).

The question that decides it

There is exactly one question worth asking, and it is not about performance: can anything other than this code see the value while it is changing? Everything else follows from the answer.

The options below are ordered by how far the mutation can be observed, which is also the order of how much reasoning it costs. Notice that the performance argument only appears in the first two rows — by the time a value is shared, mutation has stopped being an optimisation and started being a coordination problem.

Something needs to change. Where does it live?

Is mutation the right design for this piece of state?

Mutate a local buffer, return an immutable result

when The value is allocated, filled and finished inside one function — a parser's token list, an accumulator, a sort scratch space

cost None to reasoning, provided the buffer genuinely does not escape. The risk is entirely that a later refactor returns it directly for convenience, and no compiler outside Rust will notice.

Mutate inside an object that hands out copies

when The state has a lifecycle and one owner — a connection pool, an in-memory index, a session — and callers get values rather than references

cost A copy per read, and a discipline about never leaking the internal structure. Every accessor is a place the guarantee can be broken by one person in a hurry (Encapsulation).

Mutable value shared between callers

when Almost never by design. Occasionally correct for a deliberately shared cache with an explicit synchronisation contract

cost Every holder's correctness now depends on every other holder's behaviour and on timing. This is the case the immutability guideline was written for, and it needs a documented thread-safety contract to be defensible at all (The Thread-Safety Contract).

Immutable, with structural sharing

when The value crosses module boundaries, has multiple readers, needs change detection, or will ever be touched by more than one thread

cost Allocation, and a library or hand-rolled tree if the structure is large and updated often (Immutability).

What a well-owned mutable thing looks like

The parser in the requirement is the easy case because its lifetime is a single call. The harder and more common case is an object that legitimately holds mutable state for a long time, and it is worth writing out what makes one defensible.

The test is the changesWhen list. A mutable owner with one reason to change is a design; one with four is a shared cache that has not admitted it yet.

responsibilitiesTokenIndex — an in-memory search index rebuilt hourly and queried constantlyA long-lived mutable owner, drawn well
Knows
  • The current posting lists
  • Which build generation it is on
  • Its own memory footprint
Does
  • Mutates its internal maps during a build
  • Serves queries from a finished generation
  • Swaps generations atomically when a build completes
  • Returns result arrays as copies, never as internal references
Depends on
  • A tokenizer (pure)
  • A source of documents at build time
Changes when — 1 distinct reason
  • The indexing algorithm changes

One reason to change, one owner for every mutation, and no internal structure ever leaves the object. The mutation is invisible from outside because the only observable states are finished generations — which is confinement applied to an object rather than to a function. The failure mode to watch for is the first accessor that returns a posting list directly "to avoid a copy": that single line converts this from a well-owned mutable object into shared mutable state, and nothing else about the class has to change for it to happen (Exposing Too Much).

The smell this lesson is often mistaken for

Reviewers who reject the parser are pattern-matching on something real, and it is worth naming precisely so the pattern match can be made accurate rather than abandoned.

smellEscaped mutable reference

looks like A method returns an internal array, map or object directly; or a constructor stores an argument without copying it; or a closure captures a buffer that outlives the function that made it.

suggests Ownership was never decided. The object still behaves as though it owns the value, and so does the caller, so both will mutate it and neither will expect the other to. The bug appears far from both, as a value that changed for no visible reason — which is the receipt bug from Immutability, one level up.

fix Return a copy or a frozen view; copy on the way in as well as on the way out; or, where the copy genuinely costs too much, rename the method so the transfer is explicit and document that the caller now owns it. Then add the test that mutates the returned value and asserts the object is unaffected.

when this is fine Deliberate ownership transfer, where the returning function is finished with the value and says so — a builder's build(), a takeBuffer() that is named for what it does, or a factory whose entire purpose is to hand over a fresh object. What makes those correct is that exactly one party holds the value afterwards, and the name says which.

How to build it

Most important first.

  • State the owner. If you cannot name one module or one function that owns every mutation, the mutation is the problem and no amount of care will fix it.
  • Confine and then freeze: build with a mutable structure, hand out an immutable result. This is the pattern that gets the performance and the reasoning property at once (Immutability).
  • Use the builder shape where the language supports it, so "under construction" and "finished" are different types and a half-built value cannot be used by accident.
  • Never return the internal buffer. Return a copy, a frozen view, or transfer ownership explicitly and say so in the name.
  • Document the lifecycle where the type cannot express it: what may mutate this, when, and what happens after. Two comment lines on a mutable field are worth more than any general guideline (Comments).
  • Keep mutation out of anything reachable from two threads unless you have taken on the full synchronisation argument, which is a different domain and a different cost (The Thread-Safety Contract).

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.

Cost of the next change
  • Adding a rule to a locally-mutating parser costs one branch and one test, exactly as it would immutably. Local mutation does not raise the cost of local change, which is the point people miss.
  • What raises cost is escape. The first time the buffer is returned, every future change has to consider every holder, and the cost jumps from local to global in one commit (Change Amplification).
  • Adding concurrency later is the expensive change: a single-owner mutable design becomes correct-only-under-a-lock, and the audit is the whole call graph. Immutable values would have absorbed that change for free (Concurrency by Design).
  • The cheap change under this design is performance work. When the parser needs to be faster, the mutable version has somewhere to go; the immutable one usually has to be rewritten first.
What the recommended approach costs
  • Mutation buys allocation and speed and pays in reasoning: a reader has to know the execution path to know the value. Inside twenty lines that is free; across a module it is not.
  • Confinement is a discipline, not a guarantee, in most languages. One returned reference undoes it and no compiler will say so — except in Rust, where this whole lesson is a type-system feature.
  • A builder makes the lifecycle explicit and doubles the type count for the values that use it. That is worth it for three types and absurd for thirty.

What can go wrong

Failure modes
  • The buffer escapes — returned directly for convenience, or captured by a closure that outlives the function — and every reasoning guarantee evaporates silently.
  • The lifecycle is documented rather than typed, a second caller uses the half-built value, and the resulting bug looks like data corruption rather than a design error.
  • The optimisation was never needed. The four-times figure was measured on a two-megabyte file that occurs once a month, and mutation was chosen for a case that does not exist (Premature Optimization, Reclaimed).
  • The mitigation fails on its own terms: a builder pattern applied to every value type in the codebase doubles the number of types for a lifecycle that only two of them actually have (Speculative Generality).
Dependencies, and their direction
  • A locally-mutable implementation has no dependencies at all beyond its own scope, which is precisely what makes it safe: nothing can depend on its intermediate states.
  • A mutable object handed to a caller creates a dependency in both directions and no signature declares it — that bidirectional invisible dependency is the actual cost of mutation (Temporal Coupling).
  • A builder creates a dependency on calling the right methods in a permitted order, which the type system can carry if you let it.
Misreads
  • "Mutable state is bad." Mutable state with unclear ownership is bad. Every program mutates — memory, the database, the screen — and the design question is who owns each piece and how far it can be seen (Side Effects).
  • "Local mutation is fine, so a private field is fine." Private is a visibility keyword, not a lifetime. A private field on a long-lived object shared by two callers is exactly the case the guideline was written for.
  • "The performance argument settles it." Only with a measurement of a realistic input on the actual path. Most mutation defended on performance grounds has never been measured, and most that has been measured was worth it (Premature Optimization, Reclaimed).
  • "Freeze the result and it is immutable." Shallowly. A frozen object holding a mutable array is the most common way this design fails, and it fails silently (Immutability).
Smells this explains
  • shared-state-coupling
  • temporal-coupling

Testing it, and how it ages

What to test, and at which boundary
  • Test that the returned value is not affected by subsequent calls — the assertion that catches an escaped buffer, and the one people skip (Testing as Design Feedback).
  • Test the lifecycle: assert that using a builder after build() fails loudly rather than mutating a value someone else holds.
  • Benchmark the case that justified the mutation, and keep the benchmark. A performance argument without a committed measurement decays into folklore within a year.
  • If it will ever be concurrent, a stress test is the only thing that finds the failure; reasoning does not (Stress Testing: A Test That Passed Once Proves Nothing is the concurrency-side technique).
How this design ages
  • Locally-mutable code ages well as long as the confinement holds, and the thing that breaks it is always a new caller who wants "just a reference" for a good reason.
  • The pressure that forces the change is usually sharing rather than growth: a second consumer, a cache, a background thread. Any of the three converts a fine design into a bad one without a line of the original code changing.
  • It stops being right the moment the value acquires a second reader with its own lifetime, and the correct response then is to freeze at the boundary rather than to add a lock (Immutability).

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 makes this lesson a compiler feature: the borrow checker enforces single ownership, so local mutation is provably invisible and the reasoning cost is zero. In Java, Python or TypeScript confinement is a convention that one returned reference breaks, so the same design carries a risk the Rust version does not have — which should change how far you extend it.
  • SCALE-SPECIFICInside a function, mutation costs nothing in reasoning. Inside a long-lived object with two callers, it costs an audit of both. Across threads it costs the entire concurrency literature. The identical technique moves from obviously fine to obviously wrong purely on how far the value can be seen.
  • CONTESTEDThe strongest opposing view: confinement is unenforceable in most languages, so a codebase that permits local mutation will leak it within a year through an innocent refactor, and a blanket immutability rule is worth its performance cost precisely because it needs no judgement and no reviewer vigilance. That is a serious argument from experience with large teams. The counter is that the rule does not survive contact with parsers, buffers and hot loops, and a rule with unprincipled exceptions teaches nothing and is enforced arbitrarily.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • Programming Languages & Runtime Internals — ownership, borrowing and escape analysis are what it looks like when a compiler decides this question instead of a reviewer.