C# Record Types: Faster, Safer Immutable Models (2025)

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

Are you still writing DTOs and request models as classes? You’re probably shipping more bugs and boilerplate than you need. In this post I’ll show you how switching to C# record types makes your code safer, smaller, and easier to reason about – without turning your codebase upside down.

Understanding Immutability

Immutability means once an object is created, its state never changes. Instead of mutating an instance, you create a new one with the desired differences. Think of it as Google Docs “version history” for your data structures – every change creates a snapshot.

Why you (and production) care:

  • Thread-safety by default. No write-after-read surprises on multi-threaded code paths.
  • Predictable debugging. When state never changes, reproducing defects gets easier.
  • Fewer side effects. Functions become easier to compose and test.
  • Auditability. Historical data remains intact (great for CQRS and event sourcing).

Before C# 9, idiomatic immutability in classes was… verbose:

public class Person
{
    public string Name { get; }
    public int Age { get; }

    public Person(string name, int age)
    {
        Name = name;
        Age = age;
    }

    // non-destructive mutation helper
    public Person With(string? name = null, int? age = null)
        => new(name ?? Name, age ?? Age);
}

It works, but you write (and maintain) a lot of plumbing.

Introduction to Record Types in C#

What Are Record Types?

Records (introduced in C# 9) are reference types optimized for immutability and value-based equality. A minimal record:

public record Person(string Name, int Age);

Out of the box you get:

  • Primary constructor & deconstructor
  • init-only properties
  • Value-based Equals/GetHashCode
  • Nice ToString() for debugging
  • Support for with-expressions (non-destructive mutation)

How They Differ from Classes

  • Equality: classes compare by reference; records compare by content.
  • Conciseness: records eliminate constructors, Equals boilerplate, and the hand-rolled With helpers.
  • Immutability-first: properties are init-only by default (settable only during construction/initialization).
  • Pattern matching: records play nicely with property and positional patterns.

When identity doesn’t matter and content does, use a record.

Benefits of Using Record Types

  • Correctness: value-based equality aligns with how we actually compare DTOs, messages, and settings.
  • Readability: the shape of your data is obvious from one line.
  • Refactoring speed: fewer moving parts means less breakage.
  • Functional style: with encourages pure transforms.
  • Tooling happiness: better diffs, logs, and debugger views thanks to ToString() and deconstruction.

A tiny but telling example:

var c1 = new Customer("Bob", Status: "Gold");
var c2 = new Customer("Bob", Status: "Gold");
Console.WriteLine(c1 == c2); // True (value equality)

Try that with classes and you’ll get False unless you override equality yourself.

Practical Examples and Use Cases

Basic Declaration & Equality

public record Person(string Name, int Age);

var p1 = new Person("Alice", 30);
var p2 = new Person("Alice", 30);
Console.WriteLine(p1 == p2); // True

Non‑Destructive Mutation with with

var original = new Person("Alice", 30);
var older = original with { Age = 31 };

The with expression copies all members and updates only the specified ones.

Nested Updates (shallow copy reminder)

with is shallow. For nested objects, chain with calls:

public record Address(string City, string Country);
public record Customer(string Name, Address Address);

var orderer = new Customer("Bob", new Address("Paris", "FR"));
var moved   = orderer with { Address = orderer.Address with { City = "Lyon" } };

Inheritance (when you truly need it)

public record Person(string Name, int Age);
public record Employee(string Name, int Age, string Position)
    : Person(Name, Age);

Use sparingly: inheritance affects the equality contract (more in Pitfalls).

Custom Members & Computed Properties

public record Product(string Name, decimal Price)
{
    public string Label => $"{Name} ({Price:C})";
    public bool IsAffordable => Price < 20m;
}

Pattern Matching with Records

var p = new Person("Alice", 30);
if (p is Person { Name: "Alice", Age: >= 18 })
{
    // property pattern – super readable
}

// positional pattern via deconstruction
var result = p switch
{
    ("Alice", >= 30) => "Veteran",
    (_, < 18)        => "Minor",
    _                => "Regular"
};

Serialization (System.Text.Json)

Records serialize/deserialize like regular types. Positional records map by parameter names; you can also use non-positional (property) records if you prefer explicitness:

public record Order
{
    public required Guid Id { get; init; }
    public required string Number { get; init; }
}

The required keyword (C# 11) plays nicely with records to make invalid states unrepresentable.

Where Records Shine

  • DTOs / API contracts
  • Configuration snapshots
  • Event payloads (event sourcing)
  • Read models (CQRS)
  • Value Objects in DDD (Money, Email, Distance, etc.)

Record Structs (C# 10+) & Readonly Variants

For tight loops or hot paths, consider record struct or readonly record struct to reduce allocations while keeping value semantics and concise syntax:

public readonly record struct Money(decimal Amount, string Currency)
{
    public Money Convert(decimal rate, string target)
        => new(Amount * rate, target);
}

Best Practices When Working with Record Types

  1. Prefer property records for versioned contracts. Positional records tie equality to parameter order. Property records are more resilient to evolution.
  2. Keep them small and focused. Records describe data, not behavior-heavy objects.
  3. Be explicit with required. Make illegal states unrepresentable at compile time.
  4. Limit inheritance. If you must inherit, document the equality implications and consider sealing records that shouldn’t be extended.
  5. Don’t fight the model. If the object has identity and mutable lifecycle (e.g., EF Core aggregate root), a class is often a better fit.
  6. Use pure transforms. Put your business logic in functions that return new records rather than mutating input.
  7. Validate in constructors (or factory methods). Throw early to preserve invariants.
  8. Own your equality. If you override equality, also override GetHashCode and keep it consistent with the properties that define your value semantics.
  9. Choose the right flavor:
    • record (reference type) – default for DTOs/messages.
    • record structvalue type for perf-critical hotspots.
    • readonly record struct – maximum immutability & zero defensive copies.

Common Pitfalls and How to Avoid Them

Equality + Inheritance Surprises

Record equality includes all properties of the runtime type. Changing the shape in a base/derived record alters equality and hash codes. If your model evolves frequently:

  • Prefer property records with explicit properties.
  • Consider composition over inheritance to avoid fragile equality contracts.

with is Shallow

with doesn’t clone nested reference objects. For deep graphs, either chain with calls or introduce small helper functions:

public static Customer MoveCity(Customer c, string city)
    => c with { Address = c.Address with { City = city } };

Performance Considerations

  • Each with allocates a new instance (for reference records). In tight loops this increases GC pressure.
  • Use record struct/readonly record struct for hotspots.
  • In batch pipelines, prefer transforming collections and reusing lookups to minimize allocations.

Serialization Gotchas

  • Positional records rely on parameter names. Renaming a parameter without a compatible JSON name breaks deserialization. Stabilize names or use property records.
  • Combine required with JSON to fail fast on missing data.

EF Core Entities

  • EF Core tracks identity and performs lazy/proxy magic. Using records as entities can work, but the mental model (immutability/value equality) clashes with change tracking.
  • Recommended: use classes for entities, records for value objects and read models.

Over-using Records

If your type’s primary concern is behavior and state transitions (e.g., a stateful service), a record isn’t the right abstraction. Records shine for data modeling.

A Mini Walk‑Through from Class to Record

Let’s migrate a tiny HTTP request model.

Before (class):

public class CreateUserRequest
{
    public string Email { get; set; } = default!;
    public string DisplayName { get; set; } = default!;
}

Problems:

  • Mutable by default
  • No equality
  • Boilerplate when adding validation or copy operations

After (record with required + validation):

public record CreateUserRequest
{
    public required string Email { get; init; }
    public required string DisplayName { get; init; }

    public CreateUserRequest(string email, string displayName)
    {
        if (string.IsNullOrWhiteSpace(email))
            throw new ArgumentException("Email is required", nameof(email));
        Email = email;
        DisplayName = displayName;
    }
}

Usage with non-destructive mutation:

var req = new CreateUserRequest("a@dev.io", "Ada");
var renamed = req with { DisplayName = "Ada Lovelace" };

You end up with a safer, self-documenting model and less glue code.

FAQ: Working with Immutable C# Models

Can I have mutable properties in a record?

You can declare set; properties, but it defeats the purpose. Prefer init and required members.

Are records always better than classes?

No. Use records for value semantics and data contracts. Use classes for identity, lifecycle, and mutation.

How do I validate input?

Use the primary/instance constructor or factory methods. Throw early and keep invariants strong.

Do records work with AutoMapper/Mapster?

Yes. Most mapping libraries support init properties and constructors. Prefer explicit maps for clarity.

What about DDD?

Records are excellent for Value Objects. For Entities/Aggregates, classes are still the idiomatic choice.

Can records have methods and events?

Yes. They’re full-fledged types – you can add methods, operators, interfaces, and events.

When should I use record struct?

When allocations matter and copying is cheap/expected. Combine with readonly if the type is truly immutable.

Conclusion: Embrace Record Types for Cleaner, Safer C# Code

If your project is littered with boilerplate DTO classes, migrating to records will cut code, reduce bugs, and make refactoring feel like a walk in the park. Start where it hurts most – API contracts, event payloads, and value objects – and adopt required + with consistently. Your future self (and your bug tracker) will thank you.

Your turn: where will you swap a class for a record first? Drop a comment with your use case – I’ll help review the design.

Leave a Reply

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