SOLID principles & C# design patterns — short, opinionated, with code you can actually paste into a project.
Robert C. Martin's five rules of object-oriented design. Memorise them once, apply them everywhere.
A class should have one reason to change. If you describe it with "and", split it.
// ❌ Two reasons to change: persistence AND email
class UserService {
public void Register(User u) {
_db.Save(u);
_smtp.Send(u.Email, "Welcome");
}
}
// ✅ One job each
class UserRepository { public void Save(User u) => _db.Save(u); }
class WelcomeEmailer { public void Send(User u) => _smtp.Send(u.Email, "Welcome"); }
class RegisterUserHandler(UserRepository repo, WelcomeEmailer mail) {
public void Handle(User u) { repo.Save(u); mail.Send(u); }
}
Open for extension, closed for modification. New behaviour ≠ editing old code.
// ❌ Every new shape edits Area()
class AreaCalc {
public double Area(object s) => s switch {
Circle c => Math.PI * c.R * c.R,
Square q => q.Side * q.Side,
_ => throw new();
};
}
// ✅ Add a Triangle without touching AreaCalc
interface IShape { double Area(); }
record Circle(double R) : IShape { public double Area() => Math.PI * R * R; }
record Square(double Side): IShape { public double Area() => Side * Side; }
record Triangle(double B, double H) : IShape { public double Area() => B * H / 2; }
Subtypes must be usable in place of their base type — without surprises.
// ❌ Square "is-a" Rectangle... but breaks callers
class Rectangle { public virtual int W { get; set; } public virtual int H { get; set; } }
class Square : Rectangle {
public override int W { set { base.W = base.H = value; } }
public override int H { set { base.W = base.H = value; } }
}
// Caller expecting w*h area is now broken.
// ✅ Don't fake hierarchies. Model the actual contract.
interface IShape { int Area(); }
record Rect(int W, int H) : IShape { public int Area() => W * H; }
record Sq(int Side) : IShape { public int Area() => Side * Side; }
Many small interfaces beat one fat one. Don't force clients to depend on methods they never call.
// ❌ Read-only consumers must implement writes
interface IRepository<T> {
T Get(int id);
void Add(T x);
void Delete(int id);
}
// ✅ Split by capability
interface IReader<T> { T Get(int id); }
interface IWriter<T> { void Add(T x); void Delete(int id); }
class ProductCatalog(IReader<Product> reader) {
// Cannot accidentally mutate
}
High-level code depends on abstractions, not concrete classes. Inject, don't new.
// ❌ OrderService nailed to SqlOrderRepo + SmtpMailer
class OrderService {
private readonly SqlOrderRepo _repo = new();
private readonly SmtpMailer _mail = new();
}
// ✅ Depend on interfaces; let the container wire it up
interface IOrderRepo { void Save(Order o); }
interface IMailer { void Send(string to, string body); }
class OrderService(IOrderRepo repo, IMailer mail) {
public void Place(Order o) { repo.Save(o); mail.Send(o.Email, "Thanks!"); }
}
// Program.cs
builder.Services.AddScoped<IOrderRepo, SqlOrderRepo>();
builder.Services.AddScoped<IMailer, SmtpMailer>();
The classic Gang-of-Four catalogue plus the enterprise patterns I use most. Each card shows the idiomatic modern C# version.
One instance, shared globally. In .NET, prefer DI's AddSingleton over hand-rolled statics.
// Thread-safe via Lazy<T>
public sealed class Config {
private static readonly Lazy<Config> _i = new(() => new Config());
public static Config Instance => _i.Value;
private Config() { /* load settings */ }
}
// ✅ The .NET way — let DI manage lifetime
builder.Services.AddSingleton<IConfigStore, ConfigStore>();
Creational
Factory Method
Defer "which concrete type" to a subclass or method. Decouples creation from use.
abstract class Document { public abstract void Render(); }
class PdfDoc : Document { public override void Render() => Console.WriteLine("PDF"); }
class HtmlDoc : Document { public override void Render() => Console.WriteLine("HTML"); }
abstract class Exporter {
public void Export() { var d = Create(); d.Render(); }
protected abstract Document Create();
}
class PdfExporter : Exporter { protected override Document Create() => new PdfDoc(); }
class HtmlExporter : Exporter { protected override Document Create() => new HtmlDoc(); }
Fluent step-by-step construction of complex objects. Think StringBuilder, HttpRequestBuilder, WebApplication.CreateBuilder.
public class EmailBuilder {
private readonly Email _e = new();
public EmailBuilder From(string a) { _e.From = a; return this; }
public EmailBuilder To(string a) { _e.To = a; return this; }
public EmailBuilder Subject(string s) { _e.Subject = s; return this; }
public EmailBuilder Body(string b) { _e.Body = b; return this; }
public Email Build() => _e;
}
var mail = new EmailBuilder()
.From("me@x.gr").To("you@x.gr")
.Subject("Hi").Body("Hello!").Build();
Wrap an incompatible interface so it fits the one your code expects. The "shim" pattern.
// Existing legacy logger
class LegacyLogger { public void WriteLine(string s) => Console.WriteLine(s); }
// What modern code wants
interface ILogger { void Info(string msg); }
// Adapter glues them
class LegacyLoggerAdapter(LegacyLogger inner) : ILogger {
public void Info(string msg) => inner.WriteLine($"[INFO] {msg}");
}
Add behaviour by wrapping, not inheriting. Perfect for logging, caching, retries on top of an interface.
interface IPriceService { decimal Get(int id); }
class PriceService : IPriceService {
public decimal Get(int id) => /* DB call */ 9.99m;
}
class CachingPriceService(IPriceService inner, IMemoryCache cache) : IPriceService {
public decimal Get(int id) =>
cache.GetOrCreate(id, _ => inner.Get(id));
}
class LoggingPriceService(IPriceService inner, ILogger<LoggingPriceService> log) : IPriceService {
public decimal Get(int id) {
log.LogInformation("Get({Id})", id);
return inner.Get(id);
}
}
Swap algorithms behind a common interface. Kills switch statements that grow every quarter.
interface IShippingStrategy { decimal Cost(Order o); }
class FlatRate : IShippingStrategy { public decimal Cost(Order o) => 5m; }
class ByWeight : IShippingStrategy { public decimal Cost(Order o) => o.KG * 1.5m; }
class Express : IShippingStrategy { public decimal Cost(Order o) => 15m + o.KG; }
class Cart(IShippingStrategy shipping) {
public decimal Total(Order o) => o.Subtotal + shipping.Cost(o);
}
Publishers fire events, subscribers react. Built into C# via event / IObservable<T>.
class Stock {
public event Action<decimal>? PriceChanged;
private decimal _p;
public decimal Price {
get => _p;
set { _p = value; PriceChanged?.Invoke(value); }
}
}
var s = new Stock();
s.PriceChanged += p => Console.WriteLine($"Alert! {p}");
s.PriceChanged += p => auditLog.Write(p);
s.Price = 42.5m; // both subscribers fire
Encapsulate a request as an object — perfect for queues, undo, audit logs. Basis of CQRS & MediatR.
interface ICommand { void Execute(); }
class SendEmailCmd(string to, string body) : ICommand {
public void Execute() => Console.WriteLine($"-> {to}: {body}");
}
class CommandQueue {
private readonly Queue<ICommand> _q = new();
public void Enqueue(ICommand c) => _q.Enqueue(c);
public void RunAll() { while (_q.TryDequeue(out var c)) c.Execute(); }
}
Behavioral
Mediator (MediatR)
Decouple senders from handlers via a central bus. The de-facto pattern for CQRS handlers in .NET.
// Request + handler
public record CreateOrder(int CustomerId) : IRequest<int>;
public class CreateOrderHandler(AppDb db) : IRequestHandler<CreateOrder, int> {
public async Task<int> Handle(CreateOrder cmd, CancellationToken ct) {
var o = new Order { CustomerId = cmd.CustomerId };
db.Orders.Add(o);
await db.SaveChangesAsync(ct);
return o.Id;
}
}
// Controller — no idea who handles it
app.MapPost("/orders", async (CreateOrder c, IMediator m) =>
Results.Ok(await m.Send(c)));
An in-memory collection illusion over a database. Keeps query intent out of business logic.
public interface IProductRepository {
Task<Product?> GetAsync(int id);
Task<IReadOnlyList<Product>> ListActiveAsync();
Task AddAsync(Product p);
}
public class EfProductRepository(AppDb db) : IProductRepository {
public Task<Product?> GetAsync(int id) => db.Products.FindAsync(id).AsTask();
public async Task<IReadOnlyList<Product>> ListActiveAsync() =>
await db.Products.Where(p => p.IsActive).ToListAsync();
public async Task AddAsync(Product p) => await db.Products.AddAsync(p);
}
Group changes across repositories into a single atomic commit. EF Core's DbContext already is one.
public interface IUnitOfWork : IAsyncDisposable {
IOrderRepository Orders { get; }
IProductRepository Products { get; }
Task<int> SaveChangesAsync(CancellationToken ct = default);
}
public class EfUnitOfWork(AppDb db) : IUnitOfWork {
public IOrderRepository Orders { get; } = new EfOrderRepository(db);
public IProductRepository Products { get; } = new EfProductRepository(db);
public Task<int> SaveChangesAsync(CancellationToken ct = default) =>
db.SaveChangesAsync(ct);
public ValueTask DisposeAsync() => db.DisposeAsync();
}
Split writes (commands → domain) from reads (queries → optimised projections). Scale them independently.
// Commands change state, return ids/units
public record PlaceOrder(int CustomerId, List<int> Items) : IRequest<int>;
// Queries return DTOs, never touch domain entities directly
public record GetOrderSummary(int OrderId) : IRequest<OrderSummaryDto>;
public class GetOrderSummaryHandler(AppDb db)
: IRequestHandler<GetOrderSummary, OrderSummaryDto>
{
public Task<OrderSummaryDto> Handle(GetOrderSummary q, CancellationToken ct) =>
db.Orders
.Where(o => o.Id == q.OrderId)
.Select(o => new OrderSummaryDto(o.Id, o.Total, o.Status))
.FirstAsync(ct);
}
Encapsulate a business rule as an object you can combine, reuse, and test independently.
public interface ISpecification<T> {
Expression<Func<T, bool>> ToExpression();
}
public class ActiveProductSpec : ISpecification<Product> {
public Expression<Func<Product, bool>> ToExpression() => p => p.IsActive;
}
public class PriceAboveSpec(decimal min) : ISpecification<Product> {
public Expression<Func<Product, bool>> ToExpression() => p => p.Price > min;
}
// Usage: combine specs, run on IQueryable
var hot = db.Products
.Where(new ActiveProductSpec().ToExpression())
.Where(new PriceAboveSpec(50).ToExpression())
.ToListAsync();