DEV Community

Cover image for Design Patterns: Reusable Solutions to Recurring Problems
Rhuturaj Takle
Rhuturaj Takle

Posted on

Design Patterns: Reusable Solutions to Recurring Problems

Design Patterns: Reusable Solutions to Recurring Problems

A practical guide to classic design patterns in C#/.NET — Factory, Singleton, Repository, Strategy, and Mediator — covering what problem each one actually solves, working implementations, common .NET-specific variations, and honest guidance on when each pattern earns its complexity versus when it's unnecessary ceremony.


Table of Contents

  1. Introduction
  2. Factory Pattern
  3. Singleton Pattern
  4. Repository Pattern
  5. Strategy Pattern
  6. Mediator Pattern
  7. How These Patterns Combine in Practice
  8. Patterns vs. Over-Engineering
  9. Common Pitfalls
  10. Quick Reference Table
  11. Conclusion

Introduction

Design patterns are named, reusable solutions to problems that recur often enough across software projects that giving them a shared name and shape is genuinely useful — not because the specific code is copy-pasteable, but because the name lets developers communicate a design intent quickly ("just make it a Strategy") instead of re-explaining the same structural idea from scratch every time. This guide covers five of the most commonly used patterns in .NET codebases, with working C# examples, and — consistent with this series' recurring theme — honest guidance on when each pattern is solving a genuine problem versus adding structure a simpler solution wouldn't need.

// A pattern name compresses a whole design conversation into one word
"Just inject an IPaymentStrategy and pick the implementation based on the payment method" // ← Strategy
"Wrap the whole multi-step checkout process behind a single mediator call"                  // ← Mediator
Enter fullscreen mode Exit fullscreen mode

1. Factory Pattern

The problem: object creation logic that doesn't belong at the call site

// ❌ The caller needs to know about every concrete shipping provider and how to construct each one
IShippingProvider provider = order.Region switch
{
    "US" => new UpsShippingProvider(apiKey, region),
    "EU" => new DhlShippingProvider(apiKey, endpoint),
    "APAC" => new FedExShippingProvider(apiKey, credentials),
    _ => throw new NotSupportedException()
};
Enter fullscreen mode Exit fullscreen mode

Object construction sometimes involves real decision logic — which concrete type to build, what configuration it needs, how to assemble its dependencies — and scattering that logic across every place an object is needed means every caller has to know these construction details, and any future change (adding a new shipping provider) means hunting down every one of those call sites.

The Factory pattern: centralize creation logic in one place

public interface IShippingProviderFactory
{
    IShippingProvider Create(string region);
}

public class ShippingProviderFactory : IShippingProviderFactory
{
    private readonly IConfiguration _configuration;
    public ShippingProviderFactory(IConfiguration configuration) => _configuration = configuration;

    public IShippingProvider Create(string region) => region switch
    {
        "US" => new UpsShippingProvider(_configuration["Ups:ApiKey"]!, region),
        "EU" => new DhlShippingProvider(_configuration["Dhl:ApiKey"]!, _configuration["Dhl:Endpoint"]!),
        "APAC" => new FedExShippingProvider(_configuration["FedEx:Credentials"]!),
        _ => throw new NotSupportedException($"No shipping provider configured for region {region}")
    };
}
Enter fullscreen mode Exit fullscreen mode
// Callers depend only on the factory's interface — no knowledge of concrete providers needed
public class ShipOrderHandler
{
    private readonly IShippingProviderFactory _factory;
    public ShipOrderHandler(IShippingProviderFactory factory) => _factory = factory;

    public async Task ShipAsync(Order order)
    {
        var provider = _factory.Create(order.Region);
        await provider.ScheduleShipmentAsync(order);
    }
}
Enter fullscreen mode Exit fullscreen mode

The construction decision now lives in exactly one place — adding a new region/provider means changing ShippingProviderFactory alone, and every caller (ShipOrderHandler, and any other code needing a shipping provider) remains entirely unaffected by that change, since they depend only on IShippingProviderFactory's abstraction.

Factory Method vs. Abstract Factory: a quick distinction

// Factory Method: one method, creates one kind of thing, often overridden in a subclass
public abstract class OrderProcessor
{
    protected abstract IPaymentValidator CreateValidator(); // subclasses decide WHICH validator
    public async Task ProcessAsync(Order order)
    {
        var validator = CreateValidator();
        await validator.ValidateAsync(order);
    }
}
Enter fullscreen mode Exit fullscreen mode
// Abstract Factory: a family of RELATED objects, created together, consistently
public interface IUIThemeFactory
{
    IButton CreateButton();
    ITextBox CreateTextBox();
}
public class DarkThemeFactory : IUIThemeFactory
{
    public IButton CreateButton() => new DarkButton();
    public ITextBox CreateTextBox() => new DarkTextBox(); // guarantees a CONSISTENT theme across related objects
}
Enter fullscreen mode Exit fullscreen mode

The classic Gang of Four distinction: Factory Method is a single creation method, often overridden per subclass; Abstract Factory creates a family of related objects that need to stay consistent with each other (every UI control from DarkThemeFactory is guaranteed dark-themed, never an accidental mix). In modern .NET, the simple factory shown first (a class with a Create method, registered via DI) is by far the most common variation actually used in application code — the more formal GoF distinctions matter mostly for recognizing the pattern in older literature or specific UI-toolkit codebases.

The .NET-native factory: IServiceProvider and factory delegates

builder.Services.AddSingleton<Func<string, IShippingProvider>>(serviceProvider => region => region switch
{
    "US" => serviceProvider.GetRequiredService<UpsShippingProvider>(),
    "EU" => serviceProvider.GetRequiredService<DhlShippingProvider>(),
    _ => throw new NotSupportedException()
});
Enter fullscreen mode Exit fullscreen mode

For simpler cases, .NET's dependency injection container itself can serve as a factory via a registered Func<T> delegate — this avoids writing a dedicated factory class at all for cases where the "factory" logic is genuinely just a lookup/switch, letting the DI container resolve the actual concrete instances (with their own dependencies) while the delegate handles only the selection logic.

When Factory earns its place

The Factory pattern is worth reaching for specifically when object construction involves real decision logic (choosing among several implementations) or non-trivial setup (assembling configuration, credentials) that would otherwise be duplicated across multiple call sites — for a type with one single, straightforward constructor and no meaningful construction-time decisions, a factory is pure ceremony; just construct it directly, or let DI (per this series' ASP.NET Core guide) handle it.


2. Singleton Pattern

The problem: exactly one instance of something, shared everywhere

// The classic, textbook Singleton implementation
public class ConfigurationCache
{
    private static readonly Lazy<ConfigurationCache> _instance = new(() => new ConfigurationCache());
    public static ConfigurationCache Instance => _instance.Value;

    private readonly Dictionary<string, string> _cache = new();
    private ConfigurationCache() { } // private constructor prevents anyone else from creating an instance

    public string? Get(string key) => _cache.GetValueOrDefault(key);
}
Enter fullscreen mode Exit fullscreen mode
// Used anywhere in the codebase, always resolving to the SAME instance
var value = ConfigurationCache.Instance.Get("ApiKey");
Enter fullscreen mode Exit fullscreen mode

The Singleton pattern guarantees that a class has exactly one instance, globally accessible, for the lifetime of the application — the private constructor prevents anyone from creating a second instance, and the static Instance property is the only way to obtain the one that exists.

Why the classic, static Singleton is now considered an anti-pattern in most .NET application code

// Testing code that depends on ConfigurationCache.Instance is genuinely painful:
public class SomeService
{
    public string GetApiKey() => ConfigurationCache.Instance.Get("ApiKey"); // no way to substitute a test double
}
Enter fullscreen mode Exit fullscreen mode

The classic static Singleton has real, well-documented problems in a modern .NET codebase: it's essentially impossible to substitute with a test double (there's no interface, no injection point — the class is hardwired to a specific global instance), it hides a genuine dependency behind what looks like a static method call (nothing in SomeService's constructor signals that it depends on configuration), and it makes lifetime and initialization order implicit and hard to reason about, especially in an application with genuine startup-ordering concerns.

The modern .NET equivalent: DI container singleton lifetime

public interface IConfigurationCache
{
    string? Get(string key);
}

public class ConfigurationCache : IConfigurationCache
{
    private readonly Dictionary<string, string> _cache = new();
    public string? Get(string key) => _cache.GetValueOrDefault(key);
}
Enter fullscreen mode Exit fullscreen mode
builder.Services.AddSingleton<IConfigurationCache, ConfigurationCache>();
Enter fullscreen mode Exit fullscreen mode
public class SomeService
{
    private readonly IConfigurationCache _cache;
    public SomeService(IConfigurationCache cache) => _cache = cache; // an explicit, testable, substitutable dependency
}
Enter fullscreen mode Exit fullscreen mode

Registering a type with AddSingleton (covered in this series' ASP.NET Core guide) achieves the same runtime property the classic Singleton pattern is after — exactly one instance, shared across the application's lifetime — while remaining fully testable (inject a mock IConfigurationCache in a unit test), explicit about dependencies (visible right in the constructor), and consistent with how every other dependency in the application is managed. This is genuinely the recommended, idiomatic approach in modern .NET: let the DI container be your Singleton pattern, rather than hand-rolling the classic static implementation.

When a genuine, hand-rolled Singleton might still be appropriate

// A type used in contexts genuinely outside the DI container's reach (e.g., a static utility, or extremely low-level code)
public sealed class PerformanceCounters
{
    private static readonly Lazy<PerformanceCounters> _instance = new(() => new PerformanceCounters());
    public static PerformanceCounters Instance => _instance.Value;
}
Enter fullscreen mode Exit fullscreen mode

The hand-rolled pattern remains reasonable for genuinely low-level, framework-adjacent code operating outside the reach of application-level DI (a very specific utility class used in contexts where DI resolution genuinely isn't available or practical) — but for essentially all application and business logic, DI's singleton lifetime is the better, more testable choice, and reaching for the classic pattern in ordinary application code is usually a sign of not yet being familiar with the DI-based alternative rather than a deliberate, justified choice.


3. Repository Pattern

Already covered in depth elsewhere in this series — this section connects the dots

The Repository pattern — a collection-like abstraction (GetById, Add, Save) hiding the actual persistence mechanism behind an interface — is covered in genuine depth in this series' Domain-Driven Design guide (Section 7 there specifically), where it's framed as scoped to one aggregate root at a time. This section covers it here specifically as a general-purpose design pattern, independent of DDD, since it's commonly used in codebases that haven't adopted DDD's other tactical patterns at all.

public interface IProductRepository
{
    Task<Product?> GetByIdAsync(int id);
    Task<List<Product>> GetByCategoryAsync(int categoryId);
    Task AddAsync(Product product);
    Task SaveChangesAsync();
}

public class EfProductRepository : IProductRepository
{
    private readonly AppDbContext _dbContext;
    public EfProductRepository(AppDbContext dbContext) => _dbContext = dbContext;

    public async Task<Product?> GetByIdAsync(int id) => await _dbContext.Products.FindAsync(id);
    public async Task<List<Product>> GetByCategoryAsync(int categoryId) =>
        await _dbContext.Products.Where(p => p.CategoryId == categoryId).ToListAsync();
    public async Task AddAsync(Product product) => await _dbContext.Products.AddAsync(product);
    public async Task SaveChangesAsync() => await _dbContext.SaveChangesAsync();
}
Enter fullscreen mode Exit fullscreen mode

The genuine, ongoing debate: is Repository worth it on top of EF Core?

// Without a repository: application code depends on DbContext (already an abstraction over ADO.NET) directly
public class ProductService
{
    private readonly AppDbContext _dbContext;
    public async Task<Product?> GetProductAsync(int id) => await _dbContext.Products.FindAsync(id);
}
Enter fullscreen mode Exit fullscreen mode

As referenced in this series' EF Core guide, DbContext and DbSet<T> are already an abstraction over the raw database — a repository wrapping EF Core specifically adds a second abstraction layer on top of an existing one, and a genuinely common, well-argued position in the .NET community is that this second layer is often redundant ceremony: EF Core's DbContext is already substitutable in tests (via the in-memory provider or SQLite, per this series' EF Core guide's testing section), and a hand-written repository interface frequently ends up as a thinner, less capable version of what DbSet<T>/LINQ already provides.

Where Repository still earns its place

// Genuinely valuable: hiding a MORE complex persistence mechanism than a simple ORM query
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(OrderId id); // hides that this actually queries TWO tables and reconstructs an aggregate
    Task SaveAsync(Order order);            // hides that saving also dispatches domain events, per this series' DDD guide
}
Enter fullscreen mode Exit fullscreen mode

Repository earns its place specifically when it's hiding genuine complexity beyond "translate this LINQ query into SQL" — reconstructing a DDD aggregate from multiple related tables (per this series' DDD guide), abstracting over a persistence mechanism that might genuinely change (starting with EF Core, potentially migrating to Dapper for a specific hot path, per this series' Dapper guide), or providing a clean seam for substituting an entirely different implementation in tests without needing a real database or even an in-memory EF provider at all.

The pragmatic middle ground many .NET teams land on

A common, reasonable compromise: use DbContext directly for straightforward CRUD (skip the redundant wrapping layer), and introduce an explicit repository interface specifically for aggregates with genuine reconstruction complexity or where a specific testing/substitution need justifies the extra abstraction — rather than treating "always add a repository" or "never add a repository" as a universal rule applied uniformly regardless of the actual complexity being hidden.


4. Strategy Pattern

The problem: an algorithm that needs to vary, selected at runtime

// ❌ A growing if/else or switch, mixing selection logic with every algorithm's implementation inline
public decimal CalculateShipping(Order order)
{
    if (order.ShippingMethod == "standard")
    {
        return order.Weight * 0.5m;
    }
    else if (order.ShippingMethod == "express")
    {
        return order.Weight * 1.5m + 10m;
    }
    else if (order.ShippingMethod == "overnight")
    {
        return order.Weight * 3m + 25m;
    }
    throw new NotSupportedException();
}
Enter fullscreen mode Exit fullscreen mode

As this method accumulates more shipping methods, each with its own calculation logic, it becomes an ever-growing, harder-to-test, harder-to-extend block — adding a new shipping method means editing this one method, risking an accidental change to an unrelated branch, and testing any single shipping method's logic in isolation means exercising this whole method rather than something narrowly scoped to just that one algorithm.

The Strategy pattern: extract each algorithm into its own, interchangeable implementation

public interface IShippingCostStrategy
{
    decimal Calculate(Order order);
}

public class StandardShippingStrategy : IShippingCostStrategy
{
    public decimal Calculate(Order order) => order.Weight * 0.5m;
}

public class ExpressShippingStrategy : IShippingCostStrategy
{
    public decimal Calculate(Order order) => order.Weight * 1.5m + 10m;
}

public class OvernightShippingStrategy : IShippingCostStrategy
{
    public decimal Calculate(Order order) => order.Weight * 3m + 25m;
}
Enter fullscreen mode Exit fullscreen mode
public class ShippingCostCalculator
{
    private readonly Dictionary<string, IShippingCostStrategy> _strategies;
    public ShippingCostCalculator(IEnumerable<IShippingCostStrategy> strategies) =>
        _strategies = strategies.ToDictionary(s => s.GetType().Name.Replace("ShippingStrategy", "").ToLower());

    public decimal Calculate(Order order, string method) =>
        _strategies.TryGetValue(method, out var strategy)
            ? strategy.Calculate(order)
            : throw new NotSupportedException($"No shipping strategy for {method}");
}
Enter fullscreen mode Exit fullscreen mode

Each algorithm is now its own small, independently testable class, implementing a shared interface — adding a new shipping method means adding a new class, with zero risk of breaking any existing strategy's logic, and testing ExpressShippingStrategy requires no knowledge of how the other two strategies work.

Selecting a strategy via DI, keyed registration (.NET 8+)

builder.Services.AddKeyedSingleton<IShippingCostStrategy, StandardShippingStrategy>("standard");
builder.Services.AddKeyedSingleton<IShippingCostStrategy, ExpressShippingStrategy>("express");
builder.Services.AddKeyedSingleton<IShippingCostStrategy, OvernightShippingStrategy>("overnight");
Enter fullscreen mode Exit fullscreen mode
public class ShippingHandler([FromKeyedServices("express")] IShippingCostStrategy strategy)
{
    // when the specific strategy is known at the injection point
}
Enter fullscreen mode Exit fullscreen mode

For scenarios where the specific strategy needed is known at compile time (or resolvable at a specific injection point), .NET's keyed services (referenced in this series' ASP.NET Core and Minimal APIs guides) provide a clean, DI-native way to register and resolve named strategy implementations, as an alternative to the manually-built dictionary shown above.

Strategy as the mechanism behind polymorphism-driven design generally

The Strategy pattern is, at its core, simply "use an interface and depend on the abstraction, not a concrete implementation" — a principle so foundational to well-designed object-oriented code that it's easy to not notice you're using it. Recognizing it explicitly as Strategy matters mainly for communication (naming the pattern when discussing a design) and for recognizing when a growing conditional block (the anti-pattern this section opened with) is a signal that extracting a Strategy would genuinely improve the code's structure.

When Strategy might be unnecessary

For a genuinely small, fixed, unlikely-to-grow set of two or three simple branches that will realistically never need independent testing or a fourth option, a plain switch expression (as C# now supports quite elegantly) can be entirely reasonable — Strategy's real value shows up specifically as the number of variations grows, as each variation's logic becomes non-trivial, or as independent testability of each variation becomes genuinely important.


5. Mediator Pattern

The problem: components that need to communicate, without knowing about each other directly

// ❌ Every component holds direct references to every other component it needs to notify
public class OrderForm
{
    private readonly InventoryPanel _inventoryPanel;
    private readonly PricingPanel _pricingPanel;
    private readonly ShippingPanel _shippingPanel;
    // OrderForm needs a direct reference to every OTHER component it might need to affect

    public void OnItemAdded(Item item)
    {
        _inventoryPanel.UpdateStock(item);
        _pricingPanel.RecalculateTotal(item);
        _shippingPanel.RecalculateWeight(item);
    }
}
Enter fullscreen mode Exit fullscreen mode

Without a mediator, components that need to react to each other's actions end up holding direct references to one another, and this coupling grows combinatorially — every component potentially needs a reference to every other component it might need to affect, and every new component added to the system means updating every existing component that might need to know about it.

The Mediator pattern: route communication through a single, central coordinator

public interface IMediator
{
    Task Send<TRequest, TResponse>(TRequest request) where TRequest : IRequest<TResponse>;
}
Enter fullscreen mode Exit fullscreen mode

This is precisely the pattern behind MediatR, covered in depth in this series' Vertical Slices guide — rather than components calling each other directly, every component sends a request through a shared mediator, and the mediator (via its handler-discovery mechanism) routes that request to whichever handler is actually responsible for it, without the sender needing to know which handler that is, or how many other things might also need to react.

// The sender knows only about the mediator, not about any specific handler
public class PlaceOrderEndpoint
{
    public static void Map(IEndpointRouteBuilder app) =>
        app.MapPost("/orders", async (PlaceOrderCommand command, IMediator mediator) =>
            await mediator.Send(command));
}

// The handler is discovered and invoked by the mediator automatically — no direct reference needed
public class PlaceOrderHandler : IRequestHandler<PlaceOrderCommand, Result<int>>
{
    public async Task<Result<int>> Handle(PlaceOrderCommand command, CancellationToken cancellationToken)
    {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Mediator for in-process domain event dispatch

public interface INotificationHandler<TNotification>
{
    Task Handle(TNotification notification, CancellationToken cancellationToken);
}

public class SendConfirmationEmailOnOrderPlaced : INotificationHandler<OrderPlacedEvent>
{
    public async Task Handle(OrderPlacedEvent notification, CancellationToken cancellationToken)
    {
        await _emailService.SendOrderConfirmationAsync(notification.OrderId);
    }
}

public class UpdateInventoryOnOrderPlaced : INotificationHandler<OrderPlacedEvent>
{
    public async Task Handle(OrderPlacedEvent notification, CancellationToken cancellationToken)
    {
        await _inventoryService.ReserveStockAsync(notification.OrderId);
    }
}
Enter fullscreen mode Exit fullscreen mode
await _mediator.Publish(new OrderPlacedEvent(order.Id, order.CustomerId, order.Total));
// BOTH handlers above run, automatically, with neither the publisher nor either handler
// needing a direct reference to the other
Enter fullscreen mode Exit fullscreen mode

This is MediatR's Publish (as opposed to Send) — a genuine, in-process implementation of the pub/sub pattern covered in this series' Pub/Sub Patterns guide, useful specifically for reacting to domain events (per this series' DDD guide) within a single process/service, as distinct from the cross-service pub/sub covered via RabbitMQ, Kafka, or Azure Service Bus in their respective guides.

Mediator vs. a direct method call: when the indirection is worth it

// Direct call — simpler, appropriate when there's genuinely only ONE thing that needs to happen
await _orderService.PlaceOrderAsync(request);

// Mediator — worth it when MULTIPLE, independent things need to react, or when the CALLER shouldn't need to know how many
await _mediator.Publish(new OrderPlacedEvent(...));
Enter fullscreen mode Exit fullscreen mode

The Mediator pattern's value is directly proportional to how many things need to react to a given action, and how much that number is expected to grow or vary over time — for something with exactly one clear handler that will realistically never need a second, a direct method call is simpler and more traceable (you can literally jump to the implementation via your IDE); Mediator earns its indirection specifically when decoupling the sender from an unknown or growing number of reactions is a genuine, ongoing need, precisely the scenario this series' Event-Driven Architecture and Vertical Slices guides describe in depth.


6. How These Patterns Combine in Practice

A realistic feature slice using several patterns together

// Features/Orders/PlaceOrder/PlaceOrderHandler.cs
public class PlaceOrderHandler : IRequestHandler<PlaceOrderCommand, Result<int>> // MEDIATOR
{
    private readonly IOrderRepository _repository;           // REPOSITORY
    private readonly IShippingProviderFactory _shippingFactory; // FACTORY
    private readonly IMediator _mediator;                       // MEDIATOR, again, for publishing

    public PlaceOrderHandler(
        IOrderRepository repository,
        IShippingProviderFactory shippingFactory,
        IMediator mediator)
    {
        _repository = repository;
        _shippingFactory = shippingFactory;
        _mediator = mediator;
    }

    public async Task<Result<int>> Handle(PlaceOrderCommand command, CancellationToken cancellationToken)
    {
        var order = new Order(command.CustomerId);
        foreach (var item in command.Items)
            order.AddLineItem(item.ProductId, item.UnitPrice, item.Quantity);

        var shippingStrategy = _shippingFactory.Create(command.Region); // FACTORY selects...
        order.SetShippingCost(shippingStrategy.CalculateCost(order));    // ...a STRATEGY calculates

        await _repository.AddAsync(order);
        await _repository.SaveChangesAsync();

        await _mediator.Publish(new OrderPlacedEvent(order.Id, order.CustomerId, order.Total)); // MEDIATOR notifies

        return Result.Success(order.Id);
    }
}
Enter fullscreen mode Exit fullscreen mode
// PlaceOrderHandler itself is registered as a scoped service via DI's SINGLETON-lifetime container
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
Enter fullscreen mode Exit fullscreen mode

This one handler — a vertical slice, per this series' companion guide — genuinely uses four of this guide's five patterns together, naturally, without any of them feeling forced: Mediator routes the incoming command to this handler and later publishes the resulting event; Repository abstracts persistence; Factory selects the right shipping provider; Strategy (implemented by whatever IShippingCostStrategy the factory returns) calculates the actual cost. This is a realistic illustration of how these patterns aren't independent, separately-adopted choices in most real .NET codebases — they compose together naturally within the broader architectural patterns (Vertical Slices, DDD, Event-Driven Architecture) covered elsewhere in this series.


7. Patterns vs. Over-Engineering

The single most important caveat to everything in this guide

Every pattern covered here solves a genuine problem — but every pattern also adds a layer of indirection, and indirection has a real cost: more files to navigate, more abstractions to hold in your head, and more places a bug or a misunderstanding can hide. The Gang of Four patterns became genuinely famous specifically because they're useful often enough to be worth learning and recognizing — not because every single instance of object creation, state sharing, data access, algorithm variation, or component communication should automatically reach for the corresponding pattern.

The test worth applying before introducing any of these patterns

"Am I introducing this pattern because I have a GENUINE, CURRENT problem it solves,
 or because I'm anticipating a future need that may never actually materialize?"
Enter fullscreen mode Exit fullscreen mode

A Factory with exactly one concrete implementation it will ever create, a Strategy interface with exactly one strategy that will ever exist, a Repository wrapping a DbSet<T> query with no additional complexity, a Mediator handler for an event with exactly one, permanently-fixed handler — each of these is a pattern applied to a problem that doesn't yet exist, adding real navigational and cognitive overhead in exchange for flexibility that may never actually be exercised. This connects directly to this series' Microservices and DDD guides' recurring theme: match architectural investment to genuine, demonstrated need, not to a speculative future requirement or to pattern-application for its own sake.

YAGNI ("You Aren't Gonna Need It") as the counterbalance

The disciplined middle ground most experienced .NET developers converge on: start with the simplest thing that solves the actual, current problem (a direct method call, a concrete class, an inline calculation), and introduce the corresponding pattern specifically when a second, genuinely different variation, caller, or requirement actually materializes — not preemptively, on the assumption that it eventually will. Refactoring a direct call into a Strategy once a second algorithm variant genuinely appears is straightforward; removing unnecessary abstraction that was never actually needed is, in practice, something codebases rarely get around to doing.


8. Common Pitfalls

Pitfall Why it hurts Better approach
Using the classic static Singleton pattern in application code Untestable, hides a real dependency, implicit lifetime Use DI's AddSingleton lifetime instead
Adding a Repository that just thinly wraps DbSet<T> with no additional logic Redundant abstraction over an abstraction EF Core already provides Use DbContext directly for simple CRUD; reserve Repository for genuine complexity
A Factory with exactly one implementation it will ever create Speculative flexibility for a variation that doesn't exist Construct the type directly; introduce Factory when a second implementation genuinely appears
A growing if/switch block instead of extracting a Strategy once variations multiply Hard to test individual variations in isolation, risk of cross-branch bugs Extract a Strategy once the branching logic grows non-trivial or needs independent testing
Reaching for Mediator/MediatR for a single, fixed sender-to-handler relationship Adds indirection with no corresponding decoupling benefit Use a direct method call when there's genuinely only one thing that needs to happen
Applying every pattern in this guide preemptively, "in case it's needed later" Real, ongoing cognitive and navigational cost for speculative future flexibility Apply YAGNI; introduce a pattern when its problem genuinely, currently exists
Confusing "using an interface" with "correctly applying a named pattern" Leads to pattern names being used loosely/incorrectly in design discussions Understand the actual problem each pattern solves, not just its superficial shape

Quick Reference Table

Pattern Problem it solves Modern .NET default
Factory Centralizing object-creation decision logic A class with a Create method, or a registered Func<T> delegate via DI
Singleton Exactly one shared instance, application-wide AddSingleton in the DI container, not a hand-rolled static instance
Repository Abstracting persistence behind a collection-like interface DbContext directly for simple CRUD; explicit repositories for genuine complexity
Strategy Swappable algorithm implementations behind a shared interface An interface + DI, optionally with .NET 8+ keyed services
Mediator Decoupling senders from an unknown/growing number of handlers MediatR, for both request/response (Send) and pub/sub (Publish)

Conclusion

These five patterns endure in .NET codebases because each solves a genuine, recurring structural problem — centralizing creation decisions, sharing a single instance safely, abstracting persistence, swapping algorithms cleanly, and decoupling communication between components — and because giving each problem a shared name makes design conversations faster and more precise. Several of them (Singleton especially) have modern, more testable .NET-native equivalents that largely supersede their classic textbook form, and it's worth knowing the modern idiom (DI's singleton lifetime) rather than the historical implementation most literature still teaches first.

The thread connecting this guide to nearly everything else in this series is the same one running through the Vertical Slices, DDD, and Microservices guides: these patterns aren't independent boxes to check off a list — they compose naturally within a well-organized feature slice, a well-modeled domain aggregate, or a well-bounded service, and the discipline that matters most isn't memorizing each pattern's textbook shape, but recognizing the actual problem in front of you and reaching for the pattern that solves it — no more, no less, and not before that problem genuinely exists.


Found this useful? Feel free to star the repo, open an issue with corrections, or share the growing if/else block that finally convinced you to extract a Strategy.

Top comments (0)