Skip to main content
Kodelyth ECC
Skill

dotnet-patterns

Idiomatic C# and .NET patterns, conventions, dependency injection, async/await, and best practices for building robust, maintainable .NET applications.

Invoke via:use dotnet-patterns
Origin:ECC

.NET Development Patterns

Idiomatic C# and .NET patterns for building robust, performant, and maintainable applications.

When to Activate

  • Writing new C# code
  • Reviewing C# code
  • Refactoring existing .NET applications
  • Designing service architectures with ASP.NET Core

Core Principles

1. Prefer Immutability

Use records and init-only properties for data models. Mutability should be an explicit, justified choice.

// Good: Immutable value object
public sealed record Money(decimal Amount, string Currency);

// Good: Immutable DTO with init setters public sealed class CreateOrderRequest { public required string CustomerId { get; init; } public required IReadOnlyList<OrderItem> Items { get; init; } }

// Bad: Mutable model with public setters public class Order { public string CustomerId { get; set; } public List<OrderItem> Items { get; set; } }

2. Explicit Over Implicit

Be clear about nullability, access modifiers, and intent.

// Good: Explicit access modifiers and nullability
public sealed class UserService
{
    private readonly IUserRepository _repository;
    private readonly ILogger<UserService> _logger;

public UserService(IUserRepository repository, ILogger<UserService> logger) { _repository = repository ?? throw new ArgumentNullException(nameof(repository)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); }

public async Task<User?> FindByIdAsync(Guid id, CancellationToken cancellationToken) { return await _repository.FindByIdAsync(id, cancellationToken); } }

3. Depend on Abstractions

Use interfaces for service boundaries. Register via DI container.

// Good: Interface-based dependency
public interface IOrderRepository
{
    Task<Order?> FindByIdAsync(Guid id, CancellationToken cancellationToken);
    Task<IReadOnlyList<Order>> FindByCustomerAsync(string customerId, CancellationToken cancellationToken);
    Task AddAsync(Order order, CancellationToken cancellationToken);
}

// Registration builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();

Async/Await Patterns

Proper Async Usage

// Good: Async all the way, with CancellationToken
public async Task<OrderSummary> GetOrderSummaryAsync(
    Guid orderId,
    CancellationToken cancellationToken)
{
    var order = await _repository.FindByIdAsync(orderId, cancellationToken)
        ?? throw new NotFoundException($"Order {orderId} not found");

var customer = await _customerService.GetAsync(order.CustomerId, cancellationToken);

return new OrderSummary(order, customer); }

// Bad: Blocking on async public OrderSummary GetOrderSummary(Guid orderId) { var order = _repository.FindByIdAsync(orderId, CancellationToken.None).Result; // Deadlock risk return new OrderSummary(order); }

Parallel Async Operations

// Good: Concurrent independent operations
public async Task<DashboardData> LoadDashboardAsync(CancellationToken cancellationToken)
{
    var ordersTask = _orderService.GetRecentAsync(cancellationToken);
    var metricsTask = _metricsService.GetCurrentAsync(cancellationToken);
    var alertsTask = _alertService.GetActiveAsync(cancellationToken);

await Task.WhenAll(ordersTask, metricsTask, alertsTask);

return new DashboardData( Orders: await ordersTask, Metrics: await metricsTask, Alerts: await alertsTask); }

Options Pattern

Bind configuration sections to strongly-typed objects.

public sealed class SmtpOptions
{
    public const string SectionName = "Smtp";

public required string Host { get; init; } public required int Port { get; init; } public required string Username { get; init; } public bool UseSsl { get; init; } = true; }

// Registration builder.Services.Configure<SmtpOptions>( builder.Configuration.GetSection(SmtpOptions.SectionName));

// Usage via injection public class EmailService(IOptions<SmtpOptions> options) { private readonly SmtpOptions _smtp = options.Value; }

Result Pattern

Return explicit success/failure instead of throwing for expected failures.

public sealed record Result<T>
{
    public bool IsSuccess { get; }
    public T? Value { get; }
    public string? Error { get; }

private Result(T value) { IsSuccess = true; Value = value; } private Result(string error) { IsSuccess = false; Error = error; }

public static Result<T> Success(T value) => new(value); public static Result<T> Failure(string error) => new(error); }

// Usage public async Task<Result<Order>> PlaceOrderAsync(CreateOrderRequest request) { if (request.Items.Count == 0) return Result<Order>.Failure("Order must contain at least one item");

var order = Order.Create(request); await _repository.AddAsync(order, CancellationToken.None); return Result<Order>.Success(order); }

Repository Pattern with EF Core

public sealed class SqlOrderRepository : IOrderRepository
{
    private readonly AppDbContext _db;

public SqlOrderRepository(AppDbContext db) => _db = db;

public async Task<Order?> FindByIdAsync(Guid id, CancellationToken cancellationToken) { return await _db.Orders .Include(o => o.Items) .AsNoTracking() .FirstOrDefaultAsync(o => o.Id == id, cancellationToken); }

public async Task<IReadOnlyList<Order>> FindByCustomerAsync( string customerId, CancellationToken cancellationToken) { return await _db.Orders .Where(o => o.CustomerId == customerId) .OrderByDescending(o => o.CreatedAt) .AsNoTracking() .ToListAsync(cancellationToken); }

public async Task AddAsync(Order order, CancellationToken cancellationToken) { _db.Orders.Add(order); await _db.SaveChangesAsync(cancellationToken); } }

Middleware and Pipeline

// Custom middleware
public sealed class RequestTimingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestTimingMiddleware> _logger;

public RequestTimingMiddleware(RequestDelegate next, ILogger<RequestTimingMiddleware> logger) { _next = next; _logger = logger; }

public async Task InvokeAsync(HttpContext context) { var stopwatch = Stopwatch.StartNew(); try { await _next(context); } finally { stopwatch.Stop(); _logger.LogInformation( "Request {Method} {Path} completed in {ElapsedMs}ms with status {StatusCode}", context.Request.Method, context.Request.Path, stopwatch.ElapsedMilliseconds, context.Response.StatusCode); } } }

Minimal API Patterns

// Organized with route groups
var orders = app.MapGroup("/api/orders")
    .RequireAuthorization()
    .WithTags("Orders");

orders.MapGet("/{id:guid}", async ( Guid id, IOrderRepository repository, CancellationToken cancellationToken) => { var order = await repository.FindByIdAsync(id, cancellationToken); return order is not null ? TypedResults.Ok(order) : TypedResults.NotFound(); });

orders.MapPost("/", async ( CreateOrderRequest request, IOrderService service, CancellationToken cancellationToken) => { var result = await service.PlaceOrderAsync(request, cancellationToken); return result.IsSuccess ? TypedResults.Created($"/api/orders/{result.Value!.Id}", result.Value) : TypedResults.BadRequest(result.Error); });

Guard Clauses

// Good: Early returns with clear validation
public async Task<ProcessResult> ProcessPaymentAsync(
    PaymentRequest request,
    CancellationToken cancellationToken)
{
    ArgumentNullException.ThrowIfNull(request);

if (request.Amount <= 0) throw new ArgumentOutOfRangeException(nameof(request.Amount), "Amount must be positive");

if (string.IsNullOrWhiteSpace(request.Currency)) throw new ArgumentException("Currency is required", nameof(request.Currency));

// Happy path continues here without nesting var gateway = _gatewayFactory.Create(request.Currency); return await gateway.ChargeAsync(request, cancellationToken); }

Anti-Patterns to Avoid

| Anti-Pattern | Fix | |---|---| | async void methods | Return Task (except event handlers) | | .Result or .Wait() | Use await | | catch (Exception) { } | Handle or rethrow with context | | new Service() in constructors | Use constructor injection | | public fields | Use properties with appropriate accessors | | dynamic in business logic | Use generics or explicit types | | Mutable static state | Use DI scoping or ConcurrentDictionary | | string.Format in loops | Use StringBuilder or interpolated string handlers |