As an architect, when would you deliberately choose mutability over immutability, and how do you reason about the trade-off at system scale?
answer
- Immutable by default; mutability is a measured, local exception
- Choose mutable when copy-on-change cost dominates (large + hot) or interop demands it
- Confinement: thread-local mutate, publish immutable snapshot (StringBuilder->String)
- Mutability's hidden costs: synchronization, defensive copies, harder reasoning
- Try middle grounds first: persistent structures, batched builders, COW
basics
~20 sDefault to immutable because it's safe and simple. Choose mutable when you have large or frequently-updated state where making a new object every change would be too slow or use too much memory - like a big buffer in a hot loop, or in-place updates of a large data structure. Keep that mutability local and well-controlled.
solid answer
~50 sMy default is immutable: it removes data races, lets me share freely, gives valid keys, and makes code easier to reason about - that simplicity pays off across a large codebase. I deliberately choose mutability when the cost of copy-on-change dominates: large objects updated in tight loops (buffers, accumulators, big matrices), high-throughput state where allocation/GC pressure breaches latency SLOs, or where an external contract demands in-place updates (JavaBeans, ORMs, builders). The key discipline is confinement: keep mutable state thread-local or behind a clear ownership boundary so the rest of the system still sees immutable values - mutate in the hot path, expose an immutable snapshot. I decide with measurement (allocation profiling, GC logs, latency percentiles), not folklore, and I weigh the hidden cost of mutability - synchronization, defensive copying, harder reasoning, and bug surface - against the allocation it saves. Often persistent structures or batched builders capture most of immutability's safety with acceptable update cost, making pure mutability unnecessary.
go deeper
Can say 'use mutable like StringBuilder when building in a loop, immutable otherwise' without deep system reasoning.
Names concrete mutable cases (buffers, hot loops, framework entities) and knows to confine mutation and expose an immutable result.
Frames immutable-by-default with measured exceptions, applies thread confinement and freeze-on-publish, and weighs synchronization/defensive-copy costs of mutability.
Drives the decision with profiling and SLOs at system scale, accounts for mutability's hidden synchronization/reasoning costs, prefers persistent structures/builders, and sets/enforces the org-wide default and review rules.
## Start from the default and justify the exception Immutability is the **safer, simpler default**, so the question is always 'what justifies giving it up *here*?' The benefits - thread safety without locks, safe sharing/caching, valid hash keys, failure-atomic construction, and local reasoning - compound across a large system: fewer concurrency bugs, fewer defensive copies, simpler invariants. Mutability is an **optimization or an interop requirement**, and like any optimization it should be **local, justified, and measured**. ## Legitimate reasons to choose mutability ### 1. Allocation/GC cost dominates (performance) When state changes **frequently** and the object is **large**, copy-on-change becomes the bottleneck: - A buffer or accumulator updated millions of times in a hot loop (`StringBuilder`, `ByteBuffer`, primitive arrays). - Large matrices/graphs updated in place where reallocating per step would thrash the GC. - Latency-sensitive paths where minor-GC frequency or pauses breach an SLO. Here mutating in place avoids N allocations and the GC tracing they cause. ### 2. Interop / framework contracts Some ecosystems **require** mutable, setter-bearing objects: JavaBeans conventions, many ORMs/JPA entities (proxying, dirty-checking), serialization frameworks, and UI/data-binding. Fighting these with immutability adds friction (no-arg constructors, reflection hacks) that isn't worth it. ### 3. Genuinely identity/lifecycle-bearing domain objects Entities with a long lifecycle and continuous in-place evolution (a live `Connection`, a stateful session, a simulation cell grid) are naturally mutable; modeling each tick as a new object can be both unnatural and expensive. ## The discipline that makes mutability safe: confinement The danger of mutability is **shared** mutable state. The mitigation is **confinement**: - **Thread confinement**: a mutable accumulator owned by one thread (the builder pattern) is safe - no other thread sees the writes; you publish only the final **immutable snapshot**. - **Ownership boundary**: encapsulate mutable internals behind an interface that exposes only immutable views/copies, so callers can't alias the mutable state. - **Lifecycle phases**: mutate during a build phase, then **freeze** (return an immutable value) before sharing - 'mutable while local, immutable once published'. This is exactly `StringBuilder` (mutable, thread-confined) -> `String` (immutable, shared). ## Reasoning at system scale 1. **Measure, don't guess**: use allocation profiling (JFR, async-profiler), GC logs, and latency percentiles to confirm churn is the actual cost before trading away safety. 2. **Count the hidden costs of mutability**: if the mutable object is shared, you now need **synchronization** (its own cost and deadlock risk), **defensive copies** at boundaries, and you pay in **harder reasoning** and a larger bug surface - sometimes mutability is a net loss even on performance once synchronization is added. 3. **Consider middle grounds first**: persistent/structural-sharing data structures (O(log n) updates with sharing), batched builders (allocate once), copy-on-write where reads vastly outnumber writes, or flyweight/interning. These keep most of immutability's guarantees. 4. **Scope the exception**: confine mutability to the smallest module/hot path, document the ownership and publication rules, and keep the public contract immutable so the blast radius is contained. 5. **Weigh organizational cost**: in a large team, immutable-by-default reduces the chance someone mutates shared state by accident; every mutable hotspot is a place where that invariant must be taught and reviewed. ## The verdict Choose mutability when **profiling shows copy-on-change cost dominates** or when a **framework contract requires it**, and only with **confinement and an immutable public face**. Otherwise default to immutability - the safety and simplicity almost always outweigh the allocation, and persistent structures or builders close most of the remaining gap.
- Why can shared mutable state end up SLOWER than immutable, even though it avoids allocation?Because sharing mutation safely requires synchronization (locks, volatile, atomics), which adds contention, cache-coherence traffic, and possible blocking/deadlock. Under contention those costs can exceed the allocation immutability would have caused, and you also pay in defensive copies and harder reasoning. Immutable objects need none of that to be shared, so they can win even on raw performance.
- What's a middle ground that keeps most of immutability's safety while making frequent updates cheap?Persistent (structural-sharing) data structures: each update returns a new version that shares the unchanged nodes with the old one, copying only the path that changed - O(log n) instead of O(n) - so all old versions stay valid and immutable while updates stay affordable. Batched builders (mutate locally, freeze once) are another.
saying these in an interview costs you the question
- Defaulting to mutable for performance without profiling
- Sharing mutable state across threads without confinement or synchronization
- Treating it as all-or-nothing instead of confining mutation behind an immutable face
- Ignoring that adding synchronization to shared mutable state can erase the perf win