Design judgement on mutability: thread safety, caching, reasoning and hash keys versus allocation cost; readonly, init, records, ImmutableArray, FrozenDictionary; read-only views versus true immutability.
Prefer immutable types for values, messages, configuration, cache entries and anything shared across threads: an object that cannot change is automatically thread-safe, safe to cache and share, safe as a dictionary key, and easier to reason about. C# gives you readonly fields, init and get-only properties, records with with, readonly struct, ImmutableArray<T>/ImmutableList<T> and FrozenDictionary/FrozenSet for read-mostly data. Remember that read-only is not immutable: IReadOnlyList<T> over a List<T> can still change underneath. Keep mutability where it is natural and contained: entities whose state changes through methods that guard invariants, builders, and hot loops where allocating new objects would cost too much.
My default is immutable for values, messages and shared data, and controlled mutability for entities and performance-critical buffers. Immutability buys simpler reasoning and thread safety; it costs allocations and sometimes awkward updates. I'd cover benefits and costs, the C# tools, the important difference between read-only and immutable, and where mutability is still right.
with, ImmutableList.Add), increasing GC pressure in hot paths.order with { Customer = order.Customer with { Address = ... } }) get verbose.ImmutableList<T> (tree-based) is slower to read than List<T>; ImmutableArray<T> copies on every change.| Tool | What it gives |
|---|---|
readonly fields |
Field can't be reassigned after construction (object it points to may still mutate) |
const / static readonly |
Compile-time / one-time constants |
Get-only and init properties |
Set only during construction/initialisers |
required members |
Ensures initialisation without mutable setters |
Records (record, readonly record struct) |
Value equality, with for non-destructive updates |
readonly struct / readonly members |
Compiler-enforced immutability for value types; no defensive copies |
ImmutableArray<T>, ImmutableList<T>, ImmutableDictionary<K,V> |
Truly immutable collections with efficient updates (builders, structural sharing) |
FrozenSet<T> / FrozenDictionary<K,V> (.NET 8) |
Immutable, read-optimised lookups built once |
IOptionsMonitor<T> snapshots |
Immutable-ish configuration views |
This distinction catches many people:
IReadOnlyList<T>, ReadOnlyCollection<T>, a readonly field, a get-only property returning a mutable object. Someone else may still change it, and you'll see the changes.string, ImmutableArray<T>, FrozenDictionary, a readonly record struct of immutable fields.private readonly List<string> _tags = [];
public IReadOnlyList<string> Tags => _tags; // read-only VIEW; owner can still mutate; caller can cast back
public ImmutableArray<string> Tags { get; } // immutable; safe to share and cache
Also: records are shallowly immutable — a record holding a List<T> isn't immutable.
order.Ship()), which also works naturally with EF Core change tracking. Mutability is controlled, not public setters.StringBuilder, List<T> during construction, then freeze (ToImmutableArray(), ToFrozenDictionary()).Rule of thumb: mutable inside, immutable at the boundary — build with mutable structures, publish immutable snapshots.
A pricing service loaded a rate table into a Dictionary<string, Rate> singleton refreshed every 5 minutes by mutating it in place while requests read it. Occasionally readers saw half-updated data, and once a concurrent write corrupted the dictionary and a request spun at 100% CPU. We switched to building a new FrozenDictionary on refresh and swapping the reference with Volatile.Write; readers always saw a complete, consistent snapshot, lookups got faster, and no locks were needed.
Checks judgement about state, concurrency and performance.
Green flags: benefits (thread safety, caching, keys, reasoning) and costs (allocations, O(log n) collections); correct C# tools including frozen collections; read-only versus immutable; shallow versus deep; snapshot swap pattern; contained mutability for entities and builders.
Red flags: "immutable everywhere" without cost awareness, or "mutable is simpler" without concurrency awareness; thinks IReadOnlyList<T> is immutable.
In .NET, an object published to other threads through a field write can be observed before its constructor's writes are visible on weakly ordered CPUs (Arm64) unless publication is safe. In practice the CLR's memory model (and x64's strong ordering) makes most patterns safe, but for lock-free publication use Volatile.Write/Interlocked.Exchange when swapping references, and Volatile.Read when reading. Immutable objects are safe once safely published — there's no later mutation to race with.
ImmutableList<T> and ImmutableDictionary<K,V> are balanced trees; an update creates O(log n) new nodes and reuses the rest, so keeping many versions is cheap. ImmutableArray<T> is a thin wrapper over an array — O(1) reads, O(n) copy on change — best for read-heavy, rarely changed data. Builders (ToBuilder(), CreateBuilder()) batch many changes efficiently.
private ImmutableDictionary<string, Rate> _rates = ImmutableDictionary<string, Rate>.Empty;
public void Update(string key, Rate rate) =>
ImmutableInterlocked.AddOrUpdate(ref _rates, key, rate, (_, _) => rate); // CAS loop
Readers use the current snapshot without locks; writers retry if another writer intervened. For whole-table refreshes, build a new frozen dictionary and Interlocked.Exchange the reference.
with expressions bypass constructors; to keep invariants on immutable records, validate in init accessors (C# 14 field keyword) or keep properties get-only and expose factory methods that validate. Immutable doesn't automatically mean valid.
IOptionsSnapshot<T> (per request) and IOptionsMonitor<T> (with change notifications) provide snapshot semantics.If immutable updates are in a hot path, measure allocations with BenchmarkDotNet and dotnet-counters. Common fixes: batch changes with builders, use ImmutableArray for read-heavy data, or use mutable structures locally and freeze at the end.
List<T> as IReadOnlyList<T>, mutate the list, and observe the "read-only" view change.FrozenDictionary refresh with an atomic swap and hammer it from many threads.ImmutableList one by one vs via a builder.init accessor and try to bypass it with with.// Immutable configuration snapshot swapped atomically
public sealed class TaxRuleProvider(ITaxRuleStore store, ILogger<TaxRuleProvider> log)
{
private FrozenDictionary<string, TaxRule> _rules = FrozenDictionary<string, TaxRule>.Empty;
public TaxRule? For(string region) =>
Volatile.Read(ref _rules).GetValueOrDefault(region);
public async Task RefreshAsync(CancellationToken ct)
{
var latest = await store.LoadAllAsync(ct);
var snapshot = latest.ToFrozenDictionary(r => r.Region, StringComparer.OrdinalIgnoreCase);
Volatile.Write(ref _rules, snapshot); // readers see old or new, never partial
log.LogInformation("Loaded {Count} tax rules", snapshot.Count);
}
}
// Immutable value with validated creation
public sealed record TaxRule(string Region, decimal Rate)
{
public decimal Rate { get; init; } = Rate is >= 0 and <= 1 ? Rate
: throw new ArgumentOutOfRangeException(nameof(Rate));
}
// Controlled mutability in an entity
public sealed class Order
{
private readonly List<OrderLine> _lines = [];
public IReadOnlyList<OrderLine> Lines => _lines; // read-only VIEW, not immutable
public OrderStatus Status { get; private set; }
public void AddLine(OrderLine line)
{
if (Status != OrderStatus.Draft) throw new InvalidOperationException("Order is locked");
_lines.Add(line);
}
}
// Builder: mutate locally, publish immutable
var builder = ImmutableArray.CreateBuilder<string>(initialCapacity: 100);
foreach (var row in rows) builder.Add(row.Trim());
ImmutableArray<string> cleaned = builder.ToImmutable();
What happens: returning IReadOnlyList<T> over a mutable list and assuming it's safe to share.
Why it is wrong: the owner can still mutate it, and callers can cast back.
Do instead: return immutable or frozen collections, or copies.
readonly fields make objects immutableWhat happens: readonly List<T> treated as immutable.
Why it is wrong: only the reference is fixed; the list's contents can change.
Do instead: use immutable collection types.
What happens: records with mutable collection properties. Why it is wrong: state can still change; equality is by reference. Do instead: immutable collections and immutable element types.
What happens: entities as records updated with with.
Why it is wrong: untracked copies; changes don't save.
Do instead: controlled mutability through methods for entities.
What happens: ImmutableArray.Add in loops or with per item in large batches.
Why it is wrong: O(n²) copies and GC pressure.
Do instead: use builders or mutable locals, then freeze.
What happens: refreshing caches by modifying dictionaries readers use. Why it is wrong: races, inconsistent reads and possible corruption. Do instead: build new immutable snapshots and swap atomically.
What happens: domain types with public setters everywhere.
Why it is wrong: invariants bypassed; hard to track changes.
Do instead: init/private setters and intention-revealing methods.
What happens: forcing immutable designs onto buffers, UI state or performance-critical code. Why it is wrong: unnecessary complexity and allocations. Do instead: mutable inside, immutable at boundaries.
Seniors apply a simple rule: shared or long-lived data is immutable; owned, short-lived state can be mutable but encapsulated.
| Situation | Senior choice |
|---|---|
| Config, feature flags, reference data | Immutable snapshot + atomic reference swap |
| Messages, events, DTOs | Records |
| Read-mostly lookup tables | FrozenDictionary / FrozenSet |
| Entities | Private state, behaviour methods |
| Batch building | Builders, then ToImmutable() |
| Performance-critical loops | Mutable locals, measured |
They also know immutability removes whole classes of concurrency bugs without locks, which is often a bigger win than any micro-optimisation.
Immutable collections (System.Collections.Immutable) never change after creation; "modifying" methods such as Add return a new collection and leave the old one untouched, so they are safe to share across threads without locks. ImmutableArray<T> is a struct around a plain array: reads are as fast as an array, but every Add copies the whole thing (O(n)). ImmutableList<T> and ImmutableDictionary<TKey,TValue> are balanced trees that share structure: an Add is O(log n) and reuses most of the old nodes, but reads are slower. Use builders (ToBuilder() ... ToImmutable()) for many changes. A read-only view like ReadOnlyCollection<T> or IReadOnlyList<T> only stops you changing it; the underlying list can still change. FrozenDictionary/FrozenSet (.NET 8+) are immutable too but optimised purely for fast reads with no "modified copy" operations.
Use a record when a type is defined by its data: two instances with the same values are the same thing, and the data should not change after creation. Typical examples are DTOs, API contracts, messages, events, query results, cache keys and value objects such as Money. Use a class when a type has identity and a life cycle: an Order with an Id stays the same order as its status changes, holds behaviour and invariants, and is tracked by EF Core. Records also bring generated ToString (beware secrets), shallow with copies and reference equality for collection members. Wrong choices show up as broken change tracking, surprising equality, or shared mutable state.
In an anemic domain model, entities are property bags with public getters and setters, and all business rules live in service classes that read and set those properties. In a rich domain model, entities own their behaviour: state is private, changes happen through methods like order.Pay(amount) that enforce invariants, and services only orchestrate (load, call, save). Anemic models scatter and duplicate rules, let any code put objects into invalid states, and are hard to test without a database. They are acceptable for simple CRUD or reporting with few rules. Move towards a richer model incrementally: make setters private, move one rule at a time into the entity, protect collections, and use value objects, guided by where bugs actually occur.
How to tell it (STAR)
**Situation:** our pricing service sometimes threw or priced items without tax while its rules refreshed. **Task:** remove the race without adding lock contention. **Action:** I replaced in-place mutation with building a new frozen dictionary and swapping one reference, and added a parallel refresh-and-read test. **Result:** the failures went to zero, lookups got about [30%] faster, and the code lost all its locking.
Last updated 30 Oct 2026.