C# has changed more in the last five years than in the ten before it. A lot of production code still reads like C# 5, even on teams running .NET 10. Here are twelve features worth deliberately adopting — not because they're new, but because each one removes a category of bug or a page of boilerplate.
- Primary constructors and collection expressions cut real boilerplate — adopt them by default in new code.
- Pattern matching and list patterns replace nested
ifchains with something the compiler can check for exhaustiveness. - Records give you value equality and immutability for free — use them for anything that represents data rather than identity.
- Nullable reference types catch a whole class of null-reference bugs at compile time, but only if you enable them project-wide, not file-by-file.
1. Primary constructors
C# 12 lets classes (not just records) declare constructor parameters directly in the type declaration, in scope for the whole class body.
public class OrderService(IOrderRepository repository, ILogger<OrderService> logger)
{
public async Task SubmitAsync(Guid orderId)
{
logger.LogInformation("Submitting order {OrderId}", orderId);
var order = await repository.GetAsync(orderId);
order.Submit();
}
}
2. Collection expressions
One consistent syntax for arrays, lists, and spans, with the spread operator for combining collections.
int[] numbers = [1, 2, 3];
List<string> names = ["Alice", "Bob"];
int[] combined = [..numbers, 4, 5];
3. Pattern matching with property patterns
Match on the shape of an object instead of writing a chain of if statements.
decimal DiscountFor(Order order) => order switch
{
{ Total: > 1000, Customer.IsVip: true } => 0.15m,
{ Total: > 1000 } => 0.10m,
{ Customer.IsVip: true } => 0.05m,
_ => 0m
};
4. Records and record structs
Value equality, with-expressions, and a sensible ToString() — without writing any of it by hand.
public record Money(decimal Amount, string Currency)
{
public Money Add(Money other) =>
Currency != other.Currency
? throw new InvalidOperationException("Currency mismatch")
: this with { Amount = Amount + other.Amount };
}
5. Required members
Force callers to set critical properties without needing a constructor overload for every combination.
public class CreateUserRequest
{
public required string Email { get; init; }
public required string DisplayName { get; init; }
public string? PhoneNumber { get; init; }
}
6. Raw string literals
No more escaping quotes inside JSON, SQL, or regex embedded in C# source.
var json = """
{
"name": "astrivo",
"legalName": "Astrivo Technologies LLC",
"focus": ["dotnet", "aws", "azure"]
}
""";
7. File-scoped namespaces
One less indentation level in every file, with no change in behavior.
namespace Astrivo.Orders;
public class Order
{
// no extra indent needed
}
8. Global and implicit usings
Move common using directives into one file per project instead of repeating them everywhere.
// GlobalUsings.cs
global using System;
global using System.Collections.Generic;
global using System.Linq;
global using Microsoft.Extensions.Logging;
9. Nullable reference types
Enable this project-wide, not opportunistically — partial adoption gives you false confidence.
<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>
public class Customer
{
public string Name { get; set; } = string.Empty; // non-nullable, must be set
public string? MiddleName { get; set; } // explicitly nullable
}
10. Switch expressions
Replace verbose switch statements with an expression that returns a value and forces you to handle every case.
string StatusLabel(OrderStatus status) => status switch
{
OrderStatus.Draft => "Draft",
OrderStatus.Submitted => "Submitted",
OrderStatus.Shipped => "Shipped",
_ => throw new ArgumentOutOfRangeException(nameof(status))
};
11. Init-only setters
Immutable-after-construction properties without the ceremony of a full constructor.
public class Address
{
public string Street { get; init; } = string.Empty;
public string City { get; init; } = string.Empty;
}
var address = new Address { Street = "1 Main St", City = "Austin" };
// address.Street = "2 Main St"; // compile error — init-only
12. List patterns
Match on the shape of a sequence, including the first/last elements and a variable-length middle section.
string Describe(int[] values) => values switch
{
[] => "empty",
[var single] => $"single value: {single}",
[var first, .., var last] => $"starts with {first}, ends with {last}",
};
.editorconfig) will nudge the team without a big-bang cleanup.