Use C# Records and ‘with’ Expressions for Immutable Data Models
When you need value‑based equality and nondestructive mutation in C#, records with ‘with’ expressions give you a concise, compiler‑generated solution. Learn how they work, see a worked example, and understand the limits and common pitfalls before you adopt them.
09 Aug 2025, 10:42 UTC

Why Use Records Instead of Plain Classes?
When a data carrier should be compared by the values of its fields rather than by reference, and you want to avoid boilerplate Equals and GetHashCode implementations, record types are the idiomatic choice in modern C#. They are designed for DTOs, messages, and domain value objects where immutability and value equality matter.
How Records Generate Value Equality
A record is a reference type (or a value type with record struct) that the compiler expands into a class containing:
- All the declared members as
init‑onlyproperties (unless you explicitly declare setters). - A protected copy constructor that takes every property as a parameter.
- Overrides for
Equals,GetHashCode,==, and!=that compare each property pairwise. - A
Deconstructmethod for positional records. - A
ToStringthat lists property names and values.
Equality is nominal: two record types with identical shapes are never equal, even if their values match.
Non‑Destructive Mutation with the with Expression
The with expression creates a shallow copy of the original record, then applies the specified property assignments. Because the copy constructor is generated, you can write:
public record Money(decimal Amount, string Currency);
var price = new Money(100m, "USD");
var discounted = price with { Amount = price.Amount * 0.9m };
Console.WriteLine(price); // Money { Amount = 100, Currency = USD }
Console.WriteLine(discounted); // Money { Amount = 90, Currency = USD }
Running this on a .NET 6 SDK (C# 10) will produce the expected output. The original price remains unchanged, illustrating nondestructive mutation.
Practical Verification Steps
- Compile the example with
dotnet build.- Target framework: .NET 6 or later, or .NET 5 with the
IsExternalInitpolyfill for older frameworks.
- Target framework: .NET 6 or later, or .NET 5 with the
- Run a quick test program that asserts:
price != discounted– verifies the copy was made.new Money(1m, "USD") == new Money(1m, "USD")– verifies value equality.
- Inspect the generated IL using ILSpy or sharplab.io to confirm the presence of the copy constructor and overridden methods.
When Records Won’t Work
- Entity Framework Core: Records use value equality, which interferes with EF Core’s change tracking that relies on reference identity. Use regular classes for entities and records for projections or DTOs.
- Serialization Libraries: Some serializers historically had trouble with init‑only setters or positional records. Verify support in the library’s documentation (e.g., Newtonsoft.Json, System.Text.Json).
- Large or Cyclic Graphs:
GetHashCodeandToStringwalk all properties, which can be expensive or cause stack overflows if the object graph is deep or contains cycles. - Inheritance: The equality contract changes across a record hierarchy. A base‑typed reference may compare unequal to a derived instance even if all property values match.
- Mutable Reference Members: If a record holds a
List<T>or array, thewithexpression copies the reference, not the collection. Mutating the collection after a copy will affect both instances.
Common Mistakes to Avoid
- Assuming records are fully immutable. Only the properties declared without setters are immutable; explicitly declared setters break the benefit.
- Using records for types that need identity semantics (e.g., database entities). For those, stick with classes.
- Ignoring the need for the
IsExternalInitshim when targeting older frameworks. Without it,init‑onlyproperties will not compile. - Overlooking the shallow copy nature of
with. For deep cloning, implement a custom copy method or use a library that supports deep cloning.
Choosing Between Record Class and Record Struct
Use record (reference semantics) for most DTOs and value objects that may be larger than a few bytes. Use record struct (value semantics) when the type is small, stack‑allocatable, and you want to avoid heap allocations. Note that record structs do not have a copy constructor; they rely on the compiler‑generated parameterless constructor and the with expression still works by copying all fields.
Conclusion
Records with with expressions provide a concise, type‑safe way to model immutable data in C#. They relieve you from writing boilerplate equality logic and offer nondestructive updates out of the box. However, be mindful of their limitations, especially around persistence, serialization, and mutable reference members. By following the verification steps above and avoiding the common pitfalls, you can confidently adopt records for your value‑centric data models.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.