Skip to main content
C# · lesson 22 of 26
C# advanced Lesson 22 of 26

Also part of: Low-Level Design

Design Patterns in C#

C# design patterns by example: Singleton with Lazy<T> and DI lifetimes, Repository, a hand-rolled Mediator, Decorator for caching, and Strategy.

By SkillByExample · Updated

Singleton

A Singleton guarantees one instance of a class. In C#, Lazy<T> gives you a thread-safe, create-on-first-use instance with no hand-written locking.

Console.WriteLine("before first access");

var instances = new System.Collections.Concurrent.ConcurrentBag<AppConfig>();
Parallel.For(0, 8, _ => instances.Add(AppConfig.Instance));

Console.WriteLine($"{instances.Distinct().Count()} distinct instance(s), created {AppConfig.Created} time(s)");
Console.WriteLine(AppConfig.Instance.ApiUrl);

public sealed class AppConfig
{
    private static readonly Lazy<AppConfig> _lazy = new(() => new AppConfig());
    public static int Created;

    public static AppConfig Instance => _lazy.Value;

    private AppConfig()
    {
        Interlocked.Increment(ref Created);
        Console.WriteLine("AppConfig constructed");
    }

    public string ApiUrl { get; } = "https://api.example.com";
}
$ dotnet run
before first access
AppConfig constructed
1 distinct instance(s), created 1 time(s)
https://api.example.com

Eight threads raced for Instance at once and the constructor still ran exactly once, after “before first access” — construction is deferred until someone needs it. The private constructor stops anyone else from calling new AppConfig(). The catch: every class that uses AppConfig.Instance is now hard-wired to it, which makes tests awkward. That is why the next example is usually preferred.

Singleton by Dependency Injection

A DI container can give a class singleton lifetime without the class knowing anything about it. The same class can be registered as singleton, scoped or transient.

using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddSingleton<Clock>();
services.AddScoped<RequestLog>();
services.AddTransient<Ticket>();

using var provider = services.BuildServiceProvider();

Console.WriteLine(ReferenceEquals(provider.GetRequiredService<Clock>(), provider.GetRequiredService<Clock>()));

using (var scope1 = provider.CreateScope())
using (var scope2 = provider.CreateScope())
{
    var a = scope1.ServiceProvider.GetRequiredService<RequestLog>();
    var b = scope1.ServiceProvider.GetRequiredService<RequestLog>();
    var c = scope2.ServiceProvider.GetRequiredService<RequestLog>();
    Console.WriteLine($"same scope: {ReferenceEquals(a, b)}, different scope: {ReferenceEquals(a, c)}");
}

Console.WriteLine(ReferenceEquals(provider.GetRequiredService<Ticket>(), provider.GetRequiredService<Ticket>()));

public class Clock { }
public class RequestLog { }
public class Ticket { }
$ dotnet run
True
same scope: True, different scope: False
False

Singleton: one instance for the container’s life. Scoped: one per scope — in ASP.NET Core, one per HTTP request, which is why a DbContext is registered as scoped. Transient: a new one on every resolve. Clock is an ordinary class with a public constructor, so a test can simply new one up — the lifetime is configuration, not code.

Repository

A repository hides data access behind an interface shaped around your domain, so business code never sees SQL or EF Core types — and tests can use an in-memory version.

IProductRepository repo = new InMemoryProductRepository();
await repo.AddAsync(new Product { Name = "Widget", Category = "Tools", Price = 9.99m });
await repo.AddAsync(new Product { Name = "Gadget", Category = "Electronics", Price = 49.99m });
await repo.AddAsync(new Product { Name = "Anvil", Category = "Tools", Price = 89.00m, IsActive = false });
await repo.AddAsync(new Product { Name = "Spanner", Category = "Tools", Price = 12.50m });

foreach (var p in await repo.GetByCategoryAsync("Tools"))
    Console.WriteLine($"{p.Id}: {p.Name} {p.Price}");
Console.WriteLine((await repo.GetByIdAsync(3))?.Name ?? "not found");

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public string Category { get; set; } = "";
    public decimal Price { get; set; }
    public bool IsActive { get; set; } = true;
}

public interface IProductRepository
{
    Task<Product?> GetByIdAsync(int id);
    Task<IReadOnlyList<Product>> GetByCategoryAsync(string category);
    Task<Product> AddAsync(Product product);
}

public class InMemoryProductRepository : IProductRepository
{
    private readonly List<Product> _items = new();

    public Task<Product?> GetByIdAsync(int id) => Task.FromResult(_items.FirstOrDefault(p => p.Id == id));

    public Task<IReadOnlyList<Product>> GetByCategoryAsync(string category) =>
        Task.FromResult<IReadOnlyList<Product>>(
            _items.Where(p => p.IsActive && p.Category == category).OrderBy(p => p.Name).ToList());

    public Task<Product> AddAsync(Product product)
    {
        product.Id = _items.Count + 1;
        _items.Add(product);
        return Task.FromResult(product);
    }
}
$ dotnet run
4: Spanner 12.50
1: Widget 9.99
Anvil

The rule “only active products, sorted by name” lives in one place: the inactive Anvil is excluded from the category listing but can still be fetched by id. An EfProductRepository implementing the same interface would run these as SQL queries; see Entity Framework Core. Callers depend on IProductRepository and cannot tell which one they have.

Mediator

A mediator routes a request to its handler, so the sender does not reference the handler, and publishes notifications to any number of listeners. Here is the core idea in a few dozen lines; the MediatR library provides the same shape with DI integration.

var mediator = new Mediator();
var orders = new List<int>();
mediator.Register<CreateOrder, int>(cmd =>
{
    orders.Add(cmd.CustomerId);
    int id = orders.Count;
    mediator.Publish(new OrderCreated(id, cmd.CustomerId));
    return id;
});
mediator.Subscribe<OrderCreated>(e => Console.WriteLine($"  email: order {e.OrderId} confirmed to customer {e.CustomerId}"));
mediator.Subscribe<OrderCreated>(e => Console.WriteLine($"  inventory: reserve stock for order {e.OrderId}"));

int first = mediator.Send<CreateOrder, int>(new CreateOrder(7));
int second = mediator.Send<CreateOrder, int>(new CreateOrder(9));
Console.WriteLine($"created {first} and {second}");

public record CreateOrder(int CustomerId);
public record OrderCreated(int OrderId, int CustomerId);

public class Mediator
{
    private readonly Dictionary<Type, Delegate> _handlers = new();
    private readonly Dictionary<Type, List<Delegate>> _subscribers = new();

    public void Register<TRequest, TResult>(Func<TRequest, TResult> handler) => _handlers[typeof(TRequest)] = handler;

    public void Subscribe<TEvent>(Action<TEvent> handler)
    {
        if (!_subscribers.TryGetValue(typeof(TEvent), out var list)) _subscribers[typeof(TEvent)] = list = new();
        list.Add(handler);
    }

    public TResult Send<TRequest, TResult>(TRequest request) =>
        ((Func<TRequest, TResult>)_handlers[typeof(TRequest)])(request);

    public void Publish<TEvent>(TEvent e)
    {
        foreach (var h in _subscribers.GetValueOrDefault(typeof(TEvent)) ?? new())
            ((Action<TEvent>)h)(e);
    }
}
$ dotnet run
  email: order 1 confirmed to customer 7
  inventory: reserve stock for order 1
  email: order 2 confirmed to customer 9
  inventory: reserve stock for order 2
created 1 and 2

The code that sends CreateOrder knows nothing about email or inventory; the handler knows nothing about who listens for OrderCreated. Adding an audit log is one more Subscribe call, with no change to the order code. The cost is indirection: “what happens when I send this?” is no longer answered by following a method call, so use a mediator where decoupling pays for it, not everywhere.

Decorator

A decorator implements the same interface as the object it wraps and adds behaviour around the calls — caching, logging, retries — without changing the original class.

using System.Diagnostics;
using Microsoft.Extensions.Caching.Memory;

IPriceService service = new LoggingPriceService(
    new CachedPriceService(new SlowPriceService(), new MemoryCache(new MemoryCacheOptions())));

foreach (var sku in new[] { "A1", "A1", "B2", "A1" })
    Console.WriteLine($"{sku} = {await service.GetPriceAsync(sku)}");

public interface IPriceService { Task<decimal> GetPriceAsync(string sku); }

public class SlowPriceService : IPriceService
{
    public async Task<decimal> GetPriceAsync(string sku)
    {
        await Task.Delay(200);
        return sku.Length * 10m;
    }
}

public class CachedPriceService(IPriceService inner, IMemoryCache cache) : IPriceService
{
    public async Task<decimal> GetPriceAsync(string sku) =>
        await cache.GetOrCreateAsync($"price:{sku}", _ => inner.GetPriceAsync(sku));
}

public class LoggingPriceService(IPriceService inner) : IPriceService
{
    public async Task<decimal> GetPriceAsync(string sku)
    {
        var sw = Stopwatch.StartNew();
        var price = await inner.GetPriceAsync(sku);
        Console.Write($"[{(sw.ElapsedMilliseconds >= 100 ? "slow" : "fast")}] ");
        return price;
    }
}
$ dotnet run
[slow] A1 = 20
[fast] A1 = 20
[slow] B2 = 20
[fast] A1 = 20

Only the first request for each SKU reached the slow service; the repeats were served from the cache. Each layer does one job, and the order of wrapping is a decision: logging outside the cache measures what callers experience, while logging inside would only see cache misses. In ASP.NET Core you would register the layers in DI instead of nesting new calls by hand. (The log labels calls “slow” or “fast” rather than printing milliseconds, so the output is the same on every run.)

Strategy

Strategy swaps an algorithm at run time behind a common interface — or, for a single method, a plain Func.

var strategies = new Dictionary<string, Func<decimal, decimal>>
{
    ["standard"] = weightKg => 4.99m + 1.50m * weightKg,
    ["express"] = weightKg => 12.00m + 2.00m * weightKg,
    ["pickup"] = _ => 0m,
};

foreach (var (name, cost) in strategies)
    Console.WriteLine($"{name,-9} 3 kg -> {cost(3m),6:0.00}");

var checkout = new Checkout(strategies["express"]);
Console.WriteLine(checkout.Total(subtotal: 50m, weightKg: 2m));

public class Checkout(Func<decimal, decimal> shipping)
{
    public decimal Total(decimal subtotal, decimal weightKg) => subtotal + shipping(weightKg);
}
$ dotnet run
standard  3 kg ->   9.49
express   3 kg ->  18.00
pickup    3 kg ->   0.00
66.00

Checkout never contains an if (method == "express") chain; it receives the chosen strategy. Adding a shipping method means adding a dictionary entry, and each strategy can be tested on its own. Reach for an interface (IShippingStrategy) instead of a Func when a strategy needs several methods or its own dependencies.

Practice

  1. Retry decorator. Add a RetryPriceService that retries GetPriceAsync up to three times on HttpRequestException, and wrap a service that fails twice before succeeding.
Solution
IPriceService svc = new RetryPriceService(new FlakyPriceService());
Console.WriteLine(await svc.GetPriceAsync("A1"));

public interface IPriceService { Task<decimal> GetPriceAsync(string sku); }

public class FlakyPriceService : IPriceService
{
    private int _calls;
    public Task<decimal> GetPriceAsync(string sku) =>
        ++_calls < 3 ? throw new HttpRequestException($"attempt {_calls} failed") : Task.FromResult(20m);
}

public class RetryPriceService(IPriceService inner) : IPriceService
{
    public async Task<decimal> GetPriceAsync(string sku)
    {
        for (int attempt = 1; ; attempt++)
        {
            try { return await inner.GetPriceAsync(sku); }
            catch (HttpRequestException ex) when (attempt < 3) { Console.WriteLine($"retrying: {ex.Message}"); }
        }
    }
}
$ dotnet run
retrying: attempt 1 failed
retrying: attempt 2 failed
20
  1. Observer via events. Rewrite the mediator’s OrderCreated notification as a C# event on an OrderService class. What coupling comes back?
Solution
var service = new OrderService();
service.OrderCreated += id => Console.WriteLine($"email for order {id}");
service.OrderCreated += id => Console.WriteLine($"reserve stock for order {id}");
service.Create();

public class OrderService
{
    private int _next;
    public event Action<int>? OrderCreated;
    public void Create() => OrderCreated?.Invoke(++_next);
}
$ dotnet run
email for order 1
reserve stock for order 1

Subscribers now need a reference to the OrderService instance. With the mediator, publisher and subscribers only share the OrderCreated message type.

  1. Strategy with an interface. Replace the Func in Checkout with an IShippingStrategy that also exposes a Name, and print which strategy was used.
Solution
var checkout = new Checkout(new Express());
Console.WriteLine(checkout.Describe(50m, 2m));

public interface IShippingStrategy
{
    string Name { get; }
    decimal Cost(decimal weightKg);
}

public class Express : IShippingStrategy
{
    public string Name => "express";
    public decimal Cost(decimal weightKg) => 12.00m + 2.00m * weightKg;
}

public class Checkout(IShippingStrategy shipping)
{
    public string Describe(decimal subtotal, decimal kg) => $"{subtotal + shipping.Cost(kg)} via {shipping.Name}";
}
$ dotnet run
66.00 via express

Frequently Asked Questions

Should I use the Singleton pattern or dependency injection?
Prefer DI. Register a class as a singleton in the DI container (AddSingleton) rather than implementing the Singleton pattern manually. DI-managed singletons are testable, lazy by default, and don't pollute the global namespace with static state.
What problem does the Mediator pattern solve?
Mediator decouples components that would otherwise reference each other directly. Instead of A calling B directly, both A and B communicate through a mediator. This reduces coupling in event-driven systems and command/query architectures (CQRS). MediatR is the most popular .NET library for this.
What is the difference between Decorator and Inheritance for extending behavior?
Inheritance creates a new type at compile time. Decorator wraps an existing object at runtime and can be composed freely — you can add multiple decorators in any order without creating a class explosion. Use Decorator when behavior needs to be mixed and matched.
Is the Repository pattern still useful with Entity Framework Core?
It depends. DbContext already acts as a unit of work and DbSet as a generic repository, so a thin generic repository over EF Core often adds little. A specific repository interface such as IProductRepository still earns its place when you want business code free of EF types, or want to swap in an in-memory fake for fast tests.
What is the Strategy pattern in C#?
Strategy puts each interchangeable algorithm behind a common interface or delegate, and lets the caller choose one at run time — for example, different shipping-cost calculations. In modern C#, a small strategy is often just a Func<T, TResult> or a dictionary of them rather than a class hierarchy.