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

📚 Interview question

What does readonly mean in each place it can appear in C#? Explain readonly fields, static readonly versus const (and the versioning trap), readonly structs, readonly members, readonly ref returns, how the JIT treats static readonly values, and why readonly does not make an object immutable.

readonly across fields, structs, members and refs, const versus static readonly, and immutability limits.

Mid-Level Theory Intermediate const immutability jit readonly structs

🎯 Key points

  • readonly fields can be assigned only in initialisers, constructors and init accessors
  • const values are copied into callers at compile time; static readonly values are read at run time
  • Changing a public const in a library has no effect until callers recompile
  • The tiered JIT treats initialised static readonly primitives as constants
  • readonly struct and readonly members remove defensive copies
  • ref readonly returns and locals allow aliasing without writes
  • readonly protects the variable, not the object it references
  • Use immutable or frozen collections for real immutability

⏱ 30-second answer

readonly always means "this storage cannot be reassigned after initialisation", but the scope differs. A readonly field can be set only in its initialiser or a constructor (or an init accessor). const is a compile-time literal copied into every caller, so changing it in a library does nothing until callers recompile; static readonly is read at run time, and the tiered JIT treats it as a constant once initialised, so it is just as fast for primitives. A readonly struct has only readonly fields, and readonly members (C# 8) promise not to mutate this, which removes defensive copies. ref readonly returns give a reference callers cannot write through. None of these make referenced objects immutable: a readonly List<T> can still be added to.

🗣 Full answer (senior)

How I would answer this in an interview

readonly means "can't be reassigned after initialisation", but what is read-only depends on where it appears: a field, a struct, a struct member, or a reference returned by ref readonly. The common thread is that readonly protects a storage location, not the object graph behind it. I'd go through each place, static readonly vs const (and the versioning trap), how the JIT treats static readonly, and why readonly isn't immutability.

1. readonly fields

public sealed class OrderService(IOrderRepository repo)
{
    private readonly IOrderRepository _repo = repo;          // assign once
    private readonly List<string> _audit = [];                // reference can't change...
    public void Note(string s) => _audit.Add(s);              // ...but the list's contents can
}
  • Can be assigned in the declaration, constructors (of the same type), or init accessors.
  • After construction, reassignment is a compile error (CS0191).
  • Reflection (FieldInfo.SetValue) can still change it (and static readonly writes via reflection are blocked/undefined in modern runtimes after initialisation) — it's a language guarantee, not a security boundary.

2. static readonly vs const (the versioning trap)

const static readonly
Evaluated Compile time Run time (type initialisation)
Types Primitives, string, enums, null Any type
Stored in callers Value copied into caller IL Callers read the field
Change in library v2 Old callers keep old value until recompiled Picked up by old callers
Usable in attributes, case labels, default parameters Yes No

The versioning trap: public const int MaxPageSize = 50; in a NuGet package — changing it to 100 does nothing for consumers compiled against v1. Use const for true constants (BitsPerByte) or non-public values; use static readonly (or a property) for public values that may change.

3. How the JIT treats static readonly

With tiered compilation, hot methods are recompiled at tier 1 after the type's static constructor has run. The JIT then knows the actual value of static readonly fields and treats them as constants:

private static readonly bool s_verbose = Environment.GetEnvironmentVariable("VERBOSE") == "1";
if (s_verbose) Log(...);   // at tier 1, the whole branch can be eliminated when false

This also applies to static readonly object references (devirtualising calls on them) and primitive values (constant folding). So static readonly usually performs like const in steady state, while staying configurable.

4. readonly struct

public readonly struct Money(decimal amount, string currency)
{
    public decimal Amount { get; } = amount;
    public string Currency { get; } = currency;
}
  • All instance fields must be readonly; auto-properties must be get-only or init.
  • The compiler knows no member mutates this, so it skips defensive copies when the struct is accessed via in parameters, readonly fields or ref readonly refs.
  • Communicates intent: a value type that never changes.

5. readonly members (C# 8)

public struct Counter
{
    public int Value;
    public readonly int Doubled() => Value * 2;   // promises not to mutate
    public void Increment() => Value++;
}

For mutable structs, marking non-mutating members readonly avoids defensive copies when called on read-only locations. The compiler rejects mutations inside readonly members.

6. ref readonly returns (and locals)

private Matrix4x4 _transform;
public ref readonly Matrix4x4 Transform => ref _transform;   // no 64-byte copy; caller can't modify

ref readonly var t = ref obj.Transform;

Returns a reference to storage without allowing writes through it — cheap access to large structs. If the struct isn't readonly, member calls through t may still cause defensive copies.

7. Why readonly doesn't make an object immutable

  • A readonly field holding a reference type prevents reassigning the reference, but the referenced object (a List<T>, a mutable entity, an array) can still change.
  • readonly arrays: private static readonly int[] Primes = {2, 3, 5}; — anyone with access can do Primes[0] = 99.
  • A readonly struct containing a reference to a mutable object is only shallowly immutable.
  • Exposing a readonly field as a public IReadOnlyList<T> view still allows casting back.

For real immutability: immutable types all the way down (records with init, ImmutableArray<T>, FrozenSet<T>, ReadOnlySpan<T> static properties for constant data, string).

8. A production example

A rules service exposed public static readonly string[] AllowedCountries = { "AU", "NZ" }; A feature for a pilot in Singapore did AllowedCountries[1] = "SG" at start-up "temporarily" — and New Zealand customers were rejected for a day. Changing it to public static readonly FrozenSet<string> AllowedCountries = new[] { "AU", "NZ" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); made mutation impossible and lookups faster; the pilot got proper configuration.

🔍 What the interviewer is checking

The interviewer checks precise understanding of readonly's forms and their limits.

Green flags

  • Distinguishes readonly fields, readonly structs, readonly members and ref readonly.
  • Explains the const versioning trap.
  • Mentions JIT constant folding of static readonly.
  • Clearly says readonly is not deep immutability.

Red flags

  • Thinks readonly makes objects immutable.
  • Uses public const for configuration-like values.
  • Unaware of defensive copies.

📚 Learn in depth

1. IL

  • readonly field → initonly flag. The JIT/verifier allow stores only in constructors (or .cctor for static).
  • const → literal field with a Constant metadata value; no storage read at run time.
  • readonly struct → [IsReadOnly] attribute on the type; readonly members → [IsReadOnly] on methods.
  • ref readonly return → modreq(InAttribute)/[IsReadOnly] on the return.

2. static readonly and the runtime

Since .NET Core 3.0, the runtime treats static readonly fields as truly read-only after the type is initialised: writes via reflection throw FieldAccessException ("cannot set initonly static field after type is initialized"). This guarantee is what lets the JIT fold their values.

3. Defensive copy examples

private readonly MutableStruct _s;
_s.Mutate();   // operates on a COPY - original unchanged (CS1648 for direct field assignment; method calls compile)

This is a classic bug source: methods called on readonly struct fields silently don't change them.

4. readonly and threading

readonly fields assigned in constructors are visible to other threads once the object is safely published (e.g. via DI or a volatile write). But mutable objects referenced by readonly fields still need synchronisation. Immutability, not readonly, gives thread safety.

5. Choosing

Need Use
Universal constant const (internal/private, or truly fixed)
Public configurable value static readonly or property
Dependency stored once private readonly field
Immutable small value type readonly struct / readonly record struct
Large struct exposed cheaply ref readonly return
Truly immutable collection ImmutableArray<T>, FrozenSet<T>, ReadOnlySpan<T> static property

Try it yourself

  1. Change a public const in a library, rebuild only the library, and run an old consumer.
  2. Call a mutating method on a readonly field of a mutable struct and print the result.
  3. Try setting a static readonly field via reflection after initialisation.
  4. Mutate a static readonly int[] from another class.

💻 Code

using System.Collections.Frozen;
using System.Collections.Immutable;

public static class Limits
{
    public const double GstRate = 0.10;              // safe as const only if it truly never changes for callers
    public static readonly int MaxBasketItems =       // read at run time; JIT treats it as a constant at tier 1
        int.TryParse(Environment.GetEnvironmentVariable("MAX_BASKET"), out var n) ? n : 50;
    public static readonly FrozenSet<string> States =
        new[] { "NSW", "VIC", "QLD", "WA", "SA", "TAS", "ACT", "NT" }.ToFrozenSet();
}

public readonly struct Money(decimal amount, string currency)   // readonly struct: no defensive copies
{
    public decimal Amount { get; } = amount;
    public string Currency { get; } = currency;
    public Money WithGst() => new(Amount * (1 + (decimal)Limits.GstRate), Currency);
}

public struct Counter
{
    private int _value;
    public void Increment() => _value++;
    public readonly int Value => _value;              // readonly member: safe to call on readonly instances
}

public sealed class Basket
{
    private readonly List<string> _items = [];        // field cannot be reassigned...
    private readonly Counter _changes;                // readonly struct field

    public void Add(string sku)
    {
        if (_items.Count >= Limits.MaxBasketItems) throw new InvalidOperationException("Basket full");
        _items.Add(sku);                              // ...but the list itself can still change
        // _changes.Increment();                      // would mutate a defensive copy, not the field
    }

    public ImmutableArray<string> Snapshot() => [.. _items];  // truly immutable copy for callers
    public int Changes => _changes.Value;
}

public static class Program
{
    public static void Main()
    {
        var basket = new Basket();
        basket.Add("SKU-1");
        Console.WriteLine($"{basket.Snapshot().Length} item(s), max {Limits.MaxBasketItems}");
        Console.WriteLine(new Money(100m, "AUD").WithGst().Amount);   // 110.0
        Console.WriteLine(Limits.States.Contains("WA"));
    }
}

⚠ Common mistakes

1. Thinking readonly means immutable

What happens: readonly List<T> fields considered safe to share. Why it is wrong: contents can still change. Do instead: immutable or frozen collections.

2. Public const for values that change

What happens: API versions, limits, URLs as public const. Why it is wrong: consumers keep old values until recompiled. Do instead: static readonly or properties.

3. static readonly arrays as constant tables

What happens: public static readonly int[] Table = {...}. Why it is wrong: elements can be modified by anyone. Do instead: ReadOnlySpan<T> static properties or ImmutableArray<T>.

4. Mutable structs in readonly fields

What happens: calling mutating methods on readonly struct fields. Why it is wrong: changes apply to defensive copies. Do instead: readonly struct or non-readonly fields with clear mutation.

5. Avoiding static readonly for performance

What happens: using const everywhere because "static readonly is slower". Why it is wrong: tier-1 JIT folds static readonly values. Do instead: choose by versioning semantics.

6. Non-readonly struct members called via in

What happens: getters and methods without readonly modifiers. Why it is wrong: hidden copies. Do instead: mark members/structs readonly.

7. Relying on readonly for security

What happens: assuming reflection can't change readonly instance fields. Why it is wrong: reflection can modify instance readonly fields. Do instead: treat it as a design constraint.

8. Exposing ref instead of ref readonly

What happens: returning mutable refs to internal state. Why it is wrong: callers can bypass invariants. Do instead: ref readonly returns.

🏛 Senior insight: trade-offs

readonly is about intent and compiler guarantees, and each form protects a different thing. Senior engineers use the strongest form that fits:

Want to protect Use
Dependency or configuration reference readonly field
Value that may change between releases static readonly
Truly permanent literal const
Struct state readonly struct / readonly members
Collection contents immutable or frozen collection

I would mention the binary compatibility angle explicitly, because the const versioning trap causes real incidents in multi-repo organisations, and the JIT angle, because it explains why static readonly feature flags read at start-up are effectively free.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says readonly fields still allow mutation. Why: Immutable and frozen collections give real immutability.
    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 mentions readonly members. Why: Defensive copies with in parameters relate to readonly structs.
    How to answer

    An in parameter (C# 7.2) passes an argument by readonly reference: no copy of the struct, and the method cannot assign to it. It helps for large structs (roughly over 16 bytes) on hot paths. The trap is defensive copies: if the struct is not readonly, calling a non-readonly method or property on an in parameter forces the compiler to copy it first, because the call might mutate it, which can make in slower than by-value. Mark structs readonly (or individual members readonly, C# 8) to avoid this. in is optional at the call site and accepts rvalues (the compiler creates a temporary). ref readonly parameters (C# 12) express "I need a real reference to a variable": callers should pass an lvalue with ref or in, and passing a temporary produces a warning. For small structs and all classes, pass by value.

  3. When: Candidate declares readonly structs. Why: readonly record struct is the common readonly value type.
    How to answer

    A record struct (C# 10) is a value type with record features: generated Equals, GetHashCode, ==, ToString and, for positional ones, Deconstruct. Unlike a record class, its positional properties are mutable (get; set;) by default; readonly record struct makes them init and the struct readonly, which is what you usually want. Its generated equality compares fields directly, so it avoids the reflection and boxing that a plain struct's inherited ValueType.Equals can fall back to. It has no EqualityContract and no inheritance. Watch the default value: default(OrderId) bypasses constructors and validation. Use it for small, immutable identifiers and values (ids, coordinates, money) used in hot paths or as dictionary keys.

🛠 Real-world problems: how you handle them

Stale API version from a public const in a shared package
Situation
A shared contracts package exposed `public const string ApiVersion` used by [12] services calling a partner API.
Signals
After the partner retired the old version, [five] services got 410 Gone responses although they had upgraded the package.
Investigation
Decompiled a failing service with ILSpy and saw the old version string baked into its IL; those services had updated the package reference without rebuilding.
Resolution
Rebuilt and redeployed the affected services, then changed the value to `static readonly` and moved the version to configuration.
Prevention
Added an analyser rule against public const strings in shared packages and a CI check that services rebuild when shared packages change.

How to tell it (STAR)

**Situation:** when our [payments partner] retired an API version, some of our services kept calling it although we had shipped the update. **Task:** I led the incident and needed to find why only some services failed. **Action:** I decompiled a failing binary and found the old version was compiled in from a public const. I rebuilt and redeployed those services, changed the field to static readonly, and added a rule against public consts in shared packages. **Result:** service was restored in [40 minutes] and we have not had a stale-constant issue since.

↩ Usually asked after

More in Memory-Oriented C#

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me