Modeller
Coding StandardsTypes, Records, and Modern C#

Choose the Right Record Kind by Required Equality and Storage Semantics

Choose the Right Record Kind by Required Equality and Storage Semantics

The Standard

When designing a type whose primary purpose is to represent a value (as opposed to an entity with identity), default to record (a record class) so equality and hash code are structural by construction. Only fall back to a plain class when the type is an entity that needs reference/identity semantics with mutable state, and only reach for record struct when the value is small, copied cheaply, and stack allocation is actually desired.

Why

The demo puts three designs side by side against the same operation — storing values in a HashSet<T> and checking membership. A hand-written mutable class (Ticker1) never overrides Equals/GetHashCode, so two objects with identical field values are not found as equal (tickers1.Contains(obj2) is false) — reference identity leaks through even though the type was meant to represent a value like "symbol + price." A record class (Ticker2) gets compiler-generated structural equality for free, so Contains correctly returns true for two instances built from the same values, exactly matching the behavior of a ValueTuple. A record struct (Ticker3) gives the same structural equality but as a stack-allocated value type, at the cost of being copied by value on every assignment/parameter pass. An Entity (DiagramSource keyed by _id), by contrast, is correctly left as a plain mutable class — two diagram sources with the same contents but different IDs must never compare equal, so reference-based default equality is exactly right there.

Before (Anti-pattern)

// Immutable-looking type, but equality is by reference — silently breaks membership checks
class Ticker1(string symbol, decimal lastPrice)
{
    public Ticker1 Change(decimal difference) => new Ticker1(symbol, lastPrice + difference);
}

var set = new HashSet<Ticker1>();
set.Add(new Ticker1("WHATEVER", 150.00m));
set.Contains(new Ticker1("WHATEVER", 150.00m));   // false — not the same instance

After (Standard)

// Value object (reference type): structural equality generated by the compiler
record class Ticker2(string Symbol, decimal LastPrice);

var set = new HashSet<Ticker2>();
set.Add(new Ticker2("WHATEVER", 150.00m));
set.Contains(new Ticker2("WHATEVER", 150.00m));   // true

// Entity: identity matters, reference equality is correct — stays a class
class DiagramSource(int id)
{
    private readonly int _id = id;
}

Rules for LLMs / Agents

  • Default new value-representing types (anything defined purely by its data — money, coordinates, identifiers, DTOs) to record (or record class), not class.
  • Never hand-write a mutable class intended to represent a value and rely on default (reference) equality — this silently breaks Contains, Distinct, HashSet/Dictionary keys, and equality-based comparisons.
  • Use a plain class only for entities whose identity (not their current field values) determines equality; do not add a compiler-generated structural Equals to an entity type.
  • Use record struct only when the value is small, frequently copied, and the allocation profile of a reference type is a measured concern — otherwise prefer record class for its reference semantics and cheaper default copy behavior for larger payloads.
  • When two types have identical components but different meanings (e.g., Ticker2(string, decimal) vs Price(string, decimal)), do not rely on structural shape alone to interchange them — they are still distinct nominal types and the compiler will correctly reject substituting one for the other.

When NOT to apply

None observed — the demo explicitly contrasts all three shapes (class, record class, record struct) and shows each is correct for its own scenario (entity vs. reference-type value vs. value-type value); the standard is about choosing the right one, not banning any of them.

On this page