SOLID is usually taught as five definitions to memorise before an interview. That approach produces engineers who can recite "single responsibility" and still write a 900-line class.
Each principle exists because of a specific, recognisable pain. Learn the pain and the rule becomes obvious.
All five are answers to one question: when requirements change, how much code do I have to touch, and how likely am I to break something unrelated?
S — Single Responsibility
A class should have one reason to change.
The phrase "one reason to change" matters more than "does one thing", which is too vague to apply.
class Report {
fetchData() { /* queries the database */ }
calculateTotals() { /* business rules */ }
renderHtml() { /* presentation */ }
emailToManager() { /* SMTP */ }
}Count who can force this class to change:
- The DBA renames a column.
- Finance changes how tax is calculated.
- Design wants a new layout.
- Ops switches email providers.
Four unrelated teams can each break this file. That is the pain. A change to the email provider now risks breaking tax calculation, because they share a file, a test suite and a deployment.
Split by reason-to-change:
class ReportRepository { fetchData() {} }
class ReportCalculator { calculateTotals() {} }
class ReportRenderer { renderHtml() {} }
class ReportMailer { send() {} }O — Open/Closed
Open for extension, closed for modification.
You should be able to add new behaviour without editing existing, tested code.
The smell is a growing conditional:
function calculateShipping(order) {
if (order.method === 'standard') return order.weight * 2;
if (order.method === 'express') return order.weight * 5;
if (order.method === 'overnight') return order.weight * 10;
// every new option means editing this function again
}Each new shipping method forces you to reopen a function that already works and is already tested. Every edit is a chance to break the three methods that were fine.
Push the variation behind a common shape instead:
const strategies = {
standard: { cost: (o) => o.weight * 2 },
express: { cost: (o) => o.weight * 5 },
overnight: { cost: (o) => o.weight * 10 },
};
function calculateShipping(order) {
const strategy = strategies[order.method];
if (!strategy) throw new UnknownShippingMethodError(order.method);
return strategy.cost(order);
}Adding "drone delivery" now means adding an entry. The dispatch function is never touched again.
L — Liskov Substitution
A subtype must be usable anywhere its parent is expected, without surprises.
This is the least intuitive and the most valuable, because violating it produces bugs that appear far away from the cause.
The classic example is genuinely instructive:
class Rectangle {
setWidth(w) { this.width = w; }
setHeight(h) { this.height = h; }
area() { return this.width * this.height; }
}
class Square extends Rectangle {
setWidth(w) { this.width = w; this.height = w; } // keeps it square
setHeight(h) { this.width = h; this.height = h; }
}Mathematically a square is a rectangle, so inheritance feels right. Now this perfectly reasonable function breaks:
function resize(rect) {
rect.setWidth(5);
rect.setHeight(4);
console.assert(rect.area() === 20); // fails for Square: area is 16
}resize is correct. Square is internally consistent. The bug appears only when they meet, which is what makes these failures so hard to trace.
The practical signals that you are violating it:
- The subclass throws on a method the parent supports (
ReadOnlyList.add()raising an error). - The subclass strengthens what it demands — the parent accepts any integer, the subclass requires positives.
- Callers need
if (x instanceof SpecificSubclass)to work correctly. That check is the principle failing out loud.
The lesson is that inheritance should model substitutable behaviour, not real-world taxonomy. When behaviour differs, prefer composition.
I — Interface Segregation
No client should be forced to depend on methods it does not use.
class Machine {
print(doc) {}
scan(doc) {}
fax(doc) {}
}
class BasicPrinter extends Machine {
print(doc) { /* works */ }
scan(doc) { throw new Error('Not supported'); } // forced to lie
fax(doc) { throw new Error('Not supported'); }
}The fat interface forces BasicPrinter to claim capabilities it lacks. Any code that accepts a Machine may call scan and blow up at runtime — which is also a Liskov violation, since the two principles overlap here.
Split into capabilities and compose what you need:
class BasicPrinter { print(doc) {} }
class OfficeMachine { print(doc) {} scan(doc) {} fax(doc) {} }The everyday version of this appears in function signatures. A function taking an entire User object when it only reads user.email is coupled to the whole shape of User — and its tests must construct a complete user. Passing the email alone makes the real dependency visible and the test trivial.
D — Dependency Inversion
Depend on abstractions, not on concrete implementations.
class OrderService {
constructor() {
this.db = new PostgresDatabase(); // welded to Postgres
this.mailer = new SendGridMailer(); // welded to SendGrid
}
}The high-level policy — how orders work — now depends on low-level details. You cannot test it without a real database, and swapping either vendor means editing business logic.
class OrderService {
constructor(repository, mailer) { // depends on shapes, not vendors
this.repository = repository;
this.mailer = mailer;
}
}This is the same idea as the Dependency Injection lesson, viewed from the design side rather than the mechanics side: injection is how you do it; inversion is why.
Diagram, as text
- OrderService leads to PostgresDatabase
- OrderService leads to Repository shape
- PostgresRepository leads to Repository shape (implements)
Notice the arrow reversed. The database implementation now points up toward the interface the business logic defines, rather than the business logic pointing down at a vendor. That reversal is the "inversion".
Reading them as one idea
| Principle | Prevents |
|---|---|
| Single Responsibility | Unrelated changes colliding in one file |
| Open/Closed | Editing working code to add new cases |
| Liskov Substitution | Subclasses breaking callers that trusted the parent |
| Interface Segregation | Depending on capabilities you never use |
| Dependency Inversion | Business rules welded to vendors and infrastructure |
Every one is about isolating change. Good design is not about elegance; it is about making tomorrow's requirement cheap and safe to implement.
Check your understanding
Common mistakes
Reading Single Responsibility as "one method". It is about reasons to change, not size — a class doing one coherent job may be large.
Applying Open/Closed on the first case. Abstracting before you have seen the variation guesses the wrong axis. Refactor at the third.
Inheriting for reuse rather than substitutability. If the subclass cannot stand in for the parent everywhere, it is not a subtype, whatever the language allows.
Overriding a method to throw. That is a Liskov violation announcing itself — the hierarchy is wrong.
Interfaces built for the implementation. Segregation is judged from the caller's side; a fat interface forces clients to depend on methods they never call.
Reading Dependency Inversion as "add an interface everywhere". An interface with one permanent implementation adds indirection and inverts nothing.
Treating SOLID as a checklist to score. These are heuristics for managing change, and applied to code that never changes they only add ceremony.
Practice
A 900-line `OrderService` handles validation, pricing, persistence, email and analytics. Apply single responsibility, but say what you would *not* split and why.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
Reasons to change, not lines of code, define a responsibility.
Show a strong answer
Split by reason to change, which is what the principle actually says — not by line count and not by noun.
- Pricing changes when commercial policy changes, driven by the business. Its own component, with its own tests, ideally with no dependency on persistence at all so it can be tested as pure logic.
- Persistence changes when the schema or storage changes. Behind a repository, so a schema migration touches one file.
- Email and analytics change when marketing or reporting needs change, and neither is required for an order to be valid. These become reactions to an
OrderPlacedevent rather than steps in the method — which also fixes a correctness problem, since an analytics outage should not fail an order.
What I would not split:
- Validation from the order logic. Whether an order is valid is the order's domain rule; the two change together and always for the same reason. Extracting an
OrderValidatorthat reaches back into the order for every field produces two classes that must be edited in lockstep — the coupling is unchanged and there is now more of it. - The orchestration itself. Something must still coordinate: price, persist, publish. A thin service method that calls three collaborators is a responsibility — "run the order-placement process" — and dissolving it into events entirely makes the flow unreadable.
The test I would apply to each candidate split: name the person who would request a change to it. If two pieces of code are always changed by the same request from the same person, they are one responsibility however long they are.
A colleague says SOLID is 'outdated ceremony that leads to over-engineering'. Give the strongest version of their argument, then respond.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
There is a real case here. Make it properly before answering it.
Show a strong answer
The strongest version of their argument:
- Applied eagerly, these principles produce interface-per-class codebases where every concrete type hides behind an abstraction with exactly one implementation. Reading such code means following three hops to find the one thing that runs, and the flexibility is never used.
- Open/closed encourages speculative extension points. You cannot know which axis will vary, so you guess, and a wrong guess is worse than no abstraction — it must be dismantled before the real change can be made.
- They were articulated for large, statically-typed, inheritance-heavy codebases. In a small service, or in a language where functions are values, several of them dissolve: dependency inversion is just passing a function, and interface segregation stops being a concern when there is no interface.
The response:
The principles describe properties of code that turned out to be changeable, not steps to perform in advance. Read as diagnoses they are excellent — "this class has two reasons to change", "this subtype cannot substitute for its parent", "this caller depends on a concrete thing it does not control". Read as prescriptions applied before the pressure exists, they generate exactly the ceremony described.
So the honest position is: apply them when refactoring, not when first writing. Write the simple concrete version, and when a second implementation, a second reason to change or a painful test appears, the principles tell you which cut to make. The failure is not in the principles; it is in applying them to code that has not yet told you how it varies.
The one I would defend unconditionally is Liskov, because violating it is not a matter of taste — it produces code where the type system permits something the design forbids, and the failure surfaces at runtime in the caller.
Key takeaways
- SOLID is five answers to one question: when requirements change, how much must I touch and what might I break?
- Single Responsibility is about reasons to change, not method counts — if two teams can demand edits to one file for unrelated reasons, split it.
- Open/Closed: add behaviour without editing tested code. Refactor at the third case, not in anticipation.
- Liskov: a subtype must not surprise callers.
instanceofchecks and methods that throw are the principle failing out loud. - Interface Segregation: do not force clients to depend on capabilities they never use — including passing whole objects when one field is read.
- Dependency Inversion is the why behind dependency injection: business rules should define the shape, and infrastructure should conform to it.
- All five are guidelines applied in response to pain, not rules to satisfy pre-emptively.
Further reading
- Robert C. Martin, "Design Principles and Design Patterns" (2000) — where the acronym was assembled.
- Barbara Liskov and Jeannette Wing, "A Behavioral Notion of Subtyping" (ACM TOPLAS, 1994).
- Bertrand Meyer, Object-Oriented Software Construction, second edition (Prentice Hall, 1997) — the open/closed principle in its original form.
- Dan North, "CUPID — for joyful coding" (2022) — a considered critique and alternative.
- Martin Fowler, Refactoring, second edition (Addison-Wesley, 2018) — the smells that tell you a principle has been violated.