SOLID principles
Five design rules that keep code easy to change: single responsibility, open/closed, Liskov substitution, interface segregation and dependency inversion, each with a before and after.
| Letter | Principle | In one sentence |
|---|---|---|
S: Single Responsibility
public class ReportManager{ public void Run(string path) { var json = File.ReadAllText(path); // job 1: reading var beams = JsonSerializer.Deserialize<List<Beam>>(json)!; double total = beams.Sum(b => b.LengthM * b.KgPerM); // job 2: calculating var pdf = new PdfDocument(); pdf.AddText($"{total} kg"); // job 3: formatting new SmtpClient("mail.office").Send(/* … */); // job 4: sending }}public class WeightCalculator{ public double TotalKg(IEnumerable<Beam> beams) => beams.Sum(b => b.LengthM * b.KgPerM);} public record Report(IReadOnlyList<Beam> Beams, double TotalKg); public class ReportService(IModelSource source, WeightCalculator calc, IReportWriter writer, INotifier notifier){ public void Run() { var beams = source.LoadBeams(); var report = new Report(beams, calc.TotalKg(beams)); byte[] file = writer.Write(report); notifier.Send("Weight report", $"Total steel {report.TotalKg:N0} kg ({file.Length:N0} byte {writer.Extension} file)"); }}ReportService now only coordinates. Each step lives in its own small class you can read, change and test on its own. (The (IModelSource source, …) after the class name is a C# 12 primary constructor.)
O: Open/Closed
switch that grows with every new case is the classic warning sign.
// ✗ every new format means editing (and retesting) this methodpublic byte[] Export(Report r, string format) => format switch{ "pdf" => ToPdf(r), "csv" => ToCsv(r), _ => throw new NotSupportedException(format)}; // ✓ a new format is a new class; existing ones are never touchedpublic interface IReportWriter { string Extension { get; } byte[] Write(Report r); }public class PdfReportWriter : IReportWriter { public string Extension => ".pdf"; public byte[] Write(Report r) => /* … */ []; }public class ExcelReportWriter : IReportWriter { public string Extension => ".xlsx"; public byte[] Write(Report r) => /* … */ []; }L: Liskov Substitution
Model, it must also work with any class derived from Model, without surprises. A subclass that throws for methods its base promises, or quietly changes their meaning, breaks this.
public class Model{ public virtual void Save(string path) { /* write file */ }} // ✗ breaks every caller that calls Save on "a Model"public class ReadOnlyModel : Model{ public override void Save(string path) => throw new NotSupportedException();} // ✓ model the real capability insteadpublic interface IReadableModel { IReadOnlyList<Beam> Beams { get; } }public interface IEditableModel : IReadableModel { void Save(string path); }I: Interface Segregation
// ✗ one fat interface: a Grid must implement Weight and Paint it doesn't havepublic interface IModelElement{ string Id { get; } double WeightKg(); void Paint(string colour); Bounds GetBounds();} // ✓ small, focused interfaces; classes pick what they really supportpublic interface IElement { string Id { get; } Bounds GetBounds(); }public interface IHasWeight { double WeightKg(); }public interface IPaintable { void Paint(string colour); } public class Beam : IElement, IHasWeight, IPaintable { /* … */ }public class Grid : IElement { /* … */ }D: Dependency Inversion
// ✗ impossible to test without a real mail server, impossible to swappublic class AlertService{ private readonly SmtpClient _smtp = new("mail.office"); public void Overloaded(Beam b) => _smtp.Send("eng@firm.com", "Overload", b.Mark);} // ✓ depends on an abstraction; the caller decides which implementationpublic interface INotifier { void Send(string subject, string body); } public class AlertService(INotifier notifier){ public void Overloaded(Beam b) => notifier.Send("Overload", b.Mark);}Don’t overdo it
ConnectionChecker (returns a result) and a ConsoleReporter (prints it), then use both.
class Checker { public void Check(double load, double capacity) { double u = load / capacity; Console.WriteLine(u <= 1 ? $"OK {u:P0}" : $"FAIL {u:P0}"); } }
Show a hint
CheckResult(double Utilisation, bool Ok).
Show the solution
public record CheckResult(double Utilisation, bool Ok); public class ConnectionChecker{ public CheckResult Check(double load, double capacity) { double u = load / capacity; return new CheckResult(u, u <= 1); }} public class ConsoleReporter{ public void Print(CheckResult r) => Console.WriteLine(r.Ok ? $"OK {r.Utilisation:P0}" : $"FAIL {r.Utilisation:P0}");} var result = new ConnectionChecker().Check(load: 180, capacity: 240);new ConsoleReporter().Print(result);OK 75%
Remember
S: one reason to change per class. O: extend by adding classes, not editing them. L: subclasses must honour the base class’s promises. I: prefer small interfaces. D: depend on interfaces and receive implementations from outside. Use SOLID where change and testing matter; don’t add abstractions you don’t need.