Design patterns in modern C# look different from the 1994 Gang of Four catalog they came from, not because the problems changed, but because the language and the framework absorbed most of the scaffolding those patterns used to require by hand. This guide is for developers who know the classic pattern names but want to see them written the way a senior .NET engineer actually writes them today: with dependency injection instead of static singletons, records and pattern matching instead of class hierarchies, and framework features that already implement half the catalog for you. You will see creational, structural and behavioral patterns expressed in current C#, where the same patterns already live in the BCL and ASP.NET Core, and where reaching for a named pattern adds indirection instead of clarity.
What Are Design Patterns in Modern C#?#
A design pattern is a reusable solution to a recurring design problem, not a library you install or a class you must name Factory. The original Gang of Four catalog organized patterns into three families: creational patterns control how objects get created, structural patterns control how objects are composed into larger structures, and behavioral patterns control how objects communicate and share responsibility. That taxonomy still holds up; what changed is the implementation. C# now has first-class delegates, records, pattern matching and a built-in dependency injection container, so several patterns that needed several classes in Java in 1994 need one interface, one lambda, or nothing at all in C# today.
How to Approach Patterns in Modern C#
Treat a pattern as a name for a shape you recognize, not a goal to apply. The most common mistake is reaching for a pattern before a second variation of the behavior actually exists; a single if branch does not need a Strategy hierarchy, and a class with one constructor argument does not need a Builder. Patterns earn their complexity when a real second case shows up, when a dependency needs to be swapped for a test, or when a cross-cutting concern (logging, retries, caching) needs to wrap several implementations the same way.
| Category | What it controls | Typical modern C# tool |
|---|---|---|
| Creational | How and when objects are constructed | DI container, factory delegates, object initializers |
| Structural | How objects and types are composed | Interfaces, extension methods, DelegatingHandler, decoration |
| Behavioral | How responsibility and communication flow | Delegates, IEnumerable<T>, pattern matching, events |
Getting Started: Add a Pattern When a Second Case Appears#
Start with the simplest thing that compiles, and let the code tell you when it needs a pattern. A notification sender that only ever sends email does not need an abstraction:
public sealed class OrderNotifier(EmailClient email)
{
public Task NotifyOrderPlacedAsync(Order order, CancellationToken ct) =>
email.SendAsync(order.CustomerEmail, "Order confirmed", ct);
}The moment SMS notifications show up as a second, real requirement, that concrete dependency becomes an interface, and you have arrived at the Strategy pattern without ever naming it up front:
public interface INotificationChannel
{
Task SendAsync(Order order, CancellationToken ct);
}
public sealed class OrderNotifier(IEnumerable<INotificationChannel> channels)
{
public Task NotifyOrderPlacedAsync(Order order, CancellationToken ct) =>
Task.WhenAll(channels.Select(c => c.SendAsync(order, ct)));
}Creational Patterns#
Creational patterns separate the decision of what to build from the mechanics of how to build it, so callers depend on an abstraction instead of a new expression.
Factory#
A factory hides which concrete type gets created behind a method or an injected abstraction. IHttpClientFactory is the canonical example already in the framework; the same idea applies to your own types whenever construction needs to vary at runtime:
public interface IOrderExporterFactory
{
IOrderExporter Create(ExportFormat format);
}
public sealed class OrderExporterFactory(IEnumerable<IOrderExporter> exporters) : IOrderExporterFactory
{
public IOrderExporter Create(ExportFormat format) =>
exporters.SingleOrDefault(e => e.Format == format)
?? throw new NotSupportedException($"No exporter registered for {format}.");
}Builder#
A builder assembles a complex object step by step and defers producing the final, often immutable, result until every step is set. WebApplicationBuilder, HostApplicationBuilder and DbContextOptionsBuilder are all builders you already use every day; a domain builder follows the same fluent shape:
public sealed class OrderBuilder
{
private readonly List<OrderLine> _lines = [];
private Address? _shippingAddress;
public OrderBuilder AddLine(string sku, int quantity) { _lines.Add(new(sku, quantity)); return this; }
public OrderBuilder ShipTo(Address address) { _shippingAddress = address; return this; }
public Order Build() =>
_shippingAddress is null
? throw new InvalidOperationException("Shipping address is required.")
: new Order([.. _lines], _shippingAddress);
}
var order = new OrderBuilder().AddLine("SKU-1", 2).ShipTo(address).Build();Singleton via Dependency Injection#
The classic Singleton pattern, a static field guarding a lazily created instance, hides a dependency and makes the type nearly impossible to substitute in a test. A DI container gives you the same "exactly one instance per application" guarantee without either problem, because the instance is still injected through a constructor and can be swapped for a fake:
builder.Services.AddSingleton<IOrderMetrics, OrderMetrics>();
public sealed class OrderMetrics : IOrderMetrics
{
private long _ordersPlaced;
public void RecordOrderPlaced() => Interlocked.Increment(ref _ordersPlaced);
public long OrdersPlaced => Interlocked.Read(ref _ordersPlaced);
}Reach for AddSingleton for the lifetime guarantee, and reserve the classic static-instance form for the rare case where you have no DI container at all, such as a small console utility.
Structural Patterns#
Structural patterns compose existing types into a larger shape without changing their internals.
Adapter#
An adapter translates one interface into another your code already depends on, most often to isolate a third-party API behind your own abstraction so the rest of the codebase never references the vendor's types directly:
public interface IPaymentGateway
{
Task<PaymentResult> ChargeAsync(decimal amount, string currency, CancellationToken ct);
}
// Adapts a third-party SDK's shape to the interface the rest of the app depends on.
public sealed class StripeGatewayAdapter(StripeClient stripe) : IPaymentGateway
{
public async Task<PaymentResult> ChargeAsync(decimal amount, string currency, CancellationToken ct)
{
var charge = await stripe.Charges.CreateAsync(new ChargeCreateOptions
{
Amount = (long)(amount * 100), Currency = currency
}, cancellationToken: ct);
return new PaymentResult(charge.Id, charge.Status == "succeeded");
}
}Decorator#
A decorator wraps an implementation of an interface with another implementation of the same interface, adding behavior without the wrapped type knowing it happened. Stream types (GZipStream wrapping a FileStream) and HttpClient's DelegatingHandler chain are decorators built into the BCL. For your own services, the Scrutor package adds decoration to the built-in container, since Microsoft.Extensions.DependencyInjection has no Decorate method on its own:
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.Decorate<IOrderRepository, CachingOrderRepositoryDecorator>();
public sealed class CachingOrderRepositoryDecorator(IOrderRepository inner, IMemoryCache cache)
: IOrderRepository
{
public Task<Order?> GetAsync(Guid id, CancellationToken ct) =>
cache.GetOrCreateAsync($"order:{id}", _ => inner.GetAsync(id, ct));
}Facade#
A facade is a single, simplified entry point in front of several subsystems that would otherwise all need to be called and sequenced correctly by every caller. A PlaceOrderFacade that internally calls inventory, pricing, payment and notification services, in the right order with the right error handling, is a facade even without the word ever appearing in the code; the value is entirely in giving callers one method instead of four they have to get right themselves. Keep a facade thin: the moment it grows its own business rules instead of just sequencing calls to other services, it has quietly become a service in its own right and deserves to be named as one.
Proxy#
A proxy stands in for another object and controls access to it, adding a concern such as lazy loading, caching, remote invocation or access control without the caller knowing the difference. System.Reflection.DispatchProxy lets you build one generically for any interface, EF Core's change-tracking proxies are a lazy-loading proxy over your entities, and at the scale of an entire service, YARP is the same pattern applied to HTTP traffic instead of one object:
public sealed class LoggingProxy<T> : DispatchProxy where T : class
{
private T _target = null!;
private ILogger _logger = null!;
protected override object? Invoke(MethodInfo? method, object?[]? args)
{
_logger.LogInformation("Calling {Method}", method!.Name);
return method.Invoke(_target, args);
}
public static T Create(T target, ILogger logger)
{
var proxy = Create<T, LoggingProxy<T>>();
(proxy as LoggingProxy<T>)!._target = target;
(proxy as LoggingProxy<T>)!._logger = logger;
return proxy;
}
}Behavioral Patterns#
Behavioral patterns govern how responsibility is distributed and how objects communicate once they are built and composed.
Strategy#
Strategy selects one of several interchangeable algorithms at runtime. Keyed dependency injection, available since .NET 8, resolves a named strategy directly from the container instead of hand-writing a switch statement over an enum:
builder.Services.AddKeyedSingleton<IDiscountStrategy, VipDiscountStrategy>("vip");
builder.Services.AddKeyedSingleton<IDiscountStrategy, StandardDiscountStrategy>("standard");
public sealed class PricingService(IServiceProvider services)
{
public decimal ApplyDiscount(Order order, string customerTier) =>
services.GetRequiredKeyedService<IDiscountStrategy>(customerTier).Apply(order);
}Observer#
Observer lets one or more subscribers react to something happening in another object without that object knowing who, or how many, are listening. C#'s event keyword and multicast delegates are the language's built-in implementation, and IObservable<T>/IObserver<T> (and the Rx.NET library built on them) extend the same idea to asynchronous streams of values. Reach for a plain event for simple, synchronous, in-process notifications, and for the in-process integration events pattern when several independent modules need to react to the same fact.
Command#
Command turns a request into a standalone object, carrying everything needed to execute it later, queue it, log it, or undo it. System.Windows.Input.ICommand is the built-in example behind every bound button in WPF and MAUI, and at the application layer, libraries such as MediatR, the MIT-licensed Mediator source generator, or Wolverine implement the same pattern for commands and queries flowing through a pipeline of cross-cutting behaviors. Use it when you need to decouple a caller from a handler, queue work, or wrap every request in the same logging, validation and transaction behavior; skip it for a simple method call that has, and will likely always have, exactly one caller and one implementation.
Chain of Responsibility#
Chain of Responsibility passes a request along a sequence of handlers, each of which can act on it, pass it on, or short-circuit the chain. ASP.NET Core's middleware pipeline is this pattern at the framework level, and writing your own middleware follows the same shape:
public sealed class CorrelationIdMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context)
{
context.Items["CorrelationId"] = context.Request.Headers["X-Correlation-Id"].FirstOrDefault()
?? Guid.NewGuid().ToString("n");
await next(context); // pass control to the next link in the chain
}
}Template Method#
Template Method fixes the skeleton of an algorithm in a base class while letting subclasses override individual steps. It is the least fashionable pattern in modern C#, since composition and delegates usually age better than inheritance, but it still fits a small, closed set of variants that share a strict sequence:
public abstract class ReportGenerator
{
public async Task<byte[]> GenerateAsync(CancellationToken ct)
{
var data = await FetchDataAsync(ct);
var formatted = Format(data);
return await RenderAsync(formatted, ct); // the fixed skeleton
}
protected abstract Task<IReadOnlyList<OrderRecord>> FetchDataAsync(CancellationToken ct);
protected abstract string Format(IReadOnlyList<OrderRecord> data);
protected virtual Task<byte[]> RenderAsync(string formatted, CancellationToken ct) =>
Task.FromResult(Encoding.UTF8.GetBytes(formatted));
}State#
State lets an object change its behavior as its internal state changes, without a wall of if statements checking a status field everywhere the object is used. Modern C# often expresses this more concisely with a closed set of records and a switch expression than with a class per state:
public abstract record OrderState;
public sealed record Placed : OrderState;
public sealed record Shipped(string TrackingNumber) : OrderState;
public sealed record Delivered(DateOnly Date) : OrderState;
public static string Describe(OrderState state) => state switch
{
Placed => "Waiting to ship",
Shipped s => $"In transit, tracking {s.TrackingNumber}",
Delivered d => $"Delivered on {d.Date}",
_ => throw new ArgumentOutOfRangeException(nameof(state))
};A traditional class-per-state implementation, where each state type also decides which state comes next, is still worth it when the transitions themselves carry real behavior, not just data.
Best Practices#
- Name the problem before the pattern. "This needs to vary at runtime" or "this needs to wrap several implementations the same way" leads to the right pattern faster than starting from a pattern name.
- Prefer composition and delegates over inheritance hierarchies for anything except Template Method's narrow use case.
- Let the DI container own object lifetime instead of hand-rolled singletons, static factories or service locators.
- Keep a facade or mediator thin. It should sequence calls to other components, not accumulate business rules of its own.
- Reach for a library only when the built-in language and framework feature does not already cover it, such as
IHttpClientFactoryinstead of a hand-written factory, orDelegatingHandlerinstead of a hand-written decorator forHttpClient. - Write the test first for anything you are about to abstract. If the abstraction does not make the test simpler, it probably is not paying for itself yet.
Anti-Patterns and Overuse#
Speculative generality. Adding a Strategy interface, a Factory, and a configuration flag for a variation that has never actually happened, and may never happen, adds indirection with no payoff; wait for the second real case.
The service locator in disguise. Injecting IServiceProvider itself and calling GetService inside a method hides real dependencies from the constructor, defeats the DI container's ability to validate the object graph at startup, and makes the class harder to test than either a singleton or a properly injected dependency would.
Mediator or Command for every single call. Routing every method call through a mediator pipeline, even calls with one caller and one handler that will never need a second, adds a layer of indirection to the call graph that a debugger and an IDE's "Find Usages" can no longer follow directly, and it comes with a real licensing decision now that some mediator libraries are commercial products, as covered in the modular monolith guide.
Decorator stacks nobody can trace. Five layers of caching, retry, logging and validation decorators wrapped around one interface are each individually reasonable and collectively unreadable; keep the stack shallow, and put the composition in one obvious place instead of scattering Decorate calls across the startup file.
Pattern names used as a substitute for a clear name. A class called OrderFactoryManagerImpl that does not actually implement Factory, Manager, or an interface named Impl-anything is a sign the pattern vocabulary is being used to sound structured rather than to describe a real shape in the code.
Frequently Asked Questions#
Do I still need the classic Gang of Four patterns in modern C#?#
You need the underlying ideas more than the classic implementations. Several patterns, Iterator, Strategy, Observer, Decorator, are now language or framework features (IEnumerable<T>, delegates, events, DelegatingHandler) rather than hand-written class hierarchies, while others, Adapter, Facade, State, Command, still show up as named designs because no language feature replaces the shape they describe.
When should I use a builder instead of a constructor with optional parameters?#
Reach for a builder when construction has several optional steps, needs validation that spans multiple values, or benefits from a fluent, readable call chain, the way WebApplicationBuilder configures an entire application before Build() produces an immutable result. For a handful of optional parameters, a primary constructor with named arguments or an object initializer is simpler and needs no extra type.
Is the Singleton pattern an anti-pattern?#
The classic implementation, a static field holding a lazily created instance, is worth avoiding because it hides a dependency and resists substitution in tests. The underlying goal, exactly one instance per application, is not an anti-pattern at all; registering the type with AddSingleton in the DI container gets you the same guarantee while keeping the dependency explicit and the type testable.
How is Decorator different from Proxy?#
Both wrap an implementation of the same interface, but their intent differs. Decorator adds new behavior around the wrapped object, such as caching or logging, and is meant to be stacked with other decorators. Proxy controls access to the wrapped object, such as lazy-loading it or forwarding the call across a network, and typically wraps exactly one target rather than forming a chain.
Which patterns matter most for a typical ASP.NET Core application?#
Strategy and Decorator via dependency injection, Adapter around third-party SDKs, and Chain of Responsibility via middleware cover the large majority of real day-to-day design decisions in a typical web application. Builder, Factory and Observer appear constantly, but mostly inside the framework itself rather than in code you write by hand.
Summary#
- Design patterns describe recurring shapes in code, not a checklist to complete; apply one when a real second case or a real cross-cutting concern appears.
- Modern C# and .NET already implement several classic patterns as language or framework features: delegates and
IEnumerable<T>for Strategy and Iterator, events for Observer,DelegatingHandlerfor Decorator and Chain of Responsibility. - Use the DI container for Singleton and Factory instead of static fields and hand-written factory classes, so dependencies stay explicit and testable.
- Facade, Adapter, Command and State still earn their keep as named designs in day-to-day .NET code, as long as they stay thin and solve a problem that actually exists.
- Watch for speculative generality, service locators in disguise, and mediator-for-everything as the most common ways these patterns get overused.