Design Patterns in C#
Typed Singleton, Repository, Mediator, and Decorator patterns implemented in C#.
Singleton Pattern
The Singleton pattern ensures a class has only one instance and provides a global point of access to it. It is commonly needed for shared resources like configuration, logging, or a connection pool. The classic hand-rolled Singleton is an anti-pattern in modern .NET because it is hard to test, introduces hidden global state, and requires manual locking for thread safety. The right approach is to delegate lifetime management to the DI container.
// Anti-pattern: manual singleton — hard to test, hidden global state
public class ConfigurationManager
{
private static ConfigurationManager? _instance;
private static readonly object _lock = new();
private ConfigurationManager() { }
public static ConfigurationManager Instance
{
get
{
lock (_lock)
return _instance ??= new ConfigurationManager();
}
}
}
// Better: use Lazy<T> for thread-safe lazy initialization when you must roll your own
public class AppConfig
{
private static readonly Lazy<AppConfig> _lazy =
new(() => new AppConfig());
public static AppConfig Instance => _lazy.Value; // thread-safe, allocated on first access
private AppConfig() { }
public string ApiUrl { get; set; } = "https://api.example.com";
}
// Best: register in the DI container — testable, no static state, no manual locking
// In Program.cs:
// builder.Services.AddSingleton<IConfig, AppConfig>();
public class AppConfig : IConfig
{
public string ApiUrl { get; init; } = "https://api.example.com";
public int TimeoutSeconds { get; init; } = 30;
}
Repository Pattern
The Repository pattern abstracts the data access layer behind a clean interface, so the rest of your application talks to IProductRepository and never knows whether the data comes from EF Core, Dapper, an HTTP API, or an in-memory fake. This separation makes business logic independently testable and makes it easy to swap database technologies without touching service or controller code.
// Entity
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = "";
public decimal Price { get; set; }
public string Category { get; set; } = "";
public bool IsActive { get; set; } = true;
}
// Generic repository interface — common CRUD operations for any entity
public interface IRepository<T, TKey> where T : class
{
Task<T?> GetByIdAsync(TKey id, CancellationToken ct = default);
Task<IReadOnlyList<T>> GetAllAsync(CancellationToken ct = default);
Task<T> AddAsync(T entity, CancellationToken ct = default);
Task UpdateAsync(T entity, CancellationToken ct = default);
Task DeleteAsync(TKey id, CancellationToken ct = default);
}
// Specialized interface — adds product-specific queries on top of generic CRUD
public interface IProductRepository : IRepository<Product, int>
{
Task<IReadOnlyList<Product>> GetByCategoryAsync(string category, CancellationToken ct = default);
Task<IReadOnlyList<Product>> SearchAsync(string query, CancellationToken ct = default);
}
// EF Core implementation — the only class that knows about the database
public class EfProductRepository : IProductRepository
{
private readonly AppDbContext _db;
public EfProductRepository(AppDbContext db) => _db = db;
public async Task<Product?> GetByIdAsync(int id, CancellationToken ct = default)
=> await _db.Products.FindAsync(new object[] { id }, ct);
public async Task<IReadOnlyList<Product>> GetAllAsync(CancellationToken ct = default)
=> await _db.Products.Where(p => p.IsActive).ToListAsync(ct);
public async Task<IReadOnlyList<Product>> GetByCategoryAsync(string category, CancellationToken ct = default)
=> await _db.Products
.Where(p => p.IsActive && p.Category == category)
.OrderBy(p => p.Name)
.ToListAsync(ct);
public async Task<IReadOnlyList<Product>> SearchAsync(string query, CancellationToken ct = default)
=> await _db.Products
.Where(p => p.Name.Contains(query) || p.Category.Contains(query))
.ToListAsync(ct);
public async Task<Product> AddAsync(Product product, CancellationToken ct = default)
{
_db.Products.Add(product);
await _db.SaveChangesAsync(ct);
return product;
}
public async Task UpdateAsync(Product product, CancellationToken ct = default)
{
_db.Products.Update(product);
await _db.SaveChangesAsync(ct);
}
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
var product = await GetByIdAsync(id, ct);
if (product != null)
{
_db.Products.Remove(product);
await _db.SaveChangesAsync(ct);
}
}
}
// Register in DI — swap the implementation without touching any other code
// builder.Services.AddScoped<IProductRepository, EfProductRepository>();
Mediator Pattern
In large applications, components often need to trigger actions in other components — a new order might need to send an email, update inventory, and notify analytics. Wiring these together directly creates tight coupling: every class knows about every other class it must notify. The Mediator pattern routes requests and events through a central hub, so components only know about the mediator. MediatR is the standard .NET library for this.
dotnet add package MediatR
// Command — represents a write operation, returns the new order ID
public record CreateOrderCommand(
int CustomerId,
List<OrderLineDto> Lines) : IRequest<int>;
// Query — represents a read operation, returns null if not found
public record GetOrderQuery(int OrderId) : IRequest<OrderDto?>;
// Command handler — contains all the logic for creating an order
public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, int>
{
private readonly IOrderRepository _orders;
private readonly IInventoryService _inventory;
private readonly IPublisher _publisher;
public CreateOrderHandler(
IOrderRepository orders,
IInventoryService inventory,
IPublisher publisher)
{
_orders = orders;
_inventory = inventory;
_publisher = publisher;
}
public async Task<int> Handle(CreateOrderCommand cmd, CancellationToken ct)
{
// Validate inventory before creating
foreach (var line in cmd.Lines)
if (!await _inventory.IsAvailableAsync(line.ProductId, line.Quantity, ct))
throw new InsufficientInventoryException(line.ProductId, line.Quantity, 0);
var order = new Order { CustomerId = cmd.CustomerId };
foreach (var line in cmd.Lines)
order.AddLine(line.ProductId, line.Quantity, line.UnitPrice);
await _orders.AddAsync(order, ct);
// Publish a notification — other handlers react without this handler knowing about them
await _publisher.Publish(new OrderCreatedEvent(order.Id, cmd.CustomerId), ct);
return order.Id;
}
}
// Notification handler — reacts to OrderCreatedEvent independently
public class OrderCreatedEmailHandler : INotificationHandler<OrderCreatedEvent>
{
private readonly IEmailService _email;
public OrderCreatedEmailHandler(IEmailService email) => _email = email;
public async Task Handle(OrderCreatedEvent notification, CancellationToken ct)
{
await _email.SendOrderConfirmationAsync(notification.CustomerId, notification.OrderId, ct);
}
}
// Controller — only depends on IMediator, knows nothing about handlers
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
private readonly IMediator _mediator;
public OrdersController(IMediator mediator) => _mediator = mediator;
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateOrderCommand cmd)
{
int id = await _mediator.Send(cmd);
return CreatedAtAction(nameof(Get), new { id }, new { id });
}
[HttpGet("{id}")]
public async Task<IActionResult> Get(int id)
{
var order = await _mediator.Send(new GetOrderQuery(id));
return order is null ? NotFound() : Ok(order);
}
}
// Register MediatR — auto-discovers all handlers in the assembly
// builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
Decorator Pattern
The Decorator pattern adds behavior to an existing object by wrapping it in another object that implements the same interface. Unlike inheritance, decorators are composed at runtime — you can stack them in any order without a class explosion. It is the ideal solution for cross-cutting concerns like caching, logging, retry, and validation that should apply to some but not all implementations of an interface.
// Base interface — the contract that all implementations and decorators share
public interface IProductService
{
Task<Product?> GetByIdAsync(int id);
Task<IReadOnlyList<Product>> GetAllAsync();
}
// Core implementation — the real business logic, no cross-cutting concerns
public class ProductService : IProductService
{
private readonly IProductRepository _repo;
public ProductService(IProductRepository repo) => _repo = repo;
public Task<Product?> GetByIdAsync(int id) => _repo.GetByIdAsync(id);
public Task<IReadOnlyList<Product>> GetAllAsync() => _repo.GetAllAsync();
}
// Decorator 1: Caching — wraps any IProductService and adds a memory cache layer
public class CachedProductService : IProductService
{
private readonly IProductService _inner; // the wrapped service
private readonly IMemoryCache _cache;
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
public CachedProductService(IProductService inner, IMemoryCache cache)
{
_inner = inner;
_cache = cache;
}
public async Task<Product?> GetByIdAsync(int id)
{
string key = $"product:{id}";
if (_cache.TryGetValue(key, out Product? cached))
return cached; // cache hit — no database call
var product = await _inner.GetByIdAsync(id); // delegate to wrapped service
if (product != null)
_cache.Set(key, product, CacheDuration);
return product;
}
public async Task<IReadOnlyList<Product>> GetAllAsync()
{
const string key = "products:all";
if (_cache.TryGetValue(key, out IReadOnlyList<Product>? cached))
return cached!;
var products = await _inner.GetAllAsync();
_cache.Set(key, products, CacheDuration);
return products;
}
}
// Decorator 2: Logging — wraps any IProductService and adds structured logging
public class LoggingProductService : IProductService
{
private readonly IProductService _inner;
private readonly ILogger<LoggingProductService> _logger;
public LoggingProductService(IProductService inner, ILogger<LoggingProductService> logger)
{
_inner = inner;
_logger = logger;
}
public async Task<Product?> GetByIdAsync(int id)
{
_logger.LogInformation("GetByIdAsync called for product {ProductId}", id);
var sw = Stopwatch.StartNew();
var result = await _inner.GetByIdAsync(id);
_logger.LogInformation("GetByIdAsync completed in {Ms}ms, found={Found}",
sw.ElapsedMilliseconds, result != null);
return result;
}
public async Task<IReadOnlyList<Product>> GetAllAsync()
{
_logger.LogInformation("GetAllAsync called");
var result = await _inner.GetAllAsync();
_logger.LogInformation("GetAllAsync returned {Count} products", result.Count);
return result;
}
}
// Compose decorators manually in DI — stack: Logging → Caching → Core
// The Scrutor library makes this cleaner with .Decorate<IProductService, CachedProductService>()
// builder.Services.AddScoped<IProductService>(sp =>
// {
// var repo = sp.GetRequiredService<IProductRepository>();
// var cache = sp.GetRequiredService<IMemoryCache>();
// var logger = sp.GetRequiredService<ILogger<LoggingProductService>>();
// var core = new ProductService(repo);
// var cached = new CachedProductService(core, cache);
// return new LoggingProductService(cached, logger); // outermost decorator
// });