Are you still using classes for data-only structures in C#? You might be missing out on one of the most elegant features introduced in C# 9—record types. They’re not just syntactic sugar; they bring real power and clarity to your code when used right. Let me show you why.
Understanding Immutability
Immutability means once an object is created, it can’t be modified. Instead of changing an object, you create a new one with the desired modifications. This paradigm leads to a variety of benefits in real-world systems:
- Enhances thread safety (no surprises in concurrent operations). In a multi-threaded context, immutable data structures eliminate race conditions because data cannot be altered after creation.
- Improves predictability and debuggability. When objects are immutable, their state is guaranteed to remain the same, which simplifies debugging.
- Enables easier reasoning about code flows. With immutability, you reduce side effects and can trace the flow of data transformations more clearly.
Before C# 9.0, achieving immutability required verbose boilerplate code:
public class Person
{
public string Name { get; }
public int Age { get; }
public Person(string name, int age)
{
Name = name;
Age = age;
}
public Person With(string name = null, int? age = null) =>
new Person(name ?? this.Name, age ?? this.Age);
}
This approach was error-prone and tedious. Record types offer a clean, modern alternative.
Introduction to Record Types in C#
What Are Record Types?
Introduced in C# 9.0, record types are reference types optimized for immutability and value-based equality. They’re intended for scenarios where the identity of an object is less important than its content.
public record Person(string Name, int Age);
With this declaration, C# provides:
- Constructor
- Init-only properties
- Deconstructor
- Value-based equality
ToString
override
How They Differ from Classes
- Value-based equality: Records compare property values for equality, unlike classes that use reference equality.
- Built-in immutability: Properties are
init
-only, making them settable only during object creation. - Concise syntax: Records drastically reduce boilerplate code while preserving full type functionality.
This means that records are great for scenarios like modeling request/response data, snapshots of data states, or simple configuration settings.
Benefits of Using Record Types
- Value Equality: Automatically handles comparison by data content instead of references. This is ideal for data-centric applications, such as configuration management or business rules.
- Conciseness: Dramatically cuts down on the code required to define immutable data types. This increases developer productivity and reduces potential for bugs.
- Readable and Maintainable: Models defined using records are self-documenting. You can understand the shape and intent of your data with a glance.
- Built-in Features: Includes
Deconstruct
methods, support for pattern matching, andwith
expressions for easy data manipulation.
For example:
var customer1 = new Customer("Bob", "Gold");
var customer2 = new Customer("Bob", "Gold");
Console.WriteLine(customer1 == customer2); // True
This equality check would return false for two instances of a class with identical data unless you override Equals
and GetHashCode
.
Practical Examples and Use Cases
Example 1: Basic Record Declaration and 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
This works because records override Equals
and GetHashCode
based on property values, making them perfect for equality-based logic.
Example 2: Inheritance
public record Employee(string Name, int Age, string Position) : Person(Name, Age);
This demonstrates how records support positional inheritance, enabling you to build hierarchies while preserving immutability.
Example 3: Custom Logic
public record Product(string Name, decimal Price)
{
public string Label => $"{Name} (${Price})";
public bool IsAffordable => Price < 20;
}
This shows that records can include methods and computed properties just like classes, making them flexible yet robust.
Use Cases:
- DTOs (Data Transfer Objects): Easily model request/response objects without manual mapping.
- Configuration Snapshots: Maintain read-only system settings that can be swapped in without side effects.
- CQRS Models: In Command-Query Responsibility Segregation patterns, immutable models improve consistency and auditability.
- Event Sourcing: Immutable events ensure historical accuracy and facilitate replays.
Best Practices When Working with Record Types
- Use for immutable models: Ideal when you want to guarantee no mutation after object creation.
- Be consistent: Mixing mutable and immutable approaches within the same domain can confuse future maintainers.
- Avoid overusing: Don’t force records in domains where state mutation is a core requirement (e.g., UI state management).
- Combine with functional patterns: Use
with
expressions and pure functions for safer transformations.
Example of transformation:
var original = new Product("Keyboard", 99.99m);
var discounted = original with { Price = 79.99m };
This creates a new instance with the updated price while preserving immutability.
- Document assumptions: Be clear in documentation when using records, especially around expectations for equality and serialization.
Common Pitfalls and How to Avoid Them
- Inheritance surprises: Record inheritance behaves positionally. If base or derived records change, it can affect equality and serialization unexpectedly. Always review how changes to constructors or order of parameters affect downstream records.
- With-expressions on nested objects: Modifying deep structures can be tricky:
public record Order(Customer Customer);
public record Customer(string Name);
var order = new Order(new Customer("Alice"));
var modified = order with { Customer = order.Customer with { Name = "Bob" } };
You must manually use with
for nested objects since C# doesn’t do deep copies automatically.
- Performance considerations: Records allocate new instances for each
with
operation. In high-frequency or performance-critical loops, this could increase GC pressure.
To mitigate this:
- Use structs or readonly structs when performance is key.
- Reuse instances if immutability isn’t strictly required.
- Profile memory usage and test under realistic load scenarios.
FAQ: Working with Immutable C# Models
Only during object initialization. After that, they’re immutable.
Yes, you can define methods and computed properties like in classes.
No. Use them where value equality and immutability are desired.
Conclusion: Embrace Record Types for Cleaner, Safer C# Code
Record types in C# are more than a neat feature—they promote cleaner code, reduce bugs, and align perfectly with modern development paradigms like immutability and functional programming. So next time you’re creating a data model, ask yourself: class or record?
If you found this post useful, let me know in the comments or share it with your team. Want more .NET tips like this? Stay tuned!