Records made immutable data types cheap to declare in C#, and that convenience is exactly what makes them dangerous in the hands of a team that has not internalized what the compiler generates underneath. Senior interviews in this area test whether you know precisely what "value equality" compares, why a with expression is a shallow copy, when a record struct is the right call instead of a record class, and whether you understand that immutability solves some concurrency problems and not others. The ten questions below cover record equality semantics, with-expression mechanics, the record struct and readonly struct family, immutable versus frozen collections, init-only setters, designing value objects, and the real boundaries of thread safety that immutable types provide. Expect to be asked why something is safe, not only that it is.

Q1 Precisely what does record equality compare, and where does it break down in practice?#

Short answer: The compiler generates Equals and GetHashCode that compare every instance field the record declares, public or private, using EqualityComparer<T>.Default for each one, plus an EqualityContract property that folds the runtime type into the comparison so a base-typed instance never equals a derived one holding identical values. It breaks down the moment a field's own equality is not what you expect, most commonly an array or List<T>, which EqualityComparer<T>.Default compares by reference, not by content.

A nested record field composes cleanly, because that record already has value equality of its own, but a mutable collection field does not, and the failure is silent: the code compiles, == runs, and simply returns false for two records that look identical. You cannot declare Equals(object?), == or != yourself, since the generated versions route through your strongly typed Equals(R?); in a non-sealed record that method must be virtual and should compare EqualityContract itself, which is one more reason senior teams default to sealed record for leaf types.

C#
public sealed record Employee(string Name, Manager Manager, List<string> Skills);
public sealed record Manager(string Name);

var a = new Employee("Ada", new Manager("Grace"), ["C#", "F#"]);
var b = new Employee("Ada", new Manager("Grace"), ["C#", "F#"]);

Console.WriteLine(a.Manager == b.Manager);   // True: Manager composes value equality
Console.WriteLine(a == b);                   // False: List<string> compares by reference

What interviewers look for: the field-by-field, EqualityComparer<T>.Default-based mechanism stated precisely, and awareness that nested records compose correctly while mutable collections do not, rather than a blanket "records have value equality."

  • Common mistakes: assuming record equality is deep by definition; it is exactly as deep as each field's own Equals.
  • Follow-up questions: Why does the compiler warn if you declare only Equals(R?) or only GetHashCode but not both? (The two must stay consistent, so providing one without the other is very likely a mistake.)

Q2 What does a with expression actually copy, and where does "shallow" cause real bugs?#

Short answer: A with expression clones the instance, copying every field as-is, and then invokes the init accessor of each property you listed, skipping the constructor entirely. The copy is shallow: value-type fields are duplicated, but reference-type fields, such as a List<T>, still point at the exact same object the original held, so mutating the "copy's" list also mutates the original's.

A second, quieter version of the same bug hits computed properties initialized from other properties in the primary constructor: because a property initializer runs once, at construction, its value is copied as-is by with, not recomputed from the new values. A property that is instead computed on every access, using => rather than =, stays correct after with changes the values it depends on.

C#
public sealed record Cart(string Id, List<string> Items)
{
    public int InitialCount { get; } = Items.Count;      // computed once, copied stale by `with`
    public int CurrentCount => Items.Count;                // recomputed on every access
}

var original = new Cart("C-1", ["mug"]);
var renamed = original with { Id = "C-1-dup" };

renamed.Items.Add("hat");                    // both carts share one List<string>
Console.WriteLine(original.Items.Count);     // 2: the "copy" mutated the original too
Console.WriteLine(renamed.InitialCount);     // 1: stale, computed before the Add
Console.WriteLine(renamed.CurrentCount);     // 2: correct, computed on access

What interviewers look for: the "clone, then run listed init accessors, skip the constructor" mechanism stated exactly, plus the two distinct shallow-copy failure modes: shared mutable references and stale eagerly computed properties.

  • Common mistakes: copying a collection with Items = [.. original.Items] only in some call sites and not others, which leaves the bug intermittent instead of fixed everywhere.
  • Follow-up questions: Does with work on a plain struct or an anonymous type? (Yes, since C# 10, where the clone step is simply an ordinary value copy, so the shallow-copy concern does not apply to a struct's own value-type fields.)

Q3 What's the difference between record struct, readonly record struct and a plain readonly struct, and when do you choose each?#

Short answer: A record struct is a value type with compiler-generated value equality, ToString and Deconstruct, and its positional properties are read-write by default, unlike a record class. Add readonly to get readonly record struct, whose positional properties become init-only, matching the immutability of a record class. A plain readonly struct gives you the same immutability guarantee but none of the generated equality members, so its default Equals can fall back to reflection-based comparison unless you write Equals and GetHashCode yourself.

Aspectrecord structreadonly record structreadonly struct
MutabilityRead-write positional propertiesinit-only positional propertiesWhatever you declare; typically immutable
Generated equalityYes, value-basedYes, value-basedNo; you write it yourself
Typical useSmall, locally mutated valuesMoney, identifiers, coordinatesAny immutable value type without generated members
AllocationInline, copied on assignmentInline, copied on assignmentInline, copied on assignment

The practical default for a strongly typed identifier or a money-like value is readonly record struct: it is small, copies cheaply, and gets correct, strongly typed equality for free, which a plain struct's default equality does not reliably provide. Reach for a plain readonly struct only when you need custom equality semantics the generated version cannot express, or when you deliberately do not want with-expression support.

C#
public readonly record struct OrderId(Guid Value)
{
    public static OrderId New() => new(Guid.CreateVersion7());
}

var id = OrderId.New();
var copy = id;                 // copies 16 bytes, no allocation, no boxing
Console.WriteLine(id == copy); // True: generated equality

What interviewers look for: the three-way comparison stated precisely, especially that a plain struct's default equality is the weak option here, which is the reason record structs exist at all.

  • Common mistakes: using a mutable record struct as a dictionary key or a HashSet<T> element; changing a field after insertion changes its hash code and breaks lookups silently.
  • Follow-up questions: Why might a large readonly record struct hurt performance despite being "allocation-free"? (Every assignment or pass-by-value copies the whole struct; a large one can be slower to copy than passing a reference to a small class.)

Q4 When do you reach for System.Collections.Immutable versus System.Collections.Frozen, and what is actually different between them?#

Short answer: Both give you a collection that cannot be mutated in place, but they optimize for opposite access patterns. System.Collections.Immutable types, such as ImmutableList<T> and ImmutableDictionary<TKey, TValue>, use structural sharing, so producing a changed version from an existing one is cheap because the new version reuses most of the old one's internal tree. System.Collections.Frozen, added in .NET 8 with FrozenDictionary<TKey, TValue> and FrozenSet<T>, has no efficient "add one item" operation at all; you build it once from a source with ToFrozenDictionary() or ToFrozenSet(), and in exchange for a deliberately slower build step it gives you the fastest possible reads, faster than both Dictionary<TKey, TValue> and the immutable collection types.

The decision comes down to how often the collection changes versus how often it is read. Frozen collections fit data that is built once, typically at startup or on first use, and then read millions of times for the life of a long-lived service, such as configuration, routing tables or feature-flag lookups. Immutable collections fit state that changes incrementally over the application's life while still needing safe, lock-free sharing across threads, typically by swapping a field to a new version with Interlocked.CompareExchange rather than mutating shared state in place. System.Collections.Immutable ships inbox as part of the shared framework on modern .NET; a package reference is only needed when targeting .NET Standard or .NET Framework.

C#
// Frozen: built once at startup, read constantly afterward.
private static readonly FrozenDictionary<string, bool> s_featureFlags =
    LoadFlags().ToFrozenDictionary();

// Immutable: changes over time, shared safely without locking readers.
private ImmutableList<string> _recentIds = [];

public void RecordId(string id)
{
    ImmutableList<string> before, after;
    do
    {
        before = _recentIds;
        after = before.Add(id);
    } while (Interlocked.CompareExchange(ref _recentIds, after, before) != before);
}

What interviewers look for: the build-cost-versus-read-cost trade-off stated as the actual distinguishing factor, not just "both are immutable," plus a working atomic-swap pattern for updating shared immutable state without locks.

  • Common mistakes: using FrozenDictionary for data that changes frequently, which pays its expensive build cost repeatedly and never recoups the read-side benefit.
  • Follow-up questions: Why does the atomic-swap pattern above need a retry loop instead of a single Interlocked.CompareExchange call? (Another thread might update _recentIds between the read and the swap, so the loop retries with the newest value until its own change applies cleanly.)

Q5 What do init-only setters allow that a private setter did not, and how do you keep a validated invariant true through every construction path?#

Short answer: An init accessor (C# 9) can be called from an object initializer or a with expression, from outside the type, exactly once per instance, which a private set cannot: a private setter is only callable from inside the type, so external code needed a constructor parameter for every property, even ones that only need a default most of the time. The trade-off is that init accessors and constructors are two separate paths into the object, and a validation rule written only in the constructor never runs when a with expression uses the init accessor directly.

The fix that keeps validation centralized is to put the check in the init accessor itself and have the primary constructor's implicit property initializer call through the same validating logic, which the C# 14 field keyword makes concise without a hand-declared backing field.

C#
public sealed record OrderLine(int Quantity, decimal UnitPrice)
{
    public int Quantity
    {
        get;
        init => field = EnsurePositive(value);
    } = EnsurePositive(Quantity);              // constructor path uses the same check

    private static int EnsurePositive(int value)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value);
        return value;
    }
}

var line = new OrderLine(3, 9.99m);
var invalid = line with { Quantity = -1 };     // throws: the with-path is validated too

What interviewers look for: the two-paths-into-construction insight (constructor versus init via with), and a concrete pattern that validates both without duplicating the rule.

  • Common mistakes: validating only in a custom constructor and assuming a with expression is "basically the same as construction," when it explicitly is not, since it never calls the constructor.
  • Follow-up questions: Could you enforce a cross-property invariant, such as "start date before end date," the same way? (Not cleanly with per-property init accessors alone; that usually needs a factory method returning a Result-like type, or a check duplicated in both the constructor and a custom with-like method, since C# has no post-construction validation hook that also fires after with.)

Q6 How would you design a Money or strongly typed ID value object in modern C#, and what does each decision buy you?#

Short answer: Use a readonly record struct for the allocation-free copy semantics and generated equality, validate every input at the single point where raw data enters the type, implement the operators the domain actually needs rather than exposing the raw value freely, and keep the type sealed to a single representation, such as always storing the currency alongside the amount, so invalid states such as "amount with no currency" cannot be constructed.

Deciding what the type supports is itself a design decision: implementing IAdditionOperators<TSelf, TOther, TResult> from generic math is worth it when the type genuinely needs to compose with generic numeric algorithms; otherwise, plain operator + overloads read more simply. A ToString override matters more than it looks for a value object, since records log their default ToString output freely, and a Money type without one prints Money { Amount = 42.50, Currency = USD }, which is usually exactly what you want in logs, but a type wrapping a secret or a personal identifier should override PrintMembers to redact it instead.

C#
public readonly record struct Money
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(amount);
        ArgumentException.ThrowIfNullOrWhiteSpace(currency);
        Amount = amount;
        Currency = currency;
    }

    public static Money operator +(Money left, Money right)
    {
        if (left.Currency != right.Currency)
            throw new InvalidOperationException("Cannot add different currencies.");
        return new Money(left.Amount + right.Amount, left.Currency);
    }
}

What interviewers look for: deliberate choices at every level (struct versus class, which operators, validation location, ToString behavior), not a reflexive "just use a record" without reasoning about the specific type's needs. See C# generics interview questions for the generic math interfaces this can plug into.

  • Common mistakes: exposing a public constructor that skips validation alongside a validating factory method, so callers can bypass the invariant by choosing the wrong entry point.
  • Follow-up questions: Why is a readonly record struct still not a perfect guarantee against an "empty" or default-constructed instance? (Every struct has an implicit parameterless default that bypasses your constructor entirely, so code that receives a Money must still be able to detect and reject default.)

Q7 Are immutable types automatically thread-safe? What do they not guarantee?#

Short answer: An immutable object removes internal data races: because no field ever changes after construction, any thread that holds a valid reference can read it freely with no locking and will never observe a partial mutation. What immutability does not guarantee is safe publication of the reference itself across threads without any synchronization, and it does not make a group of separately updated immutable values consistent with each other just because each one, individually, cannot change.

Shallow immutability compounds this: a readonly record struct holding a reference to a mutable List<T> is only immutable about which list it points at, not about the list's contents, so sharing it across threads without also making the list immutable reintroduces the exact race immutability was meant to remove. For state that genuinely changes over an application's life, the safe pattern is to keep one immutable snapshot behind a single field and replace the whole field atomically with Interlocked.Exchange or Interlocked.CompareExchange, rather than updating several related fields independently, which is the same atomic-swap idea from the immutable-collections question applied to any immutable root object, not just a collection.

What interviewers look for: a precise account of what immutability buys (no internal mutation races) versus what it does not (safe publication, cross-field consistency), and the atomic-swap-of-a-whole-snapshot pattern as the practical answer for safely evolving shared immutable state.

  • Common mistakes: treating "immutable" and "thread-safe" as synonyms without qualification, which misses both the shallow-immutability trap and the publication question.
  • Follow-up questions: How would you update two related pieces of state, such as a cache and its version number, consistently under concurrent access? (Combine them into one immutable object and swap that single reference atomically, rather than updating two fields separately.)

Q8 How do you model a multi-state domain concept, such as an order lifecycle, using immutable records, and what changes with C# 15?#

Short answer: Give each valid state its own record carrying only the data that state actually has, so a draft order has no payment ID and a paid order always does, and write transitions as a pure function pattern-matching over the current state and an incoming command. This makes illegal states genuinely unrepresentable instead of merely undocumented, and every legal transition is visible in one place, which is easy to unit test exhaustively.

On .NET 8 through 10, the base type for such a hierarchy is an open abstract record, which means the compiler cannot prove a switch over it is exhaustive, so every switch needs a discard arm, ideally one that throws rather than silently returning a default. C# 15 on .NET 11 adds the closed modifier, which restricts direct subtypes of a record to its declaring assembly and lets the compiler treat a switch that handles every direct subtype as exhaustive with no discard arm at all, catching a forgotten case at compile time instead of at run time.

C#
public abstract record OrderState
{
    public sealed record Draft(IReadOnlyList<string> Skus) : OrderState;
    public sealed record Placed(IReadOnlyList<string> Skus, DateTimeOffset At) : OrderState;
    public sealed record Paid(Placed Order, string PaymentId) : OrderState;
}

public abstract record OrderCommand
{
    public sealed record Place(DateTimeOffset At) : OrderCommand;
    public sealed record Pay(string PaymentId) : OrderCommand;
}

public static OrderState Apply(OrderState state, OrderCommand command) => (state, command) switch
{
    (OrderState.Draft d, OrderCommand.Place p) => new OrderState.Placed(d.Skus, p.At),
    (OrderState.Placed pl, OrderCommand.Pay pay) => new OrderState.Paid(pl, pay.PaymentId),
    _ => throw new InvalidOperationException($"Invalid transition from {state.GetType().Name}."),
};

What interviewers look for: the "records for state, pattern matching for transitions" modeling approach, and accurate knowledge of what closed changes in C# 15 versus what teams on .NET 8 through 10 still have to do manually with a discard arm.

  • Common mistakes: modeling every state as one record with nullable fields for whatever each state might need, which brings back the illegal-state problem records were meant to remove.
  • Follow-up questions: When would a C# 15 union type be a better fit than a closed record hierarchy for this problem? (When the cases are unrelated types that do not share a base class or common data, rather than variations on one concept.)

Q9 What goes wrong when a record is used as a mutable dictionary key, or as an EF Core tracked entity?#

Short answer: A record's generated GetHashCode combines the same fields as Equals, so if any of those fields can change after the record is inserted into a Dictionary<TKey, TValue> or HashSet<T>, its hash code changes too, and the collection's internal bucket for that entry no longer matches where a lookup will search, so TryGetValue and Contains silently stop finding it. This applies to a mutable record struct used as a key even more easily than to a record class, because its properties are read-write by default.

Using a record as an EF Core entity type runs into a related but distinct problem: EF Core's change tracker identifies and tracks entities by reference identity and, for keyed entities, by primary key value, not by the kind of structural value equality records generate. Two separately loaded record instances with identical property values are, by EF Core's tracking model, still meant to represent the same conceptual row only if their keys match, and record value equality can make it easy to accidentally treat two distinct rows as interchangeable, or to confuse the tracker when a record's with-produced copy is passed back for an update. Microsoft's guidance is to keep records for DTOs, projections and value objects, and use ordinary classes for EF Core entity types. See EF Core: the complete guide for the entity-modeling rules this follows from.

What interviewers look for: the hash-code-changes-after-mutation mechanism stated precisely for the dictionary case, and the reference-identity-versus-value-equality distinction for the EF Core case, rather than a vague "records and EF Core don't mix."

  • Common mistakes: "fixing" the dictionary problem by overriding only GetHashCode to return a constant; that restores correctness at the cost of every lookup degrading to a linear scan within one giant bucket.
  • Follow-up questions: How would you safely use a record as a cache key when some of its data might be optional or loaded later? (Key the cache only on the fields that are fixed at construction and never change, kept in their own small immutable key type.)

Q10 A colleague proposes replacing a hand-written immutable class, with a private constructor and validation, with a plain positional record. What do you check before agreeing?#

Short answer: Check whether every property is a genuinely immutable value the type's identity is defined by, whether validation needs to hold on the with-expression path as well as construction, and whether the type will ever be compared, hashed, logged or used as a dictionary key in ways that depend on its generated members behaving correctly. A record is the right replacement when all of that lines up; it is the wrong replacement when the type has mutable internal state, entity-style identity, or validation logic that a bare positional record's constructor-only approach would silently stop enforcing.

Confirm the record will be sealed, since an open hierarchy complicates equality and invites confusing subclassing for what was meant to be a closed value type. Confirm any collection-typed property is either an immutable collection type or explicitly documented as shared, since positional syntax makes it easy to accept a List<T> without anyone noticing the equality and with-copy implications. Finally, confirm the team is comfortable with the trade-off that a record's generated ToString will start appearing in logs by default, which is usually a win for a value object but a real concern if any property holds sensitive data.

What interviewers look for: a checklist grounded in the mechanics covered throughout this page, records, with, equality, collections, rather than a one-line "yes, records are always better for value objects" or "no, hand-written classes are always safer."

  • Common mistakes: approving the change purely because records are less code, without checking whether the existing validation logic survives the with-expression path.
  • Follow-up questions: How would you migrate the type incrementally without a breaking change to callers? (Keep the public constructor's validation behavior identical, add init accessors that call the same validation, and change equality semantics only in a version bump callers are told about, since it is an observable behavior change.)

Quick-Fire Round#

QuestionAnswer
Does record equality compare private fields?Yes, every instance field, using EqualityComparer<T>.Default
Does a with expression call the constructor?No; it clones the instance and runs the listed init accessors
Are record struct properties init-only by default?No; only readonly record struct makes them init-only
Which .NET version added FrozenDictionary and FrozenSet?.NET 8, in the System.Collections.Frozen namespace
Which is cheaper to build, a frozen collection or an immutable one?The immutable collection; frozen collections trade a slow build for the fastest reads
Can you declare Equals(object?) yourself on a record?No; the generated version routes through your Equals(R?)
Does immutability guarantee thread-safe publication of a reference?No; publication still needs a proper synchronization mechanism
What breaks when a mutable record struct is used as a dictionary key?Its hash code can change after insertion, so lookups silently fail
What does C# 15's closed modifier add to a record hierarchy?Compiler-verified exhaustive switches with no discard arm needed

How to Prepare#

  • Be able to list every member a one-line positional record generates, in order, without hesitating.
  • Practice explaining the shallow-copy behavior of with using a mutable collection field as the concrete example.
  • Know the build-cost-versus-read-cost trade-off between System.Collections.Immutable and System.Collections.Frozen cold.
  • Rehearse the atomic-swap pattern (Interlocked.CompareExchange over an immutable snapshot) until you can write it without looking it up.
  • Prepare a real example of a record or value object you designed, including one validation or equality decision you would make differently today.