Design Patterns
A design pattern is a well-known solution to a repeating problem. In this lesson you see four common patterns in .NET with an online shop: Strategy, Decorator, Factory and Mediator. Knowing the problem a pattern solves is more important than its name.
Author: bezzad
What is a pattern?
An experienced programmer recognizes problems. They say “This is the same old problem. Here is the solution.” A design pattern is this experience with a name.
A shared name is very useful. In code review you say “Put a Decorator here.” Your teammate understands the structure you mean in one second.
The most famous list of patterns is in the book Design Patterns. Four authors wrote it in 1994. People call them the “Gang of Four”. The book has 23 patterns in three groups:
| Group | Main question | Example in this lesson |
|---|---|---|
| Creational | Who creates the object, and how? | Factory |
| Structural | How do we put classes together? | Decorator |
| Behavioral | How do classes work together? | Strategy, Mediator |
The Strategy pattern: one job, many ways
Our shop has three payment gateways: Bank A, Bank B and a wallet. The first code looks like this:
switch (provider)
{
case "bank-a": /* get a token, then pay */ break;
case "bank-b": /* pay in Rials, not Tomans */ break;
case "wallet": /* charge the customer wallet */ break;
}
The product team says more gateways are coming. What happens with this code?
- Each new gateway means changing this same class. Tested code is opened again.
- The class keeps growing. Each gateway adds a new dependency to the constructor.
- Changing one gateway breaks another gateway. They are all in one file.
- The same switch is repeated in other places. For example, in refunds and status checks.
The Strategy pattern says: put each way in its own class. They all share one interface. The caller knows only the interface.
public interface IPaymentProvider
{
string Key { get; }
Task<PaymentResult> PayAsync(Order order, CancellationToken ct);
}
public sealed class BankBProvider(BankBClient client) : IPaymentProvider
{
public string Key => "bank-b";
// Bank B works with Rials, so the difference stays inside this class.
public Task<PaymentResult> PayAsync(Order order, CancellationToken ct) =>
client.PayAsync(order.Id, order.AmountInTomans * 10, ct);
}
public sealed class PaymentService(IEnumerable<IPaymentProvider> providers)
{
private readonly Dictionary<string, IPaymentProvider> _byKey =
providers.ToDictionary(p => p.Key);
public Task<PaymentResult> PayAsync(Order order, string key, CancellationToken ct) =>
_byKey.TryGetValue(key, out var provider)
? provider.PayAsync(order, ct)
: throw new NotSupportedException($"Unknown payment provider: {key}");
}
All gateways are registered in DI. The payment service gets all of them together, as a list:
builder.Services.AddScoped<IPaymentProvider, BankAProvider>();
builder.Services.AddScoped<IPaymentProvider, BankBProvider>();
builder.Services.AddScoped<IPaymentProvider, WalletProvider>();
builder.Services.AddScoped<PaymentService>();
- New gateway: one class and one registration line. No old code changes. This is the open-closed principle from the SOLID lesson.
- Testing: each gateway is tested alone, only with its own dependency.
- Differences: the Toman to Rial conversion is only inside the Bank B class.
The Factory pattern: give creation to one place
In Strategy, we picked the gateway with a key. But if creating an object is a bit complex, or we want the rest of the app to not deal with DI at all, we use a Factory. A Factory is a class that decides which object to create and returns it.
Since .NET 8, DI itself supports registration with a key (Keyed Services). A simple Factory on top of it:
builder.Services.AddKeyedScoped<IPaymentProvider, BankAProvider>("bank-a");
builder.Services.AddKeyedScoped<IPaymentProvider, BankBProvider>("bank-b");
builder.Services.AddScoped<PaymentProviderFactory>();
public sealed class PaymentProviderFactory(IServiceProvider services)
{
public IPaymentProvider Get(string key) =>
services.GetKeyedService<IPaymentProvider>(key)
?? throw new NotSupportedException($"Unknown payment provider: {key}");
}
- The rest of the code works only with PaymentProviderFactory. It does not know if DI or a dictionary is behind it.
- The key comes from user input. So always handle the “unknown key” case with a clear error.
The Decorator pattern: extra behavior, no change to the main class
The shop catalog has an IProductRepository that works with EF Core. Now we need two things: cache and logging. The simple way is to write both inside the same class. But the result is this:
- Each method has three jobs. Data access, cache and logging. This breaks the single responsibility principle.
- Cache logic is repeated in all methods. Changing the cache key means changing ten methods.
- You cannot test data access without the cache.
The Decorator pattern says: make a new class that has the same interface and keeps the main class inside it. It does the extra work and then passes the work to the inner class.
public sealed class CachedProductRepository(
IProductRepository inner, HybridCache cache) : IProductRepository
{
public async Task<Product?> GetByIdAsync(int id, CancellationToken ct) =>
await cache.GetOrCreateAsync(
$"product:{id}",
async token => await inner.GetByIdAsync(id, token),
cancellationToken: ct);
}
The EfProductRepository class now only works with the database. The cache class only knows the cache. Logging is also a separate Decorator.
Registering in DI is simple with the Scrutor library:
builder.Services.AddHybridCache();
builder.Services.AddScoped<IProductRepository, EfProductRepository>();
builder.Services.Decorate<IProductRepository, CachedProductRepository>();
builder.Services.Decorate<IProductRepository, LoggingProductRepository>();
In Scrutor, the last Decorate is the outermost layer. So a request reaches logging first, then cache, then EF Core.
The order of layers matters
Each layer sees only the requests that reach it. If the cache answers, the inner layers see nothing. In the example below, request a product twice. Then change the order and try again:
- Logging outside, cache inside. All requests are logged. You measure the time the user really sees.
- Cache outside, logging inside. Only requests that passed the cache are logged. This is useful to see the load on the database.
- General rule. First decide what each layer must see. Then arrange the order.
The Mediator pattern: the sender does not know the receiver
The orders controller has slowly grown to six or seven services in its constructor. Each action needs only two of them, but all of them are created to build the controller.
The Mediator pattern puts a mediator in the middle. The controller only sends one message: “place the order”. The mediator gives the message to the class that is responsible for that job. We call this class a Handler.
public sealed record PlaceOrder(Guid CartId) : IRequest<Guid>;
app.MapPost("/orders", async (PlaceOrder command, IMediator mediator, CancellationToken ct) =>
TypedResults.Ok(await mediator.Send(command, ct)));
- Benefit: each Handler is small and has only its own dependencies. Shared work, like validation and logging, is written once around all Handlers.
- Cost: an extra indirect layer is added. “Go to definition” in the IDE does not take you to the Handler. Dependencies are also hidden behind IMediator.
A few other patterns you see every day
| Pattern | Problem | Example in the shop or .NET |
|---|---|---|
| Adapter | An outside library has a different interface. | A class that turns the bank SDK into our IPaymentProvider. |
| Observer | Several parts must know about one event. | The “order placed” event and its receivers. See the DDD lesson. |
| Builder | Creating an object has several steps and settings. | Building the app with WebApplication and the CreateBuilder method. |
| Singleton | Only one instance of a class is needed. | In .NET, register it as Singleton in DI instead of a static class. |
Common mistakes
| Mistake | Why is it bad? | The right way |
|---|---|---|
| A pattern for a problem that does not exist | The code gets heavy and hard to understand, with no benefit. | First see the problem, then choose the pattern. |
| Strategy for two fixed cases | Several classes and an interface, instead of a simple if. | Keep the if or switch until the cases grow. |
| Cache and logging inside the main class | Many responsibilities in one class and repetition in all methods. | Put each extra job in a separate Decorator. |
| Decorator order without thinking | Logging or retry sees, or does not see, something you do not expect. | For each layer ask: which requests must reach me? |
| Handlers that call each other with Send | The code flow gets lost and is hard to follow. | Put shared logic in a normal class, not in another Handler. |
| Making a Singleton with a static class | Testing gets hard and the dependency is hidden. | Register it with a Singleton lifetime in DI. |
When to use a pattern?
Good fit
- One kind of change has been repeated many times.
- A class has grown because of several different jobs.
- The team knows the pattern name and talks more easily with it.
- .NET itself has the same pattern and you can use it.
Bad fit
- “Maybe we need it one day.”
- There is only one implementation and there will not be more.
- Teammates must open five files to understand a simple job.
- The pattern is only there to make the code look “professional”.
Summary in six lines
- A design pattern is the name of a well-known solution to a repeating problem.
- With Strategy, each way is in its own class, and a new way does not change old code.
- With Factory, the decision “which object to create” is in one place.
- With Decorator, extra work like cache and logging is wrapped around the main class. The order of layers matters.
- With Mediator, the sender does not know the receiver. But this pattern does not replace good design.
- First see the problem. If there is no problem, you do not need a pattern.