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

📚 Interview question

What are records in C#? Explain record classes and record structs, what the compiler generates (value equality, ToString, Deconstruct, with-expressions), how equality works with inheritance, and where records are a poor fit.

C# records: positional syntax, generated members, value equality and EqualityContract, non-destructive mutation with `with`, record structs, and pitfalls such as collections and EF Core entities.

Mid-Level Theory Intermediate csharp immutability oop record-struct records value-equality

🎯 Key points

  • A record's equality is based on its data; the compiler generates Equals, GetHashCode, ==, !=, ToString and Deconstruct
  • Positional record classes get init-only properties and a primary constructor
  • `with` expressions clone and then set listed properties; the copy is shallow
  • EqualityContract makes a base record and a derived record unequal, keeping Equals symmetric
  • `record struct` (C# 10) is a value type with mutable positional properties unless declared `readonly record struct`
  • Collection members are compared by reference, so equal contents do not mean equal records
  • Records suit DTOs, messages and value objects; classes suit EF Core entities with identity
  • Generated ToString prints all properties, which can leak secrets into logs

⏱ 30-second answer

A record is a type whose identity is its data. public record Money(decimal Amount, string Currency); makes the compiler generate init-only properties, a constructor, Deconstruct, value-based Equals/GetHashCode/==, a readable ToString, and a hidden clone method used by with expressions for non-destructive mutation. record (or record class, C# 9) is a reference type; record struct (C# 10) is a value type whose positional properties are mutable unless you write readonly record struct. Equality includes an EqualityContract so a base and derived record are never equal. Watch out: with is a shallow copy, collection members compare by reference, and records are a poor fit for EF Core entities, which need identity-based equality.

🗣 Full answer (senior)

How I would answer this in an interview

Records are types designed for data with value semantics. The compiler generates value-based equality, a readable ToString, deconstruction and non-destructive mutation (with). There are record classes (record / record class, reference types) and record structs (record struct, value types). I'd cover what's generated, how equality works with inheritance, and where records are a poor fit.

1. Declaring records

public sealed record Money(decimal Amount, string Currency);           // positional record class
public readonly record struct OrderId(Guid Value);                      // positional record struct

public sealed record Customer                                            // nominal record
{
    public required string Name { get; init; }
    public string? Email { get; init; }
}
  • Positional records (with a parameter list) generate a primary constructor and public properties: init-only for record classes and readonly record struct; settable (get; set;) for plain record struct.
  • Records can have additional members, implement interfaces, and (record classes) inherit from other records.

2. What the compiler generates

For record Money(decimal Amount, string Currency):

Member Behaviour
Constructor + properties Amount, Currency with init
Equals(object), Equals(Money), IEquatable<Money> Value equality: same type and all fields equal
GetHashCode() Combines all fields
== / != Use value equality
ToString() Money { Amount = 10, Currency = AUD } (via PrintMembers)
Deconstruct(out decimal, out string) var (amount, currency) = money;
Copy constructor + <Clone>$ Powers with expressions
EqualityContract (record classes) typeof(Money), used in equality with inheritance

with creates a shallow copy with some properties changed: var aud20 = money with { Amount = 20m };. Records also support with on record structs and (C# 10) on anonymous types and any struct.

3. Equality with inheritance

public record Person(string Name);
public record Employee(string Name, string Role) : Person(Name);

Person p = new Person("Ada");
Person e = new Employee("Ada", "Dev");
p == e;   // false - different EqualityContract (runtime types differ)

Record classes include EqualityContract (the runtime type) in equality, so a base and derived record with the same base fields are never equal — this keeps equality symmetric, which hand-written Equals overrides often get wrong. Derived records override EqualityContract and chain Equals to the base.

ToString/PrintMembers and with also respect the runtime type: e with { Name = "Grace" } returns an Employee.

4. Record class vs record struct

record (class) record struct / readonly record struct
Kind Reference type Value type
Allocation Heap Inline (no allocation by itself)
Inheritance Yes (records only) No
Default mutability init-only Mutable (record struct) / immutable (readonly record struct)
Null Can be null Can't be null
Good for DTOs, value objects, messages Small identifiers and values (OrderId, Point), keys in hot dictionaries

I default to sealed record for DTOs/value objects and readonly record struct for small strongly-typed IDs.

5. Where records are a poor fit

  • EF Core entities: entities have identity (ID) and change over time; value equality across all properties conflicts with the change tracker's identity map, and with creates new instances EF doesn't track. Use classes for entities; records for owned value objects/complex types.
  • Types with collection properties: generated equality compares collections by reference, so two records with identical lists aren't equal. You must override equality or use immutable collections with custom comparison.
  • Large mutable objects or objects with behaviour and invariants (aggregates): init + with can bypass validation (with uses the copy constructor, not your validating constructor logic for changed properties).
  • Sensitive data: generated ToString prints every property — emails, tokens, TFNs end up in logs. Override PrintMembers or ToString.
  • Cyclic references: generated Equals/GetHashCode/ToString recurse infinitely.
  • Hot paths with large record classes compared often: field-by-field equality on many fields costs more than reference equality.

6. A production example

A team switched API DTOs and EF entities to records at the same time. DTOs were great — less boilerplate, better logging. Entities broke: with expressions created untracked copies, so updates silently didn't save, and record equality made HashSet<OrderLine> drop "duplicate" lines that had different IDs but equal values... once IDs weren't yet assigned. We reverted entities to classes, kept value objects (Money, Address) as records mapped with ComplexProperty, and added a redaction override on records containing customer emails.

🔍 What the interviewer is checking

Checks practical understanding of records beyond "immutable class".

Green flags: lists generated members; explains with and its shallow copy; knows EqualityContract; contrasts record class and record struct (and readonly); names collection-equality and EF Core pitfalls; mentions logging of secrets.

Red flags: "records are always immutable"; "records are value types"; uses records as EF entities without hesitation; unaware of shallow copy.

📚 Learn in depth

1. Generated code (simplified)

public class Money : IEquatable<Money>
{
    public Money(decimal Amount, string Currency) { this.Amount = Amount; this.Currency = Currency; }
    protected Money(Money original) { Amount = original.Amount; Currency = original.Currency; }  // copy ctor

    public decimal Amount { get; init; }
    public string Currency { get; init; }
    protected virtual Type EqualityContract => typeof(Money);

    public virtual bool Equals(Money? other) =>
        (object)this == other ||
        (other is not null && EqualityContract == other.EqualityContract &&
         EqualityComparer<decimal>.Default.Equals(Amount, other.Amount) &&
         EqualityComparer<string>.Default.Equals(Currency, other.Currency));

    public override int GetHashCode() =>
        HashCode.Combine(EqualityContract, Amount, Currency);   // conceptually

    public virtual Money <Clone>$() => new Money(this);
    public void Deconstruct(out decimal Amount, out string Currency) { ... }
    protected virtual bool PrintMembers(StringBuilder builder) { ... }
    public override string ToString() { ... }
}

with compiles to var copy = original.<Clone>$(); copy.Amount = 20m; — calling init setters on the clone, which is allowed because it's part of object creation semantics.

2. Customising generated members

  • Declare your own Equals(Money? other) and GetHashCode() to change equality (e.g. case-insensitive currency or sequence comparison for collections). If you declare Equals(T), also declare GetHashCode.
  • Override PrintMembers to control ToString (e.g. redact).
  • sealed override ToString() (C# 10) stops derived records regenerating it.
  • Explicit property declarations can replace positional ones: public string Currency { get; init; } = Currency.ToUpperInvariant(); normalises input.

3. Validation and with

public sealed record Percentage
{
    public Percentage(decimal value) => Value = value is >= 0 and <= 100 ? value : throw new ArgumentOutOfRangeException(nameof(value));
    public decimal Value { get; init; }
}
var p = new Percentage(10) with { Value = 500 };   // bypasses the constructor check!

Validation in the constructor doesn't run for with. To protect invariants, validate in the init accessor (C# 14 field keyword makes this concise: public decimal Value { get; init => field = value is >= 0 and <= 100 ? value : throw ...; }), or don't expose init.

4. Record structs

  • record struct generates the same members except EqualityContract, copy constructor and inheritance-related members.
  • Equality is generated field-by-field (fast — no reflection, unlike ValueType.Equals), making record structs good dictionary keys.
  • readonly record struct makes positional properties init-only and the struct readonly (avoids defensive copies).

5. Serialisation

System.Text.Json supports positional records via constructor binding (parameter names match property names, case-insensitively by default), init properties and required members. Record structs serialise like structs. Records are ideal API contracts and message types (e.g. for Azure Service Bus payloads).

6. Performance considerations

  • Record class equality compares every field; GetHashCode hashes every field. For records with many fields used heavily in sets, consider custom equality on a key.
  • with allocates a new object (record class).
  • Record structs avoid allocation but are copied by value — keep them small.

Try it yourself

  1. Decompile a record in SharpLab and read the generated members.
  2. Compare a base and derived record with the same values for equality.
  3. Create a record with a List<string> property and compare two instances with equal lists.
  4. Show that with bypasses constructor validation, then fix it with an init accessor using field.

💻 Code

// Value object with validation
public sealed record Money
{
    public decimal Amount { get; init; }
    public string Currency { get; init; }

    public Money(decimal amount, string currency)
    {
        if (currency is not { Length: 3 }) throw new ArgumentException("ISO code required", nameof(currency));
        Amount = decimal.Round(amount, 2);
        Currency = currency.ToUpperInvariant();
    }
    public Money Add(Money other) =>
        other.Currency == Currency ? this with { Amount = Amount + other.Amount }
                                   : throw new InvalidOperationException("Currency mismatch");
}

// Positional records for messages
public record OrderPlaced(Guid OrderId, string CustomerId, Money Total, DateTimeOffset At);

// Value-type record for a strongly typed ID (no heap allocation)
public readonly record struct OrderId(Guid Value)
{
    public static OrderId New() => new(Guid.CreateVersion7());
    public override string ToString() => Value.ToString("N");
}

// Usage
var quote = new Money(100m, "aud");
var withGst = quote with { Amount = quote.Amount * 1.1m };
Console.WriteLine(quote == new Money(100m, "AUD"));   // True: value equality
var (amount, currency) = withGst;                     // needs Deconstruct; positional records get it free

// Inheritance and EqualityContract
public record Person(string Name);
public record Employee(string Name, string Team) : Person(Name);
Person p = new Person("Ana"), e = new Employee("Ana", "IT");
Console.WriteLine(p == e);                           // False

⚠ Common mistakes

1. Using records for EF Core entities

What happens: entities declared as records and updated with with. Why it is wrong: with creates untracked copies; value equality conflicts with entity identity. Do instead: use classes for entities; records for DTOs and value objects.

2. Expecting collection properties to compare by content

What happens: two records with equal lists compare unequal. Why it is wrong: generated equality uses reference equality for collections. Do instead: override equality with SequenceEqual, or avoid collections in value-equality records.

3. Assuming with runs validation

What happens: constructor validation is bypassed by with expressions. Why it is wrong: invalid instances can be created. Do instead: validate in init accessors or restrict init access.

4. Leaking sensitive data in ToString

What happens: records with personal data are logged. Why it is wrong: generated ToString prints every property. Do instead: override PrintMembers/ToString to redact sensitive fields.

5. Thinking records are always immutable

What happens: record struct or records with set accessors are assumed immutable. Why it is wrong: plain record struct positional properties are mutable; you can declare mutable members on any record. Do instead: use readonly record struct and init; avoid mutable members on records.

6. Deep copies expected from with

What happens: modifying a list inside a with copy changes the original. Why it is wrong: with is a shallow copy. Do instead: use immutable collections or copy nested data explicitly.

7. Cyclic references between records

What happens: parent and child records reference each other. Why it is wrong: generated equality, hashing and ToString recurse until stack overflow. Do instead: reference by ID or override generated members.

8. Large record structs

What happens: record structs with many fields passed around. Why it is wrong: copying cost on every assignment and call. Do instead: keep record structs small; use record classes for larger data.

🏛 Senior insight: trade-offs

Seniors use records to make value semantics explicit and pick the record kind deliberately.

Use Choice
API request/response DTOs, messages, events sealed record
Strongly typed IDs, small value objects on hot paths readonly record struct
Domain value objects with validation Nominal sealed record with a validating constructor
Entities with identity class, equality by Id if needed
Records containing collections Immutable collection plus custom equality, or avoid

They also consider serialisation and logging: records print everything in ToString, which is great for debugging and dangerous for secrets.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions record struct Why: Checks value-type semantics, readonly structs and boxing
    How to answer

    A struct is a value type: a variable holds the data itself, assignment and parameter passing copy it, and it is stored inline (in a local, a field or an array element) rather than as a separate heap object. Structs cannot inherit (they are sealed) but can implement interfaces. default(T) always gives all-zero fields, even when you write a parameterless constructor (allowed since C# 10). readonly struct guarantees immutability and avoids defensive copies. ref struct (like Span<T>) can live only on the stack; since C# 13 it can implement interfaces and be used with allows ref struct generics. Converting a struct to object or an interface boxes it. Mutable structs cause lost updates because you often change a copy.

  2. When: Candidate explains generated Equals and GetHashCode Why: Checks the equality contract in general
    How to answer

    System.Object gives every type Equals, GetHashCode, ToString, GetType, the protected MemberwiseClone and Finalize, plus static ReferenceEquals. The review problem is the Equals/GetHashCode contract: equal objects must return equal hash codes. If Equals compares Email but GetHashCode is still identity-based, two "equal" customers land in different hash buckets, so Dictionary, HashSet, Distinct and GroupBy treat them as different. I'd implement IEquatable<Customer>, override both methods using the same immutable fields, compare emails case-insensitively in both, overload ==/!=, and seal the class. For value-like data a record generates all of this.

  3. When: Candidate mentions collections inside records Why: Probes immutable collections and their equality
    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.

🛠 Real-world problems: how you handle them

Shared mutable quote object leaking discounts between customers
Situation
A pricing API cached `PriceQuote` objects (mutable classes) in `IMemoryCache` and returned the same instance to concurrent requests.
Signals
Support reported [a handful of] customers seeing another customer's discount; logs showed two requests modifying `Discount` on the same quote within milliseconds.
Investigation
Traced the shared reference: one handler applied a loyalty discount by setting a property on the cached object.
Resolution
Changed `PriceQuote` to a positional `sealed record`. Code that modified quotes now uses `with`, producing a new instance. Cache keys moved to a `readonly record struct QuoteKey` with generated equality.
Prevention
Architecture rule: types stored in caches must be records or otherwise immutable, enforced by a NetArchTest test.

How to tell it (STAR)

**Situation:** customers occasionally saw each other's discounts because a cached quote object was mutated by concurrent requests. **Task:** fix the leak and prevent similar shared-state bugs. **Action:** I made quotes immutable records, replaced mutations with `with` expressions, used a record struct for cache keys and added an architecture test that cached types must be immutable. **Result:** the leak stopped immediately, and the cache key code shrank from [40] lines of hand-written equality to one line.

↩ Usually asked after

More in C# OOP Features

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me