Are you still writing DTOs and request models as classes? You’re probably shipping more bugs and boilerplate than you need. In this post I’ll show you how switching to C# record types makes your code safer, smaller, and easier to reason about – without turning your codebase upside down.
Understanding Immutability
Immutability means once an object is created, its state never changes. Instead of mutating an instance, you create a new one with the desired differences. Think of it as Google Docs “version history” for your data structures – every change creates a snapshot.
Why you (and production) care:
- Thread-safety by default. No write-after-read surprises on multi-threaded code paths.
- Predictable debugging. When state never changes, reproducing defects gets easier.
- Fewer side effects. Functions become easier to compose and test.
- Auditability. Historical data remains intact (great for CQRS and event sourcing).
Before C# 9, idiomatic immutability in classes was… verbose:
public class Person
{
public string Name { get; }
public int Age { get; }
public Person(string name, int age)
{
Name = name;
Age = age;
}
// non-destructive mutation helper
public Person With(string? name = null, int? age = null)
=> new(name ?? Name, age ?? Age);
}
It works, but you write (and maintain) a lot of plumbing.
Introduction to Record Types in C#
What Are Record Types?
Records (introduced in C# 9) are reference types optimized for immutability and value-based equality. A minimal record:
public record Person(string Name, int Age);
Out of the box you get:
- Primary constructor & deconstructor
init
-only properties- Value-based
Equals
/GetHashCode
- Nice
ToString()
for debugging - Support for
with
-expressions (non-destructive mutation)
How They Differ from Classes
- Equality: classes compare by reference; records compare by content.
- Conciseness: records eliminate constructors,
Equals
boilerplate, and the hand-rolledWith
helpers. - Immutability-first: properties are
init
-only by default (settable only during construction/initialization). - Pattern matching: records play nicely with property and positional patterns.
When identity doesn’t matter and content does, use a record.
Benefits of Using Record Types
- Correctness: value-based equality aligns with how we actually compare DTOs, messages, and settings.
- Readability: the shape of your data is obvious from one line.
- Refactoring speed: fewer moving parts means less breakage.
- Functional style:
with
encourages pure transforms. - Tooling happiness: better diffs, logs, and debugger views thanks to
ToString()
and deconstruction.
A tiny but telling example:
var c1 = new Customer("Bob", Status: "Gold");
var c2 = new Customer("Bob", Status: "Gold");
Console.WriteLine(c1 == c2); // True (value equality)
Try that with classes and you’ll get False
unless you override equality yourself.
Practical Examples and Use Cases
Basic Declaration & Equality
public record Person(string Name, int Age);
var p1 = new Person("Alice", 30);
var p2 = new Person("Alice", 30);
Console.WriteLine(p1 == p2); // True
Non‑Destructive Mutation with with
var original = new Person("Alice", 30);
var older = original with { Age = 31 };
The with
expression copies all members and updates only the specified ones.
Nested Updates (shallow copy reminder)
with
is shallow. For nested objects, chain with
calls:
public record Address(string City, string Country);
public record Customer(string Name, Address Address);
var orderer = new Customer("Bob", new Address("Paris", "FR"));
var moved = orderer with { Address = orderer.Address with { City = "Lyon" } };
Inheritance (when you truly need it)
public record Person(string Name, int Age);
public record Employee(string Name, int Age, string Position)
: Person(Name, Age);
Use sparingly: inheritance affects the equality contract (more in Pitfalls).
Custom Members & Computed Properties
public record Product(string Name, decimal Price)
{
public string Label => $"{Name} ({Price:C})";
public bool IsAffordable => Price < 20m;
}
Pattern Matching with Records
var p = new Person("Alice", 30);
if (p is Person { Name: "Alice", Age: >= 18 })
{
// property pattern – super readable
}
// positional pattern via deconstruction
var result = p switch
{
("Alice", >= 30) => "Veteran",
(_, < 18) => "Minor",
_ => "Regular"
};
Serialization (System.Text.Json)
Records serialize/deserialize like regular types. Positional records map by parameter names; you can also use non-positional (property) records if you prefer explicitness:
public record Order
{
public required Guid Id { get; init; }
public required string Number { get; init; }
}
The required
keyword (C# 11) plays nicely with records to make invalid states unrepresentable.
Where Records Shine
- DTOs / API contracts
- Configuration snapshots
- Event payloads (event sourcing)
- Read models (CQRS)
- Value Objects in DDD (Money, Email, Distance, etc.)
Record Structs (C# 10+) & Readonly Variants
For tight loops or hot paths, consider record struct
or readonly record struct
to reduce allocations while keeping value semantics and concise syntax:
public readonly record struct Money(decimal Amount, string Currency)
{
public Money Convert(decimal rate, string target)
=> new(Amount * rate, target);
}
Best Practices When Working with Record Types
- Prefer property records for versioned contracts. Positional records tie equality to parameter order. Property records are more resilient to evolution.
- Keep them small and focused. Records describe data, not behavior-heavy objects.
- Be explicit with
required
. Make illegal states unrepresentable at compile time. - Limit inheritance. If you must inherit, document the equality implications and consider sealing records that shouldn’t be extended.
- Don’t fight the model. If the object has identity and mutable lifecycle (e.g., EF Core aggregate root), a class is often a better fit.
- Use pure transforms. Put your business logic in functions that return new records rather than mutating input.
- Validate in constructors (or factory methods). Throw early to preserve invariants.
- Own your equality. If you override equality, also override
GetHashCode
and keep it consistent with the properties that define your value semantics. - Choose the right flavor:
record
(reference type) – default for DTOs/messages.record struct
– value type for perf-critical hotspots.readonly record struct
– maximum immutability & zero defensive copies.
Common Pitfalls and How to Avoid Them
Equality + Inheritance Surprises
Record equality includes all properties of the runtime type. Changing the shape in a base/derived record alters equality and hash codes. If your model evolves frequently:
- Prefer property records with explicit properties.
- Consider composition over inheritance to avoid fragile equality contracts.
with
is Shallow
with
doesn’t clone nested reference objects. For deep graphs, either chain with
calls or introduce small helper functions:
public static Customer MoveCity(Customer c, string city)
=> c with { Address = c.Address with { City = city } };
Performance Considerations
- Each
with
allocates a new instance (for reference records). In tight loops this increases GC pressure. - Use
record struct
/readonly record struct
for hotspots. - In batch pipelines, prefer transforming collections and reusing lookups to minimize allocations.
Serialization Gotchas
- Positional records rely on parameter names. Renaming a parameter without a compatible JSON name breaks deserialization. Stabilize names or use property records.
- Combine
required
with JSON to fail fast on missing data.
EF Core Entities
- EF Core tracks identity and performs lazy/proxy magic. Using records as entities can work, but the mental model (immutability/value equality) clashes with change tracking.
- Recommended: use classes for entities, records for value objects and read models.
Over-using Records
If your type’s primary concern is behavior and state transitions (e.g., a stateful service), a record isn’t the right abstraction. Records shine for data modeling.
A Mini Walk‑Through from Class to Record
Let’s migrate a tiny HTTP request model.
Before (class):
public class CreateUserRequest
{
public string Email { get; set; } = default!;
public string DisplayName { get; set; } = default!;
}
Problems:
- Mutable by default
- No equality
- Boilerplate when adding validation or copy operations
After (record with required + validation):
public record CreateUserRequest
{
public required string Email { get; init; }
public required string DisplayName { get; init; }
public CreateUserRequest(string email, string displayName)
{
if (string.IsNullOrWhiteSpace(email))
throw new ArgumentException("Email is required", nameof(email));
Email = email;
DisplayName = displayName;
}
}
Usage with non-destructive mutation:
var req = new CreateUserRequest("a@dev.io", "Ada");
var renamed = req with { DisplayName = "Ada Lovelace" };
You end up with a safer, self-documenting model and less glue code.
FAQ: Working with Immutable C# Models
You can declare set;
properties, but it defeats the purpose. Prefer init
and required
members.
No. Use records for value semantics and data contracts. Use classes for identity, lifecycle, and mutation.
Use the primary/instance constructor or factory methods. Throw early and keep invariants strong.
Yes. Most mapping libraries support init
properties and constructors. Prefer explicit maps for clarity.
Records are excellent for Value Objects. For Entities/Aggregates, classes are still the idiomatic choice.
Yes. They’re full-fledged types – you can add methods, operators, interfaces, and events.
record struct
?When allocations matter and copying is cheap/expected. Combine with readonly
if the type is truly immutable.
Conclusion: Embrace Record Types for Cleaner, Safer C# Code
If your project is littered with boilerplate DTO classes, migrating to records will cut code, reduce bugs, and make refactoring feel like a walk in the park. Start where it hurts most – API contracts, event payloads, and value objects – and adopt required
+ with
consistently. Your future self (and your bug tracker) will thank you.
Your turn: where will you swap a class for a record first? Drop a comment with your use case – I’ll help review the design.