Unlock Immutable Power: How to Use C# Record Types Right

C# Record Types: Faster, Safer Immutable Models

Cut boilerplate and bugs with C# records. Learn immutability, with, equality, best practices, pitfalls, and real‑world patterns.

.NET Fundamentals·By amarozka · October 28, 2025

C# Record Types: Faster, Safer Immutable Models

Still writing 30 lines of boilerplate just to compare two DTOs? Records cut that to zero and make your models safer at the same time.

I first met records in a project where we shipped a public API with dozens of request/response models. Before records, we kept fixing small bugs: missed Equals, broken GetHashCode, and accidental mutations during mapping. After switching to records, the noise vanished. In this post, you’ll learn how to use C# records to build immutable, correct, and fast models with clean code you can trust.

What is a record, in one sentence?

A record is a C# type that gives you value-based equality and ergonomic immutability out of the box. You write less code and get safer defaults.

public record User(string Id, string Email);

var a = new User("42", "a@site");
var b = new User("42", "a@site");
Console.WriteLine(a == b); // True - value equality

With a class, == would check reference identity (not useful for data). With a record, two instances with the same data are equal. That’s what we usually want for models.

Two flavors: record class and record struct

  • record class (default record): reference type, heap-allocated, value-based equality. Ideal for DTOs, messages, and value objects that you pass around.
  • record struct: value type, stack-friendly for tiny data, also value-based equality. Great for small coordinates, ranges, or money types.
public readonly record struct Money(decimal Amount, string Currency);

var m1 = new Money(10m, "USD");
var m2 = new Money(10m, "USD");
Console.WriteLine(m1 == m2); // True

Tip: prefer record struct only for small aggregates (think a few fields). Large structs copy by value and can cost performance.

Immutability by default (and why it saves you)

Records push you toward immutability. That means fewer hidden state bugs and easier reasoning.

Positional record

public record Address(string Street, string City, string Country);

Properties are init-only. You can set them at construction or with with (see below), but not later.

Nominal record with init properties

public record Address
{
    public string Street { get; init; } = default!;
    public string City   { get; init; } = default!;
    public string Country{ get; init; } = default!;
}

Why this helps:

  • No “who changed this?” hunts in logs.
  • Thread-safe sharing of models.
  • Pure functions become easy (input → output with no side effects).

Non-destructive mutation with with

You often need to tweak an object while keeping the original. with gives you a copy with selected changes.

var home = new Address("Main St", "Sofia", "BG");
var moved = home with { City = "Plovdiv" };
// home.City is still "Sofia"

Important: with is a shallow copy. If a property holds a mutable object (e.g., List<T>), both instances will point to the same list. Use immutable collections to stay safe (see below).

Equality that finally matches intent

Records generate Equals, GetHashCode, and ==/!= so two instances with the same data compare equal.

public record User(string Id, string Email);
var s = new HashSet<User> { new("42", "a@site") };
Console.WriteLine(s.Contains(new User("42", "a@site"))); // True

Customizing equality (advanced)

You can add fields and still keep value semantics, or override if needed.

public record Product(string Sku)
{
    public string? Name { get; init; }

    // Example: ignore Name in equality
    public virtual bool Equals(Product? other) => other is not null && Sku == other.Sku;
    public override int GetHashCode() => Sku.GetHashCode();
}

Don’t mix base/derived records for equality-sensitive code. Records include a hidden EqualityContract to avoid cross-type equality traps, but it’s better to keep hierarchies flat for data models.

Records and pattern matching: clean and sweet

Records play nice with switch expressions and positional deconstruction.

public record Result<T>(bool IsSuccess, T? Value, string? Error)
{
    public static Result<T> Ok(T value) => new(true, value, null);
    public static Result<T> Fail(string error) => new(false, default, error);
}

string Describe<T>(Result<T> r) => r switch
{
    Result<T>(true, var v, _) => $"OK: {v}",
    Result<T>(false, _, var e) => $"ERR: {e}",
};

Deconstruction comes for free with positional records:

var user = new User("42", "a@site");
var (id, email) = user; // thanks to compiler-generated Deconstruct

Working with collections: avoid hidden traps

If you place mutable collections inside records, with can surprise you.

public record Cart(List<string> Items);

var c1 = new Cart(new List<string> { "Book" });
var c2 = c1 with { }; // shallow copy
c2.Items.Add("Pen");
Console.WriteLine(string.Join(",", c1.Items)); // Book,Pen  ← whoops

Fix: use immutable collections.

using System.Collections.Immutable;

public record Cart(ImmutableList<string> Items)
{
    public Cart Add(string item) => this with { Items = Items.Add(item) };
}

var cart = new Cart(ImmutableList<string>.Empty)
    .Add("Book")
    .Add("Pen");

This keeps with safe and predictable.

Records in real projects: patterns that work

Below are patterns I use again and again.

1) DDD Value Objects

Keep your value objects tiny, immutable, and correct by design.

public readonly record struct Email
{
    public string Value { get; }
    public Email(string value)
    {
        if (string.IsNullOrWhiteSpace(value) || !value.Contains('@'))
            throw new ArgumentException("Invalid email", nameof(value));
        Value = value.Trim();
    }
    public override string ToString() => Value;
}

2) API Contracts (request/response)

public record CreateUserRequest(Email Email, string? Name);
public record CreateUserResponse(string Id, Email Email, DateTimeOffset CreatedAt);

These are easy to test, easy to compare, and stable across handlers.

3) CQRS Commands and Events

public record CreateUser(string Email, string? Name);
public record UserCreated(string Id, string Email, DateTimeOffset At);

4) Settings Snapshots

Load config once; pass a record snapshot to components. No drifting state.

public record MailSettings(Email From, TimeSpan Timeout, bool Sandbox);

5) Result Types

Explicit success/failure keeps flows honest.

public abstract record OpResult
{
    public sealed record Ok(string Message)    : OpResult;
    public sealed record Error(string Reason)  : OpResult;
}

string Print(OpResult r) => r switch
{
    OpResult.Ok(var m)    => m,
    OpResult.Error(var e) => $"Error: {e}",
};

Interop with JSON and EF Core

System.Text.Json

  • Positional records serialize as you’d expect. Property names come from parameters.
  • For nominal records, use get/init properties.
  • To build during deserialization, keep a public ctor or settable init props.
public record User(string Id, Email Email) // Email has converter or value factory
{
    public required string DisplayName { get; init; }
}

required (C# 11) ensures the binder sets the property; otherwise, you get a compile-time warning in your code and a runtime error if missed during manual construction.

EF Core

  • Records are great for owned value objects.
  • For tracked entities that change often, prefer class entities with mutable state; keep records for the owned values.
public class UserEntity
{
    public Guid Id { get; set; }
    public Email Email { get; set; } = default!; // value object as record struct
}

TL;DR: use records for values, not for long-lived tracked entities.

Performance notes that matter

  • record class equality compares members – cheap for small graphs. Avoid putting huge graphs inside.
  • record struct copies by value. Keep it small to avoid copying cost.
  • Prefer ImmutableArray<T>/ImmutableList<T> for collections inside records to avoid defensive copies.
  • Hash-based lookups love records (correct GetHashCode out of the box).

Micro benchmark hint:

// Rough idea; use BenchmarkDotNet in real life
var users = Enumerable.Range(1, 100_000)
    .Select(i => new User(i.ToString(), $"u{i}@site"))
    .ToArray();

var set = new HashSet<User>(users);
var found = set.Contains(new User("50000", "u50000@site")); // very fast

Common pitfalls (and how to avoid them)

  1. Shallow with copy
  • Problem: internal mutable collections are shared.
  • Fix: use immutable collections or clone deep manually.
  1. Mixing equality across inheritance
  • Problem: comparing a base record with a derived record is tricky.
  • Rule: don’t rely on cross-hierarchy equality; keep flat models for data.
  1. Large record struct
  • Problem: value copy overhead.
  • Fix: keep structs tiny or switch to record class.
  1. Silent state after JSON deserialization
  • Problem: missing members stay default.
  • Fix: use required members and validation.
  1. Using records for mutable EF entities
  • Problem: equality clashes with identity; confusing state changes.
  • Fix: use regular classes for entities; records for owned value objects and DTOs.
  1. Equality vs sequence contents
  • Problem: two records with List<T> compare equal by reference of the list, not by items.
  • Fix: switch to ImmutableArray<T>/ImmutableList<T> or write a custom equality.

Upgrade path: move your models to records step by step

  1. Pick one model that acts like a value (e.g., Address).
  2. Convert to record, keep the same public API.
  3. Replace in call sites. Add small helper methods if helpful.
  4. Add tests for equality, with, and serialization.
  5. Switch collections to immutable variants.
  6. Repeat for the next model.

This incremental path keeps risk low and wins visible.

Cheat sheet (copy/paste)

Pick type

// Default
public record OrderId(string Value);

// Tiny, hot-path value
public readonly record struct Point(int X, int Y);

Make safe updates

var u1 = new User("42", "u@site");
var u2 = u1 with { Email = "new@site" };

Immutable collections

using System.Collections.Immutable;

public record Tags(ImmutableArray<string> Values)
{
    public static Tags Empty => new(ImmutableArray<string>.Empty);
    public Tags Add(string v) => this with { Values = Values.Add(v) };
}

Pattern matching

string Route(object msg) => msg switch
{
    CreateUser(var email, _) => $"create:{email}",
    UserCreated(var id, _, _) => $"event:{id}",
    _ => "unknown"
};

Mini case study: safer user updates with with

Before (class): accidental mutation during update pipeline.

public class UserProfile
{
    public string Id { get; set; } = default!;
    public string Email { get; set; } = default!;
    public string? Name { get; set; }
}

UserProfile Apply(UserProfile p)
{
    // somewhere deep in a mapper
    p.Email = p.Email.Trim().ToLowerInvariant(); // mutates input
    return p;
}

After (record): pure function, testable.

public record UserProfile(string Id, string Email, string? Name)
{
    public UserProfile Normalize() => this with
    {
        Email = Email.Trim().ToLowerInvariant()
    };
}

This eliminates spooky changes and makes the flow safe for parallel calls.

FAQ: practical questions I keep getting

When should I use record class vs record struct?

Use record class by default. Use record struct for tiny values in tight loops when you want value-type behavior.

Can I make a mutable record?

You can, but don’t. If you add set; instead of init;, you lose the main win. Prefer helper methods that return new records.

How do I make deep copies with with?

with is shallow. For deep copies, store immutable collections or write a CloneDeep() that recreates nested structures.

Do records work with required?

Yes. It’s great with nominal records: mark important members as required to force callers (and binders) to set them.

Are records okay for protobuf/JSON contracts?

Yes. Keep public getters and a public ctor or init props. For protobuf, check your generator’s support for record – many treat them like classes.

Can I inherit from a record?

You can, but think twice. Equality across hierarchies gets subtle. Prefer composition for data models.

How do I compare sequences by value inside a record?

Use immutable collections or plug in a value-based equality in a custom Equals/GetHashCode.

Conclusion: ship simpler, safer models today

Records remove busywork and close a whole class of bugs: bad equality, random mutations, fragile mapping. Start with one model, switch to immutable collections, and let with do the lifting. Your tests get shorter, your code gets clearer, and your data stops changing behind your back. Give it a try in one hotspot this week and watch the noise drop.

What model in your codebase would benefit the most from becoming a record right now? Tell me in the comments – I’ll suggest a safe migration sketch.

Leave a Reply

Your email address will not be published. Required fields are marked *