Articles › Software Architecture
8 Design Patterns Every C# Developer Should Know (with Real Examples)
Learn the design patterns you'll actually use in .NET projects — Strategy, Factory, Decorator, Adapter, Observer, Builder, Options and Mediator — with modern C# examples and DI.
Design patterns are named solutions to recurring design problems. You don't need to memorize all 23 Gang of Four patterns — a handful show up constantly in real .NET code, often hidden inside the framework itself. Knowing them helps you recognize good designs, communicate with your team ("let's use a decorator for caching") and avoid reinventing awkward solutions.
1. Strategy — swap algorithms at runtime
Problem: several interchangeable ways to do something (shipping cost, payment gateway, discount rule), selected at runtime.
public interface IPaymentGateway
{
string Name { get; }
Task<PaymentResult> ChargeAsync(decimal amount, string orderId, CancellationToken ct);
}
public class ZarinpalGateway : IPaymentGateway { public string Name => "zarinpal"; /* ... */ }
public class StripeGateway : IPaymentGateway { public string Name => "stripe"; /* ... */ }
public class PaymentService(IEnumerable<IPaymentGateway> gateways)
{
public Task<PaymentResult> PayAsync(string gateway, decimal amount, string orderId, CancellationToken ct) =>
gateways.Single(g => g.Name == gateway).ChargeAsync(amount, orderId, ct);
}
With DI, adding a gateway is a new class plus one registration — the Open/Closed Principle in action. In .NET 8+, keyed services offer a built-in alternative for selecting strategies by name.
2. Factory — centralize object creation
Problem: creating an object requires logic (configuration, choosing a subtype) that shouldn't be repeated everywhere.
public interface INotificationFactory
{
INotifier Create(NotificationChannel channel);
}
public class NotificationFactory(IServiceProvider services) : INotificationFactory
{
public INotifier Create(NotificationChannel channel) => channel switch
{
NotificationChannel.Email => services.GetRequiredService<EmailNotifier>(),
NotificationChannel.Sms => services.GetRequiredService<SmsNotifier>(),
NotificationChannel.Telegram => services.GetRequiredService<TelegramNotifier>(),
_ => throw new ArgumentOutOfRangeException(nameof(channel))
};
}
The framework is full of factories: IHttpClientFactory, ILoggerFactory, IDbContextFactory<T>.
3. Decorator — add behavior without changing the class
Problem: add cross-cutting behavior (caching, logging, retries, metrics) to an existing service without modifying it.
public interface IArticleService
{
Task<ArticleDto?> GetAsync(string slug, CancellationToken ct);
}
public class CachedArticleService(IArticleService inner, IMemoryCache cache) : IArticleService
{
public Task<ArticleDto?> GetAsync(string slug, CancellationToken ct) =>
cache.GetOrCreateAsync($"article:{slug}", entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
return inner.GetAsync(slug, ct);
});
}
// Registration: the decorator wraps the real implementation
builder.Services.AddScoped<ArticleService>();
builder.Services.AddScoped<IArticleService>(sp =>
new CachedArticleService(sp.GetRequiredService<ArticleService>(), sp.GetRequiredService<IMemoryCache>()));
The core service stays focused on business logic; caching is layered on top. Libraries like Scrutor add a Decorate<T>() helper for this. ASP.NET Core middleware is itself a chain of decorators around the request.
4. Adapter — make incompatible interfaces work together
Problem: you depend on a third-party API whose shape doesn't match your abstraction.
// Your abstraction
public interface ISmsSender
{
Task SendAsync(string phone, string message, CancellationToken ct);
}
// Adapter around a vendor SDK
public class KavenegarSmsAdapter(KavenegarApi api, IOptions<SmsOptions> options) : ISmsSender
{
public Task SendAsync(string phone, string message, CancellationToken ct) =>
api.SendAsync(options.Value.SenderLine, phone, message);
}
If you switch providers, you write a new adapter; the rest of the application never notices. Adapters are the backbone of the Infrastructure layer in Clean Architecture.
5. Observer — notify many listeners of a change
Problem: when something happens, several independent parts must react (send email, update stats, invalidate cache) without the source knowing about them.
public record OrderPlaced(int OrderId, string CustomerEmail) : IDomainEvent;
public interface IDomainEventHandler<in TEvent> where TEvent : IDomainEvent
{
Task HandleAsync(TEvent domainEvent, CancellationToken ct);
}
public class SendReceiptHandler(IEmailSender email) : IDomainEventHandler<OrderPlaced>
{
public Task HandleAsync(OrderPlaced e, CancellationToken ct) =>
email.SendAsync(e.CustomerEmail, "Your receipt", ct);
}
public class UpdateSalesStatsHandler(IStatsService stats) : IDomainEventHandler<OrderPlaced>
{
public Task HandleAsync(OrderPlaced e, CancellationToken ct) => stats.IncrementAsync(ct);
}
C# events, IObservable<T>, domain events and message brokers are all forms of Observer.
6. Builder — construct complex objects step by step
Problem: an object needs many optional parts, and constructors with ten parameters are unreadable.
var report = new ReportBuilder()
.WithTitle("Monthly sales")
.ForPeriod(new DateOnly(2026, 4, 1), new DateOnly(2026, 4, 30))
.IncludeChart(ChartType.Line)
.GroupBy("Category")
.Build();
You use builders constantly: WebApplication.CreateBuilder, StringBuilder, ModelBuilder in EF Core, UriBuilder. In tests, test data builders keep setup readable.
7. Options — strongly typed configuration
A .NET-specific pattern that replaces magic strings:
public class StorageOptions
{
public const string Section = "Storage";
[Required] public string Root { get; set; } = "";
public int MaxFileSizeMb { get; set; } = 5;
}
builder.Services.AddOptions<StorageOptions>()
.BindConfiguration(StorageOptions.Section)
.ValidateDataAnnotations()
.ValidateOnStart();
public class UploadService(IOptions<StorageOptions> options) { /* options.Value.Root */ }
ValidateOnStart makes misconfiguration fail at startup instead of at 3 a.m. on the first upload.
8. Mediator — decouple senders from handlers
Problem: controllers depending on many services; cross-cutting behavior needed around every use case.
public record GetArticleQuery(string Slug) : IRequest<ArticleDto?>;
public class GetArticleHandler(AppDbContext db) : IRequestHandler<GetArticleQuery, ArticleDto?>
{
public Task<ArticleDto?> Handle(GetArticleQuery q, CancellationToken ct) =>
db.Articles.Where(a => a.Slug == q.Slug)
.Select(a => new ArticleDto(a.Title, a.Content))
.FirstOrDefaultAsync(ct);
}
// Controller
[HttpGet("{slug}")]
public async Task<IActionResult> Get(string slug, ISender sender) =>
await sender.Send(new GetArticleQuery(slug)) is { } dto ? Ok(dto) : NotFound();
Each use case becomes one small handler, and pipeline behaviors add validation, logging or transactions around all of them. This is the usual foundation for CQRS (separate commands and queries). Weigh it carefully: for small apps, injecting a service directly is simpler.
Patterns you're already using
| Pattern | In .NET |
|---|---|
| Chain of Responsibility / Decorator | middleware pipeline |
| Factory | IHttpClientFactory, ILoggerFactory |
| Builder | WebApplicationBuilder, ModelBuilder |
| Observer | events, IObservable<T>, IChangeToken |
| Strategy | IComparer<T>, authorization handlers |
| Unit of Work / Repository | DbContext / DbSet<T> |
| Iterator | IEnumerable<T>, yield return |
When not to use a pattern
Patterns add indirection. Before introducing one, ask: what concrete problem does this solve right now? A strategy with one implementation, a factory that only calls new, or a mediator in a five-endpoint API mostly adds files to navigate. Start simple and refactor towards a pattern when the need appears.
Summary
Strategy and Factory handle variation and creation, Decorator and Adapter wrap behavior and external systems, Observer and Mediator decouple components, Builder and Options make construction and configuration clean. Learn to spot them in the framework, use them when a real problem calls for them, and your C# designs will be flexible without being over-engineered.
FAQ
Are design patterns still relevant with modern C#?
Yes, though language features change their shape: delegates replace some strategy classes, records simplify value objects, and DI containers implement factories for you.
What's the difference between Decorator and Adapter?
A decorator keeps the same interface and adds behavior; an adapter converts one interface into another.
Should I use MediatR in every project?
No. It's helpful in larger applications with many use cases and cross-cutting behaviors. In small APIs, it can add unnecessary indirection.