Mutable vs Immutable Design
When something changes, you have two choices: edit the existing thing in place (mutable), or leave it alone and produce a new version (immutable). This single decision shapes how easy your code is to reason about, test, parallelise, audit, and debug. Our strong default is immutability — favour it everywhere it is affordable, and treat in-place mutation as a deliberate, justified exception rather than the norm.
A mutable value can be changed after it is created: you update a field, push into a list, overwrite a row. An immutable value never changes once created — to "change" it you build a new value from the old one and leave the original untouched. Most bugs that are hard to reproduce — the ones that only happen "sometimes" — come from shared mutable state being changed from somewhere you did not expect.
This is a house preference, and a strong one. In a regulated, money-handling, multi-tenant system, the qualities immutability gives you — values you can trust not to change under you, a history you can reconstruct, state that is safe to share across threads — are worth the small extra cost of allocating new objects. This page sets the default; Event Sourcing, Data Integrity & Transactions, Audit Trails, and Concurrency apply it in specific places.
Default to immutable
- AlwaysMake value-like types immutable: model them as C# records with init-only properties, expose no setters, and produce changes with `with` expressions that return a new instance.
- DoPrefer immutable collections (`IReadOnlyList`, `ImmutableArray`, `IReadOnlyDictionary`) on public surfaces. Return read-only views; never hand a caller your private mutable list to modify behind your back.
- DoTreat money, screening decisions, audit entries, and anything legally significant as immutable facts. Once written they are never edited — a correction is a new record that supersedes the old, leaving the original intact (see Audit Trails, Money & Currency).
- DoConstruct objects fully valid in one step and keep them valid. An object that cannot represent an invalid state cannot drift into one.
- ConsiderPersisting state transitions as new rows/events rather than overwriting a single mutable row when the history itself has business or regulatory value (see Event Sourcing).
- NeverMutate an object after you have shared it — passed it to another method, stored it in a cache, published it on a queue, or returned it to a caller. The other holder will see your change and you will not know.
screening.Status = "Clear"; // overwrites the only copy
screening.ReviewedBy = userId;
screening.ReviewedAt = DateTime.UtcNow;
// the previous decision, and who made it, are gone forever
The earlier decision is destroyed. In an AML context that is the history a regulator asks for — and it is unrecoverable.
public record ScreeningDecision(string Status, Guid ReviewedBy, DateTimeOffset ReviewedAt);
var cleared = decision with { Status = "Clear", ReviewedBy = userId, ReviewedAt = now };
store.Append(cleared); // the original decision is still on record
The new decision is derived from the old one; the old one is untouched and still queryable. History is preserved by construction.
Why immutable is the safer default
- DoLean on local reasoning: if a value cannot change, you can understand a function by reading it alone, without tracing every other place that might hold a reference and mutate it.
- DoShare immutable data freely across threads with no locks. The hardest concurrency bugs simply cannot occur when the data never changes (see Concurrency).
- DoGet safe caching, safe keys, and safe equality for free. An immutable value is a reliable dictionary key and a safe thing to memoise; a mutable one silently corrupts both when it changes.
- ConsiderThe cost honestly: immutability allocates new objects on each change. For the vast majority of code this is irrelevant; profile before trading it away (see SQL Performance Tuning for the data-tier equivalent).
When mutability is the right call
- DoUse local, unshared mutation inside a method — a loop accumulator, a `StringBuilder`, a list you build and then freeze before returning. Mutation you never expose is not a hazard.
- ConsiderMutable buffers and in-place algorithms on a proven hot path where allocation pressure is measured and material. This is an optimisation, justified by numbers, not a starting point.
- AvoidLong-lived mutable objects shared across components, requests, or threads as your default design. That is the exact shape that produces spooky-action-at-a-distance bugs.
- NeverExposing a mutable static or singleton field that more than one request or thread can write. It is a data race and a cross-request leak waiting to happen.
Self-review checklist
- AskCould this type be a record with no setters? If yes, why isn't it?
- AskAm I mutating something after I have shared it with a caller, cache, or queue?
- AskDoes overwriting this destroy information someone (an auditor, a debugger, a customer) will later need?
- AskIf two threads touched this value at once, would it still be correct — and if I needed a lock to answer yes, would immutability remove the need?