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

📚 Interview question

How do you use records well in production C#? Explain what the compiler generates for a record class (equality, EqualityContract, ToString, Deconstruct, copy constructor), how inheritance affects equality, why with is a shallow copy, the collection-equality trap, and where records fit or do not fit (DTOs, value objects, EF Core entities).

Practical record class usage: generated members, inheritance, with expressions, collection equality and EF Core fit.

Mid-Level Theory Intermediate ddd ef-core immutability records value-equality

🎯 Key points

  • Records generate value equality, EqualityContract, ToString via PrintMembers, a copy constructor and Deconstruct for positional records
  • EqualityContract makes base and derived records unequal
  • with is a shallow copy; nested collections are shared
  • Collection members compare by reference unless you write custom equality
  • Records suit DTOs, messages, events and value objects
  • Records are a poor fit for EF Core entities, which need identity semantics
  • Override PrintMembers to keep personal data out of logs
  • Generated equality and ToString touch every member, which has a cost in hot paths

⏱ 30-second answer

A record (record class) is a reference type for which the compiler generates value-based equality (Equals, GetHashCode, ==, IEquatable<T>), a virtual EqualityContract so a base and derived record are never equal, ToString via PrintMembers, a copy constructor used by with, and, for positional records, init properties and Deconstruct. with is a shallow copy: nested objects and lists are shared. Equality compares members with EqualityComparer<T>.Default, so a List<T> member is compared by reference, which breaks the "value" idea. Records fit DTOs, messages, events and value objects; they are a poor fit for EF Core entities, which need identity, not value equality, and are tracked by reference.

🗣 Full answer (senior)

How I would answer this in an interview

Records are excellent for data with value semantics — DTOs, messages, value objects — because the compiler generates correct equality, readable ToString, deconstruction and non-destructive mutation. Using them well in production means knowing exactly what's generated, how inheritance affects equality, why with is shallow, the collection-equality trap, and where records don't fit.

1. What the compiler generates for a record class

public record Money(decimal Amount, string Currency);
Member Purpose
Primary constructor + init properties Amount, Currency Positional data
protected virtual Type EqualityContract => typeof(Money) Runtime type participates in equality
virtual bool Equals(Money? other), override Equals(object?), IEquatable<Money> Value equality: same EqualityContract + all fields equal (via EqualityComparer<T>.Default)
override GetHashCode() Combines EqualityContract and all fields
== / != operators Use value equality
override ToString() + protected virtual bool PrintMembers(StringBuilder) Money { Amount = 10, Currency = AUD }
Deconstruct(out decimal, out string) var (amount, currency) = price;
protected Money(Money original) copy constructor + <Clone>$() Powers with

All generated members can be customised by declaring them yourself (e.g. your own Equals(Money?) + GetHashCode, or PrintMembers).

2. How inheritance affects equality

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

Person a = new Person("Ada");
Person b = new Employee("Ada", "Engineer");
a == b;   // false: EqualityContract differs (Person vs Employee)
b == new Employee("Ada", "Engineer");   // true
  • EqualityContract returns the runtime type; derived records override it, so base and derived instances are never equal — this keeps equality symmetric (a common bug in hand-written Equals).
  • Derived Equals calls the base Equals and compares added fields.
  • ToString and with respect the runtime type (b with { Name = "Grace" } returns an Employee).
  • A record can only inherit from a record (or object), and a class can't inherit from a record.
  • Seal leaf records (sealed record) to simplify and allow sealed override ToString().

3. with is a shallow copy

public record Order(Guid Id, List<OrderLine> Lines);
var copy = order with { };
copy.Lines.Add(newLine);   // ALSO modifies order.Lines - same List object

with calls the copy constructor, which copies field values: for reference-type fields, the reference is copied. Nested mutable objects are shared. Also, with bypasses constructor validation (it uses the copy constructor + init setters).

Fixes: use immutable collections (ImmutableArray<T>, IReadOnlyList<T> backed by new arrays), immutable nested records, and validate in init accessors.

4. The collection-equality trap

var r1 = new Basket(["apple", "pear"]);
var r2 = new Basket(["apple", "pear"]);
r1 == r2;   // false - List<string>/arrays compare by REFERENCE via EqualityComparer<T>.Default

Generated equality uses each field's default comparer; collections (List<T>, arrays, ImmutableArray<T>) don't implement content equality. Options:

public sealed record Basket(ImmutableArray<string> Items)
{
    public bool Equals(Basket? other) => other is not null && Items.SequenceEqual(other.Items);
    public override int GetHashCode()
    {
        var hash = new HashCode();
        foreach (var i in Items) hash.Add(i);
        return hash.ToHashCode();
    }
}

Or keep collections out of value-equality records (reference them by ID), or use a custom value-collection type.

5. Where records fit

Use Fit
API DTOs (requests/responses) Great — concise, immutable, good logging
Messages / integration and domain events Great — immutable facts
Value objects (Money, Address, DateRange) Great — value equality; map with EF Core complex types
Strongly typed IDs readonly record struct
Cache keys Great — value equality and hashing
Configuration snapshots Good
EF Core entities Poor — identity-based, mutable over time, change tracker relies on reference identity; with creates untracked copies
Aggregates with invariants Usually poor — behaviour and controlled mutation fit classes better
Types with collections needing content equality Requires customisation
Types with sensitive data Override PrintMembers to avoid logging secrets/PII

6. A production example

An event-driven system used records for integration events — a good fit — but logging them via ToString() wrote customer emails and phone numbers into logs ingested by a third-party tool, a privacy issue under APP 11. Separately, de-duplication of events by record equality failed because events contained List<string> tags. We overrode PrintMembers on events with personal data (or logged only IDs), switched tags to ImmutableArray<string> with custom equality, and documented the "records for data, classes for entities" rule.

🔍 What the interviewer is checking

The interviewer checks that you know what records generate and use them for the right kinds of objects.

Green flags

  • Lists generated members including EqualityContract and the copy constructor.
  • Knows with is shallow and collection members compare by reference.
  • Recommends records for DTOs, events and value objects, not EF Core entities.
  • Mentions PrintMembers for sensitive data.

Red flags

  • "Records are just immutable classes."
  • Uses records everywhere including entities.
  • Unaware of the collection-equality trap.

📚 Learn in depth

1. Generated code details

  • Equals(Money? other): (object)this == other || (other is not null && EqualityContract == other.EqualityContract && EqualityComparer<decimal>.Default.Equals(<Amount>k__BackingField, other.<Amount>k__BackingField) && ...).
  • Equality compares backing fields, including those of non-positional properties and any explicit fields.
  • GetHashCode uses EqualityComparer<T>.Default.GetHashCode per field combined with multipliers.
  • PrintMembers prints public fields and properties (not private), separated by commas.

2. Customisation rules

  • If you declare Equals(R? other), you must also declare GetHashCode (compiler warns).
  • You can't override Equals(object) directly in records (it's generated to call Equals(R?)).
  • Declaring PrintMembers or ToString replaces generated ones; sealed override ToString() (C# 10) prevents derived regeneration.
  • Explicit property declarations replace positional ones (e.g. to add validation or change accessibility).

3. Records and EF Core complex types

modelBuilder.Entity<Order>().ComplexProperty(o => o.ShippingAddress);
order.ShippingAddress = order.ShippingAddress with { Postcode = "2000" };   // EF detects value change

Complex types fit records perfectly; owned entity types have hidden identity and are less ideal for value semantics.

4. Serialisation

System.Text.Json binds positional records through the constructor (matching parameter names case-insensitively), supports init, required, and polymorphism with [JsonDerivedType] on abstract base records.

5. Performance

  • Record class creation allocates like any class; with allocates a new instance.
  • Equality on many fields costs more than reference equality; avoid huge records as hot dictionary keys.
  • record struct avoids allocation for small values (see record structs).

Try it yourself

  1. Decompile a record in SharpLab and read EqualityContract, PrintMembers and <Clone>$.
  2. Compare base and derived records with equal base values.
  3. Show that with shares a list.
  4. Implement custom equality for a record with an ImmutableArray<T>.

💻 Code

using System.Collections.Immutable;
using System.Text;

public sealed record Money(decimal Amount, string Currency)
{
    public static Money Aud(decimal amount) => new(amount, "AUD");
    public Money Add(Money other) =>
        Currency == other.Currency ? this with { Amount = Amount + other.Amount }
                                   : throw new InvalidOperationException("Currency mismatch");
}

public sealed record OrderLine(string Sku, int Quantity, Money Price);

// Value equality over a collection: write it yourself
public sealed record OrderSnapshot(int OrderId, ImmutableArray<OrderLine> Lines)
{
    public bool Equals(OrderSnapshot? other) =>
        other is not null && OrderId == other.OrderId && Lines.SequenceEqual(other.Lines);

    public override int GetHashCode()
    {
        var hash = new HashCode();
        hash.Add(OrderId);
        foreach (var line in Lines) hash.Add(line);
        return hash.ToHashCode();
    }
}

public sealed record Basket(int Id, List<string> Items);   // List member: reference equality

public record Person(string Name, string Email)
{
    // Records are often logged: hide personal data from the generated ToString
    protected virtual bool PrintMembers(StringBuilder builder)
    {
        builder.Append($"Name = {Name}, Email = ***");
        return true;
    }
}

// The compiler generates PrintMembers for Employee that calls the base version, then appends StaffId
public sealed record Employee(string Name, string Email, int StaffId) : Person(Name, Email);

public static class Program
{
    public static void Main()
    {
        Console.WriteLine(Money.Aud(10) == Money.Aud(10));               // True: value equality
        Console.WriteLine(Money.Aud(10).Add(Money.Aud(5)));              // Money { Amount = 15, Currency = AUD }

        var a = new OrderSnapshot(1, [new OrderLine("A1", 2, Money.Aud(5))]);
        var b = new OrderSnapshot(1, [new OrderLine("A1", 2, Money.Aud(5))]);
        Console.WriteLine(a == b);                                        // True only because of the custom Equals

        var basket1 = new Basket(1, ["apple"]);
        var basket2 = new Basket(1, ["apple"]);
        Console.WriteLine(basket1 == basket2);                            // False: different List instances

        var copy = basket1 with { Id = 2 };
        copy.Items.Add("pear");                                           // shallow copy: shared list
        Console.WriteLine(basket1.Items.Count);                           // 2

        Person p = new("Mia", "[email protected]");
        Person e = new Employee("Mia", "[email protected]", 7);
        Console.WriteLine(p == e);                                        // False: different EqualityContract
        Console.WriteLine(e);                                             // Employee { Name = Mia, Email = ***, StaffId = 7 }
    }
}

⚠ Common mistakes

1. Records as EF Core entities

What happens: entities declared as records and updated with with. Why it is wrong: untracked copies and identity confusion. Do instead: classes for entities; records for value objects/DTOs.

2. Expecting deep copies from with

What happens: modifying nested collections in a copy. Why it is wrong: shared references — both instances change. Do instead: immutable nested data or explicit deep copies.

3. Collections in value-equality records

What happens: equal-looking records compare unequal. Why it is wrong: collection fields compare by reference. Do instead: custom equality or avoid collections in such records.

4. Logging sensitive data via ToString

What happens: records with PII logged directly. Why it is wrong: privacy exposure. Do instead: override PrintMembers or log IDs only.

5. Hand-written Equals without GetHashCode

What happens: customising equality partially. Why it is wrong: breaks hashing collections. Do instead: customise both consistently.

6. Expecting with to validate

What happens: constructor validation assumed for copies. Why it is wrong: with bypasses constructors. Do instead: validate in init accessors.

7. Deep record hierarchies

What happens: many levels of record inheritance. Why it is wrong: complex equality and serialisation, confusing semantics. Do instead: shallow, sealed records; composition.

8. Mutable members in records

What happens: records with set properties. Why it is wrong: hash codes change while stored in sets/dictionaries. Do instead: keep records immutable.

🏛 Senior insight: trade-offs

Records are a tool for data with no identity. The senior skill is matching the type to the semantics:

Question If yes
Is it defined entirely by its values? record (value object, DTO, event)
Does it have an identity that persists while values change? class (entity)
Is it small, short-lived and hot? consider readonly record struct
Does it hold collections? immutable collections and custom equality

I also watch for hidden costs: generated equality and GetHashCode touch every field, and ToString builds strings with every member, which matters in hot paths and logs. Records make the common case concise, but the generated behaviour is still behaviour you own.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate defines what a record is. Why: Checks record basics from the OOP phase.
    How to 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.

  2. When: Candidate mentions allocation or small value objects. Why: Record structs are the value-type alternative.
    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.

  3. When: Candidate talks about Equals and GetHashCode. Why: Equality and hash code contracts underpin record behaviour.
    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.

🛠 Real-world problems: how you handle them

Records as EF Core entities broke change tracking
Situation
A new [inventory] service modelled EF Core entities as positional records to save boilerplate.
Signals
Updates via `with` silently did nothing, and items vanished from a HashSet of products after a price change.
Investigation
Reproduced in a test: `with` created an untracked copy, so SaveChanges had nothing to save; the HashSet lost items because GetHashCode changed with the mutable Price.
Resolution
Converted entities to classes with identity-based equality, kept records for DTOs and value objects mapped as EF Core complex types.
Prevention
Added an architecture test that fails if a type registered as an EF Core entity is a record, and documented when to use records.

How to tell it (STAR)

**Situation:** in our [inventory service] stock updates were sometimes lost. **Task:** I picked up the bug and needed a root cause, not a workaround. **Action:** I found our entities were records, so `with` produced untracked copies and value-based hash codes broke our sets. I moved entities back to classes, kept records for DTOs and value objects, and added an architecture test to stop it recurring. **Result:** lost updates stopped, the test caught [two] similar cases in other services, and the team got a clear rule for when to use records.

↩ Usually asked after

More in Modern C#

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me