A design pattern is a named solution to a problem that keeps recurring. In C# several classic patterns are already built into the language or the framework, so knowing which ones still need writing is more useful than memorising all twenty-three. This guide covers six that earn their place, and names the ones that no longer do.
Strategy#
Swap an algorithm without changing the code that uses it. This is the pattern C# uses most, usually without anyone naming it:
public interface IDiscountPolicy
{
decimal Apply(decimal price);
}
public class NoDiscount : IDiscountPolicy
{
public decimal Apply(decimal price) => price;
}
public class PercentageDiscount : IDiscountPolicy
{
private readonly decimal _percent;
public PercentageDiscount(decimal percent) => _percent = percent;
public decimal Apply(decimal price) => price * (1 - _percent / 100m);
}
public class Checkout
{
private readonly IDiscountPolicy _policy;
public Checkout(IDiscountPolicy policy) => _policy = policy;
public decimal Total(IEnumerable<decimal> prices) =>
prices.Sum(_policy.Apply);
}
For a single method, a delegate is often enough and needs no interface at all:
public class Checkout
{
private readonly Func<decimal, decimal> _discount;
public Checkout(Func<decimal, decimal> discount) => _discount = discount;
}
var checkout = new Checkout(price => price * 0.9m);
Factory#
Create objects without the caller knowing which concrete type it gets:
public interface IExporter
{
string Export(IEnumerable<Order> orders);
}
public class ExporterFactory
{
private readonly IServiceProvider _services;
public ExporterFactory(IServiceProvider services) => _services = services;
public IExporter Create(string format) => format.ToLowerInvariant() switch
{
"csv" => _services.GetRequiredService<CsvExporter>(),
"json" => _services.GetRequiredService<JsonExporter>(),
_ => throw new ArgumentException($"Unknown format: {format}"),
};
}
Use a factory when the type is chosen at run time from data. When it is known at compile time, plain constructor injection is simpler.
Repository#
public interface IOrderRepository
{
Task<Order?> GetAsync(int id);
Task<IReadOnlyList<Order>> FindPaidAsync();
Task AddAsync(Order order);
}
The case for it: your business logic no longer mentions the database, which makes it testable with an in-memory implementation.
The case against it, which is worth taking seriously: Entity Framework’s DbContext is already a unit of work and DbSet is already a repository. Wrapping them often produces a thin layer that hides useful query capability and adds a file per entity for no benefit.
Decorator#
Add behaviour to something without changing it. This is where the pattern is genuinely at its best:
public class CachingProductService : IProductService
{
private readonly IProductService _inner;
private readonly IMemoryCache _cache;
public CachingProductService(IProductService inner, IMemoryCache cache)
{
_inner = inner;
_cache = cache;
}
public async Task<Product?> GetAsync(int id)
{
if (_cache.TryGetValue(id, out Product? cached)) return cached;
var product = await _inner.GetAsync(id);
if (product is not null)
_cache.Set(id, product, TimeSpan.FromMinutes(5));
return product;
}
}
Caching, logging, retries and metrics all fit this shape. Each decorator does one thing, and they stack — a logging decorator wrapping a caching decorator wrapping the real service. The core class stays unaware of all of it.
Observer#
C# has this in the language as events:
public class OrderService
{
public event EventHandler<OrderPlacedEventArgs>? OrderPlaced;
public void Place(Order order)
{
Save(order);
OrderPlaced?.Invoke(this, new OrderPlacedEventArgs(order));
}
}
service.OrderPlaced += (sender, e) => SendConfirmation(e.Order);
service.OrderPlaced += (sender, e) => UpdateStock(e.Order);
The null-conditional call is not optional — invoking an event with no subscribers throws. For anything crossing process boundaries, a message queue is the right version of this idea.
Options#
Not one of the classic twenty-three, but the pattern you will use most in .NET:
public class EmailOptions
{
public const string Section = "Email";
public string SmtpHost { get; set; } = "";
public int Port { get; set; } = 587;
public string FromAddress { get; set; } = "";
}
// Registration
builder.Services.Configure<EmailOptions>(
builder.Configuration.GetSection(EmailOptions.Section));
// Use
public class EmailSender
{
private readonly EmailOptions _options;
public EmailSender(IOptions<EmailOptions> options) => _options = options.Value;
}
Configuration becomes a typed object rather than string lookups scattered through the code, and a typo in a key surfaces at start-up rather than at the moment you send an email.
Patterns C# has absorbed#
| Pattern | Use instead |
|---|---|
| Singleton | DI container with singleton lifetime |
| Iterator | IEnumerable and yield return |
| Observer | events, or IObservable |
| Command | Action and Func delegates |
| Template method | a delegate parameter |
| Adapter | often an extension method |
Choosing when to use one#
Patterns are a response to a problem you already have, not a way to structure code you are about to write. Three questions worth asking:
- Is the thing I am abstracting actually going to vary? An interface with one implementation that never gains a second is pure overhead.
- Would a plain function do? A pattern with one method is usually a delegate.
- Can I name the problem? “I need to swap the discount rule per customer type” justifies strategy. “It seemed more professional” does not.
Questions people ask#
Do I need to learn all the Gang of Four patterns?
No. Knowing the names helps you read other people’s code and discuss designs. In modern C# perhaps eight see regular use, and several of the rest are solved by language features.
Is dependency injection a pattern?
It is a form of inversion of control — a class receives its dependencies rather than creating them. It is the foundation most other patterns here build on.
Is MVC a design pattern?
It is an architectural pattern, describing how a whole application is arranged rather than how a few classes collaborate. Different scale, same idea.
When is a pattern too much?
When explaining the design takes longer than the code it replaces, or when adding a feature means editing five files. Both are signs the abstraction is not paying for itself.
Where to go next#
- What is object-oriented programming? — the ideas these build on.
- C# advanced language features — the features that replaced several patterns.
- C# testing — where the testability argument gets checked.