| name | csharp-idioms |
| description | Use when applying modern C# idioms — records, pattern matching, primary constructors, collection expressions.
|
| metadata | {"category":"core","agent":"dotnet-architect","alwaysApply":true} |
Modern C# Idioms
Core Principles
- Prefer the latest stable C# features supported by the project's target framework
- Use file-scoped namespaces in all new files
- Prefer records for immutable data transfer objects and value objects
- Use pattern matching over chains of if-else or type checks
- Apply primary constructors to reduce boilerplate in DI-heavy classes
- Gate features by target framework: do not emit C# 14 syntax for .NET 8 projects
Version Feature Matrix
| Feature | Minimum | C# Version |
|---|
| File-scoped namespaces | .NET 6 | C# 10 |
required modifier | .NET 7 | C# 11 |
| Primary constructors | .NET 8 | C# 12 |
| Collection expressions | .NET 8 | C# 12 |
field keyword | .NET 10 | C# 14 |
| Extension types | .NET 10 | C# 14 |
Patterns
File-Scoped Namespaces
namespace {Company}.{Domain}.Features.Orders;
public sealed class OrderService { }
Records for DTOs and Value Objects
public sealed record OrderResponse(Guid Id, string CustomerName, decimal Total);
public sealed record Money(decimal Amount, string Currency)
{
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException("Cannot add different currencies");
return this with { Amount = Amount + other.Amount };
}
}
public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
}
Primary Constructors (.NET 8+ / C# 12)
public sealed class OrderService(
IOrderRepository repository,
ILogger<OrderService> logger)
{
public async Task<Order?> GetAsync(Guid id, CancellationToken ct)
{
logger.LogInformation("Fetching order {OrderId}", id);
return await repository.FindAsync(id, ct);
}
}
Collection Expressions (C# 12)
int[] numbers = [1, 2, 3];
List<string> merged = [..existing, ..additional, "extra"];
ReadOnlySpan<byte> bytes = [0x01, 0x02, 0x03];
List<Order> orders = [];
Pattern Matching
string label = order.State switch
{
OrderState.Pending when order.Total > 1000 => "Requires approval",
OrderState.Pending => "Awaiting processing",
OrderState.Shipped => "In transit",
OrderState.Delivered => "Complete",
_ => "Unknown"
};
if (order is { Status: OrderStatus.Shipped, Total: > 500 })
ApplyDiscount(order);
var result = args switch
{
[var cmd, ..var rest] => ProcessCommand(cmd, rest),
[] => ShowHelp()
};
if (order is not null)
Process(order);
Sealed Classes and Expression Bodies
public sealed class OrderValidator
{
public bool IsValid(Order order) => order.Items.Count > 0 && order.Total > 0;
public string Name => nameof(OrderValidator);
}
Naming Conventions
namespace {Company}.{Domain}.Features.Orders;
public sealed class OrderService
{
private const string DefaultCurrency = "USD";
private readonly IOrderRepository _repository;
public async Task<OrderResponse?> GetOrderAsync(Guid orderId)
{
var order = await _repository.FindAsync(orderId);
return order is null ? null : MapToResponse(order);
}
private static OrderResponse MapToResponse(Order order) => new(
Id: order.Id,
CustomerName: order.CustomerName,
Total: order.Total);
}
field Keyword (C# 14 / .NET 10)
public string Name
{
get => field;
set => field = value?.Trim() ?? throw new ArgumentNullException(nameof(value));
}
Anti-Patterns
- Using block-scoped namespaces in new files (wastes indentation)
- Mutable classes where a record would suffice
- Long if-else chains instead of switch expressions
- Using
== null instead of is null
- String interpolation in log messages (loses structure)
- Emitting C# 14 syntax for projects targeting .NET 8
Detect Existing Patterns
- Check
<LangVersion> in .csproj or Directory.Build.props
- Check
<TargetFramework> for net8.0, net9.0, net10.0
- Scan
.cs files for primary constructor usage
- Look for
record keyword usage in model/DTO files
- Check
.editorconfig for naming and style rules
- Detect file-scoped vs block-scoped namespaces in existing files
- Check private field naming convention (
_camelCase vs camelCase)
Adding to Existing Project
- Match existing style first — if the project uses block-scoped namespaces, continue until a team-wide migration is agreed
- Adopt incrementally — introduce records for new DTOs, primary constructors for new services
- Do not mix conventions — all files in a project should follow the same naming rules
- Add
.editorconfig if missing to enforce style rules:
[*.cs]
csharp_style_namespace_declarations = file_scoped:warning
csharp_style_var_for_built_in_types = false:suggestion
csharp_style_prefer_pattern_matching = true:suggestion
dotnet_naming_rule.private_fields_should_be_camel_case.severity = warning
Decision Guide
| Scenario | Recommendation |
|---|
| New DTO / response type | Use record |
| Service class with DI | Use primary constructor (.NET 8+) |
| Multiple type checks | Use switch expression with patterns |
| Null check | Use is null / is not null |
| New file | Use file-scoped namespace |
| Lightweight ID type | Use readonly record struct |
References