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.

01

Design Patterns — Why They Matter

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
02

Repository + Unit of Work

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.

03

Factory + Abstract Factory

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) { /* ... */ }
04

Observer + Domain Events

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
05

Decorator — Cross-Cutting Without Inheritance

Decorator wraps an object to add behaviour (logging, caching, retry) while keeping the same interface. Prefer over subclass explosion.

5.1

Caching decorator

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);
        }
    }
}
06

Specification — Composable Query Logic

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
07

Mediator — MediatR & CQRS

Mediator decouples senders from handlers. MediatR is the de-facto .NET library — one request, one handler; controllers stay thin.

7.1

Command + handler

// NuGet: MediatR
builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));

public sealed record CreateProductCommand(string Name, decimal Price)
    : IRequest<ProductDto>;

public sealed class CreateProductHandler(AppDbContext db)
    : IRequestHandler<CreateProductCommand, ProductDto>
{
    public async Task<ProductDto> Handle(CreateProductCommand cmd, CancellationToken ct)
    {
        var product = new Product { Name = cmd.Name, Price = cmd.Price };
        db.Products.Add(product);
        await db.SaveChangesAsync(ct);
        return new ProductDto(product.Id, product.Name, product.Price);
    }
}

// Minimal API — controller is one line
app.MapPost("/products", async (CreateProductCommand cmd, IMediator mediator, CancellationToken ct)
    => Results.Created($"/products/{(await mediator.Send(cmd, ct)).Id}", await mediator.Send(cmd, ct)));
7.2

Pipeline behaviours — Decorator at scale

// MediatR pipeline = Chain of Responsibility for every request
public sealed class ValidationBehaviour<TReq, TRes>(IEnumerable<IValidator<TReq>> validators)
    : IPipelineBehavior<TReq, TRes> where TReq : notnull
{
    public async Task<TRes> Handle(TReq req, RequestHandlerDelegate<TRes> next, CancellationToken ct)
    {
        var failures = validators
            .Select(v => v.Validate(req))
            .SelectMany(r => r.Errors)
            .Where(f => f is not null).ToList();
        if (failures.Count != 0) throw new ValidationException(failures);
        return await next();
    }
}

// Register: logging, validation, transaction, caching — all cross-cutting
08

Chain of Responsibility

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
    }
}
09

Anti-Patterns — Patterns Gone Wrong

9.1

Common mistakes

  • 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 LocatorIServiceProvider.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));
    }
}
9.2

YAGNI vs. readiness

// 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
10

Production Pattern Picker

10.1

Decision chart

// 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)
Mastery Move — where to go next

1. Strategy Pattern for Dummies — the companion tutorial with a full payment + discount console app.

2. MassTransit & RabbitMQ — Observer pattern across service boundaries with outbox.

3. Redis & Resilience — caching decorator and Polly in production.