📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

Mutable or immutable types: how do you decide in a C# system? Explain the benefits and costs of immutability, the C# tools for it (readonly, init, records, immutable and frozen collections), the difference between read-only and immutable, and where mutability is still the right choice.

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.

Senior Architecture Advanced csharp design immutability immutable-collections oop records thread-safety

🎯 Key points

  • Immutable objects are thread-safe to read, safe to cache and share, and safe as dictionary keys
  • Immutability costs allocations on change; ImmutableList and ImmutableDictionary are O(log n) trees
  • C# tools: readonly fields, get-only and init properties, required, records with `with`, readonly structs
  • ImmutableArray suits rarely changing lists; FrozenDictionary and FrozenSet suit read-mostly lookups
  • Read-only views such as `IReadOnlyList<T>` are not immutable; the owner can still change the data
  • readonly and init stop reassignment, not mutation of referenced objects (shallow versus deep)
  • Swap a whole immutable snapshot atomically instead of mutating shared data in place
  • Keep mutability for entities (through methods), builders and measured hot loops

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. Benefits of immutability

  • Thread safety for free: immutable objects can be shared across threads without locks — no torn reads, no races.
  • Easier reasoning: once constructed and validated, an object stays valid; no "who changed this?" debugging.
  • Safe sharing and caching: cached values, configuration snapshots and dictionary keys can't be corrupted by callers.
  • Value semantics: equality and hash codes don't change, so objects behave as keys and set members.
  • Simpler testing: functions on immutable inputs are deterministic.
  • History and undo: keep old versions cheaply (structural sharing in immutable collections, event sourcing).

2. Costs

  • Allocations: every change creates a new object (with, ImmutableList.Add), increasing GC pressure in hot paths.
  • Update ergonomics: deep updates (order with { Customer = order.Customer with { Address = ... } }) get verbose.
  • Framework friction: some frameworks expect mutable objects (older serialisers, data binding, EF Core change tracking for entities).
  • Performance of immutable collections: ImmutableList<T> (tree-based) is slower to read than List<T>; ImmutableArray<T> copies on every change.

3. C# tools for immutability

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

4. Read-only vs immutable

This distinction catches many people:

  • Read-only means you can't change it through this reference: 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.
  • Immutable means nobody can change it, ever: 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.

5. Where mutability is still right

  • Entities/aggregates in a domain model: identity persists while state changes through methods that enforce invariants (order.Ship()), which also works naturally with EF Core change tracking. Mutability is controlled, not public setters.
  • Builders and accumulators: StringBuilder, List<T> during construction, then freeze (ToImmutableArray(), ToFrozenDictionary()).
  • Performance-critical code: buffers, spans, pooled arrays, hot loops where allocation per change is unacceptable.
  • UI state / view models needing change notification.
  • Local variables within a method — mutability that never escapes is harmless.

Rule of thumb: mutable inside, immutable at the boundary — build with mutable structures, publish immutable snapshots.

6. A production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. Immutability and the memory model

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.

2. Structural sharing

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.

3. Atomic updates of shared immutable state

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.

4. Records and invariants

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.

5. Immutability in architecture

  • Events and messages are immutable facts — records are a natural fit.
  • Event sourcing: state is derived by applying immutable events; the aggregate may be mutable in memory but the history isn't.
  • Configuration: IOptionsSnapshot<T> (per request) and IOptionsMonitor<T> (with change notifications) provide snapshot semantics.
  • Caching: immutable cached values avoid defensive copying.
  • Functional core, imperative shell: pure functions over immutable data at the core, side effects at the edges.

6. Measuring the cost

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.

Try it yourself

  1. Expose a List<T> as IReadOnlyList<T>, mutate the list, and observe the "read-only" view change.
  2. Build a FrozenDictionary refresh with an atomic swap and hammer it from many threads.
  3. Benchmark adding 10,000 items to ImmutableList one by one vs via a builder.
  4. Write a record with a validating init accessor and try to bypass it with with.

💻 Code

// 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();

⚠ Common mistakes

1. Confusing read-only with immutable

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.

2. Assuming readonly fields make objects immutable

What 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.

3. Shallowly immutable records

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.

4. Immutable entities in EF Core

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.

5. Excessive allocations in hot paths

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.

6. Mutating shared singletons in place

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.

7. Public setters "for convenience"

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.

8. Treating immutability as dogma

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions immutable collections Why: Checks ImmutableArray versus ImmutableList trade-offs
    How to answer

    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.

  2. When: Candidate recommends records for immutable data Why: Checks record versus class judgement
    How to answer

    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.

  3. When: Candidate says entities change through methods Why: Moves to rich versus anemic domain models
    How to answer

    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.

🛠 Real-world problems: how you handle them

Tax rule refresh races fixed with frozen snapshots
Situation
A pricing service kept tax rules in a shared `Dictionary` and refreshed them every [five] minutes by clearing and re-adding entries.
Signals
During refreshes, [a few dozen] requests per day failed with `InvalidOperationException` (collection modified) or priced items with no tax.
Investigation
Correlated failures with refresh timestamps in logs; reproduced with a load test that refreshed while reading.
Resolution
Built each refresh into a new `FrozenDictionary` and swapped the reference with `Volatile.Write`; readers use `Volatile.Read`. No locks needed.
Prevention
Guideline: shared reference data is always an immutable snapshot; a concurrency test runs refresh and reads in parallel in CI.

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.

↩ Usually asked after

More in Design Judgment

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me