Levelwise
English
Code design

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.

Not reviewedWritten with AI helpReading time: 18 minOnline shop payment and catalog exampleC# and .NET 10 code

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
Problem first, pattern second. Do not use a pattern to show your knowledge. If the problem the pattern solves is not in your code, the pattern is only extra complexity.

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?

  1. Each new gateway means changing this same class. Tested code is opened again.
  2. The class keeps growing. Each gateway adds a new dependency to the constructor.
  3. Changing one gateway breaks another gateway. They are all in one file.
  4. 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.

User chose"bank-b"PaymentServiceIPaymentProviderBankAProviderBankBProviderWalletProviderNew gateway?Only one new classPayment service does not change
The payment service does not know how Bank B works. It only knows it has an IPaymentProvider.
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.
When is the switch enough? When you have only two or three fixed cases, each one is a line or two, and no new case is coming. In this situation, an interface and several classes only make the code longer.

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.
A familiar example in .NET: The IHttpClientFactory class is a Factory. It takes care of creating HttpClient and managing its connections. Your code only says “give me an HttpClient for this name”.

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:

  1. Each method has three jobs. Data access, cache and logging. This breaks the single responsibility principle.
  2. Cache logic is repeated in all methods. Changing the cache key means changing ten methods.
  3. 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.

ControllerLoggingProductRepositoryLogs each request and its timeCachedProductRepositoryIf cached, answer right hereEfProductRepositoryOnly database workSame interface in each layerand each calls the inner one
Like nesting dolls. The caller does not know how many layers there are.
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:

Live example: the order of Decorators
Logged requests: 0Database queries: 0
    • 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.
    A familiar example in .NET: The DelegatingHandler class for HttpClient has the same idea. Each Handler does its own job and passes the request to the inner Handler. ASP.NET Core middlewares are also a chain like this.

    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.

    Before: controller knows allOrdersControllerOrderCustomerStockPaymentDiscountNotifySix dependencies, even for a simple jobAfter: controller sends one messageOrdersControllerSend(PlaceOrder)IMediatorPlaceOrderHandlerPayOrderHandlerCancelOrderHandlerEach class has only its own job's dependencies
    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.
    Do not hide the mess. If a controller has seven dependencies, maybe it is a design problem. Maybe the controller should be split into several smaller controllers. Mediator does not solve this problem, it only hides it. More details, the MediatR library and its license are in the CQRS and Mediator lesson.

    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

    1. A design pattern is the name of a well-known solution to a repeating problem.
    2. With Strategy, each way is in its own class, and a new way does not change old code.
    3. With Factory, the decision “which object to create” is in one place.
    4. With Decorator, extra work like cache and logging is wrapped around the main class. The order of layers matters.
    5. With Mediator, the sender does not know the receiver. But this pattern does not replace good design.
    6. First see the problem. If there is no problem, you do not need a pattern.