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.