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?
Real-world analogy
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
- A pattern is a name — It is a named answer to a problem that keeps coming back. It is a way to talk to your team, not a goal to reach.
- Strategy swaps behaviour — Put each variant behind one interface and pick one at runtime. It usually replaces a growing switch on a type or a flag.
- Factory hides the choice — The caller asks for "a payment gateway" and does not know which class it gets. The choice of class lives in one place.
- Decorator wraps to add — A decorator implements the same interface as the thing it wraps, and calls it. Caching or logging can be added around a service without editing it.
- Observer is events — A publisher raises an event, and any number of subscribers react. The publisher does not know who is listening, which keeps the two sides apart.
- Singleton: let DI own it — A hand-written static singleton hides a dependency and is hard to test. AddSingleton gives one shared instance that the container creates and tests can replace.
- Mediator and CQRS, briefly — A mediator sends a request to one handler so callers do not know the handlers. CQRS splits reads from writes. For a small CRUD app, both usually add files without adding value.
- The smell of overuse — Interfaces with one implementation, factories that build one type, and layers that only forward calls are warning signs. Add a pattern when the pain shows up, not before.
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. ProductDto is a small record that carries only the three values the caller needs.
- 2. IProductService is the contract. Both the real service and the decorator implement it, so callers cannot tell them apart.
- 3. ProductService reads one product from the database and projects it into the DTO.
- 4. CachedProductService takes another IProductService in its constructor. This is the wrapped object.
- 5. GetOrCreateAsync returns a cached value if the key exists. If not, it calls inner.GetAsync and stores the result for five minutes.
- 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?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Dependency injection in ASP.NET Core (Microsoft Learn)
- Introduction to events in C# (Microsoft Learn)
- Cache in-memory in ASP.NET Core (Microsoft Learn)
Lock it in