dotnet-csharp-modern-patterns
Modern C# language feature guidance adapted to the project's target framework. Always run
[skill:dotnet-version-detection] first to determine TFM and C# version.
Scope
- Records, pattern matching, primary constructors, collection expressions
- C# 12-15 feature usage mapped to TFM
- Language feature adoption guidance
Out of scope
- Naming and style conventions -- see [skill:dotnet-csharp-coding-standards]
- Async/await patterns -- see [skill:dotnet-csharp-async-patterns]
- Source generator usage (GeneratedRegex, LoggerMessage) -- see [skill:dotnet-csharp-source-generators]
Cross-references: [skill:dotnet-csharp-coding-standards] for naming/style conventions,
[skill:dotnet-csharp-async-patterns] for async-specific patterns.
Quick Reference: TFM to C# Version
| TFM |
C# |
Key Language Features |
| net8.0 |
12 |
Primary constructors, collection expressions, alias any type |
| net9.0 |
13 |
params collections, Lock type, partial properties |
| net10.0 |
14 |
field keyword, extension blocks, nameof unbound generics |
| net11.0 |
15 (preview) |
Collection expression with() arguments |
Records
Use records for immutable data transfer objects, value semantics, and domain modeling where equality is based on values
rather than identity.
Record Classes (reference type)
// Positional record: concise, immutable, value equality
public record OrderSummary(int OrderId, decimal Total, DateOnly OrderDate);
// With additional members
public record Customer(string Name, string Email)
{
public string DisplayName => $"{Name} <{Email}>";
}
```text
### Record Structs (value type, C# 10+)
```csharp
// Positional record struct: value type with value semantics
public readonly record struct Point(double X, double Y);
// Mutable record struct (rare -- prefer readonly)
public record struct MutablePoint(double X, double Y);
```text
### When to Use Records vs Classes
| Use Case | Prefer |
| ------------------------------------ | ------------------------ |
| DTOs, API responses | `record` |
| Domain value objects (Money, Email) | `readonly record struct` |
| Entities with identity (User, Order) | `class` |
| High-throughput, small data | `readonly record struct` |
| Inheritance needed | `record` (class-based) |
### Non-destructive Mutation
```csharp
var updated = order with { Total = order.Total + tax };
```csharp
---
## Primary Constructors (C# 12+, net8.0+)
Capture constructor parameters directly in the class/struct body. Parameters become available throughout the type but
are **not** fields or properties -- they are captured state.
### For Services (DI injection)
```csharp
public class OrderService(IOrderRepository repo, ILogger<OrderService> logger)
{
public async Task<Order> GetAsync(int id)
{
logger.LogInformation("Fetching order {OrderId}", id);
return await repo.GetByIdAsync(id);
}
}
```text
### Gotchas
- Primary constructor parameters are **mutable** captures, not `readonly` fields. If immutability matters, assign to a
`readonly` field in the body.
- Do not use primary constructors when you need to validate parameters at construction time -- use a traditional
constructor with guard clauses instead.
- For records, positional parameters become public properties automatically. For classes/structs, they remain private
captures.
```csharp
// Explicit readonly field when immutability matters
public class Config(string connectionString)
{
private readonly string _connectionString = connectionString
?? throw new ArgumentNullException(nameof(connectionString));
}
```text
---
## Collection Expressions (C# 12+, net8.0+)
Unified syntax for creating collections with `[...]`.
```csharp
// Array
int[] numbers = [1, 2, 3];
// List
List<string> names = ["Alice", "Bob"];
// Span
ReadOnlySpan<byte> bytes = [0x00, 0xFF];
// Spread operator
int[] combined = [..first, ..second, 99];
// Empty collection
List<int> empty = [];
```text
### Collection Expression with Arguments (C# 15 preview, net11.0+)
Specify capacity, comparers, or other constructor arguments:
```csharp
// Capacity hint
List<int> nums = [with(capacity: 1000), ..Generate()];
// Custom comparer
HashSet<string> set = [with(comparer: StringComparer.OrdinalIgnoreCase), "Alice", "bob"];
// Dictionary with comparer
Dictionary<string, int> map = [with(comparer: StringComparer.OrdinalIgnoreCase),
new("key1", 1), new("key2", 2)];
```text
> **net11.0+ only.** Requires `<LangVersion>preview</LangVersion>`. Do not use on earlier TFMs.
---
## Pattern Matching
### Switch Expressions (C# 8+)
```csharp
string GetDiscount(Customer customer) => customer switch
{
{ Tier: "Gold", YearsActive: > 5 } => "30%",
{ Tier: "Gold" } => "20%",
{ Tier: "Silver" } => "10%",
_ => "0%"
};
```text
### List Patterns (C# 11+)
```csharp
bool IsValid(int[] data) => data is [> 0, .., > 0]; // first and last positive
string Describe(int[] values) => values switch
{
[] => "empty",
[var single] => $"single: {single}",
[var first, .., var last] => $"range: {first}..{last}"
};
```text
### Type and Property Patterns
```csharp
decimal CalculateShipping(object package) => package switch
{
Letter { Weight: < 50 } => 0.50m,
Parcel { Weight: var w } when w < 1000 => 5.00m + w * 0.01m,
Parcel { IsOversized: true } => 25.00m,
_ => 10.00m
};
```text
---
## `required` Members (C# 11+)
Force callers to initialize properties at construction via object initializers.
```csharp
public class UserDto
{
public required string Name { get; init; }
public required string Email { get; init; }
public string? Phone { get; init; }
}
// Compiler enforces Name and Email
var user = new UserDto { Name = "Alice", Email = "alice@example.com" };
```text
Useful for DTOs that need to be deserialized (System.Text.Json honors `required` in .NET 8+).
---
## `field` Keyword (C# 14, net10.0+)
Access the compiler-generated backing field directly in property accessors.
```csharp
public class TemperatureSensor
{
public double Reading
{
get => field;
set => field = value >= -273.15
? value
: throw new ArgumentOutOfRangeException(nameof(value));
}
}
```text
Replaces the manual pattern of declaring a private field plus a property with custom logic. Use when you need validation
or transformation in a setter without a separate backing field.
> **net10.0+ only.** On earlier TFMs, use a traditional private field.
---
## Extension Blocks (C# 14, net10.0+)
Group extension members for a type in a single block.
```csharp
public static class EnumerableExtensions
{
extension<T>(IEnumerable<T> source) where T : class
{
public IEnumerable<T> WhereNotNull()
=> source.Where(x => x is not null);
public bool IsEmpty()
=> !source.Any();
}
}
```text
> **net10.0+ only.** On earlier TFMs, use traditional `static` extension methods.
---
## Alias Any Type (`using`, C# 12+, net8.0+)
```csharp
using Point = (double X, double Y);
using UserId = System.Guid;
Point origin = (0, 0);
UserId id = UserId.NewGuid();
```text
Useful for tuple aliases and domain type aliases without creating a full type.
---
## `params` Collections (C# 13, net9.0+)
`params` now supports additional collection types beyond arrays, including `Span<T>`, `ReadOnlySpan<T>`, and types
implementing certain collection interfaces.
```csharp
public void Log(params ReadOnlySpan<string> messages)
{
foreach (var msg in messages)
Console.WriteLine(msg);
}
// Callers: compiler may avoid heap allocation with span-based params
Log("hello", "world");
```text
> **net9.0+ only.** On net8.0, `params` only supports arrays.
---
## `Lock` Type (C# 13, net9.0+)
Use `System.Threading.Lock` instead of `object` for locking.
```csharp
private readonly Lock _lock = new();
public void DoWork()
{
lock (_lock)
{
// thread-safe operation
}
}
```text
`Lock` provides a `Scope`-based API for advanced scenarios and is more expressive than `lock (object)`.
> **net9.0+ only.** On net8.0, use `private readonly object _gate = new();` and `lock (_gate)`.
---
## Partial Properties (C# 13, net9.0+)
Partial properties enable source generators to define property signatures that users implement, or vice versa.
```csharp
// In generated file
public partial class ViewModel
{
public partial string Name { get; set; }
}
// In user file
public partial class ViewModel
{
private string _name = "";
public partial string Name
{
get => _name;
set => SetProperty(ref _name, value);
}
}
```text
> **net9.0+ only.** See [skill:dotnet-csharp-source-generators] for generator patterns.
---
## `nameof` for Unbound Generic Types (C# 14, net10.0+)
```csharp
string name = nameof(List<>); // "List"
string name2 = nameof(Dictionary<,>); // "Dictionary"
```csharp
Useful in logging, diagnostics, and reflection scenarios.
> **net10.0+ only.**
---
## Polyfill Guidance for Multi-Targeting
When targeting multiple TFMs, newer language features may not compile on older targets. Use these approaches:
1. **PolySharp** -- Polyfills compiler-required types (`IsExternalInit`, `RequiredMemberAttribute`, etc.) so language
features like `init`, `required`, and `record` work on older TFMs.
2. **Polyfill** -- Polyfills runtime APIs (e.g., `string.Contains(char)` for netstandard2.0).
3. **Conditional compilation** -- Use `#if` for features that cannot be polyfilled:
```csharp
#if NET10_0_OR_GREATER
// Use field keyword
public double Value { get => field; set => field = Math.Max(0, value); }
#else
private double _value;
public double Value { get => _value; set => _value = Math.Max(0, value); }
#endif
```text
See [skill:dotnet-multi-targeting] for comprehensive polyfill guidance.
---
## Knowledge Sources
Feature guidance in this skill is grounded in publicly available language design rationale from:
- **C# Language Design Notes (Mads Torgersen et al.)** -- Design decisions behind each C# version's features. Key
rationale relevant to this skill: primary constructors (reducing boilerplate for DI-heavy services), collection
expressions (unifying collection initialization syntax), `field` keyword (eliminating backing field ceremony), and
extension blocks (grouping extensions by target type). Each feature balances expressiveness with safety -- e.g.,
primary constructor parameters are intentionally mutable captures (not readonly) to keep the feature simple; use
explicit readonly fields when immutability is needed. Source: https://github.com/dotnet/csharplang/tree/main/meetings
- **C# Language Proposals Repository** -- Detailed specifications and design rationale for accepted and proposed
features. Source: https://github.com/dotnet/csharplang/tree/main/proposals
> **Note:** This skill applies publicly documented design rationale. It does not represent or speak for the named
> sources.
## Code Navigation (Serena MCP)
**Primary approach:** Use Serena symbol operations for efficient code navigation:
1. **Find definitions**: `serena_find_symbol` instead of text search
2. **Understand structure**: `serena_get_symbols_overview` for file organization
3. **Track references**: `serena_find_referencing_symbols` for impact analysis
4. **Precise edits**: `serena_replace_symbol_body` for clean modifications
**When to use Serena vs traditional tools:**
- ✅ **Use Serena**: Navigation, refactoring, dependency analysis, precise edits
- ✅ **Use Read/Grep**: Reading full files, pattern matching, simple text operations
- ✅ **Fallback**: If Serena unavailable, traditional tools work fine
**Example workflow:**
```text
# Instead of:
Read: src/Services/OrderService.cs
Grep: "public void ProcessOrder"
# Use:
serena_find_symbol: "OrderService/ProcessOrder"
serena_get_symbols_overview: "src/Services/OrderService.cs"
```
## References
- [C# Language Reference](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/)
- [What's new in C# 12](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-12)
- [What's new in C# 13](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-13)
- [What's new in C# 14](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-14)
- [C# Language Design Notes](https://github.com/dotnet/csharplang/tree/main/meetings)
- [.NET Framework Design Guidelines](https://learn.microsoft.com/en-us/dotnet/standard/design-guidelines/)
1---2name: dotnet-csharp-modern-patterns-83description: Using records, pattern matching, primary constructors, collection expressions. C# 12-15 by TFM.4license: MIT5---67# dotnet-csharp-modern-patterns89Modern C# language feature guidance adapted to the project's target framework. Always run10[skill:dotnet-version-detection] first to determine TFM and C# version.1112## Scope1314- Records, pattern matching, primary constructors, collection expressions15- C# 12-15 feature usage mapped to TFM16- Language feature adoption guidance1718## Out of scope1920- Naming and style conventions -- see [skill:dotnet-csharp-coding-standards]21- Async/await patterns -- see [skill:dotnet-csharp-async-patterns]22- Source generator usage (GeneratedRegex, LoggerMessage) -- see [skill:dotnet-csharp-source-generators]2324Cross-references: [skill:dotnet-csharp-coding-standards] for naming/style conventions,25[skill:dotnet-csharp-async-patterns] for async-specific patterns.2627---2829## Quick Reference: TFM to C# Version3031| TFM | C# | Key Language Features |32| ------- | ------------ | ------------------------------------------------------------ |33| net8.0 | 12 | Primary constructors, collection expressions, alias any type |34| net9.0 | 13 | `params` collections, `Lock` type, partial properties |35| net10.0 | 14 | `field` keyword, extension blocks, `nameof` unbound generics |36| net11.0 | 15 (preview) | Collection expression `with()` arguments |3738---3940## Records4142Use records for immutable data transfer objects, value semantics, and domain modeling where equality is based on values43rather than identity.4445### Record Classes (reference type)4647````csharp4849// Positional record: concise, immutable, value equality50public record OrderSummary(int OrderId, decimal Total, DateOnly OrderDate);5152// With additional members53public record Customer(string Name, string Email)54{55 public string DisplayName => $"{Name} <{Email}>";56}5758```text5960### Record Structs (value type, C# 10+)6162```csharp6364// Positional record struct: value type with value semantics65public readonly record struct Point(double X, double Y);6667// Mutable record struct (rare -- prefer readonly)68public record struct MutablePoint(double X, double Y);6970```text7172### When to Use Records vs Classes7374| Use Case | Prefer |75| ------------------------------------ | ------------------------ |76| DTOs, API responses | `record` |77| Domain value objects (Money, Email) | `readonly record struct` |78| Entities with identity (User, Order) | `class` |79| High-throughput, small data | `readonly record struct` |80| Inheritance needed | `record` (class-based) |8182### Non-destructive Mutation8384```csharp8586var updated = order with { Total = order.Total + tax };8788```csharp8990---9192## Primary Constructors (C# 12+, net8.0+)9394Capture constructor parameters directly in the class/struct body. Parameters become available throughout the type but95are **not** fields or properties -- they are captured state.9697### For Services (DI injection)9899```csharp100101public class OrderService(IOrderRepository repo, ILogger<OrderService> logger)102{103 public async Task<Order> GetAsync(int id)104 {105 logger.LogInformation("Fetching order {OrderId}", id);106 return await repo.GetByIdAsync(id);107 }108}109110```text111112### Gotchas113114- Primary constructor parameters are **mutable** captures, not `readonly` fields. If immutability matters, assign to a115 `readonly` field in the body.116- Do not use primary constructors when you need to validate parameters at construction time -- use a traditional117 constructor with guard clauses instead.118- For records, positional parameters become public properties automatically. For classes/structs, they remain private119 captures.120121```csharp122123// Explicit readonly field when immutability matters124public class Config(string connectionString)125{126 private readonly string _connectionString = connectionString127 ?? throw new ArgumentNullException(nameof(connectionString));128}129130```text131132---133134## Collection Expressions (C# 12+, net8.0+)135136Unified syntax for creating collections with `[...]`.137138```csharp139140// Array141int[] numbers = [1, 2, 3];142143// List144List<string> names = ["Alice", "Bob"];145146// Span147ReadOnlySpan<byte> bytes = [0x00, 0xFF];148149// Spread operator150int[] combined = [..first, ..second, 99];151152// Empty collection153List<int> empty = [];154155```text156157### Collection Expression with Arguments (C# 15 preview, net11.0+)158159Specify capacity, comparers, or other constructor arguments:160161```csharp162163// Capacity hint164List<int> nums = [with(capacity: 1000), ..Generate()];165166// Custom comparer167HashSet<string> set = [with(comparer: StringComparer.OrdinalIgnoreCase), "Alice", "bob"];168169// Dictionary with comparer170Dictionary<string, int> map = [with(comparer: StringComparer.OrdinalIgnoreCase),171 new("key1", 1), new("key2", 2)];172173```text174175> **net11.0+ only.** Requires `<LangVersion>preview</LangVersion>`. Do not use on earlier TFMs.176177---178179## Pattern Matching180181### Switch Expressions (C# 8+)182183```csharp184185string GetDiscount(Customer customer) => customer switch186{187 { Tier: "Gold", YearsActive: > 5 } => "30%",188 { Tier: "Gold" } => "20%",189 { Tier: "Silver" } => "10%",190 _ => "0%"191};192193```text194195### List Patterns (C# 11+)196197```csharp198199bool IsValid(int[] data) => data is [> 0, .., > 0]; // first and last positive200201string Describe(int[] values) => values switch202{203 [] => "empty",204 [var single] => $"single: {single}",205 [var first, .., var last] => $"range: {first}..{last}"206};207208```text209210### Type and Property Patterns211212```csharp213214decimal CalculateShipping(object package) => package switch215{216 Letter { Weight: < 50 } => 0.50m,217 Parcel { Weight: var w } when w < 1000 => 5.00m + w * 0.01m,218 Parcel { IsOversized: true } => 25.00m,219 _ => 10.00m220};221222```text223224---225226## `required` Members (C# 11+)227228Force callers to initialize properties at construction via object initializers.229230```csharp231232public class UserDto233{234 public required string Name { get; init; }235 public required string Email { get; init; }236 public string? Phone { get; init; }237}238239// Compiler enforces Name and Email240var user = new UserDto { Name = "Alice", Email = "alice@example.com" };241242```text243244Useful for DTOs that need to be deserialized (System.Text.Json honors `required` in .NET 8+).245246---247248## `field` Keyword (C# 14, net10.0+)249250Access the compiler-generated backing field directly in property accessors.251252```csharp253254public class TemperatureSensor255{256 public double Reading257 {258 get => field;259 set => field = value >= -273.15260 ? value261 : throw new ArgumentOutOfRangeException(nameof(value));262 }263}264265```text266267Replaces the manual pattern of declaring a private field plus a property with custom logic. Use when you need validation268or transformation in a setter without a separate backing field.269270> **net10.0+ only.** On earlier TFMs, use a traditional private field.271272---273274## Extension Blocks (C# 14, net10.0+)275276Group extension members for a type in a single block.277278```csharp279280public static class EnumerableExtensions281{282 extension<T>(IEnumerable<T> source) where T : class283 {284 public IEnumerable<T> WhereNotNull()285 => source.Where(x => x is not null);286287 public bool IsEmpty()288 => !source.Any();289 }290}291292```text293294> **net10.0+ only.** On earlier TFMs, use traditional `static` extension methods.295296---297298## Alias Any Type (`using`, C# 12+, net8.0+)299300```csharp301302using Point = (double X, double Y);303using UserId = System.Guid;304305Point origin = (0, 0);306UserId id = UserId.NewGuid();307308```text309310Useful for tuple aliases and domain type aliases without creating a full type.311312---313314## `params` Collections (C# 13, net9.0+)315316`params` now supports additional collection types beyond arrays, including `Span<T>`, `ReadOnlySpan<T>`, and types317implementing certain collection interfaces.318319```csharp320321public void Log(params ReadOnlySpan<string> messages)322{323 foreach (var msg in messages)324 Console.WriteLine(msg);325}326327// Callers: compiler may avoid heap allocation with span-based params328Log("hello", "world");329330```text331332> **net9.0+ only.** On net8.0, `params` only supports arrays.333334---335336## `Lock` Type (C# 13, net9.0+)337338Use `System.Threading.Lock` instead of `object` for locking.339340```csharp341342private readonly Lock _lock = new();343344public void DoWork()345{346 lock (_lock)347 {348 // thread-safe operation349 }350}351352```text353354`Lock` provides a `Scope`-based API for advanced scenarios and is more expressive than `lock (object)`.355356> **net9.0+ only.** On net8.0, use `private readonly object _gate = new();` and `lock (_gate)`.357358---359360## Partial Properties (C# 13, net9.0+)361362Partial properties enable source generators to define property signatures that users implement, or vice versa.363364```csharp365366// In generated file367public partial class ViewModel368{369 public partial string Name { get; set; }370}371372// In user file373public partial class ViewModel374{375 private string _name = "";376 public partial string Name377 {378 get => _name;379 set => SetProperty(ref _name, value);380 }381}382383```text384385> **net9.0+ only.** See [skill:dotnet-csharp-source-generators] for generator patterns.386387---388389## `nameof` for Unbound Generic Types (C# 14, net10.0+)390391```csharp392393string name = nameof(List<>); // "List"394string name2 = nameof(Dictionary<,>); // "Dictionary"395396```csharp397398Useful in logging, diagnostics, and reflection scenarios.399400> **net10.0+ only.**401402---403404## Polyfill Guidance for Multi-Targeting405406When targeting multiple TFMs, newer language features may not compile on older targets. Use these approaches:4074081. **PolySharp** -- Polyfills compiler-required types (`IsExternalInit`, `RequiredMemberAttribute`, etc.) so language409 features like `init`, `required`, and `record` work on older TFMs.4102. **Polyfill** -- Polyfills runtime APIs (e.g., `string.Contains(char)` for netstandard2.0).4113. **Conditional compilation** -- Use `#if` for features that cannot be polyfilled:412413```csharp414415#if NET10_0_OR_GREATER416 // Use field keyword417 public double Value { get => field; set => field = Math.Max(0, value); }418#else419 private double _value;420 public double Value { get => _value; set => _value = Math.Max(0, value); }421#endif422423```text424425See [skill:dotnet-multi-targeting] for comprehensive polyfill guidance.426427---428429## Knowledge Sources430431Feature guidance in this skill is grounded in publicly available language design rationale from:432433- **C# Language Design Notes (Mads Torgersen et al.)** -- Design decisions behind each C# version's features. Key434 rationale relevant to this skill: primary constructors (reducing boilerplate for DI-heavy services), collection435 expressions (unifying collection initialization syntax), `field` keyword (eliminating backing field ceremony), and436 extension blocks (grouping extensions by target type). Each feature balances expressiveness with safety -- e.g.,437 primary constructor parameters are intentionally mutable captures (not readonly) to keep the feature simple; use438 explicit readonly fields when immutability is needed. Source: https://github.com/dotnet/csharplang/tree/main/meetings439- **C# Language Proposals Repository** -- Detailed specifications and design rationale for accepted and proposed440 features. Source: https://github.com/dotnet/csharplang/tree/main/proposals441442> **Note:** This skill applies publicly documented design rationale. It does not represent or speak for the named443> sources.444445446447## Code Navigation (Serena MCP)448449**Primary approach:** Use Serena symbol operations for efficient code navigation:4504511. **Find definitions**: `serena_find_symbol` instead of text search4522. **Understand structure**: `serena_get_symbols_overview` for file organization4533. **Track references**: `serena_find_referencing_symbols` for impact analysis4544. **Precise edits**: `serena_replace_symbol_body` for clean modifications455456**When to use Serena vs traditional tools:**457- ✅ **Use Serena**: Navigation, refactoring, dependency analysis, precise edits458- ✅ **Use Read/Grep**: Reading full files, pattern matching, simple text operations459- ✅ **Fallback**: If Serena unavailable, traditional tools work fine460461**Example workflow:**462```text463# Instead of:464Read: src/Services/OrderService.cs465Grep: "public void ProcessOrder"466467# Use:468serena_find_symbol: "OrderService/ProcessOrder"469serena_get_symbols_overview: "src/Services/OrderService.cs"470```471## References472473- [C# Language Reference](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/)474- [What's new in C# 12](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-12)475- [What's new in C# 13](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-13)476- [What's new in C# 14](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-14)477- [C# Language Design Notes](https://github.com/dotnet/csharplang/tree/main/meetings)478- [.NET Framework Design Guidelines](https://learn.microsoft.com/en-us/dotnet/standard/design-guidelines/)479````