Five Design Patterns I Use Daily in C#
Strategy, Decorator, Adapter, Builder, and Mediator — with the C# idioms that make them feel natural rather than ceremonial.
- Author
- Randhir Jassal
- Published
- Reading time
- 9 min read
Strategy — swap algorithms at runtime
public interface IPricingStrategy {
decimal Apply(Order order);
}
public sealed class FlatRatePricing(decimal flat) : IPricingStrategy {
public decimal Apply(Order o) => flat;
}
public sealed class TieredPricing : IPricingStrategy {
public decimal Apply(Order o) => o.Total switch {
> 1000m => o.Total * 0.9m,
> 500m => o.Total * 0.95m,
_ => o.Total
};
}
public sealed class Checkout(IPricingStrategy pricing) {
public decimal Calculate(Order o) => pricing.Apply(o);
}
DI swaps strategies per environment, per tenant, per A/B test. No conditionals in the consumer.
Decorator — cross-cutting concerns without base classes
Already covered in the composition-over-inheritance post but worth repeating in this list. Caching is the most common real-world use:
public sealed class CachedRepository<T>(IRepository<T> inner, IMemoryCache cache) : IRepository<T> {
public async Task<T?> GetAsync(Guid id) {
if (cache.TryGetValue(id, out T? hit)) return hit;
var loaded = await inner.GetAsync(id);
if (loaded is not null) cache.Set(id, loaded, TimeSpan.FromMinutes(5));
return loaded;
}
}
Register the decorator in DI; consumers don't know caching exists.
Adapter — bridging two interfaces
When you must wrap a third-party library to match your domain interface:
public interface IPaymentGateway {
Task<PaymentResult> ChargeAsync(decimal amount, string currency, string token);
}
public sealed class StripeAdapter(StripeClient stripe) : IPaymentGateway {
public async Task<PaymentResult> ChargeAsync(decimal amount, string currency, string token) {
var r = await stripe.Charges.CreateAsync(new ChargeCreateOptions {
Amount = (long)(amount * 100),
Currency = currency,
Source = token
});
return new PaymentResult(r.Id, r.Paid);
}
}
Your domain talks to IPaymentGateway; swap to BraintreeAdapter later without touching domain code.
Builder — fluent construction
The C# idiom for builder is the fluent return-this chain.
public sealed class HttpRequestBuilder {
private string _url = "";
private readonly Dictionary<string, string> _headers = new();
private HttpContent? _body;
public HttpRequestBuilder Url(string u) { _url = u; return this; }
public HttpRequestBuilder Header(string k, string v) { _headers[k] = v; return this; }
public HttpRequestBuilder JsonBody<T>(T b) { _body = JsonContent.Create(b); return this; }
public HttpRequestMessage Build() {
var msg = new HttpRequestMessage(HttpMethod.Post, _url) { Content = _body };
foreach (var (k, v) in _headers) msg.Headers.Add(k, v);
return msg;
}
}
var req = new HttpRequestBuilder()
.Url("/api/orders")
.Header("Authorization", "Bearer abc")
.JsonBody(new { customerId = "123" })
.Build();
Avoid this for 2-property objects. Use it when there are 5+ optional fields with complex defaults.
Mediator — decoupling commands from handlers
I avoid the heavyweight MediatR-style framework. Plain DI does the job:
public interface ICommand<TResult> {}
public interface ICommandHandler<TCommand, TResult> where TCommand : ICommand<TResult> {
Task<TResult> HandleAsync(TCommand cmd, CancellationToken ct);
}
public sealed record PlaceOrder(Guid CustomerId, decimal Amount) : ICommand<Guid>;
public sealed class PlaceOrderHandler(IOrderRepository repo) : ICommandHandler<PlaceOrder, Guid> {
public async Task<Guid> HandleAsync(PlaceOrder cmd, CancellationToken ct) {
var order = new Order(Guid.NewGuid(), cmd.CustomerId, cmd.Amount);
await repo.InsertAsync(order, ct);
return order.Id;
}
}
// Resolve and invoke from controller:
var handler = sp.GetRequiredService<ICommandHandler<PlaceOrder, Guid>>();
var id = await handler.HandleAsync(new(customerId, 99.95m), ct);
Five patterns I avoid
- Singleton — DI containers handle lifetimes; explicit
static Instanceis a code smell - Visitor — pattern matching does the same job with less code
- Abstract Factory — usually one too many indirections for what's actually a Strategy
- Chain of Responsibility — middleware pipelines already cover this
- Template Method — composition + delegates do it without the base-class trap
Patterns are vocabulary, not requirements. The day you find yourself naming a class OrderFactoryBuilderProvider, take a step back.
Get the next issue
A short, curated email with the newest posts and questions.