A C# record is a reference type (or record struct) built for value-shaped data: equality by contents, a concise constructor, and non-destructive copy with with. Use records for messages, query results, and value objects. Do not use them as EF Core entities with identity unless you know the tracking implications.
Positional vs explicit
public sealed record Money(decimal Amount, string Currency);
var price = new Money(10m, "USD");
var tax = price with { Amount = 1.5m };
Money equality is by amount and currency, not by reference. decimal already makes 0.1m + 0.2m exact. Records do not choose scale or rounding (two places, away from zero or to even). Do that at the boundary.
var price = new Money(10m, "USD");
var tax = price with { Amount = 1.5m };
bool same = price == new Money(10m, "USD");
var (amount, currency) = price;
Members that matter
- Primary constructor parameters become public init properties unless you declare otherwise.
withcopies the record’s own fields and sets the ones you name. It is shallow: a nestedList<T>or class is still the same reference.- Synthesized members:
Equals,GetHashCode,==and!=,ToString(fine for logs, not for UI),Deconstruct,PrintMembers, and the copywithuses. - Inheritance:
recordcan inherit record. Equality includes runtime type. ADogis not equal to anAnimalwith the same fields.
record class vs record struct vs class
record class (default) is a heap object with value equality — cheap to pass around, still a reference. record struct is a value type; copies are copies, large structs in collections hurt. Ordinary class is the right default for entities with identity and lifecycle.
What breaks it
A record with public setters is a dictionary key, then a property changes. ContainsKey misses because the hash changed. Keep the positional Money above, whose properties are init.
public record MutableMoney
{
public decimal Amount { get; set; }
public string Currency { get; set; } = "";
}
static bool LookupAfterMutate()
{
var money = new MutableMoney { Amount = 10m, Currency = "USD" };
var map = new Dictionary<MutableMoney, string> { [money] = "price" };
money.Amount = 11m;
return map.ContainsKey(money);
}
with on a nested list still shares the list. b.Lines.Add changes a.Lines.
sealed record Basket(List<string> Lines);
static bool SharedList()
{
var a = new Basket(new List<string> { "a" });
var b = a with { };
b.Lines.Add("b");
return a.Lines.Contains("b");
}
Pitfalls
- Mutable record properties (
setinstead ofinit) plus using them as dictionary keys. - EF Core: records can work, but identity-map + value equality is easy to get wrong. Prefer classes for aggregates.
- Huge nested records copied with
within a hot loop.
See value objects.