C# · 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
- Retry decorator. Add a
RetryPriceServicethat retriesGetPriceAsyncup to three times onHttpRequestException, 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
- Observer via events. Rewrite the mediator’s
OrderCreatednotification as a C#eventon anOrderServiceclass. 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.
- Strategy with an interface. Replace the
FuncinCheckoutwith anIShippingStrategythat also exposes aName, 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