C# Records and Immutability: Value Semantics for Domain Models

C records (introduced in C 9) provide a concise syntax for immutable data objects with value-based equality. They replace the boilerplate of overriding Equals, GetHashCode, and ToString, and enable no

Introduction#

C# records (introduced in C# 9) provide a concise syntax for immutable data objects with value-based equality. They replace the boilerplate of overriding Equals, GetHashCode, and ToString, and enable non-destructive mutation with with expressions. Records are well-suited for DTOs, domain value objects, and event payloads.

Record Basics#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Positional record: constructor, properties, equality, ToString generated
public record Point(double X, double Y);

// Usage
var p1 = new Point(1.0, 2.0);
var p2 = new Point(1.0, 2.0);

Console.WriteLine(p1 == p2);     // True (value equality)
Console.WriteLine(p1.Equals(p2)); // True
Console.WriteLine(p1);           // Point { X = 1, Y = 2 }

// Non-destructive mutation with 'with' expression
var p3 = p1 with { Y = 5.0 };
Console.WriteLine(p3); // Point { X = 1, Y = 5 }
Console.WriteLine(p1); // Point { X = 1, Y = 2 } — original unchanged

Record vs Class vs Struct#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Class: reference equality by default, mutable
public class PointClass
{
    public double X { get; set; }
    public double Y { get; set; }
}

// Record: value equality, immutable init-only properties
public record PointRecord(double X, double Y);

// Record struct (C# 10): value equality + stack allocation
public readonly record struct PointStruct(double X, double Y);

var c1 = new PointClass { X = 1, Y = 2 };
var c2 = new PointClass { X = 1, Y = 2 };
Console.WriteLine(c1 == c2); // False — reference equality

var r1 = new PointRecord(1, 2);
var r2 = new PointRecord(1, 2);
Console.WriteLine(r1 == r2); // True — value equality

var s1 = new PointStruct(1, 2);
var s2 = new PointStruct(1, 2);
Console.WriteLine(s1 == s2); // True — value equality, no heap allocation

Domain Value Objects with Records#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// Money value object — immutable, value equality
public record Money(decimal Amount, string Currency)
{
    // Validation in constructor
    public Money(decimal amount, string currency) : this(amount, currency.ToUpperInvariant())
    {
        if (amount < 0)
            throw new ArgumentException("Amount cannot be negative", nameof(amount));
        if (string.IsNullOrWhiteSpace(currency))
            throw new ArgumentException("Currency is required", nameof(currency));
    }

    public static Money Zero(string currency) => new(0, currency);

    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException($"Cannot add {Currency} and {other.Currency}");
        return this with { Amount = Amount + other.Amount };
    }

    public Money Scale(decimal factor) => this with { Amount = Amount * factor };

    public override string ToString() => $"{Amount:F2} {Currency}";
}

// Usage
var price = new Money(29.99m, "USD");
var tax = new Money(2.40m, "USD");
var total = price.Add(tax);

Console.WriteLine(total); // 32.39 USD
Console.WriteLine(price); // 29.99 USD — original unchanged

Event Payloads#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// Immutable event records for event sourcing
public abstract record DomainEvent(Guid EventId, DateTimeOffset OccurredAt)
{
    protected DomainEvent() : this(Guid.NewGuid(), DateTimeOffset.UtcNow) { }
}

public record OrderPlaced(
    Guid OrderId,
    Guid CustomerId,
    IReadOnlyList<OrderLine> Lines,
    Money Total
) : DomainEvent;

public record OrderShipped(
    Guid OrderId,
    string TrackingNumber,
    DateTimeOffset ShippedAt
) : DomainEvent;

public record OrderLine(Guid ProductId, int Quantity, Money UnitPrice)
{
    public Money LineTotal => UnitPrice.Scale(Quantity);
}

// Event handler
public class OrderProjection
{
    public void Apply(DomainEvent evt) => evt switch
    {
        OrderPlaced e => HandleOrderPlaced(e),
        OrderShipped e => HandleOrderShipped(e),
        _ => throw new ArgumentOutOfRangeException(nameof(evt)),
    };

    private void HandleOrderPlaced(OrderPlaced evt)
    {
        Console.WriteLine($"Order {evt.OrderId} placed by {evt.CustomerId} for {evt.Total}");
    }

    private void HandleOrderShipped(OrderShipped evt)
    {
        Console.WriteLine($"Order {evt.OrderId} shipped. Tracking: {evt.TrackingNumber}");
    }
}

API DTOs with Records#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
using Microsoft.AspNetCore.Mvc;

// Request/response DTOs as records
public record CreateOrderRequest(
    Guid CustomerId,
    IReadOnlyList<OrderLineRequest> Lines
);

public record OrderLineRequest(Guid ProductId, int Quantity);

public record CreateOrderResponse(
    Guid OrderId,
    string Status,
    Money Total,
    DateTimeOffset CreatedAt
);

[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
    [HttpPost]
    public async Task<ActionResult<CreateOrderResponse>> CreateOrder(
        CreateOrderRequest request,
        CancellationToken ct
    )
    {
        // Records serialize/deserialize cleanly with System.Text.Json
        var orderId = Guid.NewGuid();
        var total = new Money(99.99m, "USD");

        return Ok(new CreateOrderResponse(
            OrderId: orderId,
            Status: "Pending",
            Total: total,
            CreatedAt: DateTimeOffset.UtcNow
        ));
    }
}

Immutable Collections#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
using System.Collections.Immutable;

// Records with immutable collections prevent mutation of nested state
public record ShoppingCart(
    Guid CartId,
    ImmutableList<CartItem> Items
)
{
    public static ShoppingCart Empty(Guid cartId) =>
        new(cartId, ImmutableList<CartItem>.Empty);

    public ShoppingCart AddItem(CartItem item) =>
        this with { Items = Items.Add(item) };

    public ShoppingCart RemoveItem(Guid productId) =>
        this with { Items = Items.RemoveAll(i => i.ProductId == productId) };

    public Money Total => Items
        .Select(i => i.UnitPrice.Scale(i.Quantity))
        .Aggregate(Money.Zero("USD"), (acc, m) => acc.Add(m));
}

public record CartItem(Guid ProductId, string Name, int Quantity, Money UnitPrice);

// Usage
var cart = ShoppingCart.Empty(Guid.NewGuid());
var cart2 = cart.AddItem(new CartItem(Guid.NewGuid(), "Widget", 2, new Money(9.99m, "USD")));
var cart3 = cart2.AddItem(new CartItem(Guid.NewGuid(), "Gadget", 1, new Money(24.99m, "USD")));

Console.WriteLine(cart.Items.Count);  // 0 — original unchanged
Console.WriteLine(cart3.Items.Count); // 2
Console.WriteLine(cart3.Total);       // 44.97 USD

Deconstruction and Pattern Matching#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var point = new Point(3.0, 4.0);

// Deconstruction
var (x, y) = point;
Console.WriteLine($"x={x}, y={y}");

// Pattern matching with records
static string Describe(object shape) => shape switch
{
    Point { X: 0, Y: 0 } => "origin",
    Point { X: var px, Y: 0 } => $"on x-axis at {px}",
    Point { X: 0, Y: var py } => $"on y-axis at {py}",
    Point(var px, var py) => $"at ({px}, {py})",
    _ => "unknown",
};

Console.WriteLine(Describe(new Point(0, 0)));    // origin
Console.WriteLine(Describe(new Point(5, 0)));    // on x-axis at 5
Console.WriteLine(Describe(new Point(3, 4)));    // at (3, 4)

When Not to Use Records#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Records are NOT appropriate when:
// 1. You need identity equality (same object = same entity)
// 2. The object has mutable state

// Use class for entities with identity
public class Order  // NOT a record — has identity (OrderId)
{
    public Guid OrderId { get; } = Guid.NewGuid();
    public List<OrderLine> Lines { get; } = new();
    public OrderStatus Status { get; private set; } = OrderStatus.Pending;

    public void Ship(string trackingNumber)
    {
        if (Status != OrderStatus.Pending)
            throw new InvalidOperationException("Order is not pending");
        Status = OrderStatus.Shipped;
        // Raise domain event...
    }
}

// Use record for value objects and DTOs
public record OrderStatus(string Value)
{
    public static OrderStatus Pending => new("Pending");
    public static OrderStatus Shipped => new("Shipped");
}

Conclusion#

C# records are ideal for DTOs, value objects, events, and any data that should be compared by value rather than reference. Combine records with ImmutableList<T> for fully immutable aggregates. Use positional records for conciseness and with expressions for non-destructive updates. Reserve classes for entities that require identity-based equality and mutable state. The distinction between records (value semantics) and classes (reference semantics) maps directly onto the DDD distinction between value objects and entities.

Contents