الدرس 2 من 2
Immutability and Where State Is Allowed to Change
Most of the state in a system is mutable by habit rather than by need. Constraining where change happens pays off in concurrency, caching, auditability and — most of all — in debugging.
An immutable value is one that cannot change after it is created; to "change" it you produce a new value. This sounds like a language preference and is actually an architectural lever, because a very large share of hard bugs are questions of the form "what changed this, and when?" — a question that cannot be asked about a value that never changes.
| Property | Mechanism | Quality attribute served |
|---|---|---|
| Safe to share across threads and requests | No writer means no race and no lock | Reliability, and often throughput |
| Safe to cache and to memoise | A value cannot become stale under you in memory | Performance |
| Reproducible debugging | A captured value is exactly what the code saw | Operability and mean time to repair |
| History is available by construction | Previous versions still exist rather than being overwritten | Auditability, and the basis for event sourcing |
| Comparison and change detection are cheap | Identity comparison can stand in for deep equality | Performance in user-interface rendering |
export class Money {
private constructor(
readonly amountMinor: number,
readonly currency: Currency,
) {}
static of(amountMinor: number, currency: Currency): Money {
if (!Number.isInteger(amountMinor)) {
throw new Error('Money must be a whole number of minor units');
}
return new Money(amountMinor, currency);
}
plus(other: Money): Money {
if (other.currency !== this.currency) {
throw new Error(`Cannot add ${other.currency} to ${this.currency}`);
}
return new Money(this.amountMinor + other.amountMinor, this.currency);
}
}
// Two consequences worth noticing. Once a Money exists it is valid, so no
// caller ever has to re-check it. And a Money handed to another module cannot
// be modified behind your back, so passing it is safe without copying.What must stay mutable, and what it costs
Immutability is not free and is not universally applicable. Some state is inherently mutable — the current stock level, a user's session, a connection pool, a counter — and pretending otherwise produces elaborate machinery to simulate change. The useful discipline is not "no mutable state" but "know exactly where the mutable state is, and keep that set small and named".
- Allocation and copying cost real time and memory. Copying a large collection on every update inside a hot loop is a genuine performance problem, and the answer there is a mutable local buffer that never escapes the function.
- Ergonomics suffer in languages without good support. Deep updates through several levels of nested objects are verbose without a library, and verbosity that annoys people is eventually worked around.
- Identity versus value: an entity with a lifecycle — a customer, an order — has identity and changes over time. Model those as an identity plus a sequence of immutable states, not as a value you keep replacing and hoping everyone noticed.
- External systems are mutable by nature. The database, the file system and the message broker all have state, so the boundary code that touches them is where mutation lives by definition.
In practice
Finding a defect that only immutability made findable
A booking platform had an intermittent bug: roughly one in four thousand bookings was priced with a discount the customer was not entitled to. It survived three weeks of investigation, because the final booking record looked internally consistent.
Constraints
- Reproduces roughly once per four thousand bookings
- Pricing involves six steps and three external calls
- The pricing object was mutated in place through all six steps
- Customer-facing incident, with a hard deadline to explain it
Decision
Change the pricing pipeline so each step takes a priced quote and returns a new one, and record the intermediate values on the booking for a limited retention period. The defect was identified within two days: a cached promotions response was being shared between concurrent requests and one step mutated it in place.
Why
With in-place mutation there was no way to tell which of six steps introduced the wrong figure, and adding logging to each step changed the timing enough to hide the race. Making each step produce a value turned an untraceable end state into a sequence, and the sequence showed the discount appearing at a step that should not have been able to add one.
What it cost
Storing intermediate quotes increased booking write size by about 40% and required a retention policy so the table did not grow without limit. The team kept it for pricing specifically — the highest-value, hardest-to-debug path — and did not extend it elsewhere, on the grounds that the cost is only justified where reconstructing history has repeatedly been needed.
Asked whether to make the domain model immutable
As a developer
Weighs it as an ergonomics question. Immutable objects mean more constructors and more copying, which is friction on every feature, and the codebase is single-threaded anyway, so the usual thread-safety argument does not apply here.
As an architect
Weighs it as an operability and correctness question. How often does this team debug "what changed this value?" How much would an audit trail be worth if a regulator asked? Is any of this data shared between concurrent requests through a cache? Where the answers are "often", "a lot" and "yes", immutability is bought by an incident cost that is already being paid; where they are "never", "nothing" and "no", the ergonomics argument should win.
A team makes every domain object immutable, including a `ShoppingCart` that is updated dozens of times per session. Adding an item copies the entire cart. Is this a good decision?RevealHide
It depends on numbers nobody in that argument usually has. Copying a cart of twenty lines dozens of times per session is trivially cheap and buys a clean history of how the cart reached its current state, which is valuable for support. Copying a cart of five thousand lines on every scan in a warehouse application is a real cost that will show up in latency. The reasonable position is that immutability is the default for domain values and that hot paths are permitted to opt out with a measurement attached — and note that the opt-out should be local, using a mutable structure inside a function that returns an immutable value, so the escape does not leak into the model everyone else uses.
Key takeaways
- Immutability turns "what changed this?" from an investigation into a question the code cannot raise.
- Push mutation to a small, named set of boundary places and keep the core pure.
- The strongest practical argument is operability: immutable values leave a record that incident response can read.
- Entities with a lifecycle are an identity plus a sequence of immutable states, not a mutable blob.
- Allocation cost and ergonomics are real; permit local mutable escapes in hot paths, with a measurement, and do not let them leak into the shared model.