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.
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.
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.
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; }
}
init-only for record classes and readonly record struct; settable (get; set;) for plain record struct.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.
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.
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.
with creates new instances EF doesn't track. Use classes for entities; records for owned value objects/complex types.init + with can bypass validation (with uses the copy constructor, not your validating constructor logic for changed properties).ToString prints every property — emails, tokens, TFNs end up in logs. Override PrintMembers or ToString.Equals/GetHashCode/ToString recurse infinitely.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.
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.
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.
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.PrintMembers to control ToString (e.g. redact).sealed override ToString() (C# 10) stops derived records regenerating it.public string Currency { get; init; } = Currency.ToUpperInvariant(); normalises input.withpublic 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.
record struct generates the same members except EqualityContract, copy constructor and inheritance-related members.ValueType.Equals), making record structs good dictionary keys.readonly record struct makes positional properties init-only and the struct readonly (avoids defensive copies).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).
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).List<string> property and compare two instances with equal lists.with bypasses constructor validation, then fix it with an init accessor using field.// 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
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.
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.
with runs validationWhat 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.
ToStringWhat 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.
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.
withWhat 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.
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.
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.
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.
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.
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.
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.
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.
Last updated 30 Oct 2026.