C# Records and Pattern Matching: Modern Data Modeling
When it comes to building robust, maintainable software, how we model our data matters. In the past, crafting immutable, expressive, and concise domain models in C# often required extra boilerplate code and workarounds. But with the advent of C# 9 and subsequent releases, the introduction of records and improved pattern matching features has revolutionized how developers approach data modeling. These modern tools allow us to write clean, elegant, and powerful code with ease.
In this post, we’ll dive deep into C# records and pattern matching, exploring how they can transform your development process. You’ll learn how to build immutable data structures, leverage pattern matching for expressive domain logic, and avoid common pitfalls along the way.
Why Should You Care About Records and Pattern Matching?
Imagine you're developing an application to manage orders for an e-commerce platform. You need to model domain entities like Customer
, Order
, and Product
. These entities should:
- Be immutable to ensure thread safety and predictability.
- Have value-based equality to compare objects based on their data rather than references.
- Be expressive and concise to reduce boilerplate code.
Traditionally, achieving these goals in C# meant implementing a lot of repetitive code: manually overriding Equals
, GetHashCode
, and ToString
, and writing constructors to enforce immutability. But with records, this boilerplate becomes a thing of the past.
Couple that with pattern matching, and you gain the ability to write expressive, declarative logic for handling complex scenarios—no more nested if
statements or verbose type checks.
What Are C# Records?
At their core, records are reference types designed for immutability and value-based equality. Think of a record as a lightweight way to define a data-centric object. Unlike traditional classes, records automatically generate essential boilerplate code like equality members and ToString
out of the box.
A Simple Example
Here’s how you can define and use a record:
public record Product(string Name, decimal Price);
public class Program
{
public static void Main()
{
var product1 = new Product("Laptop", 999.99m);
var product2 = new Product("Laptop", 999.99m);
Console.WriteLine(product1); // Output: Product { Name = Laptop, Price = 999.99 }
Console.WriteLine(product1 == product2); // Output: True (value-based equality)
// Immutability
// product1.Name = "Tablet"; // Not allowed: Records are immutable by default
}
}
In this example:
- The
Product
record automatically generates:- A constructor (
Product(string Name, decimal Price)
). -
Equals
andGetHashCode
for value-based equality. - A
ToString
method for human-readable output.
- A constructor (
- Records are immutable by default, meaning their properties cannot be modified once initialized.
Positional vs. Non-Positional Records
The above example used a positional record, where the properties are defined in the record's constructor. You can also define records in a more traditional way, using property declarations:
public record Customer
{
public string FirstName { get; init; }
public string LastName { get; init; }
}
The init
accessor ensures that the properties can only be set during object initialization, preserving immutability.
Advanced Features of Records
with
Expressions for Non-Destructive Mutation
Sometimes, you might need to create a modified copy of a record. Records support non-destructive mutation via with
expressions:
var originalProduct = new Product("Laptop", 999.99m);
var discountedProduct = originalProduct with { Price = 899.99m };
Console.WriteLine(originalProduct); // Output: Product { Name = Laptop, Price = 999.99 }
Console.WriteLine(discountedProduct); // Output: Product { Name = Laptop, Price = 899.99 }
The with
expression creates a new instance while preserving the original record's immutability.
Inheritance with Records
Records support inheritance, making them ideal for polymorphic domain models:
public record Shape;
public record Circle(double Radius) : Shape;
public record Rectangle(double Width, double Height) : Shape;
This becomes especially powerful when combined with pattern matching, as we’ll see shortly.
Pattern Matching in C#: Writing Expressive Logic
Pattern matching allows you to simplify complex conditional logic by matching on object types, properties, or values in a declarative way. Combined with records, it becomes a natural companion for modeling and processing domain data.
Type Patterns
Let’s revisit our Shape
hierarchy. Using pattern matching, we can process shapes in a clean and expressive way:
public static double CalculateArea(Shape shape) => shape switch
{
Circle c => Math.PI * c.Radius * c.Radius,
Rectangle r => r.Width * r.Height,
_ => throw new ArgumentException("Unknown shape")
};
The switch
expression here evaluates the type of shape
and applies the correct logic for each derived type. No need for cumbersome if-else
chains or manual type checks!
Property Patterns
You can also match on specific properties of an object:
public static string GetProductCategory(Product product) => product switch
{
{ Price: < 50 } => "Budget",
{ Price: >= 50 and < 500 } => "Standard",
{ Price: >= 500 } => "Premium",
_ => "Unknown"
};
This approach is both concise and expressive, making it easy to see the intent of the logic.
Combining Patterns
Patterns can be combined to handle more complex scenarios. Here’s an example using a tuple pattern:
public static string DescribeOrderStatus(string status, bool isPaid) => (status, isPaid) switch
{
("Pending", false) => "Order is pending payment.",
("Pending", true) => "Order is pending shipment.",
("Shipped", _) => "Order has been shipped.",
_ => "Unknown order status."
};
By combining multiple conditions, you can handle intricate logic in a clean and maintainable way.
Common Pitfalls and How to Avoid Them
While records and pattern matching are powerful tools, there are some pitfalls to watch out for:
Misusing Immutability: Records are immutable by default, but if you accidentally use mutable types (e.g.,
List<T>
), you might break immutability. Use immutable collections likeImmutableList<T>
to avoid this.Overusing Inheritance: While records support inheritance, excessive use can lead to tight coupling. Prefer composition over inheritance where possible.
Performance Concerns with Large Records: Records implement value-based equality, which can be expensive for large objects. Be cautious when using records with many properties or deeply nested structures.
Overcomplicating Patterns: Pattern matching is meant to simplify logic. Avoid nesting patterns too deeply, as it can hurt readability.
Key Takeaways
C# records and pattern matching are a game-changer for modern data modeling. Here’s what you should remember:
- Records simplify immutable, value-based data modeling, drastically reducing boilerplate code.
- Use
with
expressions for non-destructive mutation andinit
for immutability. - Pattern matching enables declarative and expressive logic, making your code easier to read and maintain.
- Avoid common pitfalls like breaking immutability and overcomplicating patterns.
Next Steps
To deepen your understanding of these features, try applying them in a real-world project. Experiment with:
- Building a domain model using records.
- Refactoring existing
if-else
chains with pattern matching. - Combining records and pattern matching in advanced scenarios like parsing or processing data streams.
You can also explore the official C# documentation for more details on records and pattern matching.
Happy coding! 🎉
Top comments (0)