Leveraging C# Record Types for Immutable Domain Models
Introduction
When I started working on a new e‑commerce platform, I needed a way to model entities that would never change after creation. Traditional mutable classes forced me to add a barrage of null‑checks, copy constructors, and equality overrides. The pain point was clear: we were spending more time defending data integrity than delivering features. Then C# 9 introduced record types, and my codebase became cleaner, safer, and easier to reason about.
Why Records Matter
Records are reference types that are **implicitly immutable**, meaning their fields cannot be reassigned after the object is created. This gives us three immediate benefits:
- Structural equality. Two records are equal when all their members have the same values, not just the same memory address.
- Automatic
ToString,GetHashCode, andThe compiler generates these members for us, eliminating boilerplate.Equals. - Pattern matching friendliness. Records support deconstruction and positional pattern matching, which makes switch expressions a breeze.
Because of these traits, records are an excellent fit for **domain models** where you want an object that represents a fact about the system and never changes.
Real‑World Example: Modeling an Order Aggregate
Imagine we need to track an Order in our system. An order consists of a unique identifier, a customer, a collection of line items, and a status. The status can transition only a few times, and once an order is shipped we must not allow further changes.
Below is a production‑ready set of record types that we shipped in a recent project. The code includes validation in the constructor and a few helper methods, all while staying immutable.
public record Customer(string Id, string Name, Email Email)
{
// Immutable, automatically generates Equals, GetHashCode, ToString
public Email Address => Email; // convenience property
}
public record Email(string Value)
{
// Small value object – also immutable
public override string ToString() => Value;
}
public record Money(decimal Amount, string Currency)
{
// Helper for addition – returns a new Money instance
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException("Cannot add money with different currencies.");
return new Money(Amount + other.Amount, Currency);
}
}
public record LineItem(Guid Id, Product Product, int Quantity, Money UnitPrice)
{
public Money Total => UnitPrice * Quantity;
}
public record Order
(
Guid Id,
Customer Customer,
IReadOnlyList Items,
OrderStatus Status
)
{
// Immutable collection – we expose a read‑only view
public IReadOnlyList Items { get; } = Items ?? new List();
// Business rule: Shipped orders cannot be modified
public Order Ship()
{
if (Status != OrderStatus.Pending)
throw new InvalidOperationException("Only pending orders can be shipped.");
return this with { Status = OrderStatus.Shipped };
}
// Convenience method to calculate the order total
public Money GetTotal() => Items.Aggregate(Money.Zero, (acc, li) => acc.Add(li.Total));
}
public enum OrderStatus { Pending, Shipped, Cancelled }
// Extension method to create a zero Money amount
public static class MoneyExtensions
{
public static Money Zero(this object _) => new Money(0m, "USD");
}
Notice how each record is defined with positional syntax. The Order record uses the with expression in Ship to create a new instance with an updated status, preserving immutability.
Advanced Patterns: Pattern Matching and Deconstruction
Because records implement Deconstruct automatically when you declare positional fields, we can easily unpack them in a using statement or a switch expression. This is handy when we need to log or serialize domain objects.
// Example of deconstruction for logging
public static void LogOrderDetails(Order order)
{
using var _ = order.Deconstruct(out var id, out var customer, out var items, out var status);
Console.WriteLine($"Order {id} belongs to {customer.Name} and has {items.Count} items.");
}
// Switch expression to handle different order states
public static string Describe(Order order) => order.Status switch
{
OrderStatus.Pending => $"Order {order.Id} is awaiting processing.",
OrderStatus.Shipped => $"Order {order.Id} has been dispatched.",
OrderStatus.Cancelled => $"Order {order.Id} was cancelled.",
_ => "Unknown status."
};
The deconstruction example shows a lightweight way to access fields without repetitive property access. The switch expression leverages positional pattern matching, making the intent crystal clear.
Performance and Maintenance Benefits
While records introduce a tiny overhead (the compiler‑generated methods), the gain in **developer productivity** is significant. We no longer need to write:
- Boilerplate
public override bool Equals(object obj) implementations. - Manual equality checks that are error‑prone.
- Copy constructors for immutable data transfer objects.
Moreover, the compiler guarantees that the generated equality respects value semantics, which means our unit tests become more stable. When two orders have the same identifiers, they are truly equal, regardless of object identity.
When to Use Regular Classes Instead
Records shine when you need **value‑like** objects that should be compared by content. However, they are less suitable for **behavior‑rich** entities that need mutable state or complex lifecycle management. If a class requires setters, backing fields, or a non‑trivial Dispose pattern, a plain class is the better choice.
Summary
Record types give us a concise, immutable, and expressive way to model domain concepts. By leveraging structural equality, automatic member generation, and pattern‑matching support, we can write cleaner code that is both safer and easier to maintain. The example above demonstrates how a typical e‑commerce aggregate can be represented with just a few lines, while still enforcing business rules through immutable updates.
If you are building systems where correctness and clarity are paramount, give records a try. You’ll likely find that the learning curve is minimal and the payoff in code quality is substantial.