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.