
Dotnet 10 Csharp 14
- 1.8k installs
- 2 repo stars
- Updated May 7, 2026
- mhagrelius/dotfiles
dotnet-10-csharp-14 is an agent skill that Use when building .NET 10 or C# 14 applications; when using minimal APIs, modular monolith patterns, or feature folders; when implementing HTTP resilience, Options patter.
About
The dotnet-10-csharp-14 skill. Use when building .NET 10 or C# 14 applications; when using minimal APIs, modular monolith patterns, or feature folders; when implementing HTTP resilience, Options pattern, Channels, or validation; when seeing outdated patterns like old extension method syntax. The workflow follows the source SKILL.md contract with progressive reference loading, clear trigger phrases, and practical steps developers can apply directly in agent sessions. The workflow follows the source SKILL.md contract with progressive reference loading, clear trigger phrases, and practical steps developers can apply directly in agent sessions. The workflow follows the source SKILL.md contract with progressive reference loading, clear trigger phrases, and practical steps developers can apply directly in agent sessions. The workflow follows the source SKILL.md contract with progressive reference loading, clear trigger phrases, and practical steps developers can apply directly in agent sessions.
- Use when building .NET 10 or C# 14 applications; when using minimal APIs, modular monolith patterns, or feature folders;
- Use when building .NET 10 or C# 14 applications; when using minimal APIs, modular monolith patterns, or feature folders;
- Use when building .NET 10 or C# 14 applications; when using minimal APIs, modular monolith patterns, or feature folders;
- Use when building .NET 10 or C# 14 applications; when using minimal APIs, modular monolith patterns, or feature folders;
- Use when building .NET 10 or C# 14 applications; when using minimal APIs, modular monolith patterns, or feature folders;
Dotnet 10 Csharp 14 by the numbers
- 1,775 all-time installs (skills.sh)
- +18 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #7 of 154 .NET & C# skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
dotnet-10-csharp-14 capabilities & compatibility
- Capabilities
- use when building .net 10 or c# 14 applications;
- Use cases
- testing · debugging · ci cd
What dotnet-10-csharp-14 says it does
# .NET 10 & C# 14 Best Practices .NET 10 (LTS, Nov 2025) with C# 14. Covers minimal APIs, not MVC. **Official docs:** [.NET 10](https://learn.microsoft.com/en
# .NET 10 & C# 14 Best Practices .NET 10 (LTS, Nov 2025) with C# 14. Covers minimal APIs, not MVC. **Official docs:** [.NET 10](https://learn.microsoft.com/en
npx skills add https://github.com/mhagrelius/dotfiles --skill dotnet-10-csharp-14Add your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.8k |
|---|---|
| repo stars | ★ 2 |
| Security audit | 3 / 3 scanners passed |
| Last updated | May 7, 2026 |
| Repository | mhagrelius/dotfiles ↗ |
How do I apply dotnet-10-csharp-14 correctly using the SKILL.md workflows and reference files?
Use when building .NET 10 or C# 14 applications; when using minimal APIs, modular monolith patterns, or feature folders; when implementing HTTP resilience, Options pattern, Channels, or validation; wh
Who is it for?
Developers and software engineers working with dotnet-10-csharp-14 patterns from the skill documentation.
Skip if: Skip when cached docs are empty, boilerplate-only, or outside the skill documented scope.
When should I use this skill?
Use when building .NET 10 or C# 14 applications; when using minimal APIs, modular monolith patterns, or feature folders; when implementing HTTP resilience, Options pattern, Channels, or validation; when seeing outdated p
What you get
Grounded dotnet-10-csharp-14 guidance with highlights, triggers, and evidence quotes from SKILL.md.
- Corrected service patterns
- Resilience and HTTP client configuration
By the numbers
- Covers 10 documented anti-pattern replacements in the quick-reference table
Files
.NET 10 & C# 14 Best Practices
.NET 10 (LTS, Nov 2025) with C# 14. Covers minimal APIs, not MVC.
Official docs: .NET 10 | C# 14 | ASP.NET Core 10
Detail Files
| File | Topics |
|---|---|
| csharp-14.md | Extension blocks, field keyword, null-conditional assignment |
| minimal-apis.md | Validation, TypedResults, filters, modular monolith, vertical slices |
| security.md | JWT auth, CORS, rate limiting, OpenAPI security, middleware order |
| infrastructure.md | Options, resilience, channels, health checks, caching, Serilog, EF Core, keyed services |
| testing.md | WebApplicationFactory, integration tests, auth testing |
| anti-patterns.md | HttpClient, DI captive, blocking async, N+1 queries |
| libraries.md | MediatR, FluentValidation, Mapster, ErrorOr, Polly, Aspire |
---
Quick Start
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>var builder = WebApplication.CreateBuilder(args);
// Core services
builder.Services.AddValidation();
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
// Security
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
builder.Services.AddRateLimiter(opts => { /* see security.md */ });
// Infrastructure
builder.Services.AddHealthChecks();
builder.Services.AddOutputCache();
// Modules
builder.Services.AddUsersModule();
var app = builder.Build();
// Middleware (ORDER MATTERS - see security.md)
app.UseExceptionHandler();
app.UseHttpsRedirection();
app.UseCors();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.UseOutputCache();
app.MapOpenApi();
app.MapHealthChecks("/health");
app.MapUsersEndpoints();
app.Run();---
Decision Flowcharts
Result vs Exception
digraph {
"Error type?" [shape=diamond];
"Expected?" [shape=diamond];
"Result<T>/ErrorOr" [shape=box];
"Exception" [shape=box];
"Error type?" -> "Expected?" [label="domain"];
"Error type?" -> "Exception" [label="infrastructure"];
"Expected?" -> "Result<T>/ErrorOr" [label="yes"];
"Expected?" -> "Exception" [label="no"];
}IOptions Selection
digraph {
"Runtime changes?" [shape=diamond];
"Per-request?" [shape=diamond];
"IOptions<T>" [shape=box];
"IOptionsSnapshot<T>" [shape=box];
"IOptionsMonitor<T>" [shape=box];
"Runtime changes?" -> "IOptions<T>" [label="no"];
"Runtime changes?" -> "Per-request?" [label="yes"];
"Per-request?" -> "IOptionsSnapshot<T>" [label="yes"];
"Per-request?" -> "IOptionsMonitor<T>" [label="no"];
}Channel Type
digraph {
"Trust producer?" [shape=diamond];
"Can drop?" [shape=diamond];
"Bounded+Wait" [shape=box,style=filled,fillcolor=lightgreen];
"Bounded+Drop" [shape=box];
"Unbounded" [shape=box];
"Trust producer?" -> "Unbounded" [label="yes"];
"Trust producer?" -> "Can drop?" [label="no"];
"Can drop?" -> "Bounded+Drop" [label="yes"];
"Can drop?" -> "Bounded+Wait" [label="no"];
}---
Key Patterns Summary
C# 14 Extension Blocks
extension<T>(IEnumerable<T> source)
{
public bool IsEmpty => !source.Any();
}.NET 10 Built-in Validation
builder.Services.AddValidation();
app.MapPost("/users", (UserDto dto) => TypedResults.Ok(dto));TypedResults (Always Use)
app.MapGet("/users/{id}", async (int id, IUserService svc) =>
await svc.GetAsync(id) is { } user
? TypedResults.Ok(user)
: TypedResults.NotFound());Module Pattern
public static class UsersModule
{
public static IServiceCollection AddUsersModule(this IServiceCollection s) => s
.AddScoped<IUserService, UserService>();
public static IEndpointRouteBuilder MapUsersEndpoints(this IEndpointRouteBuilder app)
{
var g = app.MapGroup("/api/users").WithTags("Users");
g.MapGet("/{id}", GetUser.Handle);
return app;
}
}HTTP Resilience
builder.Services.AddHttpClient<IApi, ApiClient>()
.AddStandardResilienceHandler();Error Handling (RFC 9457)
builder.Services.AddProblemDetails();
app.UseExceptionHandler();
app.UseStatusCodePages();---
MANDATORY Patterns (Always Use These)
| Task | ✅ ALWAYS Use | ❌ NEVER Use |
|---|---|---|
| Extension members | C# 14 extension<T>() blocks | Traditional this extension methods |
| Property validation | C# 14 field keyword | Manual backing fields |
| Null assignment | obj?.Prop = value | if (obj != null) obj.Prop = value |
| API returns | TypedResults.Ok() | Results.Ok() |
| Options validation | .ValidateOnStart() | Missing validation |
| HTTP resilience | AddStandardResilienceHandler() | Manual Polly configuration |
| Timestamps | DateTime.UtcNow | DateTime.Now |
---
Quick Reference Card
┌─────────────────────────────────────────────────────────────────┐
│ .NET 10 / C# 14 PATTERNS │
├─────────────────────────────────────────────────────────────────┤
│ EXTENSION PROPERTY: extension<T>(IEnumerable<T> s) { │
│ public bool IsEmpty => !s.Any(); │
│ } │
├─────────────────────────────────────────────────────────────────┤
│ FIELD KEYWORD: public string Name { │
│ get => field; │
│ set => field = value?.Trim(); │
│ } │
├─────────────────────────────────────────────────────────────────┤
│ OPTIONS VALIDATION: .BindConfiguration(Section) │
│ .ValidateDataAnnotations() │
│ .ValidateOnStart(); // CRITICAL! │
├─────────────────────────────────────────────────────────────────┤
│ HTTP RESILIENCE: .AddStandardResilienceHandler(); │
├─────────────────────────────────────────────────────────────────┤
│ TYPED RESULTS: TypedResults.Ok(data) │
│ TypedResults.NotFound() │
│ TypedResults.Created(uri, data) │
├─────────────────────────────────────────────────────────────────┤
│ ERROR PATTERN: ErrorOr<User> or user?.Match(...) │
├─────────────────────────────────────────────────────────────────┤
│ IOPTIONS: IOptions<T> → startup, no reload │
│ IOptionsSnapshot<T> → per-request reload │
│ IOptionsMonitor<T> → live + OnChange() │
└─────────────────────────────────────────────────────────────────┘---
Anti-Patterns Quick Reference
| Anti-Pattern | Fix |
|---|---|
new HttpClient() | Inject HttpClient or IHttpClientFactory |
Results.Ok() | TypedResults.Ok() |
| Manual Polly config | AddStandardResilienceHandler() |
| Singleton → Scoped | Use IServiceScopeFactory |
GetAsync().Result | await GetAsync() |
| Exceptions for flow | Use ErrorOr<T> Result pattern |
DateTime.Now | DateTime.UtcNow |
Missing .ValidateOnStart() | Always add to Options registration |
See anti-patterns.md for complete list.
---
Libraries Quick Reference
| Library | Package | Purpose |
|---|---|---|
| MediatR | MediatR | CQRS |
| FluentValidation | FluentValidation.DependencyInjectionExtensions | Validation |
| Mapster | Mapster.DependencyInjection | Mapping |
| ErrorOr | ErrorOr | Result pattern |
| Polly | Microsoft.Extensions.Http.Resilience | Resilience |
| Serilog | Serilog.AspNetCore | Logging |
See libraries.md for usage examples.
Anti-Patterns to Avoid
Quick Reference: What to Use Instead
| ❌ Anti-Pattern | ✅ Use Instead |
|---|---|
new HttpClient() | Inject HttpClient or IHttpClientFactory |
Results.Ok() | TypedResults.Ok() |
| Manual Polly config | AddStandardResilienceHandler() |
DateTime.Now | DateTime.UtcNow |
GetAsync().Result | await GetAsync() |
| Exceptions for flow control | ErrorOr<T> / Result pattern |
| Manual backing fields | C# 14 field keyword |
| Traditional extension methods | C# 14 extension blocks |
if (x != null) x.Prop = y | x?.Prop = y (null-conditional assignment) |
Missing ValidateOnStart() | Always add .ValidateOnStart() |
| Singleton → Scoped injection | Use IServiceScopeFactory |
_count++ in singleton | Interlocked.Increment(ref _count) |
---
HttpClient Misuse
// WRONG: Creates socket exhaustion
public async Task Bad()
{
using var client = new HttpClient(); // Don't instantiate per-request
await client.GetAsync("...");
}
// CORRECT: Inject IHttpClientFactory or typed client
public class MyService(HttpClient client)
{
public Task<string> GetAsync() => client.GetStringAsync("...");
}
// CORRECT: Named client
public class MyService(IHttpClientFactory factory)
{
public async Task<string> GetAsync()
{
var client = factory.CreateClient("ExternalApi");
return await client.GetStringAsync("...");
}
}---
DI Captive Dependencies
// WRONG: Singleton captures scoped service (memory leak)
public class SingletonService(IScopedService scoped) { }
// CORRECT: Inject IServiceScopeFactory
public class SingletonService(IServiceScopeFactory factory)
{
public async Task DoWork()
{
await using var scope = factory.CreateAsyncScope();
var scoped = scope.ServiceProvider.GetRequiredService<IScopedService>();
// Use scoped service
}
}
// CORRECT: Use IServiceProvider for optional resolution
public class SingletonService(IServiceProvider sp)
{
public async Task DoWork()
{
await using var scope = sp.CreateAsyncScope();
var scoped = scope.ServiceProvider.GetRequiredService<IScopedService>();
}
}---
Exceptions as Flow Control
// WRONG: Expensive, hard to trace
public async Task<IResult> GetUser(int id)
{
try
{
var user = await _service.GetUserAsync(id);
return TypedResults.Ok(user);
}
catch (UserNotFoundException)
{
return TypedResults.NotFound();
}
}
// CORRECT: Result pattern with ErrorOr
public async Task<IResult> GetUser(int id)
{
var result = await _service.GetUserAsync(id);
return result.Match(
user => TypedResults.Ok(user),
errors => TypedResults.NotFound()
);
}
// CORRECT: Nullable return
public async Task<IResult> GetUser(int id)
{
var user = await _service.GetUserAsync(id);
return user is not null
? TypedResults.Ok(user)
: TypedResults.NotFound();
}---
Blocking Async
// WRONG: Deadlock risk
var result = GetAsync().Result;
var result2 = GetAsync().GetAwaiter().GetResult();
GetAsync().Wait();
// CORRECT: Async all the way
var result = await GetAsync();
// If you MUST block (rare), use this pattern
var result = Task.Run(() => GetAsync()).GetAwaiter().GetResult();---
N+1 Queries
// WRONG: N+1 query problem
var orders = await db.Orders.ToListAsync();
foreach (var order in orders)
{
var items = await db.OrderItems.Where(i => i.OrderId == order.Id).ToListAsync();
}
// CORRECT: Include related data
var orders = await db.Orders
.Include(o => o.Items)
.ToListAsync();
// CORRECT: Explicit loading when needed
var order = await db.Orders.FindAsync(id);
await db.Entry(order).Collection(o => o.Items).LoadAsync();---
Async Void
// WRONG: Exceptions can't be caught
public async void HandleEvent(object sender, EventArgs e)
{
await ProcessAsync(); // If this throws, app may crash
}
// CORRECT: Return Task
public async Task HandleEventAsync(CancellationToken ct)
{
await ProcessAsync(ct);
}
// If you must use event handlers, wrap in try-catch
public async void HandleEvent(object sender, EventArgs e)
{
try
{
await ProcessAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Event handler failed");
}
}---
Mutable Shared State
// WRONG: Race condition in singleton
public class CounterService
{
private int _count;
public void Increment() => _count++; // Not thread-safe
}
// CORRECT: Use Interlocked
public class CounterService
{
private int _count;
public void Increment() => Interlocked.Increment(ref _count);
}
// CORRECT: Use ConcurrentDictionary for collections
public class CacheService
{
private readonly ConcurrentDictionary<string, object> _cache = new();
}---
Swallowing Exceptions
// WRONG: Silent failures
try
{
await ProcessAsync();
}
catch { } // Never do this
// WRONG: Logging but not handling
try
{
await ProcessAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed");
// Continues as if nothing happened
}
// CORRECT: Log and rethrow or handle appropriately
try
{
await ProcessAsync();
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Processing failed for {ItemId}", item.Id);
throw; // Or return error result
}---
String Concatenation in Loops
// WRONG: O(n²) allocations
var result = "";
foreach (var item in items)
{
result += item.ToString();
}
// CORRECT: StringBuilder
var sb = new StringBuilder();
foreach (var item in items)
{
sb.Append(item);
}
var result = sb.ToString();
// CORRECT: String.Join
var result = string.Join("", items);---
Disposing IDisposable Incorrectly
// WRONG: May not dispose on exception
var stream = new FileStream(path, FileMode.Open);
// ... use stream
stream.Dispose();
// CORRECT: using statement
using var stream = new FileStream(path, FileMode.Open);
// ... use stream (disposed automatically)
// CORRECT: using block
using (var stream = new FileStream(path, FileMode.Open))
{
// ... use stream
}---
Exposing Internal Implementation
// WRONG: Exposes internal list
public class OrderService
{
private readonly List<Order> _orders = new();
public List<Order> GetOrders() => _orders; // Caller can modify!
}
// CORRECT: Return IReadOnlyList or copy
public class OrderService
{
private readonly List<Order> _orders = new();
public IReadOnlyList<Order> GetOrders() => _orders.AsReadOnly();
}---
DateTime.Now vs UTC
// WRONG: Local time causes issues across timezones
var timestamp = DateTime.Now;
// CORRECT: Always use UTC for storage/comparison
var timestamp = DateTime.UtcNow;
// CORRECT: Use DateTimeOffset for timezone-aware scenarios
var timestamp = DateTimeOffset.UtcNow;C# 14 Language Features
MANDATORY: Use C# 14 Syntax for New Code
When writing new code, ALWAYS use C# 14 syntax. Do not fall back to older patterns.
---
Extension Members (ALWAYS Use Extension Blocks)
MANDATORY: Use C# 14 extension block syntax for all new extension members.
C# 14 supports extension properties, operators, and static members via extension blocks:
❌ WRONG (Traditional - Do Not Use for New Code):
public static class EnumerableExtensions
{
public static bool IsEmpty<T>(this IEnumerable<T> source)
=> !source.Any();
public static T SafeElementAt<T>(this IEnumerable<T> source, int index)
=> source.Skip(index).FirstOrDefault();
}✅ CORRECT (C# 14 Extension Block):
public static class EnumerableExtensions
{
extension<T>(IEnumerable<T> source)
{
// Extension property
public bool IsEmpty => !source.Any();
// Extension indexer
public T this[int index] => source.Skip(index).First();
// Extension method
public IEnumerable<T> Filter(Func<T, bool> predicate)
=> source.Where(predicate);
}
// Static extension members (no parameter name)
extension<T>(IEnumerable<T>)
{
public static IEnumerable<T> Empty => Enumerable.Empty<T>();
}
}
// Usage
var items = new[] { 1, 2, 3 };
if (!items.IsEmpty) Console.WriteLine(items[0]);Why extension blocks are better:
- Enable extension properties (not just methods)
- Enable extension indexers
- Enable static extension members
- Cleaner, more expressive syntax
- Requires
<LangVersion>14</LangVersion>in .csproj
---
The field Keyword (ALWAYS Use for Property Validation)
MANDATORY: Use the `field` keyword instead of manual backing fields when adding validation to properties.
❌ WRONG (Manual Backing Field - Verbose):
public class Person
{
private string _name = "";
private int _age;
public string Name
{
get => _name;
set => _name = value?.Trim() ?? throw new ArgumentNullException(nameof(value));
}
public int Age
{
get => _age;
set => _age = value >= 0 ? value : throw new ArgumentOutOfRangeException(nameof(value));
}
}✅ CORRECT (C# 14 field Keyword):
public class Person
{
public string Name
{
get => field;
set => field = value?.Trim() ?? throw new ArgumentNullException();
}
// With validation and transformation
public int Age
{
get => field;
set => field = value >= 0 ? value : throw new ArgumentOutOfRangeException();
}
}Why `field` is better:
- Less boilerplate (no manual backing field declaration)
- Compiler manages the backing field
- Cleaner, more maintainable code
- Works with
initandrequiredmodifiers
---
Null-Conditional Assignment (ALWAYS Use)
MANDATORY: Replace null checks followed by assignment with `?.=` syntax.
❌ WRONG (Verbose null check):
if (customer != null)
{
customer.LastVisit = DateTime.UtcNow;
}
if (list != null && list.Count > 0)
{
list[0] = newValue;
}✅ CORRECT (C# 14 Null-Conditional Assignment):
// Direct assignment
customer?.LastVisit = DateTime.UtcNow; // NOTE: Always use UtcNow, not Now
// Works with indexers
list?[0] = newValue;
// Compound assignment
customer?.Points += 100;
customer?.Orders.Add(newOrder);IMPORTANT: Always use DateTime.UtcNow, never DateTime.Now. See anti-patterns.md.
Other C# 14 Features
| Feature | Example | Use Case |
|---|---|---|
nameof unbound generics | nameof(List<>) → "List" | Logging, reflection |
| Lambda parameter modifiers | (ref int x) => x++ | High-perf lambdas |
| Partial constructors/events | Split across files | Code generation |
| First-class Span support | Implicit Span↔T[] | Memory-efficient APIs |
nameof with Unbound Generics
// Before C# 14: nameof(List<int>) → "List"
// C# 14: nameof(List<>) → "List"
public class Repository<T>
{
private readonly string _typeName = nameof(T); // Works in generic context
}Lambda Parameter Modifiers
// No need to specify types when using modifiers
Span<int> span = stackalloc int[10];
span.Sort((ref int a, ref int b) => a.CompareTo(b));
// in, out, scoped also work
ProcessItems((in ReadOnlySpan<byte> data) => data.Length);Partial Constructors
// File1.cs
public partial class Widget
{
public partial Widget(string name);
}
// File2.cs (generated)
public partial class Widget
{
public partial Widget(string name)
{
Name = name;
Initialize();
}
}Project Configuration
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>Infrastructure Patterns
Options Pattern & Validation (CRITICAL)
MANDATORY: All configuration classes MUST use ValidateOnStart() to fail fast on invalid configuration.
IOptions Interface Selection
digraph {
"Runtime changes?" [shape=diamond];
"Per-request scope?" [shape=diamond];
"IOptions<T>" [shape=box, style=filled, fillcolor=lightblue];
"IOptionsSnapshot<T>" [shape=box, style=filled, fillcolor=lightgreen];
"IOptionsMonitor<T>" [shape=box, style=filled, fillcolor=lightyellow];
"Runtime changes?" -> "IOptions<T>" [label="no"];
"Runtime changes?" -> "Per-request scope?" [label="yes"];
"Per-request scope?" -> "IOptionsSnapshot<T>" [label="yes"];
"Per-request scope?" -> "IOptionsMonitor<T>" [label="no"];
}| Interface | Lifetime | Reload | Use When |
|---|---|---|---|
IOptions<T> | Singleton | No | Config won't change at runtime (most common) |
IOptionsSnapshot<T> | Scoped | Per-request | Need consistent config within request + reload between requests |
IOptionsMonitor<T> | Singleton | Real-time | Need live config changes + OnChange() notifications |
Default choice: Use IOptions<T> unless you specifically need runtime reload.
MANDATORY: ValidateOnStart Pattern
ALWAYS use `ValidateOnStart()` to fail immediately on invalid configuration.
❌ WRONG (Fails at first use - hard to debug):
builder.Services.AddOptions<DatabaseOptions>()
.BindConfiguration("Database");
// App starts, then crashes when first request uses invalid config✅ CORRECT (Fails immediately at startup):
public class DatabaseOptions
{
public const string Section = "Database";
[Required(ErrorMessage = "ConnectionString is required")]
public required string ConnectionString { get; init; }
[Range(1, 100, ErrorMessage = "MaxPoolSize must be between 1 and 100")]
public int MaxPoolSize { get; init; } = 10;
[Range(1, 300, ErrorMessage = "CommandTimeout must be between 1 and 300 seconds")]
public int CommandTimeout { get; init; } = 30;
}
// Registration - ALWAYS include all three methods
builder.Services.AddOptions<DatabaseOptions>()
.BindConfiguration(DatabaseOptions.Section)
.ValidateDataAnnotations() // Validates [Required], [Range], etc.
.ValidateOnStart(); // CRITICAL: Fails at startup, not at first useComplete Options Setup Pattern
// 1. Define options class with C# 14 features
public class JwtOptions
{
public const string Section = "Jwt";
[Required]
public required string Authority { get; init; }
[Required]
public required string Audience { get; init; }
[Required, StringLength(256, MinimumLength = 32)]
public required string SecretKey { get; init; }
[Range(1, 1440)]
public int TokenLifetimeMinutes { get; init; } = 60;
[Range(1, 43200)]
public int RefreshTokenLifetimeMinutes { get; init; } = 10080;
}
// 2. Register with validation
builder.Services.AddOptions<JwtOptions>()
.BindConfiguration(JwtOptions.Section)
.ValidateDataAnnotations()
.ValidateOnStart();
// 3. Use in services (prefer IOptions for startup config)
public class TokenService(IOptions<JwtOptions> options)
{
private readonly JwtOptions _config = options.Value;
public string GenerateToken(User user) { /* use _config */ }
}IValidateOptions for Complex Validation
Use when validation requires:
- Cross-property validation
- Async validation (database/API calls)
- Conditional validation rules
- Custom error messages with context
public class DatabaseOptionsValidator : IValidateOptions<DatabaseOptions>
{
public ValidateOptionsResult Validate(string? name, DatabaseOptions options)
{
var errors = new List<string>();
// Required field check
if (string.IsNullOrWhiteSpace(options.ConnectionString))
errors.Add("ConnectionString is required");
// Format validation
else if (!options.ConnectionString.Contains("Server=", StringComparison.OrdinalIgnoreCase))
errors.Add("ConnectionString must contain 'Server=' component");
// Cross-property validation
if (options.MaxPoolSize > 50 && options.CommandTimeout < 60)
errors.Add("High pool size requires CommandTimeout >= 60 seconds");
// Environment-specific validation
if (options.ConnectionString.Contains("localhost") &&
Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Production")
errors.Add("Cannot use localhost connection string in Production");
return errors.Count > 0
? ValidateOptionsResult.Fail(errors)
: ValidateOptionsResult.Success;
}
}
// Register the validator
builder.Services.AddSingleton<IValidateOptions<DatabaseOptions>, DatabaseOptionsValidator>();Named Options (Multiple Configurations)
// Register multiple named configurations
builder.Services.AddOptions<DatabaseOptions>("Primary")
.BindConfiguration("Database:Primary")
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services.AddOptions<DatabaseOptions>("Replica")
.BindConfiguration("Database:Replica")
.ValidateDataAnnotations()
.ValidateOnStart();
// Access named options
public class DataService(IOptionsSnapshot<DatabaseOptions> options)
{
public void UseReplica()
{
var replicaConfig = options.Get("Replica");
}
}Options Validation Checklist
- [ ] Options class has
const string Sectionfor config path - [ ] All required properties use
[Required]attribute - [ ] All required properties use
requiredmodifier (C# 14) - [ ] Numeric properties have
[Range]constraints - [ ] String properties have
[StringLength]where appropriate - [ ] Registration uses
.ValidateDataAnnotations() - [ ] Registration uses
.ValidateOnStart()(CRITICAL) - [ ] Complex validation uses
IValidateOptions<T> - [ ] Error messages are descriptive and actionable
---
HTTP Resilience (ALWAYS Use Built-in Handler)
MANDATORY: Use `AddStandardResilienceHandler()` instead of manual Polly configuration.
Why Built-in Over Manual Polly
❌ WRONG (Manual Polly - Verbose, Error-Prone):
// DON'T DO THIS - 30+ lines of error-prone configuration
builder.Services.AddHttpClient<IPaymentApi, PaymentClient>()
.AddPolicyHandler(Policy
.HandleResult<HttpResponseMessage>(r => !r.IsSuccessStatusCode)
.WaitAndRetryAsync(3, i => TimeSpan.FromSeconds(Math.Pow(2, i))))
.AddPolicyHandler(Policy
.HandleResult<HttpResponseMessage>(r => r.StatusCode == HttpStatusCode.ServiceUnavailable)
.CircuitBreakerAsync(5, TimeSpan.FromSeconds(30)))
.AddPolicyHandler(Policy.TimeoutAsync<HttpResponseMessage>(10));✅ CORRECT (.NET 10 Built-in - One Line):
builder.Services.AddHttpClient<IPaymentApi, PaymentClient>()
.AddStandardResilienceHandler(); // Includes ALL 5 strategies!Standard Handler Components (All Included Automatically)
| Strategy | Purpose | Default |
|---|---|---|
| Rate Limiter | Limit concurrent requests | 1000 concurrent |
| Total Timeout | Overall timeout including retries | 30 seconds |
| Retry | Retry on transient failures (429, 5xx) | 3 attempts, exponential backoff |
| Circuit Breaker | Stop calling unhealthy dependencies | Opens after 10% failures |
| Attempt Timeout | Timeout for single attempt | 2 seconds |
Customize Only When Needed
builder.Services.AddHttpClient<IPaymentGateway, PaymentClient>()
.AddStandardResilienceHandler(options =>
{
// Only override what you need
options.Retry.MaxRetryAttempts = 5;
options.Retry.Delay = TimeSpan.FromMilliseconds(500);
options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(60);
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(10);
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(120);
});Hedging for Latency-Critical Services
// Send parallel requests, use first successful response
builder.Services.AddHttpClient<ISearchService, SearchClient>()
.AddStandardHedgingHandler(options =>
{
options.Hedging.MaxHedgedAttempts = 2;
options.Hedging.Delay = TimeSpan.FromMilliseconds(200);
});Complete Typed HttpClient Example
// 1. Define interface and client
public interface IPaymentApi
{
Task<PaymentResult> ProcessAsync(PaymentRequest request, CancellationToken ct);
}
public class PaymentApiClient(HttpClient http) : IPaymentApi
{
public async Task<PaymentResult> ProcessAsync(PaymentRequest request, CancellationToken ct)
{
var response = await http.PostAsJsonAsync("/payments", request, ct);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<PaymentResult>(ct)
?? throw new InvalidOperationException("Invalid response");
}
}
// 2. Register with resilience (Program.cs)
builder.Services.AddHttpClient<IPaymentApi, PaymentApiClient>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["PaymentApi:BaseUrl"]!);
client.DefaultRequestHeaders.Add("X-Api-Key", builder.Configuration["PaymentApi:Key"]!);
})
.AddStandardResilienceHandler();---
Channels (Producer/Consumer)
Bounded Channel (Preferred)
// Create with backpressure
var channel = Channel.CreateBounded<WorkItem>(new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.Wait, // Backpressure
SingleWriter = false,
SingleReader = true
});
// Register in DI
builder.Services.AddSingleton(channel);
builder.Services.AddSingleton(sp => sp.GetRequiredService<Channel<WorkItem>>().Reader);
builder.Services.AddSingleton(sp => sp.GetRequiredService<Channel<WorkItem>>().Writer);
// Producer (API endpoint)
app.MapPost("/work", async (WorkItem item, ChannelWriter<WorkItem> writer) =>
{
await writer.WriteAsync(item);
return TypedResults.Accepted();
});
// Consumer (background service)
public class WorkProcessor(ChannelReader<WorkItem> reader, ILogger<WorkProcessor> logger)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
await foreach (var item in reader.ReadAllAsync(ct))
{
try
{
await ProcessAsync(item);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to process {ItemId}", item.Id);
}
}
}
}FullMode Options
| Mode | Behavior |
|---|---|
Wait | Producer waits (backpressure) - recommended |
DropNewest | Drop incoming item |
DropOldest | Drop oldest in queue |
DropWrite | Silently fail write |
---
Health Checks
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy(), tags: new[] { "live" })
.AddDbContextCheck<AppDbContext>(tags: new[] { "ready" })
.AddRedis(builder.Configuration["Redis:Connection"]!, tags: new[] { "ready" })
.AddUrlGroup(new Uri("https://api.example.com/health"), "external-api", tags: new[] { "ready" });
var app = builder.Build();
// Basic endpoint
app.MapHealthChecks("/health");
// Detailed (internal only)
app.MapHealthChecks("/health/detailed", new HealthCheckOptions
{
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
}).RequireAuthorization("AdminOnly");
// Kubernetes probes
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("live")
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready")
});---
Caching
In-Memory Cache
builder.Services.AddMemoryCache();
public class ProductService(IMemoryCache cache, IProductRepository repo)
{
public async Task<Product?> GetAsync(int id, CancellationToken ct)
{
var key = $"product:{id}";
if (cache.TryGetValue(key, out Product? product))
return product;
product = await repo.GetAsync(id, ct);
if (product is not null)
{
cache.Set(key, product, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5),
SlidingExpiration = TimeSpan.FromMinutes(1)
});
}
return product;
}
}Distributed Cache (Redis)
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration["Redis:Connection"];
options.InstanceName = "MyApp:";
});
public class SessionService(IDistributedCache cache)
{
public async Task<UserSession?> GetSessionAsync(string sessionId, CancellationToken ct)
{
var data = await cache.GetStringAsync($"session:{sessionId}", ct);
return data is null ? null : JsonSerializer.Deserialize<UserSession>(data);
}
public async Task SetSessionAsync(string sessionId, UserSession session, CancellationToken ct)
{
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24),
SlidingExpiration = TimeSpan.FromMinutes(30)
};
await cache.SetStringAsync(
$"session:{sessionId}",
JsonSerializer.Serialize(session),
options,
ct);
}
}Output Caching (.NET 7+)
builder.Services.AddOutputCache(options =>
{
options.AddBasePolicy(builder => builder.Expire(TimeSpan.FromMinutes(5)));
options.AddPolicy("Products", builder => builder
.Expire(TimeSpan.FromMinutes(10))
.Tag("products"));
});
app.UseOutputCache();
app.MapGet("/api/products", GetProducts.Handle)
.CacheOutput("Products");
// Invalidate cache
app.MapPost("/api/products", async (IOutputCacheStore cache, ...) =>
{
// ... create product
await cache.EvictByTagAsync("products", ct);
});---
Structured Logging (Serilog)
// Program.cs
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
.Enrich.FromLogContext()
.Enrich.WithEnvironmentName()
.Enrich.WithMachineName()
.WriteTo.Console(new CompactJsonFormatter())
.WriteTo.Seq("http://localhost:5341")
.CreateLogger();
builder.Host.UseSerilog();
var app = builder.Build();
// Request logging middleware
app.UseSerilogRequestLogging(options =>
{
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("UserId", httpContext.User.FindFirstValue("sub"));
diagnosticContext.Set("ClientIP", httpContext.Connection.RemoteIpAddress);
};
});Structured Logging in Handlers
public static class CreateOrder
{
public static async Task<Results<Created<OrderResponse>, BadRequest>> Handle(
CreateOrderRequest request,
IOrderService service,
ILogger<CreateOrder> logger,
CancellationToken ct)
{
using var _ = logger.BeginScope(new Dictionary<string, object>
{
["CustomerId"] = request.CustomerId,
["OrderItems"] = request.Items.Count
});
logger.LogInformation("Creating order for customer {CustomerId}", request.CustomerId);
var order = await service.CreateAsync(request, ct);
logger.LogInformation("Order {OrderId} created successfully", order.Id);
return TypedResults.Created($"/api/orders/{order.Id}", new OrderResponse(order));
}
}---
EF Core Patterns
DbContext Registration
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("Default"))
.UseSnakeCaseNamingConvention());
// For high-throughput scenarios
builder.Services.AddDbContextPool<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("Default")));Query Patterns
public static async Task<Results<Ok<UserResponse>, NotFound>> GetUser(
int id,
AppDbContext db,
CancellationToken ct)
{
var user = await db.Users
.AsNoTracking() // Read-only, better performance
.Where(u => u.Id == id)
.Select(u => new UserResponse(u.Id, u.Email, u.Name)) // Project to DTO
.FirstOrDefaultAsync(ct);
return user is not null
? TypedResults.Ok(user)
: TypedResults.NotFound();
}Transaction Pattern
public static async Task<Results<Created<OrderResponse>, BadRequest>> CreateOrder(
CreateOrderRequest request,
AppDbContext db,
CancellationToken ct)
{
var strategy = db.Database.CreateExecutionStrategy();
return await strategy.ExecuteAsync(async () =>
{
await using var transaction = await db.Database.BeginTransactionAsync(ct);
try
{
var order = new Order { /* ... */ };
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
// Other operations...
await transaction.CommitAsync(ct);
return TypedResults.Created($"/api/orders/{order.Id}", new OrderResponse(order));
}
catch
{
await transaction.RollbackAsync(ct);
throw;
}
});
}---
Keyed Services (.NET 8+)
// Register multiple implementations with keys
builder.Services.AddKeyedScoped<INotificationService, EmailService>("email");
builder.Services.AddKeyedScoped<INotificationService, SmsService>("sms");
builder.Services.AddKeyedScoped<INotificationService, PushService>("push");
// Inject specific implementation
app.MapPost("/notify/email", async (
[FromKeyedServices("email")] INotificationService notifier,
NotifyRequest request) =>
{
await notifier.SendAsync(request.Message);
return TypedResults.Ok();
});
// Dynamic resolution
app.MapPost("/notify/{channel}", async (
string channel,
IServiceProvider sp,
NotifyRequest request) =>
{
var notifier = sp.GetKeyedService<INotificationService>(channel);
if (notifier is null) return TypedResults.NotFound();
await notifier.SendAsync(request.Message);
return TypedResults.Ok();
});---
Error Handling (RFC 9457)
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = ctx =>
{
ctx.ProblemDetails.Extensions["traceId"] = ctx.HttpContext.TraceIdentifier;
ctx.ProblemDetails.Extensions["requestPath"] = ctx.HttpContext.Request.Path.Value;
};
});
app.UseExceptionHandler();
app.UseStatusCodePages();
// Custom exception mapping
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async ctx =>
{
var exception = ctx.Features.Get<IExceptionHandlerFeature>()?.Error;
var problemDetails = exception switch
{
ValidationException ve => new ProblemDetails
{
Status = 400, Title = "Validation Error", Detail = ve.Message
},
NotFoundException => new ProblemDetails
{
Status = 404, Title = "Not Found"
},
UnauthorizedAccessException => new ProblemDetails
{
Status = 403, Title = "Forbidden"
},
_ => new ProblemDetails
{
Status = 500, Title = "Internal Server Error"
}
};
ctx.Response.StatusCode = problemDetails.Status ?? 500;
await ctx.Response.WriteAsJsonAsync(problemDetails);
});
});Key Libraries Reference
MediatR - CQRS / Mediator Pattern
// Installation
// dotnet add package MediatR
// Registration
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
// Command
public record CreateOrderCommand(int CustomerId, List<OrderItem> Items) : IRequest<OrderResult>;
// Handler
public class CreateOrderHandler(AppDbContext db) : IRequestHandler<CreateOrderCommand, OrderResult>
{
public async Task<OrderResult> Handle(CreateOrderCommand request, CancellationToken ct)
{
var order = new Order { CustomerId = request.CustomerId };
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return new OrderResult(order.Id);
}
}
// Usage in endpoint
app.MapPost("/orders", async (CreateOrderCommand cmd, IMediator mediator) =>
{
var result = await mediator.Send(cmd);
return TypedResults.Created($"/orders/{result.Id}", result);
});
// Pipeline behavior (cross-cutting)
public class LoggingBehavior<TRequest, TResponse>(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
: IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
logger.LogInformation("Handling {RequestName}", typeof(TRequest).Name);
var response = await next();
logger.LogInformation("Handled {RequestName}", typeof(TRequest).Name);
return response;
}
}Docs: MediatR Wiki
---
FluentValidation
// Installation
// dotnet add package FluentValidation.DependencyInjectionExtensions
// Registration
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
// Validator
public class CreateOrderValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderValidator()
{
RuleFor(x => x.CustomerId).GreaterThan(0);
RuleFor(x => x.Items).NotEmpty().WithMessage("Order must have items");
RuleForEach(x => x.Items).SetValidator(new OrderItemValidator());
}
}
// With MediatR pipeline
public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
var context = new ValidationContext<TRequest>(request);
var failures = validators
.Select(v => v.Validate(context))
.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
if (failures.Count != 0)
throw new ValidationException(failures);
return await next();
}
}Docs: FluentValidation Documentation
---
Mapster - Object Mapping
// Installation
// dotnet add package Mapster
// dotnet add package Mapster.DependencyInjection
// Registration
builder.Services.AddMapster();
// Simple mapping
var dto = entity.Adapt<UserDto>();
// With configuration
TypeAdapterConfig<User, UserDto>
.NewConfig()
.Map(dest => dest.FullName, src => $"{src.FirstName} {src.LastName}")
.Ignore(dest => dest.Password);
// Projection in EF Core
var users = await db.Users
.ProjectToType<UserDto>()
.ToListAsync();
// Interface-based (for DI)
public class UserService(IMapper mapper)
{
public UserDto Map(User user) => mapper.Map<UserDto>(user);
}Docs: Mapster Wiki
---
ErrorOr - Result Pattern
// Installation
// dotnet add package ErrorOr
// Define errors
public static class UserErrors
{
public static Error NotFound(int id) => Error.NotFound("User.NotFound", $"User {id} not found");
public static Error DuplicateEmail => Error.Conflict("User.DuplicateEmail", "Email already exists");
}
// Service returning ErrorOr
public async Task<ErrorOr<User>> GetUserAsync(int id)
{
var user = await _db.Users.FindAsync(id);
return user is null
? UserErrors.NotFound(id)
: user;
}
// Endpoint handling
app.MapGet("/users/{id}", async (int id, IUserService svc) =>
{
var result = await svc.GetUserAsync(id);
return result.Match(
user => TypedResults.Ok(user),
errors => errors.First().Type switch
{
ErrorType.NotFound => TypedResults.NotFound(),
ErrorType.Conflict => TypedResults.Conflict(),
ErrorType.Validation => TypedResults.BadRequest(errors),
_ => TypedResults.Problem()
}
);
});
// Chaining operations
public async Task<ErrorOr<OrderConfirmation>> CreateOrderAsync(CreateOrderRequest request)
{
return await ValidateRequest(request)
.ThenAsync(req => CreateOrder(req))
.ThenAsync(order => SendConfirmation(order));
}Docs: ErrorOr README
---
Polly - Resilience
// Installation
// dotnet add package Microsoft.Extensions.Http.Resilience
// Already covered in infrastructure.md, here's advanced usage:
// Custom resilience pipeline
builder.Services.AddResiliencePipeline("custom", builder =>
{
builder
.AddRetry(new RetryStrategyOptions
{
MaxRetryAttempts = 3,
Delay = TimeSpan.FromSeconds(1),
BackoffType = DelayBackoffType.Exponential,
ShouldHandle = new PredicateBuilder().Handle<HttpRequestException>()
})
.AddCircuitBreaker(new CircuitBreakerStrategyOptions
{
FailureRatio = 0.5,
SamplingDuration = TimeSpan.FromSeconds(10),
MinimumThroughput = 10,
BreakDuration = TimeSpan.FromSeconds(30)
})
.AddTimeout(TimeSpan.FromSeconds(10));
});
// Usage
public class MyService(ResiliencePipelineProvider<string> pipelineProvider)
{
public async Task<string> GetDataAsync()
{
var pipeline = pipelineProvider.GetPipeline("custom");
return await pipeline.ExecuteAsync(async ct =>
{
// Your operation here
return await FetchDataAsync(ct);
});
}
}Docs: Polly Documentation
---
Serilog - Structured Logging
// Installation
// dotnet add package Serilog.AspNetCore
// dotnet add package Serilog.Sinks.Console
// dotnet add package Serilog.Sinks.Seq
// Configuration
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
.Enrich.FromLogContext()
.Enrich.WithEnvironmentName()
.Enrich.WithCorrelationId()
.WriteTo.Console(new CompactJsonFormatter())
.WriteTo.Seq("http://localhost:5341")
.CreateLogger();
builder.Host.UseSerilog();
// Request logging
app.UseSerilogRequestLogging(options =>
{
options.EnrichDiagnosticContext = (ctx, httpCtx) =>
{
ctx.Set("UserId", httpCtx.User.FindFirstValue("sub"));
};
});
// Structured logging
logger.LogInformation("Order {OrderId} created for {CustomerId} with {ItemCount} items",
order.Id, order.CustomerId, order.Items.Count);Docs: Serilog Wiki
---
.NET Aspire - Cloud-Native Orchestration
// AppHost/Program.cs
var builder = DistributedApplication.CreateBuilder(args);
var postgres = builder.AddPostgres("postgres")
.AddDatabase("mydb");
var redis = builder.AddRedis("redis");
var api = builder.AddProject<Projects.MyApi>("api")
.WithReference(postgres)
.WithReference(redis);
builder.AddProject<Projects.MyFrontend>("frontend")
.WithReference(api);
builder.Build().Run();
// In API project
builder.AddServiceDefaults(); // Adds health checks, telemetry, etc.
var postgres = builder.AddNpgsqlDataSource("mydb");
var redis = builder.AddRedisClient("redis");Docs: .NET Aspire Documentation
---
Quick Reference Table
| Library | Purpose | NuGet Package |
|---|---|---|
| MediatR | CQRS, mediator | MediatR |
| FluentValidation | Validation rules | FluentValidation.DependencyInjectionExtensions |
| Mapster | Object mapping | Mapster.DependencyInjection |
| ErrorOr | Result pattern | ErrorOr |
| Polly | Resilience | Microsoft.Extensions.Http.Resilience |
| Serilog | Structured logging | Serilog.AspNetCore |
| Aspire | Cloud orchestration | Aspire.Hosting |
| Refit | Type-safe HTTP | Refit.HttpClientFactory |
| Bogus | Fake data | Bogus |
| Humanizer | String manipulation | Humanizer |
Minimal API Patterns
Built-in Validation (.NET 10) - PREFERRED
PREFER .NET 10's built-in validation over FluentValidation for simple cases.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddValidation(); // One line enables automatic validation!
var app = builder.Build();
// DTO with DataAnnotations
public record CreateUserDto(
[Required, EmailAddress] string Email,
[Required, StringLength(100, MinimumLength = 2)] string Name,
[Range(0, 150)] int? Age
);
// Endpoint - validation happens automatically
app.MapPost("/users", (CreateUserDto user) => TypedResults.Ok(user));
// Returns 400 with validation errors automatically
// Opt out for internal endpoints
app.MapPost("/internal", (InternalDto dto) => TypedResults.Ok())
.DisableValidation();When to use FluentValidation instead:
- Complex cross-property validation
- Async validation (database uniqueness checks)
- Conditional validation rules
- Custom error message formatting
---
TypedResults (MANDATORY - Not Optional)
NEVER use `Results.Ok()`. ALWAYS use `TypedResults.Ok()`.
The difference is critical for OpenAPI documentation:
❌ WRONG (No OpenAPI metadata):
app.MapGet("/users/{id}", async (int id, IUserService svc) =>
{
var user = await svc.GetAsync(id);
return user is not null ? Results.Ok(user) : Results.NotFound();
});
// OpenAPI doesn't know the response type!✅ CORRECT (Full OpenAPI metadata):
app.MapGet("/users/{id}", async (int id, IUserService svc) =>
{
var user = await svc.GetAsync(id);
return user is not null
? TypedResults.Ok(user)
: TypedResults.NotFound();
})
.WithName("GetUser")
.WithOpenApi();
// OpenAPI correctly shows UserDto as 200 responseMultiple Return Types
// Explicit return type documents ALL possible responses
app.MapPost("/users", async Task<Results<Created<UserResponse>, ValidationProblem, Conflict>>
(CreateUserDto dto, IUserService svc, CancellationToken ct) =>
{
if (await svc.EmailExistsAsync(dto.Email, ct))
return TypedResults.Conflict();
var user = await svc.CreateAsync(dto, ct);
return TypedResults.Created($"/api/users/{user.Id}", new UserResponse(user));
});TypedResults Quick Reference
| Method | Status | Use When |
|---|---|---|
TypedResults.Ok(data) | 200 | Successful GET/PUT |
TypedResults.Created(uri, data) | 201 | Successful POST |
TypedResults.Accepted() | 202 | Async processing started |
TypedResults.NoContent() | 204 | Successful DELETE |
TypedResults.NotFound() | 404 | Resource not found |
TypedResults.BadRequest() | 400 | Invalid request |
TypedResults.Conflict() | 409 | Resource conflict |
TypedResults.ValidationProblem(errors) | 400 | Validation failed |
TypedResults.Problem() | 500 | Server error |
Endpoint Filters
// Reusable validation filter
public class ValidationFilter<T> : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext ctx,
EndpointFilterDelegate next)
{
var validator = ctx.HttpContext.RequestServices
.GetService<IValidator<T>>();
if (validator is null) return await next(ctx);
var arg = ctx.Arguments.OfType<T>().FirstOrDefault();
if (arg is null) return await next(ctx);
var result = await validator.ValidateAsync(arg);
return result.IsValid
? await next(ctx)
: TypedResults.ValidationProblem(result.ToDictionary());
}
}
app.MapPost("/orders", Handler)
.AddEndpointFilter<ValidationFilter<CreateOrderDto>>();Server-Sent Events (.NET 10)
app.MapGet("/events", (CancellationToken ct) =>
{
async IAsyncEnumerable<SseEvent> GenerateEvents()
{
while (!ct.IsCancellationRequested)
{
yield return new SseEvent { Data = DateTime.Now.ToString() };
await Task.Delay(1000, ct);
}
}
return TypedResults.ServerSentEvents(GenerateEvents());
});---
Modular Monolith / Feature Folders
Structure
src/
├── Features/
│ ├── Users/
│ │ ├── UsersModule.cs # DI + endpoints registration
│ │ ├── Endpoints/
│ │ │ ├── CreateUser.cs # Handler + Request + Response
│ │ │ └── GetUser.cs
│ │ ├── Services/
│ │ └── Data/
│ ├── Orders/
│ │ └── OrdersModule.cs
│ └── Shared/ # Cross-cutting concerns only
├── Program.csModule Registration Pattern
// Features/Users/UsersModule.cs
public static class UsersModule
{
public static IServiceCollection AddUsersModule(this IServiceCollection services)
{
services.AddScoped<IUserService, UserService>();
services.AddScoped<IUserRepository, UserRepository>();
return services;
}
public static IEndpointRouteBuilder MapUsersEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/users")
.WithTags("Users")
.RequireAuthorization();
group.MapGet("/{id}", GetUser.Handle);
group.MapPost("/", CreateUser.Handle);
return app;
}
}
// Program.cs
builder.Services
.AddUsersModule()
.AddOrdersModule();
app.MapUsersEndpoints()
.MapOrdersEndpoints();Vertical Slice Handler
// Features/Users/Endpoints/CreateUser.cs
public static class CreateUser
{
public record Request(string Email, string Name);
public record Response(Guid Id, string Email);
public static async Task<Results<Created<Response>, ValidationProblem>> Handle(
Request request,
IUserService service,
CancellationToken ct)
{
var user = await service.CreateAsync(request, ct);
return TypedResults.Created($"/api/users/{user.Id}",
new Response(user.Id, user.Email));
}
}Module Communication Rules
- Modules communicate via public APIs or events only
- Never access another module's database directly
- Shared kernel for cross-cutting only (not business logic)
- Use MassTransit/MediatR for inter-module events
// Good: Public API call
var user = await _usersApi.GetUserAsync(userId);
// Good: Event-based
await _mediator.Publish(new OrderCreatedEvent(order.Id));
// BAD: Direct DB access to another module
var user = await _otherModuleDbContext.Users.FindAsync(userId);Security Patterns
Authentication - JWT Bearer
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://your-identity-provider.com";
options.Audience = "your-api-audience";
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(5)
};
});
builder.Services.AddAuthorizationBuilder()
.AddPolicy("AdminOnly", policy => policy.RequireRole("Admin"))
.AddPolicy("CanReadOrders", policy => policy.RequireClaim("scope", "orders:read"))
.AddPolicy("PremiumUser", policy => policy
.RequireAuthenticatedUser()
.RequireClaim("subscription", "premium"));Securing Endpoints
// Require authentication for entire group
var group = app.MapGroup("/api/orders")
.RequireAuthorization();
// Specific policy
group.MapDelete("/{id}", DeleteOrder.Handle)
.RequireAuthorization("AdminOnly");
// Allow anonymous for specific endpoint
app.MapGet("/api/health", () => "OK")
.AllowAnonymous();
// Multiple policies (all must pass)
app.MapPost("/api/admin/settings", UpdateSettings.Handle)
.RequireAuthorization("AdminOnly", "PremiumUser");---
CORS Configuration
builder.Services.AddCors(options =>
{
// Named policy for specific origins
options.AddPolicy("AllowFrontend", policy =>
{
policy.WithOrigins("https://app.example.com", "https://admin.example.com")
.WithMethods("GET", "POST", "PUT", "DELETE")
.WithHeaders("Content-Type", "Authorization")
.AllowCredentials()
.SetPreflightMaxAge(TimeSpan.FromMinutes(10));
});
// Development policy
options.AddPolicy("Development", policy =>
{
policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
var app = builder.Build();
// Apply globally
app.UseCors("AllowFrontend");
// Or per-endpoint
app.MapGet("/api/public", GetPublicData)
.RequireCors("AllowFrontend");---
Rate Limiting
builder.Services.AddRateLimiter(options =>
{
// Fixed window policy
options.AddFixedWindowLimiter("fixed", opt =>
{
opt.Window = TimeSpan.FromMinutes(1);
opt.PermitLimit = 100;
opt.QueueLimit = 10;
});
// Sliding window per user
options.AddSlidingWindowLimiter("perUser", opt =>
{
opt.Window = TimeSpan.FromMinutes(1);
opt.SegmentsPerWindow = 6;
opt.PermitLimit = 60;
});
// Token bucket for API
options.AddTokenBucketLimiter("api", opt =>
{
opt.TokenLimit = 100;
opt.ReplenishmentPeriod = TimeSpan.FromSeconds(10);
opt.TokensPerPeriod = 10;
});
// Concurrency limiter
options.AddConcurrencyLimiter("concurrent", opt =>
{
opt.PermitLimit = 10;
opt.QueueLimit = 5;
});
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
// Custom response
options.OnRejected = async (context, ct) =>
{
context.HttpContext.Response.StatusCode = 429;
await context.HttpContext.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = 429,
Title = "Too Many Requests",
Detail = "Rate limit exceeded. Try again later."
}, ct);
};
});
app.UseRateLimiter();
// Apply to endpoints
app.MapGet("/api/search", Search.Handle)
.RequireRateLimiting("perUser");
// Apply to group
app.MapGroup("/api/public")
.RequireRateLimiting("fixed");---
OpenAPI Security Documentation
builder.Services.AddOpenApi(options =>
{
options.AddDocumentTransformer((document, context, ct) =>
{
// Add security scheme
document.Components ??= new OpenApiComponents();
document.Components.SecuritySchemes = new Dictionary<string, OpenApiSecurityScheme>
{
["Bearer"] = new OpenApiSecurityScheme
{
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
Description = "JWT Authorization header using the Bearer scheme."
},
["ApiKey"] = new OpenApiSecurityScheme
{
Type = SecuritySchemeType.ApiKey,
In = ParameterLocation.Header,
Name = "X-API-Key",
Description = "API Key authentication"
}
};
// Apply security globally
document.SecurityRequirements.Add(new OpenApiSecurityRequirement
{
[new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
}] = Array.Empty<string>()
});
return Task.CompletedTask;
});
});
// Per-endpoint security documentation
app.MapGet("/api/admin/users", GetUsers.Handle)
.WithOpenApi(op =>
{
op.Security = new List<OpenApiSecurityRequirement>
{
new()
{
[new OpenApiSecurityScheme
{
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" }
}] = new[] { "admin:read" }
}
};
return op;
});---
Middleware Pipeline (Order Matters!)
var app = builder.Build();
// 1. Exception handling (catch everything downstream)
app.UseExceptionHandler();
app.UseStatusCodePages();
// 2. HTTPS redirection
app.UseHttpsRedirection();
// 3. Static files (if any)
app.UseStaticFiles();
// 4. Routing (before auth)
app.UseRouting();
// 5. CORS (after routing, before auth)
app.UseCors();
// 6. Rate limiting
app.UseRateLimiter();
// 7. Authentication (identify user)
app.UseAuthentication();
// 8. Authorization (check permissions)
app.UseAuthorization();
// 9. Custom middleware
app.UseRequestLogging();
// 10. Endpoints
app.MapEndpoints();Critical rules:
UseExceptionHandler→ firstUseAuthentication→ beforeUseAuthorizationUseRouting→ before auth middlewareUseCors→ before auth but after routing
Testing Minimal APIs
WebApplicationFactory Setup
public class ApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
private readonly WebApplicationFactory<Program> _factory;
public ApiTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
_client = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Replace real services with mocks
services.AddScoped<IUserService, MockUserService>();
// Replace database
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase("TestDb"));
});
builder.ConfigureTestServices(services =>
{
// Configure test-specific services
});
}).CreateClient();
}
[Fact]
public async Task CreateUser_ReturnsCreated()
{
var request = new { Email = "test@example.com", Name = "Test" };
var response = await _client.PostAsJsonAsync("/api/users", request);
response.StatusCode.Should().Be(HttpStatusCode.Created);
var user = await response.Content.ReadFromJsonAsync<UserResponse>();
user!.Email.Should().Be("test@example.com");
}
[Fact]
public async Task CreateUser_InvalidEmail_Returns400()
{
var request = new { Email = "", Name = "Test" };
var response = await _client.PostAsJsonAsync("/api/users", request);
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
problem!.Errors.Should().ContainKey("Email");
}
}Testing with Authentication
public class AuthenticatedApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public AuthenticatedApiTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
// Replace auth with test scheme
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", null);
});
});
}
private HttpClient CreateAuthenticatedClient(string userId, params string[] roles)
{
return _factory.CreateClient();
// TestAuthHandler reads claims from custom header
}
[Fact]
public async Task AdminEndpoint_WithAdminRole_ReturnsOk()
{
var client = CreateAuthenticatedClient("user-1", "Admin");
client.DefaultRequestHeaders.Add("X-Test-UserId", "user-1");
client.DefaultRequestHeaders.Add("X-Test-Roles", "Admin");
var response = await client.GetAsync("/api/admin/settings");
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
[Fact]
public async Task AdminEndpoint_WithoutRole_Returns403()
{
var client = CreateAuthenticatedClient("user-1");
client.DefaultRequestHeaders.Add("X-Test-UserId", "user-1");
var response = await client.GetAsync("/api/admin/settings");
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}
}
// Test auth handler
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder) { }
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var userId = Request.Headers["X-Test-UserId"].FirstOrDefault();
if (string.IsNullOrEmpty(userId))
return Task.FromResult(AuthenticateResult.NoResult());
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, userId) };
var roles = Request.Headers["X-Test-Roles"].FirstOrDefault()?.Split(',') ?? [];
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r.Trim())));
var identity = new ClaimsIdentity(claims, "Test");
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, "Test");
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}Testing Database Operations
public class DatabaseTests : IClassFixture<WebApplicationFactory<Program>>, IAsyncLifetime
{
private readonly WebApplicationFactory<Program> _factory;
private AsyncServiceScope _scope;
private AppDbContext _db = null!;
public DatabaseTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase($"TestDb-{Guid.NewGuid()}"));
});
});
}
public async Task InitializeAsync()
{
_scope = _factory.Services.CreateAsyncScope();
_db = _scope.ServiceProvider.GetRequiredService<AppDbContext>();
await _db.Database.EnsureCreatedAsync();
}
public async Task DisposeAsync()
{
await _db.Database.EnsureDeletedAsync();
await _scope.DisposeAsync();
}
[Fact]
public async Task GetUser_ExistingUser_ReturnsUser()
{
// Arrange
var user = new User { Email = "test@example.com", Name = "Test" };
_db.Users.Add(user);
await _db.SaveChangesAsync();
var client = _factory.CreateClient();
// Act
var response = await client.GetAsync($"/api/users/{user.Id}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<UserResponse>();
result!.Email.Should().Be("test@example.com");
}
}Testing Endpoint Filters
[Fact]
public async Task ValidationFilter_InvalidRequest_Returns422()
{
var client = _factory.CreateClient();
var invalidOrder = new { CustomerId = 0, Items = Array.Empty<object>() };
var response = await client.PostAsJsonAsync("/api/orders", invalidOrder);
response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
problem!.Errors.Should().NotBeEmpty();
}Test Project Setup
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.0" />
<PackageReference Include="FluentAssertions" Version="7.0.0" />
<PackageReference Include="Moq" Version="4.20.0" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\src\MyApi\MyApi.csproj" />
</ItemGroup>
</Project>Integration Test Base Class
public abstract class IntegrationTestBase : IClassFixture<WebApplicationFactory<Program>>, IAsyncLifetime
{
protected readonly HttpClient Client;
protected readonly IServiceProvider Services;
protected IntegrationTestBase(WebApplicationFactory<Program> factory)
{
var customFactory = factory.WithWebHostBuilder(ConfigureWebHost);
Client = customFactory.CreateClient();
Services = customFactory.Services;
}
protected virtual void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
// Override services for all tests
});
}
public virtual Task InitializeAsync() => Task.CompletedTask;
public virtual Task DisposeAsync() => Task.CompletedTask;
protected async Task<T> GetAsync<T>(string url)
{
var response = await Client.GetAsync(url);
response.EnsureSuccessStatusCode();
return (await response.Content.ReadFromJsonAsync<T>())!;
}
protected async Task<HttpResponseMessage> PostAsync<T>(string url, T content)
{
return await Client.PostAsJsonAsync(url, content);
}
}Related skills
How it compares
Use alongside static analyzers when you need opinionated .NET 10 and C# 14 idioms explained as replace-this-with-that guidance during code review.
FAQ
Who is dotnet-10-csharp-14 for?
Developers and software engineers working with dotnet-10-csharp-14 patterns from the skill documentation.
When should I use dotnet-10-csharp-14?
Use when building .NET 10 or C# 14 applications; when using minimal APIs, modular monolith patterns, or feature folders; when implementing HTTP resilience, Options pattern, Channels, or validation; when seeing outdated patterns like old extension method syntax.
Is dotnet-10-csharp-14 safe to install?
Review the Security Audits panel on this page before installing in production.