csharp-design-patterns

star 0

.NET C# design patterns for .NET 8 / C# 12. Covers 10 high-frequency GoF patterns with modern DI-based implementations for ASP.NET Core and library development.

AndyHsuTW By AndyHsuTW schedule Updated 2/25/2026

name: csharp-design-patterns description: .NET C# design patterns for .NET 8 / C# 12. Covers 10 high-frequency GoF patterns with modern DI-based implementations for ASP.NET Core and library development.

C# Design Patterns

10 high-frequency GoF design patterns implemented with modern .NET 8 / C# 12 idioms. Each pattern emphasizes DI container integration over manual instantiation.

When to Use Design Patterns

  • Use patterns to solve recurring design problems, not as decoration
  • Prefer the simplest solution; apply patterns when complexity justifies them
  • Many patterns are already built into .NET / ASP.NET Core (noted per pattern)
  • Favor composition over inheritance in all patterns

Creational Patterns

1. Singleton

Intent: Ensure a class has exactly one instance with a global access point.

Built into .NET: IServiceCollection.AddSingleton<T>() handles this automatically.

// GOOD: Let the DI container manage singleton lifetime
builder.Services.AddSingleton<ICacheService, InMemoryCacheService>();

public class InMemoryCacheService : ICacheService
{
    private readonly ConcurrentDictionary<string, object> _cache = new();

    public T? Get<T>(string key) =>
        _cache.TryGetValue(key, out var value) ? (T)value : default;

    public void Set<T>(string key, T value) =>
        _cache[key] = value!;
}

// BAD: Manual singleton with static instance (testability nightmare)
public class CacheService
{
    private static readonly Lazy<CacheService> _instance = new(() => new CacheService());
    public static CacheService Instance => _instance.Value;

    private CacheService() { }  // Hidden constructor, hard to test
}

When to use: Thread-safe shared state (caches, connection pools, configuration). Always prefer DI AddSingleton over static Instance properties.

Anti-pattern: Using static singletons makes unit testing impossible. Always inject via interface.


2. Factory Method

Intent: Define an interface for creating objects, letting subclasses or configuration decide which concrete type to instantiate.

// Define the product interface and implementations
public interface INotificationSender
{
    Task SendAsync(string recipient, string message, CancellationToken ct);
}

public class EmailSender(IOptions<SmtpOptions> options) : INotificationSender
{
    public async Task SendAsync(string recipient, string message, CancellationToken ct)
    {
        // Send via SMTP
    }
}

public class SmsSender(IOptions<TwilioOptions> options) : INotificationSender
{
    public async Task SendAsync(string recipient, string message, CancellationToken ct)
    {
        // Send via Twilio
    }
}

// GOOD: Factory using DI with keyed services (.NET 8)
builder.Services.AddKeyedScoped<INotificationSender, EmailSender>("email");
builder.Services.AddKeyedScoped<INotificationSender, SmsSender>("sms");

public class NotificationService(IServiceProvider provider)
{
    public INotificationSender Create(string channel) =>
        provider.GetRequiredKeyedService<INotificationSender>(channel);
}

// GOOD: Factory with delegate
builder.Services.AddSingleton<Func<string, INotificationSender>>(sp => channel =>
    channel switch
    {
        "email" => sp.GetRequiredService<EmailSender>(),
        "sms" => sp.GetRequiredService<SmsSender>(),
        _ => throw new ArgumentException($"Unknown channel: {channel}")
    });

// BAD: Switch statement scattered across business logic
public class OrderService
{
    public void Notify(Order order)
    {
        if (order.Channel == "email")
            new EmailSender().Send(order.Email, "..."); // Hard-coded, untestable
        else if (order.Channel == "sms")
            new SmsSender().Send(order.Phone, "...");
    }
}

When to use: Multiple implementations of the same interface selected at runtime (payment providers, notification channels, export formats).


3. Builder

Intent: Construct complex objects step by step, allowing different configurations through the same construction process.

Built into .NET: HostBuilder, WebApplicationBuilder, HttpClient pipeline, IConfigurationBuilder.

// GOOD: Fluent Builder for complex configuration
public class QueryBuilder
{
    private string _table = string.Empty;
    private readonly List<string> _conditions = [];
    private string? _orderBy;
    private int? _limit;

    public QueryBuilder From(string table)
    {
        return new QueryBuilder { _table = table, _conditions = [.._conditions], _orderBy = _orderBy, _limit = _limit };
    }

    public QueryBuilder Where(string condition)
    {
        return new QueryBuilder { _table = _table, _conditions = [.._conditions, condition], _orderBy = _orderBy, _limit = _limit };
    }

    public QueryBuilder OrderBy(string column)
    {
        return new QueryBuilder { _table = _table, _conditions = [.._conditions], _orderBy = column, _limit = _limit };
    }

    public QueryBuilder Take(int count)
    {
        return new QueryBuilder { _table = _table, _conditions = [.._conditions], _orderBy = _orderBy, _limit = count };
    }

    public string Build()
    {
        var sql = $"SELECT * FROM {_table}";
        if (_conditions.Count > 0)
            sql += " WHERE " + string.Join(" AND ", _conditions);
        if (_orderBy is not null)
            sql += $" ORDER BY {_orderBy}";
        if (_limit.HasValue)
            sql += $" LIMIT {_limit}";
        return sql;
    }
}

// Usage
var query = new QueryBuilder()
    .From("markets")
    .Where("status = 'active'")
    .Where("volume > 100")
    .OrderBy("created_at DESC")
    .Take(10)
    .Build();

// GOOD: .NET built-in builder patterns
var host = Host.CreateDefaultBuilder(args)
    .ConfigureServices(services =>
    {
        services.AddHttpClient("github", client =>
        {
            client.BaseAddress = new Uri("https://api.github.com");
            client.DefaultRequestHeaders.Add("Accept", "application/json");
        })
        .AddStandardResilienceHandler();
    })
    .Build();

When to use: Objects with many optional parameters, fluent configuration APIs, immutable object construction.

Anti-pattern: Constructors with 5+ parameters -- use a builder or an options record instead.


Structural Patterns

4. Adapter

Intent: Convert the interface of a class into another interface clients expect. Enables classes with incompatible interfaces to work together.

// Target interface your application expects
public interface IPaymentGateway
{
    Task<PaymentResult> ChargeAsync(decimal amount, string currency, string token, CancellationToken ct);
}

public record PaymentResult(bool Success, string TransactionId, string? Error);

// Third-party SDK with incompatible interface
public class StripeClient
{
    public Task<StripeCharge> CreateChargeAsync(StripeChargeRequest request) => /* ... */;
}

// GOOD: Adapter wraps third-party SDK behind your interface
public class StripePaymentAdapter(StripeClient client, ILogger<StripePaymentAdapter> logger) : IPaymentGateway
{
    public async Task<PaymentResult> ChargeAsync(
        decimal amount, string currency, string token, CancellationToken ct)
    {
        try
        {
            var charge = await client.CreateChargeAsync(new StripeChargeRequest
            {
                Amount = (long)(amount * 100),  // Stripe uses cents
                Currency = currency.ToLowerInvariant(),
                Source = token
            });

            return new PaymentResult(true, charge.Id, null);
        }
        catch (StripeException ex)
        {
            logger.LogError(ex, "Stripe charge failed for {Amount} {Currency}", amount, currency);
            return new PaymentResult(false, string.Empty, ex.Message);
        }
    }
}

// Registration
builder.Services.AddSingleton<StripeClient>();
builder.Services.AddScoped<IPaymentGateway, StripePaymentAdapter>();

// BAD: Leaking third-party types into business logic
public class OrderService
{
    private readonly StripeClient _stripe;

    public async Task ProcessOrder(Order order)
    {
        // Business logic directly depends on Stripe SDK -- cannot swap providers
        var charge = await _stripe.CreateChargeAsync(new StripeChargeRequest { ... });
    }
}

When to use: Integrating third-party SDKs, wrapping legacy code, unifying multiple services behind a common interface.


5. Decorator

Intent: Attach additional responsibilities to an object dynamically. More flexible than subclassing for extending functionality.

Built into .NET: ASP.NET Core middleware pipeline is a decorator chain.

// Base interface
public interface IMarketRepository
{
    Task<Market?> GetByIdAsync(string id, CancellationToken ct);
    Task<IReadOnlyList<Market>> ListAsync(CancellationToken ct);
}

// Core implementation
public class MarketRepository(AppDbContext context) : IMarketRepository
{
    public async Task<Market?> GetByIdAsync(string id, CancellationToken ct) =>
        await context.Markets.FindAsync([id], ct);

    public async Task<IReadOnlyList<Market>> ListAsync(CancellationToken ct) =>
        await context.Markets.AsNoTracking().ToListAsync(ct);
}

// GOOD: Caching decorator
public class CachedMarketRepository(
    IMarketRepository inner,
    IMemoryCache cache,
    ILogger<CachedMarketRepository> logger) : IMarketRepository
{
    private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);

    public async Task<Market?> GetByIdAsync(string id, CancellationToken ct)
    {
        var cacheKey = $"market:{id}";
        if (cache.TryGetValue(cacheKey, out Market? cached))
        {
            logger.LogDebug("Cache hit for market {MarketId}", id);
            return cached;
        }

        var market = await inner.GetByIdAsync(id, ct);
        if (market is not null)
        {
            cache.Set(cacheKey, market, CacheDuration);
        }
        return market;
    }

    public async Task<IReadOnlyList<Market>> ListAsync(CancellationToken ct) =>
        await inner.ListAsync(ct);  // Skip caching for list queries
}

// GOOD: Logging decorator
public class LoggingMarketRepository(
    IMarketRepository inner,
    ILogger<LoggingMarketRepository> logger) : IMarketRepository
{
    public async Task<Market?> GetByIdAsync(string id, CancellationToken ct)
    {
        logger.LogInformation("Fetching market {MarketId}", id);
        var stopwatch = Stopwatch.StartNew();
        var result = await inner.GetByIdAsync(id, ct);
        logger.LogInformation("Fetched market {MarketId} in {ElapsedMs}ms", id, stopwatch.ElapsedMilliseconds);
        return result;
    }

    public async Task<IReadOnlyList<Market>> ListAsync(CancellationToken ct) =>
        await inner.ListAsync(ct);
}

// Registration: Stack decorators (inner -> outer)
builder.Services.AddScoped<MarketRepository>();
builder.Services.AddScoped<IMarketRepository>(sp =>
    new LoggingMarketRepository(
        new CachedMarketRepository(
            sp.GetRequiredService<MarketRepository>(),
            sp.GetRequiredService<IMemoryCache>(),
            sp.GetRequiredService<ILogger<CachedMarketRepository>>()),
        sp.GetRequiredService<ILogger<LoggingMarketRepository>>()));

// TIP: Use Scrutor NuGet for cleaner decorator registration
// builder.Services.AddScoped<IMarketRepository, MarketRepository>();
// builder.Services.Decorate<IMarketRepository, CachedMarketRepository>();
// builder.Services.Decorate<IMarketRepository, LoggingMarketRepository>();

When to use: Adding cross-cutting concerns (caching, logging, retry, metrics) without modifying the original class.

Anti-pattern: Inheritance chains for extending behavior. Prefer composition via decorators.


6. Facade

Intent: Provide a simplified interface to a complex subsystem, reducing coupling between clients and subsystem components.

// Complex subsystem components
public interface IInventoryService
{
    Task<bool> CheckStockAsync(string productId, int quantity, CancellationToken ct);
    Task ReserveStockAsync(string productId, int quantity, CancellationToken ct);
}

public interface IPaymentGateway
{
    Task<PaymentResult> ChargeAsync(decimal amount, string currency, string token, CancellationToken ct);
}

public interface IShippingService
{
    Task<string> CreateShipmentAsync(Address address, List<OrderItem> items, CancellationToken ct);
}

public interface INotificationSender
{
    Task SendAsync(string recipient, string message, CancellationToken ct);
}

// GOOD: Facade simplifies order processing
public class OrderFacade(
    IInventoryService inventory,
    IPaymentGateway payment,
    IShippingService shipping,
    INotificationSender notification,
    ILogger<OrderFacade> logger)
{
    public async Task<OrderResult> PlaceOrderAsync(PlaceOrderRequest request, CancellationToken ct)
    {
        // Step 1: Check inventory
        foreach (var item in request.Items)
        {
            if (!await inventory.CheckStockAsync(item.ProductId, item.Quantity, ct))
                return OrderResult.Fail($"Product {item.ProductId} is out of stock");
        }

        // Step 2: Charge payment
        var paymentResult = await payment.ChargeAsync(
            request.TotalAmount, request.Currency, request.PaymentToken, ct);
        if (!paymentResult.Success)
            return OrderResult.Fail($"Payment failed: {paymentResult.Error}");

        // Step 3: Reserve inventory
        foreach (var item in request.Items)
        {
            await inventory.ReserveStockAsync(item.ProductId, item.Quantity, ct);
        }

        // Step 4: Create shipment
        var trackingId = await shipping.CreateShipmentAsync(request.ShippingAddress, request.Items, ct);

        // Step 5: Notify customer
        await notification.SendAsync(request.CustomerEmail,
            $"Order confirmed. Tracking: {trackingId}", ct);

        logger.LogInformation("Order placed: Payment={TransactionId}, Tracking={TrackingId}",
            paymentResult.TransactionId, trackingId);

        return OrderResult.Ok(paymentResult.TransactionId, trackingId);
    }
}

// BAD: Controller directly orchestrates all subsystems
public class OrderController
{
    public async Task<IResult> Post(PlaceOrderRequest request)
    {
        // 50+ lines of inventory + payment + shipping + notification logic
        // Duplicated wherever orders are placed (API, background jobs, etc.)
    }
}

When to use: Orchestrating multiple services in a business workflow, simplifying complex initialization sequences, providing a clean API for a subsystem.


Behavioral Patterns

7. Strategy

Intent: Define a family of algorithms, encapsulate each one, and make them interchangeable at runtime.

Built into .NET: DI with multiple interface implementations.

// Strategy interface
public interface IPricingStrategy
{
    decimal CalculatePrice(Order order);
}

// Concrete strategies
public class RegularPricing : IPricingStrategy
{
    public decimal CalculatePrice(Order order) =>
        order.Items.Sum(i => i.Price * i.Quantity);
}

public class PremiumPricing : IPricingStrategy
{
    private const decimal Discount = 0.15m;

    public decimal CalculatePrice(Order order) =>
        order.Items.Sum(i => i.Price * i.Quantity) * (1 - Discount);
}

public class BulkPricing : IPricingStrategy
{
    public decimal CalculatePrice(Order order)
    {
        var total = order.Items.Sum(i => i.Price * i.Quantity);
        return order.Items.Sum(i => i.Quantity) > 100
            ? total * 0.80m   // 20% bulk discount
            : total * 0.90m;  // 10% for smaller bulk
    }
}

// GOOD: Strategy selection via keyed services (.NET 8)
builder.Services.AddKeyedScoped<IPricingStrategy, RegularPricing>("regular");
builder.Services.AddKeyedScoped<IPricingStrategy, PremiumPricing>("premium");
builder.Services.AddKeyedScoped<IPricingStrategy, BulkPricing>("bulk");

public class OrderService(IServiceProvider sp)
{
    public decimal CalculateTotal(Order order, string pricingTier)
    {
        var strategy = sp.GetRequiredKeyedService<IPricingStrategy>(pricingTier);
        return strategy.CalculatePrice(order);
    }
}

// GOOD: Strategy via constructor injection when fixed at startup
public class OrderService(IPricingStrategy pricing)
{
    public decimal CalculateTotal(Order order) =>
        pricing.CalculatePrice(order);
}

// BAD: if/else chain instead of strategy
public decimal CalculateTotal(Order order, string tier)
{
    if (tier == "premium") return /* ... */;
    else if (tier == "bulk") return /* ... */;
    else return /* ... */;  // Grows with every new tier
}

When to use: Multiple interchangeable algorithms (pricing, sorting, validation, compression), behavior selected at runtime.


8. Observer

Intent: Define a one-to-many dependency between objects so that when one object changes state, all its dependents are notified automatically.

Built into .NET: event, IObservable<T>, and MediatR INotification.

// GOOD: Using MediatR Notifications (recommended for ASP.NET Core)
// 1. Define the notification (event)
public record OrderPlacedEvent(string OrderId, string CustomerEmail, decimal Total)
    : INotification;

// 2. Define handlers (observers)
public class SendOrderConfirmationHandler(
    INotificationSender email,
    ILogger<SendOrderConfirmationHandler> logger) : INotificationHandler<OrderPlacedEvent>
{
    public async Task Handle(OrderPlacedEvent notification, CancellationToken ct)
    {
        await email.SendAsync(notification.CustomerEmail,
            $"Order {notification.OrderId} confirmed: ${notification.Total}", ct);
        logger.LogInformation("Confirmation sent for order {OrderId}", notification.OrderId);
    }
}

public class UpdateInventoryHandler(
    IInventoryService inventory) : INotificationHandler<OrderPlacedEvent>
{
    public async Task Handle(OrderPlacedEvent notification, CancellationToken ct)
    {
        await inventory.DeductStockAsync(notification.OrderId, ct);
    }
}

public class RecordAnalyticsHandler(
    IAnalyticsService analytics) : INotificationHandler<OrderPlacedEvent>
{
    public async Task Handle(OrderPlacedEvent notification, CancellationToken ct)
    {
        await analytics.TrackPurchaseAsync(notification.OrderId, notification.Total, ct);
    }
}

// 3. Publish the notification
public class OrderService(IPublisher publisher)
{
    public async Task PlaceOrderAsync(Order order, CancellationToken ct)
    {
        // ... save order ...

        // All handlers execute independently
        await publisher.Publish(
            new OrderPlacedEvent(order.Id, order.CustomerEmail, order.Total), ct);
    }
}

// Registration
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));

// BAD: Tight coupling between publisher and subscribers
public class OrderService
{
    private readonly EmailService _email;
    private readonly InventoryService _inventory;
    private readonly AnalyticsService _analytics;

    public async Task PlaceOrderAsync(Order order)
    {
        // Must modify this class every time a new subscriber is added
        await _email.Send(...);
        await _inventory.Deduct(...);
        await _analytics.Track(...);
    }
}

When to use: Decoupled event broadcasting (order events, user registration, status changes), domain events in DDD.


9. Chain of Responsibility

Intent: Pass a request along a chain of handlers, each deciding whether to process it or pass it along.

Built into .NET: ASP.NET Core middleware pipeline, DelegatingHandler for HttpClient.

// GOOD: ASP.NET Core middleware (built-in chain pattern)
public class RequestValidationMiddleware(RequestDelegate next)
{
    public async Task InvokeAsync(HttpContext context)
    {
        if (!context.Request.Headers.ContainsKey("X-Api-Key"))
        {
            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
            await context.Response.WriteAsJsonAsync(new { error = "API key required" });
            return;  // Short-circuit: do NOT call next
        }

        await next(context);  // Pass to next handler in chain
    }
}

// Registration order defines the chain
app.UseMiddleware<RequestValidationMiddleware>();
app.UseMiddleware<RequestTimingMiddleware>();
app.UseMiddleware<RequestLoggingMiddleware>();

// GOOD: HttpClient DelegatingHandler chain
public class RetryHandler(int maxRetries = 3) : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken ct)
    {
        HttpResponseMessage? response = null;
        for (var attempt = 0; attempt <= maxRetries; attempt++)
        {
            response = await base.SendAsync(request, ct);
            if (response.IsSuccessStatusCode) return response;

            if (attempt < maxRetries)
            {
                var delay = TimeSpan.FromMilliseconds(Math.Pow(2, attempt) * 100);
                await Task.Delay(delay, ct);
            }
        }
        return response!;
    }
}

public class AuthorizationHandler(ITokenProvider tokenProvider) : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken ct)
    {
        var token = await tokenProvider.GetTokenAsync(ct);
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
        return await base.SendAsync(request, ct);
    }
}

// Registration: Chain of HttpClient handlers
builder.Services.AddHttpClient("api")
    .AddHttpMessageHandler<AuthorizationHandler>()
    .AddHttpMessageHandler<RetryHandler>();

// GOOD: Custom chain for business rules (validation pipeline)
public interface IOrderValidator
{
    Task<ValidationResult> ValidateAsync(Order order, CancellationToken ct);
}

public class StockValidator(IInventoryService inventory) : IOrderValidator
{
    public async Task<ValidationResult> ValidateAsync(Order order, CancellationToken ct)
    {
        foreach (var item in order.Items)
        {
            if (!await inventory.CheckStockAsync(item.ProductId, item.Quantity, ct))
                return ValidationResult.Fail($"Insufficient stock for {item.ProductId}");
        }
        return ValidationResult.Ok();
    }
}

public class CreditValidator(ICreditService credit) : IOrderValidator
{
    public async Task<ValidationResult> ValidateAsync(Order order, CancellationToken ct)
    {
        if (!await credit.CheckCreditAsync(order.CustomerId, order.Total, ct))
            return ValidationResult.Fail("Insufficient credit");
        return ValidationResult.Ok();
    }
}

// Run all validators in chain
public class OrderValidationPipeline(IEnumerable<IOrderValidator> validators)
{
    public async Task<ValidationResult> ValidateAsync(Order order, CancellationToken ct)
    {
        foreach (var validator in validators)
        {
            var result = await validator.ValidateAsync(order, ct);
            if (!result.IsValid) return result;  // Short-circuit on first failure
        }
        return ValidationResult.Ok();
    }
}

// Registration: DI resolves all implementations in order
builder.Services.AddScoped<IOrderValidator, StockValidator>();
builder.Services.AddScoped<IOrderValidator, CreditValidator>();
builder.Services.AddScoped<OrderValidationPipeline>();

When to use: Request processing pipelines, validation chains, middleware stacks, approval workflows.


10. Mediator

Intent: Reduce chaotic dependencies between objects by forcing them to communicate through a mediator object. Enables CQRS (Command Query Responsibility Segregation).

Built into .NET: MediatR library (de facto standard).

// GOOD: CQRS with MediatR

// Command (write operation)
public record CreateMarketCommand(
    string Name,
    string Description,
    DateTimeOffset EndDate) : IRequest<Market>;

public class CreateMarketHandler(
    AppDbContext context,
    IPublisher publisher,
    ILogger<CreateMarketHandler> logger) : IRequestHandler<CreateMarketCommand, Market>
{
    public async Task<Market> Handle(CreateMarketCommand request, CancellationToken ct)
    {
        var market = new Market
        {
            Id = Guid.NewGuid().ToString(),
            Name = request.Name,
            Description = request.Description,
            EndDate = request.EndDate,
            Status = MarketStatus.Active
        };

        context.Markets.Add(market);
        await context.SaveChangesAsync(ct);

        await publisher.Publish(new MarketCreatedEvent(market.Id, market.Name), ct);

        logger.LogInformation("Market created: {MarketId}", market.Id);
        return market;
    }
}

// Query (read operation)
public record GetMarketQuery(string Id) : IRequest<Market?>;

public class GetMarketHandler(
    AppDbContext context) : IRequestHandler<GetMarketQuery, Market?>
{
    public async Task<Market?> Handle(GetMarketQuery request, CancellationToken ct) =>
        await context.Markets
            .AsNoTracking()
            .FirstOrDefaultAsync(m => m.Id == request.Id, ct);
}

// GOOD: Pipeline Behaviors (cross-cutting concerns for all requests)
public class LoggingBehavior<TRequest, TResponse>(
    ILogger<LoggingBehavior<TRequest, TResponse>> logger)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        var requestName = typeof(TRequest).Name;
        logger.LogInformation("Handling {RequestName}: {@Request}", requestName, request);

        var stopwatch = Stopwatch.StartNew();
        var response = await next();
        stopwatch.Stop();

        logger.LogInformation("Handled {RequestName} in {ElapsedMs}ms",
            requestName, stopwatch.ElapsedMilliseconds);

        return response;
    }
}

public class ValidationBehavior<TRequest, TResponse>(
    IEnumerable<IValidator<TRequest>> validators)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        if (!validators.Any()) return await next();

        var context = new ValidationContext<TRequest>(request);
        var failures = (await Task.WhenAll(
                validators.Select(v => v.ValidateAsync(context, ct))))
            .SelectMany(r => r.Errors)
            .Where(f => f is not null)
            .ToList();

        if (failures.Count > 0)
            throw new ValidationException(failures);

        return await next();
    }
}

// Endpoint uses ISender (thin controller)
app.MapPost("/api/markets", async (
    CreateMarketCommand command,
    ISender sender,
    CancellationToken ct) =>
{
    var market = await sender.Send(command, ct);
    return Results.Created($"/api/markets/{market.Id}", ApiResponse<Market>.Ok(market));
});

// Registration
builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
});

// BAD: Fat controller with all logic inlined
public class MarketController
{
    [HttpPost]
    public async Task<IActionResult> Create(CreateMarketRequest request)
    {
        // 50+ lines of validation, DB access, event publishing, logging
        // Impossible to reuse from background jobs or other entry points
    }
}

When to use: CQRS architecture, decoupling request handling from API layer, cross-cutting concerns via pipeline behaviors, keeping controllers/endpoints thin.


Pattern Selection Guide

Problem Pattern .NET Implementation
Need exactly one shared instance Singleton AddSingleton<T>()
Create objects based on runtime conditions Factory Method Keyed services / Func<T> delegate
Construct complex objects step by step Builder Fluent API with method chaining
Wrap third-party SDK behind your interface Adapter Interface + wrapper class
Add caching/logging/retry without modifying code Decorator DI + Scrutor Decorate<T>()
Simplify complex multi-service workflows Facade Orchestration service
Swap algorithms at runtime Strategy Keyed services / IEnumerable<T>
Broadcast events to multiple handlers Observer MediatR INotification
Build request processing pipelines Chain of Responsibility Middleware / DelegatingHandler
Decouple request handling, enable CQRS Mediator MediatR IRequest<T>

Patterns Already Built into .NET

Pattern Built-in Usage
Singleton IServiceCollection.AddSingleton()
Builder WebApplicationBuilder, IHostBuilder, IConfigurationBuilder
Iterator IEnumerable<T>, IAsyncEnumerable<T>, LINQ
Observer event, IObservable<T>
Chain of Responsibility ASP.NET Core Middleware, DelegatingHandler
Strategy IComparer<T>, IEqualityComparer<T>, DI interfaces
Template Method BackgroundService.ExecuteAsync(), ControllerBase
Decorator Stream wrapping, Middleware pipeline

Remember: Design patterns are tools, not goals. Apply them when they solve a real problem, not to impress code reviewers.

Install via CLI
npx skills add https://github.com/AndyHsuTW/everything-llm-workspace --skill csharp-design-patterns
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator