Modern C# Patterns Every Senior Developer Should Know
Records, pattern matching, ref struct, ValueTask, and primary constructors — how they change production code in 2026.
- Author
- Randhir Jassal
- Published
- Reading time
- 7 min read
Records vs classes — when to actually use records
Records are immutable by default, get value equality for free, and have built-in with expressions. Use them for DTOs and value objects.
public record CustomerOrder(Guid Id, string Email, decimal Total);
var o1 = new CustomerOrder(Guid.NewGuid(), "a@b.com", 100m);
var o2 = o1 with { Total = 120m };
Console.WriteLine(o1 == o2); // False — value equality compares all props
Console.WriteLine(o1.Email == o2.Email); // True
Avoid records for entities that own behaviour or have a long mutable lifecycle.
Pattern matching that replaces 50-line switches
Modern C# pattern matching collapses what used to be nested switch statements into a single expression.
public static decimal CalculateShipping(IOrder order) => order switch
{
{ Customer.IsPremium: true, Total: > 100m } => 0m,
{ Customer.IsPremium: true } => 4.99m,
{ Total: > 50m, Region: "IN" } => 8.99m,
{ Region: "IN" } => 12.99m,
_ => 19.99m
};
The property-pattern syntax { Customer.IsPremium: true } lets you reach into nested object graphs without null checks.
ValueTask for hot-path async
Task allocates a state machine on every await. ValueTask skips allocation when the result is already available — perfect for cache lookups.
public ValueTask<User?> GetUserAsync(Guid id)
{
if (_cache.TryGetValue(id, out var cached))
return new ValueTask<User?>(cached); // zero allocation
return new ValueTask<User?>(LoadFromDb(id));
}
Rule of thumb: use ValueTask only when the synchronous-completion case is common (>50% of calls). Otherwise stick with Task.
Primary constructors
C# 12 lets you declare constructor parameters directly on the class signature. They become readonly captures usable in any member.
public sealed class OrderService(IOrderRepository repo, ILogger<OrderService> logger)
{
public async Task<Order> PlaceAsync(Guid customerId, decimal amount)
{
logger.LogInformation("Placing order for {Customer}", customerId);
return await repo.InsertAsync(new Order(customerId, amount));
}
}
No backing fields, no constructor body. The parameters are scoped to the entire class.
ref struct for stack-only types
Span<T> and ReadOnlySpan<T> let you slice arrays and strings without allocating. Use them in tight loops.
public static int SumDigits(ReadOnlySpan<char> input)
{
int sum = 0;
foreach (var c in input)
if (char.IsDigit(c)) sum += c - '0';
return sum;
}
// Caller — no allocation when passing a substring:
var total = SumDigits("order-2026-04-72".AsSpan(6, 4));
ref structs can't be boxed, captured in closures, or held across await. The compiler enforces this so you can't accidentally heap-allocate.
When NOT to reach for these features
- Records as entities — you'll fight EF Core's change tracking
- ValueTask everywhere — the API surface gets confusing
- Pattern matching over a
Result<T>type when a simpleif (result.IsSuccess)reads cleaner - Span in code paths that don't show up in a profiler
Performance features are a trap when applied speculatively. Profile first, then optimise.
Get the next issue
A short, curated email with the newest posts and questions.