C# Records vs Classes vs Structs: A Decision Guide With Examples

Since records landed in C# 9 (and record structs in C# 10), I keep seeing two extremes in code reviews: teams that never use them, and teams that declare everything a record because it's new. Both are leaving value on the table. Each of the three type kinds — class, record, struct — encodes a different answer to two questions: how is equality defined? and where does the data live?

Get those questions right and the choice makes itself.

The 30-second summary

  • Class — reference type, identity equality (two objects are equal only if they're the same object). The default for entities and services.
  • Record (record class) — reference type, value equality (two records are equal if all their properties are equal), with built-in immutability support. The default for data.
  • Struct — value type, lives inline (stack, or inside its containing object), copied on assignment. For small, high-frequency values.
  • Record struct — a struct with the record conveniences (value equality generated, with expressions, deconstruction).

What records actually give you

csharp
public record Money(decimal Amount, string Currency);

That one line generates: a constructor, init-only properties, value-based Equals and GetHashCode, ==/!= operators, a readable ToString (Money { Amount = 10, Currency = ZAR }), deconstruction, and with support:

csharp
var price = new Money(100m, "ZAR");
var discounted = price with { Amount = 80m };  // new instance, original untouched

Console.WriteLine(price == new Money(100m, "ZAR")); // True — value equality

Writing that by hand on a class is 40+ lines of error-prone boilerplate that someone will forget to update when a property is added. (The compiler never forgets.)

Decision 1: Does identity matter, or only the values?

This is the heart of it.

An entity has identity. A Customer with Id 42 is the same customer even after their email changes. Two customers with identical names are still different people. Entities want reference equality and mutable-over-time semantics → class.

csharp
public class Customer
{
    public int Id { get; init; }
    public string Email { get; set; }  // changes over time, same customer
}

A value is only its data. Money, a date range, coordinates, a search filter, an API response shape. Two Money(100, "ZAR") instances are interchangeable — there's no meaningful "which one." Values want value equality and immutability → record.

csharp
public record DateRange(DateOnly Start, DateOnly End)
{
    public bool Contains(DateOnly date) => date >= Start && date <= End;
}

A useful smell: if you've ever written a custom Equals override or an IEqualityComparer for a class, it probably wanted to be a record.

Decision 2: Reference or inline storage?

Structs aren't "faster classes" — they're a different storage model. A struct is copied on every assignment and method pass, and it never touches the garbage collector on its own.

Reach for a struct when all of these hold:

  • It's small (the guideline: ≤16 bytes, roughly two references or four ints — beyond that, copying costs overtake allocation savings).
  • It represents a single value conceptually (a point, an amount, an ID wrapper).
  • It's created in large numbers or in hot paths, where heap allocation pressure is measurable.
csharp
public readonly record struct Point(double X, double Y);

Note the readonly — mutable structs are a legendary source of bugs (mutating a copy and wondering where your change went). Make every struct readonly unless you have a profiler-verified reason not to.

If the type is bigger, long-lived, or ever needs polymorphism, stay with a class or record. Boxing (a struct getting wrapped into an object, e.g. when cast to an interface) silently erases the performance benefit.

Where each shines in a real codebase

Records:

  • DTOs and API contracts — public record CreateOrderRequest(int CustomerId, List<OrderLine> Lines);
  • Domain value objects — money, measurements, addresses.
  • CQRS commands/queries and MediatR-style messages.
  • Configuration snapshots and immutable state in pipelines.

Classes:

  • EF Core entities. (Records technically work but fight the ORM: value equality confuses identity-based change tracking, and with copies break the tracked-instance model.)
  • Services, handlers, controllers — anything the DI container manages.
  • Anything with virtual members or an inheritance hierarchy designed for behavior.

Structs / record structs:

  • Coordinates, small measurement types, strongly-typed ID wrappers (readonly record struct OrderId(int Value) — a great trick to stop passing the wrong int).
  • High-volume value types in performance-sensitive code.

Two traps worth knowing

Records with collection properties get shallow equality. Two records holding equal-but-distinct List<T> instances compare unequal — the generated equality calls List.Equals, which is reference-based. Use ImmutableArray/custom equality, or keep collections out of records you compare.

Non-destructive mutation is shallow, too. order with { Status = Paid } copies the reference to the same Lines list into the new record. If something mutates that list, both "immutable" records see it. True immutability needs immutable collections all the way down.

The decision tree

  1. Managed by DI, has behavior/lifecycle? → class
  2. Database entity with identity? → class (init-only setters are fine)
  3. Just data, compared by contents? → record
  4. Just data, tiny, allocated by the million? → readonly record struct
  5. Not sure? → record. Immutable value semantics are the safest default for data, and converting later is mechanical.

The types you choose are documentation. A record in a signature tells every future reader "this is pure data, safely shareable, compare it freely" — and that's worth as much as the generated code.

Where to go next