<style> .reveal-up, .reveal-left, .reveal-right, .reveal-stagger > * { opacity: 1 !important; transform: none !important; } </style>
  1. Learn
  2. C#
  3. Master level
All lessons in this course

Lesson 53 of 60 18 minMaster

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.

Code that works is only half the job. Real projects keep changing: a new export format, a different database, a new design code. SOLID is five principles (collected by Robert C. Martin) that make those changes small and safe instead of scary.

Interactive

What does one change cost?

Pick a change request from the project manager and compare the two designs.

Before: one class does everything
ReportManager(420 lines)
  • read the model file
  • calculate weights
  • write the PDF report
  • email the report
1 class edited4 jobs to retest
After: one job per class
JsonModelSource : IModelSource
WeightCalculator
PdfReportWriter : IReportWriter
EmailNotifier : INotifier
ReportService
SqlModelSource : IModelSource
0 edited1 added1 to retest
edited new at risk untouched
Open/Closed + Dependency Inversion. Add one new class that implements IModelSource and register it. No existing class is edited, so nothing else needs retesting.
LetterPrincipleIn one sentence
SSingle Responsibilitya class should have only one reason to change
OOpen/Closedadd new behaviour by adding code, not by editing working code
LLiskov Substitutiona derived class must work anywhere its base class is expected
IInterface Segregationmany small interfaces beat one big one
DDependency Inversiondepend on abstractions (interfaces), not on concrete classes

S: Single Responsibility

If a class reads files and calculates and formats reports, then a change to any one of those risks breaking the others. Split it so each class has one job, and one reason to change.

Before.cs
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    }}
After.cs
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

A class is open for extension, closed for modification. A long switch that grows with every new case is the classic warning sign.

OpenClosed.cs
// ✗ 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

If code works with a 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.

Liskov.cs
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

Segregation.cs
// ✗ 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

High-level code (the business rules) should not depend on low-level details (SQL, SMTP, file paths). Both depend on an interface, and the concrete class is passed in from outside.

Inversion.cs
// ✗ 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);}

Passing dependencies in through the constructor is called dependency injection, and it has its own lesson next.

Don’t overdo it

Quick check

A ProjectExporter class has a switch on file type that you edit every time a new format is added. Which principle does that violate most directly?

Quick check

Square : Rectangle where setting Width also changes Height. Code that sets width 4 and height 5 then expects area 20 gets 25. Which principle is broken?

Practice

This class mixes calculation and console output. Split it into a 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

Return a small record such as CheckResult(double Utilisation, bool Ok).

Show the solution
Solution.cs
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);
Output
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.
0 of 60 lessons completed