Lesson 23 of 45 intermediate

Design Patterns You Will Actually Meet

Strategy, Factory, Decorator, Observer and Singleton, and the smell of using too many

Guess first

Pick an answer. You will see if you were right at the end of the lesson.

What is the main job of a decorator?

Open interactive version (quiz + challenge)

Real-world analogy

A cafe has a barista who makes coffee. The owner puts a loyalty clerk at the front. The clerk takes orders exactly like the barista, stamps your card, then passes each order on. Customers order the same way as before, and the barista's work did not change. The clerk is the decorator: the same way to order, the real worker behind it, and one behaviour added.

What is it?

A design pattern is a name for a solution shape that people found again and again. It is not code you copy. It is a way to arrange classes so one kind of change stays cheap. Each pattern answers one question, such as how to swap behaviour, hide object creation, add behaviour around a class, or tell many listeners that something happened. Knowing the names lets you read other people's code faster and explain your own.

Real-world relevance

In a typical ASP.NET Core codebase you meet these constantly without the labels. Middleware is a chain of wrappers. Logging and caching around a service are decorators. ILogger and IOptions come from the container, which is the Singleton idea done safely. C# events are Observer. In interviews you are usually asked to explain one pattern with a real example and to say when you would not use it.

Key points

Quick check

Answer from memory — scrolling up first makes it stick less.

Why is registering a service with AddSingleton usually preferred over a hand-written static Singleton?

A switch on a payment type grows with every new provider. Which pattern fits best?

Code example

public record ProductDto(int Id, string Name, decimal Price);

public interface IProductService
{
    Task<ProductDto?> GetAsync(int id, CancellationToken ct);
}

public class ProductService(ShopContext db) : IProductService
{
    public Task<ProductDto?> GetAsync(int id, CancellationToken ct) =>
        db.Products.AsNoTracking().Where(p => p.Id == id)
            .Select(p => new ProductDto(p.Id, p.Name, p.Price))
            .FirstOrDefaultAsync(ct);
}

// Decorator: same interface, wraps another IProductService
public class CachedProductService(IProductService inner, IMemoryCache cache) : IProductService
{
    public Task<ProductDto?> GetAsync(int id, CancellationToken ct) =>
        cache.GetOrCreateAsync($"product:{id}", entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
            return inner.GetAsync(id, ct);
        });
}

// Program.cs
builder.Services.AddMemoryCache();
builder.Services.AddScoped<ProductService>();
builder.Services.AddScoped<IProductService>(sp => new CachedProductService(
    sp.GetRequiredService<ProductService>(), sp.GetRequiredService<IMemoryCache>()));

Line-by-line walkthrough

  1. 1. ProductDto is a small record that carries only the three values the caller needs.
  2. 2. IProductService is the contract. Both the real service and the decorator implement it, so callers cannot tell them apart.
  3. 3. ProductService reads one product from the database and projects it into the DTO.
  4. 4. CachedProductService takes another IProductService in its constructor. This is the wrapped object.
  5. 5. GetOrCreateAsync returns a cached value if the key exists. If not, it calls inner.GetAsync and stores the result for five minutes.
  6. 6. In Program.cs the real service is registered by its class, and the interface is registered as the decorator that wraps it.

Spot the bug

public class Settings
{
    private static Settings? _instance;

    public static Settings Instance
    {
        get
        {
            if (_instance == null) _instance = new Settings();
            return _instance;
        }
    }

    private Settings() { }
}
Need a hint?
What can happen if two threads read Instance at the same moment?
Show answer
The null check and the assignment are not one atomic step. Two threads can both see null and each create their own Settings, so the "single" instance is not single. Fix: use private static readonly Lazy<Settings> _lazy = new(() => new Settings()) and return _lazy.Value, or better, register the class with AddSingleton and let the container own it.

Explain like I'm 5

A pattern is a name for a trick that people use again and again. When you know the name, you can tell a friend "use the wrapping trick" instead of explaining the whole thing. But you only need a trick when you have the problem.

Fun fact

The famous "Gang of Four" book that named many of these patterns came out in 1994. Its examples were in C++ and Smalltalk, and many of them are now built into modern languages and frameworks.

Hands-on challenge

Say it out loud in 30 seconds: "Explain the Decorator pattern with an example from a .NET project, and say when you would not use a pattern." Then check: did you mention the same interface, wrapping an inner service, caching or logging, and adding a pattern only when the pain is real?

More resources

Open interactive version (quiz + challenge) Download the whole course as PDF / Word

Lock it in

← Back to course: .NET + React Playbook