C# Records as Immutable Domain Models and DTOs: An Architecture Note
C# records provide value equality and init-only properties that make them suitable for DTOs and domain models at trust boundaries. This architecture note covers the minimal design, validation patterns, shallow immutability limits, and when to switch to entity semantics.
19 Jul 2026, 02:43 UTC

Requirements: Why Records at Trust Boundaries
When data crosses a trust boundary—between a web API and a domain layer, between services, or between a persistence layer and application code—you need guarantees that the data cannot be silently mutated after validation. C# records (introduced in C# 9, enhanced in C# 10 with record struct) provide value-based equality, non-destructive mutation via with expressions, and init-only properties by default. These characteristics make them a natural fit for DTOs and domain models where identity is defined by field values rather than reference.
The core requirement: after a caller validates input at the boundary, no downstream code should be able to observe partially invalid state or mutate the instance in a way that breaks invariants. Records address this by making mutation explicit (through with) and by generating IEquatable<T>, GetHashCode, and ==/!= operators automatically.
Smallest Suitable Design: Positional Records with Constructor Validation
The minimal record definition for a domain model or DTO uses positional syntax:
public record OrderDto(
Guid OrderId,
string CustomerEmail,
decimal TotalAmount,
IReadOnlyList<OrderLineDto> Lines
);
public record OrderLineDto(
string ProductSku,
int Quantity,
decimal UnitPrice
);
This generates a primary constructor, public init-only properties, IEquatable<T>, GetHashCode, ToString, and equality operators. The IReadOnlyList<T> signals read-only intent at the type level, though it does not enforce deep immutability (see Failure Modes).
Validation belongs in the constructor or a factory method so invalid instances cannot exist:
public record OrderDto(
Guid OrderId,
string CustomerEmail,
decimal TotalAmount,
IReadOnlyList<OrderLineDto> Lines
)
{
public OrderDto(
Guid orderId,
string customerEmail,
decimal totalAmount,
IReadOnlyList<OrderLineDto> lines)
{
if (orderId == Guid.Empty)
throw new ArgumentException("OrderId must not be empty.", nameof(orderId));
if (string.IsNullOrWhiteSpace(customerEmail))
throw new ArgumentException("CustomerEmail is required.", nameof(customerEmail));
if (totalAmount < 0)
throw new ArgumentOutOfRangeException(nameof(totalAmount), "TotalAmount must be non-negative.");
if (lines is null || lines.Count == 0)
throw new ArgumentException("At least one order line is required.", nameof(lines));
// Defensive copy to prevent external mutation of the list reference
Lines = lines.ToList().AsReadOnly();
}
}
Run this with dotnet build targeting .NET 6 or later. The compiler enforces init-only semantics: attempting to assign order.CustomerEmail = "[contact removed]" outside an object initializer or with expression produces CS8852.
Trust and Data Boundaries: Where Validation Lives
At a trust boundary (for example, an ASP.NET Core controller receiving JSON), deserialize into the record, then validate. System.Text.Json supports records via constructor-based deserialization; the primary constructor is used automatically when all parameters match property names case-insensitively. If you need a non-primary constructor or custom logic, annotate with [JsonConstructor]:
[JsonConstructor]
public OrderDto(Guid orderId, string customerEmail, decimal totalAmount, IReadOnlyList<OrderLineDto> lines)
: this(orderId, customerEmail, totalAmount, lines) { }
XML serialization (XmlSerializer) requires a parameterless constructor and public setters, so records are a poor fit there—use plain classes with [DataContract] instead.
The boundary rule: validate once, at the entry point. Downstream code receives a fully validated, immutable record. If downstream code needs a modified version, it uses a with expression, producing a new instance that re-runs constructor validation:
var updated = order with { TotalAmount = order.TotalAmount + 50m };
// Constructor validation executes again for the new instance
Operational Checks: Verifying Immutability Guarantees
Add these checks to your test suite to confirm the design behaves as expected:
- Value equality: Create two records with identical values; assert
Equalsand==return true. - Init-only enforcement: Attempt to assign to an init-only property outside an initializer or
with; confirm the compiler rejects it (CS8852). - Constructor validation: Pass invalid data to the constructor; assert the expected exception type and message.
- With-expression validation: Use
withto produce an invalid state; assert validation runs on the new instance.
[Fact]
public void WithExpression_RevalidatesConstructor()
{
var valid = new OrderDto(Guid.NewGuid(), "[contact removed]", 100m, new[] { new OrderLineDto("SKU", 1, 100m) });
Assert.Throws<ArgumentOutOfRangeException>(() => valid with { TotalAmount = -1 });
}
Failure Modes: Shallow Immutability and Nested Mutability
Records provide shallow immutability. A readonly or init-only property holding a reference type (for example, List<string>, Dictionary<K,V>, or a mutable class) still allows mutation of the referenced object:
public record BadExample(
string Name,
List<string> Tags // mutable reference type
);
var a = new BadExample("Test", new List<string> { "tag1" });
var b = a with { Name = "Test2" };
// a.Tags and b.Tags reference the SAME list
a.Tags.Add("tag2");
// b.Tags now contains "tag2" — mutation leaked across "immutable" instances
This is the most common production bug with records. Mitigations:
- Use immutable collection types:
IReadOnlyList<T>,ImmutableArray<T>,ImmutableList<T>(fromSystem.Collections.Immutable). - Defensively copy in the constructor:
Lines = lines.ToList().AsReadOnly();(as shown above). - For domain models requiring deep immutability, make nested objects records too, and ensure they contain only value types or other immutable records.
Record struct caveat: record struct (C# 10) provides value semantics but copies the entire struct on assignment. A non-readonly record struct allows property mutation unless declared readonly record struct. Large record structs can cause unintended allocation pressure; prefer record class for DTOs crossing boundaries.
Design-Change Triggers: When Records Are the Wrong Tool
Replace record-based value equality with explicit identity when:
- Database-generated keys: Two
Orderrows with identical field values but different primary keys are distinct domain entities. Value equality would incorrectly treat them as equal. - Distributed identity: In event-sourced or CQRS systems, an entity's identity is its stream ID or correlation ID, not its field values.
- Mutable lifecycle: If the object's fields change over time while its identity remains stable (for example,
Customerchanging email), use a class with anIdproperty and override equality to compare IDs only.
Example of the switch:
// Before: record (value equality)
public record Customer(string Email, string Name);
// After: entity with explicit identity
public class Customer
{
public Guid Id { get; }
public string Email { get; private set; }
public string Name { get; private set; }
public Customer(Guid id, string email, string name)
{
Id = id;
Email = email;
Name = name;
}
public void ChangeEmail(string newEmail) => Email = newEmail;
public override bool Equals(object obj) =>
obj is Customer other && Id == other.Id;
public override int GetHashCode() => Id.GetHashCode();
}
Record Inheritance: Keep It Sealed
Record inheritance with positional syntax complicates the generated equality contract. The derived record's equality includes base properties, but the with expression only copies declared properties. Prefer sealed record for DTOs and domain models. If inheritance is necessary, override equality members explicitly and document the contract.
Verification Checklist
| Check | Method | Expected Result |
|---|---|---|
| Generated members | Inspect with ILSpy or reflection | IEquatable<T>, GetHashCode, ==/!=, with support present |
| Init-only enforcement | Compile assignment outside initializer | CS8852 error |
| Nested mutation leak | Unit test: mutate nested list via one instance | Other instance sees change (confirms shallow immutability) |
| Constructor validation | Unit test with invalid inputs | Exceptions thrown before instance creation |
| With-expression revalidation | Unit test: with producing invalid state | Constructor validation runs on new instance |
Summary
Records are a strong default for DTOs and value-object domain models at trust boundaries. They give you value equality, explicit mutation via with, and init-only properties with minimal boilerplate. The critical discipline: validate in the constructor, defensively copy nested collections, and recognize that shallow immutability is not deep immutability. When identity must persist across field changes or database rows, switch to an entity class with an explicit Id.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.