Practical record class usage: generated members, inheritance, with expressions, collection equality and EF Core fit.
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.
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.
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).
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).Equals calls the base Equals and compares added fields.ToString and with respect the runtime type (b with { Name = "Grace" } returns an Employee).object), and a class can't inherit from a record.sealed record) to simplify and allow sealed override ToString().with is a shallow copypublic 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.
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.
| 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 |
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.
The interviewer checks that you know what records generate and use them for the right kinds of objects.
Green flags
EqualityContract and the copy constructor.with is shallow and collection members compare by reference.PrintMembers for sensitive data.Red flags
Equals(Money? other): (object)this == other || (other is not null && EqualityContract == other.EqualityContract && EqualityComparer<decimal>.Default.Equals(<Amount>k__BackingField, other.<Amount>k__BackingField) && ...).GetHashCode uses EqualityComparer<T>.Default.GetHashCode per field combined with multipliers.PrintMembers prints public fields and properties (not private), separated by commas.Equals(R? other), you must also declare GetHashCode (compiler warns).Equals(object) directly in records (it's generated to call Equals(R?)).PrintMembers or ToString replaces generated ones; sealed override ToString() (C# 10) prevents derived regeneration.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.
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.
with allocates a new instance.record struct avoids allocation for small values (see record structs).EqualityContract, PrintMembers and <Clone>$.with shares a list.ImmutableArray<T>.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 }
}
}
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.
withWhat 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.
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.
ToStringWhat happens: records with PII logged directly.
Why it is wrong: privacy exposure.
Do instead: override PrintMembers or log IDs only.
Equals without GetHashCodeWhat happens: customising equality partially. Why it is wrong: breaks hashing collections. Do instead: customise both consistently.
with to validateWhat happens: constructor validation assumed for copies.
Why it is wrong: with bypasses constructors.
Do instead: validate in init accessors.
What happens: many levels of record inheritance. Why it is wrong: complex equality and serialisation, confusing semantics. Do instead: shallow, sealed records; composition.
What happens: records with set properties.
Why it is wrong: hash codes change while stored in sets/dictionaries.
Do instead: keep records immutable.
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.
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.
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.
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.
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.
Last updated 30 Oct 2026.