SOLID Principles: Why They Exist, What They Cost, and When to Break Them
Understand the problem each principle solves, the cost of applying it, and the situations where breaking it leads to better software with pr...
Understand the problem each principle solves, the cost of applying it, and the situations where breaking it leads to better software with practical C# examples and architectural insights.
Five principles, one acronym, and roughly a thousand posts explaining them with shapes and animals. The definitions are easy to find and easy to recite. What is harder to find is the part that matters: what each principle costs, which mistake it exists to prevent, and when applying it makes your code worse.
That is what this post is for. Each principle gets a definition, a diagram, working C# you can paste, and an honest note on where the idea breaks down.
The principles come from Robert C. Martin's paper Design Principles and Design Patterns. Michael Feathers later noticed the five could be rearranged into a word, and SOLID stuck. Worth keeping in mind that the acronym was spotted afterwards rather than designed. People treat the letters as a checklist and then wonder why they have forty interfaces and no behaviour.
Single Responsibility Principle
A class should have one and only one reason to change.
The usual gloss is "a class should do one thing", which sounds obvious and is almost useless, because "one thing" has no fixed size. Is "handle payments" one thing? Is "validate a card number"? Both, depending on where you stand.
The version that actually helps is Martin's later phrasing: a module should be responsible to one, and only one, actor. Not one thing. One source of change requests. If the finance team can make you edit this class, and so can the UI team, then it has two reasons to change and both will eventually arrive in the same week.
Three reasons to change in one class, versus one reason each.
The mistake that looks like the fix
Start with a calculator that reads two numbers, adds them, and prints the result. Everyone writes this first:
double first = double.Parse(Console.ReadLine()); double second = double.Parse(Console.ReadLine()); double sum = first + second; Console.WriteLine(sum);
Three responsibilities: take input, process it, display output. So you extract three methods and put them in a class:
// This looks like SRP. It is not.
public class Calculator
{
public void TakeInput() { /* ... */ }
public double Add(double first, double second) { /* ... */ }
public void Display(double sum) { /* ... */ }
}
Ask the question the principle actually asks. How many reasons does Calculator have to change?
- The console becomes a GUI.
TakeInputchanges. - Output must go to a file.
Displaychanges. - Addition must round to two decimals.
Addchanges.
Three reasons, one class. Nothing has been fixed.
Methods are not responsibilities. Extracting a method organises code inside a class. It does not change what the class is responsible to. This is the most common misunderstanding of SRP I run into when reviewing designs, and it is convincing precisely because the result looks tidier than what you started with.
Separating by actor
// Changes when the maths changes. Knows nothing about screens or files.
public class Calculator
{
public double Add(double first, double second) => first + second;
}
// Changes when the input mechanism changes.
public interface IInputSource
{
(double First, double Second) ReadOperands();
}
public class ConsoleInput : IInputSource
{
public (double, double) ReadOperands()
{
var first = double.Parse(Console.ReadLine()!);
var second = double.Parse(Console.ReadLine()!);
return (first, second);
}
}
// Changes when the output destination changes.
public interface IResultWriter
{
void Write(double result);
}
public class ConsoleWriter : IResultWriter
{
public void Write(double result) => Console.WriteLine(result);
}
// Changes when the workflow changes. Nothing else.
public class CalculatorApp
{
private readonly IInputSource _input;
private readonly Calculator _calculator;
private readonly IResultWriter _writer;
public CalculatorApp(IInputSource input, Calculator calculator, IResultWriter writer)
{
_input = input;
_calculator = calculator;
_writer = writer;
}
public void Run()
{
var (first, second) = _input.ReadOperands();
var sum = _calculator.Add(first, second);
_writer.Write(sum);
}
}
Move to a GUI and you write a new IInputSource. Calculator does not know. Move to a file and you write a new IResultWriter. Calculator still does not know.
A test that takes ten seconds: describe the class in one sentence without using "and" or "or". If you cannot, you have found the seam.
Open/Closed Principle
Software entities should be open for extension but closed for modification.
You should be able to add behaviour by adding code, not by editing code that already works. The existing code has been tested and shipped. Every edit puts that at risk, and risk to working code is what this principle is really about.
A switch that every new type must edit, versus a type that answers for itself.
A salary calculator with three employee types: Manager on 100000, Officer on 50000, Clerk on 20000.
public enum EmployeeType { Manager, Officer, Clerk }
public class SalaryCalculator
{
public double GetSalary(EmployeeType type) => type switch
{
EmployeeType.Manager => 100000,
EmployeeType.Officer => 50000,
EmployeeType.Clerk => 20000,
_ => 0
};
}
Then the client adds a Human Resource Manager on 35000. Add an enum value, add a case, rebuild, retest everything. Add an intern next month and do it again. The switch grows, and every new type is a chance to break an old one.
Let the type answer for itself:
public interface IEmployee
{
double Salary { get; }
}
public class Manager : IEmployee { public double Salary => 100000; }
public class Officer : IEmployee { public double Salary => 50000; }
public class Clerk : IEmployee { public double Salary => 20000; }
Now the HR manager is a new file, and you edit nothing:
public class HumanResourceManager : IEmployee { public double Salary => 35000; }
Closed against which axis?
Here is where most explanations stop and where the interesting problem starts. The client asks for tax on salaries above 50000. The tempting answer:
public double GetSalary(IEmployee employee)
{
double salary = employee.Salary;
if (salary > 50000)
{
return salary - (salary * 0.01);
}
return salary;
}
That is a modification, the exact thing OCP asks you not to do. The design solved the "new employee type" axis and left the "new salary rule" axis running straight through the same class. Rules change more often than types do.
So make the rule a thing:
public interface ISalaryRule
{
double Apply(double salary);
}
public class TaxRule : ISalaryRule
{
private readonly double _threshold;
private readonly double _rate;
// Naming this _rate prevents the classic bug: "1% tax" written
// as 0.1 deducts ten times too much, and nothing will tell you.
public TaxRule(double threshold, double rate)
{
_threshold = threshold;
_rate = rate;
}
public double Apply(double salary) =>
salary > _threshold ? salary - (salary * _rate) : salary;
}
public class SalaryCalculator
{
private readonly IReadOnlyList<ISalaryRule> _rules;
public SalaryCalculator(IReadOnlyList<ISalaryRule> rules) => _rules = rules;
// Closed. A new rule is a new class and a line of configuration,
// never an edit here.
public double GetSalary(IEmployee employee) =>
_rules.Aggregate(employee.Salary, (salary, rule) => rule.Apply(salary));
}
Provident fund next quarter? New class. Bonus for one department? New class. SalaryCalculator was tested once and stays tested.
The C++ trap that silently deletes your polymorphism
Worth a detour, because the same design in C++ has a way of failing that produces no error at all.
class Officer : Employee // trap 1
{
public:
double getSalary() { return 50000; }
};
double SalaryCalculator::getSalary(Employee type) // trap 2
{
return type.getSalary();
}
Trap 1: class Officer : Employee is private inheritance. In C++, class defaults to private inheritance where struct defaults to public. Without the word public, Officer IS-A Employee only inside Officer, and the conversion the whole design depends on is not legal outside it.
Trap 2 is worse: taking the base class by value slices the object. Pass a Manager and C++ copy-constructs an Employee from it, keeping the base part and discarding everything that made it a Manager. type.getSalary() then calls Employee::getSalary. Every employee earns the base salary. The program compiles, runs, produces numbers, and the numbers are wrong.
Slicing punishes exactly the instinct that good design encourages: take the base type and let polymorphism sort it out. Polymorphism needs a reference or a pointer. Take a value and you have kept the shell and thrown away the object.
// By reference. Now the vtable survives the call.
double SalaryCalculator::getSalary(const Employee& employee)
{
return employee.getSalary();
}
And virtual double getSalary(); with no body and no = 0 is not an abstract method. It is a promise to define it later. The compiler believes you and the linker does not. Pure virtual is = 0.
The honest caveat
OCP is the principle people most often take too far. You cannot be closed against every axis of change. Being open to new employee types cost nothing here. Being open to new salary rules cost an interface and an aggregation. Being open to everything costs a configuration language and a program nobody can read.
Wait until you have seen a change happen twice before building the seam for it. The first time, just edit the code.
Liskov Substitution Principle
If code works with a base type, it must keep working when handed any subtype, without knowing.
This is the least understood of the five, because most explanations reduce it to "a subclass should not remove methods". That is a symptom. Barbara Liskov's actual point is about behaviour: a subtype may not strengthen what it demands, nor weaken what it promises. A subclass that accepts less, or delivers less, than its base advertised has broken the contract even if every method is present and every signature matches.
Substitution holds until a subtype refuses what the base promised.
The obvious violation
Animals eat, drink, run and fly:
public class Animal
{
public virtual void Eat() { }
public virtual void Drink() { }
public virtual void Run() { }
public virtual void Fly() { } // the mistake is already here
}
Then a lion arrives. It eats, drinks, runs. It does not fly. Whatever you write in Lion.Fly is a lie: throw, and you break every caller holding an Animal; do nothing, and you have an animal that silently fails to fly. Neither is substitutable.
The base class is the problem. It promised something not every animal can keep. Common behaviour goes in the base, specific capabilities go in interfaces:
public abstract class Animal
{
public abstract void Eat();
public abstract void Drink();
}
public interface IRunnable { void Run(); }
public interface IFlyable { void Fly(); }
public class Lion : Animal, IRunnable
{
public override void Eat() => Console.WriteLine("Eats flesh");
public override void Drink() => Console.WriteLine("Drinks water");
public void Run() => Console.WriteLine("Runs fast");
}
public class Eagle : Animal, IFlyable
{
public override void Eat() => Console.WriteLine("Eats flesh");
public override void Drink() => Console.WriteLine("Drinks water");
public void Fly() => Console.WriteLine("Flies high");
}
The trap: hiding is not overriding
There is a way to write this that looks correct and destroys substitutability without a single warning in C++:
class Animal
{
public:
void Eat(); // not virtual
};
class Lion : public Animal
{
public:
void Eat(); // this does NOT override. it HIDES.
};
Without virtual, Lion::Eat does not override Animal::Eat. It hides it. Which one runs is decided by the type of the reference, at compile time, not by the object:
Lion lion; lion.Eat(); // "Eat flesh" Animal* animal = &lion; animal->Eat(); // "Can eat" <-- the SAME object
The same object gives two different answers depending on what you are holding it by. That is the precise opposite of substitutability, and nothing in the build will tell you.
C# is stricter and saves you here. Methods are non-virtual by default too, but hiding a base member without saying new is a compiler warning, and you cannot write override unless the base is virtual or abstract. The language makes you say which one you meant:
public class Lion : Animal
{
public new void Eat() { } // legal, explicit, and still almost always wrong
public override void Eat() { } // what you actually wanted
}
The violation no signature will catch
The animal example is really about missing capability, which is the easy case. Genuine LSP violations are quieter. The classic:
public class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
public int Area => Width * Height;
}
// A square IS-A rectangle. Ask any mathematician.
public class Square : Rectangle
{
public override int Width
{
get => base.Width;
set { base.Width = value; base.Height = value; } // keep it square
}
public override int Height
{
get => base.Height;
set { base.Width = value; base.Height = value; }
}
}
Every method is present. Every signature matches. It compiles. And this passes for Rectangle and fails for Square:
void ResizeAndVerify(Rectangle r)
{
r.Width = 5;
r.Height = 4;
// Rectangle: 20. Square: 16, because setting Height also changed Width.
Debug.Assert(r.Area == 20);
}
Nothing is missing. The behaviour differs, and the caller had no way to know. This is what Liskov was talking about, and it is why "IS-A" from the real world is a trap. A square is a rectangle in geometry. A mutable square is not a mutable rectangle, because the base's contract includes "setting one dimension leaves the other alone".
The practical rule: if a subclass overrides a method to throw, to do nothing, or to tighten what it accepts, the inheritance is wrong. Not the method. The inheritance.
Interface Segregation Principle
No client should be forced to depend on methods it does not use.
Make interfaces small, specific and cohesive. If a class implements an interface and half the methods throw, the interface is describing several things at once.
A fat interface forces implementations that throw. Segregation removes the lie.
A smart device that prints, faxes and scans:
public interface ISmartDevice
{
void Print();
void Fax();
void Scan();
}
The all-in-one is delighted. Then the cheap printer arrives:
public class EconomicPrinter : ISmartDevice
{
public void Print() => Console.WriteLine("Yes I can print.");
// Forced. These exist only to satisfy the compiler.
public void Fax() => throw new NotSupportedException();
public void Scan() => throw new NotSupportedException();
}
Those two throws are the interface telling you it is wrong. A type is publicly claiming a capability and then failing at run time, which also makes this an LSP violation: hand an ISmartDevice to code that faxes and it explodes. ISP violations usually arrive wearing an LSP violation.
Split it:
public interface IPrinter { void Print(); }
public interface IFax { void Fax(); }
public interface IScanner { void Scan(); }
// Implements exactly what it can do.
public class EconomicPrinter : IPrinter
{
public void Print() => Console.WriteLine("Yes I can print.");
}
// Still free to do everything.
public class AllInOnePrinter : IPrinter, IFax, IScanner
{
public void Print() => Console.WriteLine("Printing.");
public void Fax() => Console.WriteLine("Beep boop biiip.");
public void Scan() => Console.WriteLine("Scanning.");
}
Now a method that needs to print takes IPrinter, both devices qualify, and nothing throws.
Two notes if you write this in C++. __interface is a Microsoft-specific extension, not standard C++; portable code uses a class with pure virtual functions and a virtual destructor. And throw new exception; throws a pointer. C++ throws by value and catches by reference; throw new leaks and is rarely caught by the handler you expected.
The judgement ISP needs: segregate by client, not by method. One interface per method is not the goal, and plenty of codebases read that way. If every caller who prints also scans, IPrintAndScan is a fine interface. Split when a real client needs a subset, not because a method count looked high.
Dependency Inversion Principle
High level modules should not depend on low level modules. Both should depend on abstractions.
And the half everyone forgets: abstractions should not depend on details. Details should depend on abstractions.
The inversion: one arrow from high to low becomes two arrows into the abstraction.
A Manager directs a Worker:
public class Worker
{
public void DoWork() => Console.WriteLine("working...");
}
public class Manager
{
private Worker _worker;
public void SetWorker(Worker worker) => _worker = worker;
public void ManageWorker() => _worker.DoWork();
}
Then a SuperWorker appears, and watch what it does to Manager:
public class Manager
{
private Worker _worker;
private SuperWorker _superWorker;
public void SetWorker(Worker worker) => _worker = worker;
public void SetWorker(SuperWorker worker) => _superWorker = worker;
public void ManageWorker() => _worker.DoWork();
public void ManageSuperWorker() => _superWorker.DoWork();
}
Manager has doubled for a change that has nothing to do with managing. Now imagine Manager is not this toy but a real class, with working time calculation and daily salary generation, half of it built on Worker. A third worker type arrives and you are editing the most valuable class in the system for a reason that is not its own.
Invert it. Put an abstraction between them and make both point at it:
public interface IWorker
{
void DoWork();
}
public class Worker : IWorker
{
public void DoWork() => Console.WriteLine("working...");
}
public class SuperWorker : IWorker
{
public void DoWork() => Console.WriteLine("working much more");
}
public class Manager
{
private readonly IWorker _worker;
// Constructor injection: a Manager cannot exist without a worker,
// and can never be handed a different one behind its back.
public Manager(IWorker worker) => _worker = worker;
public void ManageWorker() => _worker.DoWork();
}
A fourth worker type is a new class. Manager is untouched.
The word "inversion" is doing real work in that name, and the diagram above is the whole idea. Before, the arrow ran from Manager down to Worker: the important class depended on the replaceable one. After, both arrows point at IWorker. The low level module now depends on the high level module's requirements, which is the direction dependencies should run.
The half that gets missed
The interface belongs to the consumer, not the implementation. IWorker should live next to Manager and be shaped by what Manager needs. Put it in the same assembly as Worker, shaped like Worker, and you have added an interface without inverting anything: your abstraction now depends on the detail, which is exactly what the second half of the principle forbids.
That is the difference between dependency inversion and dependency injection. Injection is a technique for supplying a dependency. Inversion is about which side owns the contract. You can inject your way into a design that is still pointing the wrong way.
Prefer constructor injection over a setter. A settable SetWorker means Manager has a legal state where the worker is null, and every method must defend against it. Take it in the constructor and that state cannot exist. If you use a DI container, this is what it is for. If you do not, do it by hand; the container is a convenience, not the principle.
When not to do any of this
Every principle here trades a concrete cost now for flexibility later. An interface is a file, an indirection, and one more hop for whoever reads the code in six months. That trade is excellent when the change arrives and pure loss when it does not.
Things I have watched go wrong, more than once:
- An interface with exactly one implementation, forever. Named
IThingforThing, in the same folder, changed only ever together. That is not abstraction, it is a second file with the same information. - SRP taken to atoms. Forty classes, each with one method, and no way to find where anything happens. Cohesion is a virtue too. SRP says one reason to change, not one line of code.
- OCP against changes that never came. A configurable rules engine for a rule that has not moved in four years, built because a rule might move.
- DIP as ceremony. Every class behind an interface, every interface registered in a container, a stack trace nobody can follow, in an application with one implementation of everything.
Martin said it himself: they are not laws, and they are not perfect truths. They are the accumulated experience of people who watched code rot in specific ways. They tell you what to do when the rot appears. Applied pre-emptively to everything, they produce a different kind of rot with better vocabulary.
The rule I would give anyone learning these: let the code ask. Write the switch. When it grows a third time, that is OCP asking for an interface, and now you know exactly which axis to open. Put the logic in the class. When two teams keep colliding in it, that is SRP asking for a seam, and now you know where. These principles are much better at recognising a problem than at predicting one.
The five, in one line each
- SRP: one actor may make you change this. Not one thing, one source of change.
- OCP: add a file, do not edit a tested one. Along the axis that actually changes.
- LSP: a subtype may not demand more or promise less. Matching signatures are not enough.
- ISP: nobody should implement what they cannot do. Segregate by client, not by method.
- DIP: both sides point at the abstraction, and the abstraction belongs to the consumer.
Learn them well enough to recognise which one a piece of code is failing, and you will get more out of them than any checklist can give you.
Thanks for your endurance.






