In-Depth Guide · 10 Sections · GoF & Beyond
Design Patterns In Depth
Beyond Strategy: Repository + Unit of Work, Factory, Observer, Decorator, Specification, Mediator (MediatR), Chain of Responsibility, anti-patterns and a production pattern picker. Plain English, copy-paste C#.
Repository & UoW
Mediator & MediatR
Decorator & Spec
Production Picker
Companion: start with Strategy Pattern for Dummies if you haven't yet — it covers swappable algorithms with a real payment app.
For SOLID principles see OOP & SOLID; for clean API structure see REST API tutorial.
A design pattern is a proven solution to a recurring problem. In C# you don't implement patterns for their own sake — you reach for them when code smells appear: giant controllers, duplicated queries, tangled dependencies.
1.1
GoF categories — quick map
// CREATIONAL — how objects are born
// Factory, Abstract Factory, Builder, Singleton (use DI instead)
// STRUCTURAL — how objects compose
// Decorator, Adapter, Facade, Proxy
// BEHAVIORAL — how objects collaborate
// Strategy (see companion tutorial), Observer, Mediator,
// Chain of Responsibility, Command, Specification
// .NET-native equivalents you already use:
// DI container → Inversion of Control (not a GoF pattern, but foundational)
// IHostedService → background worker pattern
// Middleware → Chain of Responsibility in ASP.NET Core pipeline
1.2
When to apply — smell → pattern
// Smell → Pattern
// if/else on "type" → Strategy, Factory
// Fat controller / service → Mediator (CQRS handlers)
// Duplicated query logic → Specification, Repository
// Cross-cutting concerns mixed in → Decorator
// One object knows too many → Observer, MediatR notifications
// Validation pipeline → Chain of Responsibility
// Multiple implementations → Abstract Factory + DI registration
Repository hides data access behind a collection-like interface. Unit of Work tracks changes across repositories and commits them in one transaction.
2.1
Generic repository interface
public interface IRepository<T> where T : class
{
Task<T?> GetByIdAsync(int id, CancellationToken ct = default);
Task<IReadOnlyList<T>> ListAsync(ISpecification<T> spec, CancellationToken ct = default);
Task AddAsync(T entity, CancellationToken ct = default);
void Update(T entity);
void Remove(T entity);
}
public sealed class EfRepository<T>(AppDbContext db) : IRepository<T> where T : class
{
public Task<T?> GetByIdAsync(int id, CancellationToken ct)
=> db.Set<T>().FindAsync([id], ct).AsTask();
public async Task<IReadOnlyList<T>> ListAsync(ISpecification<T> spec, CancellationToken ct)
=> await SpecificationEvaluator.GetQuery(db.Set<T>(), spec).ToListAsync(ct);
public Task AddAsync(T entity, CancellationToken ct)
=> db.Set<T>().AddAsync(entity, ct).AsTask();
public void Update(T entity) => db.Set<T>().Update(entity);
public void Remove(T entity) => db.Set<T>().Remove(entity);
}
2.2
Unit of Work — one SaveChanges
public interface IUnitOfWork
{
IOrderRepository Orders { get; }
ICustomerRepository Customers { get; }
Task<int> SaveChangesAsync(CancellationToken ct = default);
}
public sealed class UnitOfWork(AppDbContext db) : IUnitOfWork
{
public IOrderRepository Orders { get; } = new OrderRepository(db);
public ICustomerRepository Customers { get; } = new CustomerRepository(db);
public Task<int> SaveChangesAsync(CancellationToken ct) => db.SaveChangesAsync(ct);
}
// Handler — atomic order + inventory update
public async Task Handle(PlaceOrderCommand cmd, CancellationToken ct)
{
var order = Order.Create(cmd);
await _uow.Orders.AddAsync(order, ct);
await _uow.Customers.DeductCreditAsync(cmd.CustomerId, order.Total, ct);
await _uow.SaveChangesAsync(ct); // single transaction
}
Watch Out — over-abstracted repositories
Don't wrap every EF Core query in a repository method — you end up with IOrderRepository.GetOrdersByCustomerAndStatusOrderedByDateAsync. Use repositories for aggregate roots; use Specification or direct DbContext for complex reads.
3.1
Simple Factory — pick implementation
public interface INotificationSender
{
Task SendAsync(Notification msg, CancellationToken ct);
}
public sealed class NotificationSenderFactory(IEnumerable<INotificationSender> senders)
{
public INotificationSender Create(NotificationChannel channel) => channel switch
{
NotificationChannel.Email => senders.OfType<EmailSender>().First(),
NotificationChannel.Sms => senders.OfType<SmsSender>().First(),
NotificationChannel.Push => senders.OfType<PushSender>().First(),
_ => throw new ArgumentOutOfRangeException(nameof(channel))
};
}
// Better in .NET: register keyed services (.NET 8+)
builder.Services.AddKeyedScoped<INotificationSender, EmailSender>("email");
builder.Services.AddKeyedScoped<INotificationSender, SmsSender>("sms");
3.2
Abstract Factory — families of related objects
// Create matching UI components for a theme (WinForms/WPF legacy;
// still useful for multi-tenant report/export pipelines)
public interface IReportFactory
{
IReportHeader CreateHeader();
IReportTable CreateTable();
IReportFooter CreateFooter();
}
public sealed class PdfReportFactory : IReportFactory
{
public IReportHeader CreateHeader() => new PdfHeader();
public IReportTable CreateTable() => new PdfTable();
public IReportFooter CreateFooter() => new PdfFooter();
}
public sealed class ExcelReportFactory : IReportFactory { /* ... */ }
// Consumer never mixes PDF header with Excel table
public class ReportBuilder(IReportFactory factory) { /* ... */ }
Observer: when something happens, notify interested parties without the publisher knowing who listens. In .NET: C# events, IObservable, MediatR notifications, or MassTransit publish.
4.1
Domain events — in-process Observer
public abstract class Entity
{
private readonly List<IDomainEvent> _events = [];
public IReadOnlyList<IDomainEvent> DomainEvents => _events.AsReadOnly();
protected void Raise(IDomainEvent e) => _events.Add(e);
public void ClearEvents() => _events.Clear();
}
public sealed record OrderPlacedEvent(int OrderId, int CustomerId) : IDomainEvent;
public sealed class Order : Entity
{
public static Order Create(int customerId, decimal total)
{
var order = new Order { CustomerId = customerId, Total = total };
order.Raise(new OrderPlacedEvent(order.Id, customerId));
return order;
}
}
4.2
Dispatch after SaveChanges
// DbContext override — dispatch events AFTER commit succeeds
public override async Task<int> SaveChangesAsync(CancellationToken ct = default)
{
var entities = ChangeTracker.Entries<Entity>()
.Where(e => e.Entity.DomainEvents.Any())
.Select(e => e.Entity).ToList();
var events = entities.SelectMany(e => e.DomainEvents).ToList();
entities.ForEach(e => e.ClearEvents());
var result = await base.SaveChangesAsync(ct);
foreach (var evt in events)
await _mediator.Publish(evt, ct); // Observer fan-out
return result;
}
// Handlers: SendConfirmationEmail, UpdateAnalytics, PublishToServiceBus
Decorator wraps an object to add behaviour (logging, caching, retry) while keeping the same interface. Prefer over subclass explosion.
public interface IProductService
{
Task<ProductDto?> GetByIdAsync(int id, CancellationToken ct);
}
public sealed class ProductService(AppDbContext db) : IProductService
{
public async Task<ProductDto?> GetByIdAsync(int id, CancellationToken ct)
=> await db.Products.FindAsync([id], ct) is { } p ? Map(p) : null;
}
public sealed class CachingProductService(
IProductService inner, IDistributedCache cache) : IProductService
{
public async Task<ProductDto?> GetByIdAsync(int id, CancellationToken ct)
{
var key = $"product:{id}";
var hit = await cache.GetStringAsync(key, ct);
if (hit is not null) return JsonSerializer.Deserialize<ProductDto>(hit);
var result = await inner.GetByIdAsync(id, ct);
if (result is not null)
await cache.SetStringAsync(key, JsonSerializer.Serialize(result), ct);
return result;
}
}
// DI: decorator registered OUTSIDE core implementation
builder.Services.AddScoped<ProductService>();
builder.Services.AddScoped<IProductService>(sp =>
new CachingProductService(sp.GetRequiredService<ProductService>(), sp.GetRequiredService<IDistributedCache>()));
5.2
Logging decorator with Scrutor
// NuGet: Scrutor — auto-wrap all services matching interface
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.Decorate<IOrderService, LoggingOrderServiceDecorator>();
public sealed class LoggingOrderServiceDecorator(
IOrderService inner, ILogger<LoggingOrderServiceDecorator> log) : IOrderService
{
public async Task<OrderDto> CreateAsync(CreateOrderDto dto, CancellationToken ct)
{
log.LogInformation("Creating order for {CustomerId}", dto.CustomerId);
var sw = Stopwatch.StartNew();
try
{
return await inner.CreateAsync(dto, ct);
}
finally
{
log.LogInformation("Order created in {ElapsedMs}ms", sw.ElapsedMilliseconds);
}
}
}
Specification encapsulates business rules as reusable, composable predicates — especially powerful with EF Core IQueryable.
6.1
Specification interface
public interface ISpecification<T>
{
Expression<Func<T, bool>>? Criteria { get; }
List<Expression<Func<T, object>>> Includes { get; }
Expression<Func<T, object>>? OrderBy { get; }
Expression<Func<T, object>>? OrderByDescending { get; }
int Take { get; }
int Skip { get; }
}
public sealed class ActiveCustomersSpec : Specification<Customer>
{
public ActiveCustomersSpec() =>
Query.Where(c => c.IsActive && !c.IsDeleted)
.OrderBy(c => c.LastName)
.Include(c => c.Orders);
}
6.2
Compose with And / Or
// Combine specifications — no duplicated LINQ in every service
var spec = new ActiveCustomersSpec()
.And(new CustomerInRegionSpec("EU"))
.And(new MinOrderCountSpec(5));
var customers = await _repo.ListAsync(spec, ct);
// Same spec used by:
// • API list endpoint
// • Export job
// • Admin dashboard count query
// NuGet: Ardalis.Specification — battle-tested base classes + evaluator
Pass a request through a chain of handlers — each decides to process or pass along. ASP.NET Core middleware is the canonical example.
8.1
Discount approval chain
public abstract class DiscountHandler
{
protected DiscountHandler? Next { get; private set; }
public DiscountHandler SetNext(DiscountHandler next) { Next = next; return next; }
public virtual decimal Handle(DiscountRequest req)
{
if (Next is not null) return Next.Handle(req);
return req.Amount;
}
}
public sealed class ManagerApprovalHandler : DiscountHandler
{
public override decimal Handle(DiscountRequest req)
{
if (req.DiscountPercent <= 10) return req.Amount * (1 - req.DiscountPercent / 100m);
return Next?.Handle(req) ?? throw new UnauthorizedAccessException();
}
}
public sealed class DirectorApprovalHandler : DiscountHandler
{
public override decimal Handle(DiscountRequest req)
{
if (req.DiscountPercent <= 25) return req.Amount * (1 - req.DiscountPercent / 100m);
return Next?.Handle(req) ?? throw new UnauthorizedAccessException();
}
}
8.2
Middleware — built-in chain
// ASP.NET Core pipeline IS Chain of Responsibility
app.UseExceptionHandler();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.MapControllers();
// Custom middleware link
public sealed class TenantMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext ctx, ITenantResolver resolver)
{
var tenantId = ctx.Request.Headers["X-Tenant-Id"].FirstOrDefault();
if (string.IsNullOrEmpty(tenantId))
{ ctx.Response.StatusCode = 400; return; }
resolver.SetTenant(tenantId);
await next(ctx); // pass to next handler in chain
}
}
- Golden Hammer — MediatR for every CRUD endpoint when a simple service suffices.
- Anemic Domain Model — entities are DTO bags; all logic in handlers.
- God Repository — one interface with 40 methods instead of focused specs.
- Singleton abuse — mutable state in singleton services (use scoped).
- Pattern layering — Factory → Builder → Abstract Factory for a two-line object.
- Service Locator —
IServiceProvider.GetService hidden in domain code.
// ❌ Anemic — logic scattered in handlers
public class Order { public int Id; public decimal Total; public string Status; }
// ✅ Rich domain — behaviour lives with data
public class Order
{
public void Ship()
{
if (Status != OrderStatus.Paid) throw new DomainException("Unpaid");
Status = OrderStatus.Shipped;
Raise(new OrderShippedEvent(Id));
}
}
// Start simple:
// • 3 endpoints, one DbContext → no MediatR yet
// • 2 payment providers → Strategy (companion tutorial)
// • Duplicated query in 2 places → extract Specification
// Add patterns when you FEEL the pain:
// • Controller > 200 lines → MediatR handler
// • 5 cross-cutting concerns → Decorator or pipeline behaviour
// • Complex approval flow → Chain of Responsibility
// Rule: make it work, make it right, make it fast — in that order
// Need swappable algorithm at runtime?
// → Strategy (companion: strategy-pattern-tutorial.html)
// Hide EF Core / SQL from business layer?
// → Repository + Specification (not generic repo per method)
// Atomic multi-entity commit?
// → Unit of Work (DbContext.SaveChanges is already UoW — don't over-wrap)
// Thin controllers, testable use cases?
// → Mediator + MediatR handlers + pipeline behaviours
// Cross-cutting: cache, log, retry around a service?
// → Decorator (Scrutor) or MediatR IPipelineBehavior
// "When X happens, do Y and Z"?
// → Observer (domain events + INotification)
// Multi-step validation / approval?
// → Chain of Responsibility (or MediatR pipeline)
// Create object without caller knowing concrete type?
// → Factory + DI keyed services
// Related family of objects (PDF vs Excel export)?
// → Abstract Factory
10.2
Typical Clean Architecture stack
// Presentation → Minimal API endpoints (Send command/query via MediatR)
// Application → Handlers, validators, DTOs, pipeline behaviours
// Domain → Entities, domain events, specifications (pure C#)
// Infrastructure→ EF Repository, decorators (cache), external APIs
// Patterns by layer:
// Domain: Specification, Observer (events), rich entities
// Application: Mediator, Chain (pipeline), Strategy (via interfaces)
// Infra: Repository, Decorator, Factory (keyed DI)