SOLID Principles in Detail
SOLID is five object-oriented design principles that share one goal: let you change a system by adding code rather than editing code that already works. Introduced by Robert C. Martin (the principles) and later given the SOLID mnemonic by Michael Feathers, they are the backbone of every low-level design and machine-coding round - and they are far more useful understood by the refactor each one names than by the one-line definition. This post takes each principle in depth: the precise statement, a concrete violation, the fix, and the nuance people get wrong. Examples are in TypeScript, because its explicit interfaces and types put the design on the page; the ideas carry to any object-oriented language.
This is part of the System Design Guide series, on the LLD track. It is the deep-dive companion to the low-level design concepts overview - if you want the wider set of principles and patterns, read that; if you want SOLID done properly, this is it.
S - Single Responsibility Principle
The statement people memorize: a class should have one reason to change. The statement that is actually useful: a module should be responsible to one, and only one, actor - where an actor is the person or group who would request a change. This reframing matters, because "does one thing" is the wrong reading and leads people to shatter their code into a hundred trivial classes.
The canonical violation is a class whose methods answer to different stakeholders:
class Employee {
calculatePay(): number { return 0; } // requested by Accounting (the CFO's org)
reportHours(): number { return 0; } // requested by HR (the COO's org)
save(): void { /* persistence */ } // requested by the DBA (the CTO's org)
}
Three actors share one class. The danger is not aesthetic: suppose calculatePay and reportHours both call a shared private regularHours() helper. Accounting asks for a change to how overtime is computed, a developer edits regularHours(), and HR's hour report silently breaks - because two actors were coupled through one method. A change requested by one stakeholder damaged a feature owned by another. That is the specific failure SRP prevents.
The fix is to separate along the actor boundaries and keep the shared data in a plain structure:
class Employee { /* just the data */ }
class PayCalculator { calculate(e: Employee): number { return 0; } }
class HourReporter { report(e: Employee): number { return 0; } }
class EmployeeRepository { save(e: Employee): void {} }
The nuance to hold onto: SRP is really about cohesion and coupling - group together the things that change for the same reason, and separate the things that change for different reasons. It is not a license to make every class a single method. Martin himself has said he regrets how easily "responsibility" is misread; the honest test is "who asks for changes to this, and is it more than one group?", not "does this do more than one thing?"
O - Open/Closed Principle
The statement: software entities should be open for extension but closed for modification. You should be able to add new behavior by writing new code, not by editing existing, tested code. The reason is risk: every edit to working code is a chance to break it and forces you to re-test it. New code that plugs into a stable interface carries far less risk.
The tell of a violation is a conditional on a type that grows every time you add a case:
function area(shape: any): number {
if (shape.kind === "circle") return Math.PI * shape.r ** 2;
if (shape.kind === "square") return shape.s ** 2;
// ...add "triangle" here, and here, and in every other switch
return 0;
}
Add a triangle and you are editing (and re-testing) area, plus every other function that switches on the shape type. The refactor pushes the varying behavior behind an interface so each shape owns its own area, and the calculator never changes again:
interface Shape { area(): number; }
class Circle implements Shape {
constructor(private r: number) {}
area(): number { return Math.PI * this.r ** 2; }
}
class Square implements Shape {
constructor(private s: number) {}
area(): number { return this.s ** 2; }
}
function totalArea(shapes: Shape[]): number {
return shapes.reduce((sum, s) => sum + s.area(), 0); // never edited for a new shape
}
The nuance - strategic closure: you cannot make code closed against every possible change, and trying to is over-engineering. You choose the axis you expect to vary and close against that one. Here we bet that "new shapes get added" is likely, so we made shapes extensible. If instead the operations varied (area today, perimeter tomorrow, rendering next) while shapes stayed fixed, a different structure would be right. The deliberate, strategic choice of what to make extensible - rather than speculatively making everything pluggable - is the actual skill. Plugin architectures are OCP at its best, precisely because the people writing plugins are not the people who own the core.
L - Liskov Substitution Principle
The statement: if you have code that works with a base type, it must keep working when handed any subtype, without knowing the difference. Named for Barbara Liskov's work on behavioral subtyping, it is really a contract: a subtype may not strengthen preconditions, weaken postconditions, break invariants the base promised, or throw new exceptions the caller does not expect. Inheritance that merely reuses code while violating the base's behavior is the trap.
The textbook violation is the one that looks most innocent - a Square that "is a" Rectangle:
class Rectangle {
constructor(protected w: number, protected h: number) {}
setWidth(w: number) { this.w = w; }
setHeight(h: number) { this.h = h; }
area(): number { return this.w * this.h; }
}
class Square extends Rectangle {
setWidth(w: number) { this.w = w; this.h = w; } // must keep sides equal
setHeight(h: number) { this.w = h; this.h = h; }
}
function grow(r: Rectangle) {
r.setWidth(5);
r.setHeight(4);
console.assert(r.area() === 20); // FAILS for Square: area === 16
}
Mathematically a square is a rectangle; behaviorally it is not substitutable, because setting width and height independently is part of the rectangle's contract and the square breaks it. Other everyday violations: a Bird base with a fly() method that a Penguin subtype cannot honor, or a ReadOnlyList that inherits add() and throws at runtime (a "refused bequest").
The fix is not a clever override - it is to stop forcing the false is-a. Model what is actually shared behind an honest abstraction (Shape with area()), and let Square and Rectangle be siblings rather than parent and child, or use composition. LSP's practical lesson: inheritance is a promise about behavior, not a code-reuse shortcut. When a subtype has to lie about the base's contract, the hierarchy is wrong.
I - Interface Segregation Principle
The statement: clients should not be forced to depend on methods they do not use. Prefer several small, role-focused interfaces over one fat, do-everything interface. A bloated interface couples every implementer to capabilities it may not have, and couples every client to methods it never calls.
The violation - one interface that assumes every device does everything:
interface Machine {
print(doc: string): void;
scan(doc: string): void;
fax(doc: string): void;
}
class SimplePrinter implements Machine {
print(doc: string) { /* ok */ }
scan(doc: string) { throw new Error("not supported"); } // smell
fax(doc: string) { throw new Error("not supported"); } // smell
}
Those runtime errors are the interface telling you it is too big. Split it into roles, and let each device implement exactly what it does:
interface Printer { print(doc: string): void; }
interface Scanner { scan(doc: string): void; }
interface Fax { fax(doc: string): void; }
class SimplePrinter implements Printer {
print(doc: string) { /* only what it can actually do */ }
}
class OfficeMachine implements Printer, Scanner, Fax {
print(d: string) {} scan(d: string) {} fax(d: string) {}
}
The nuance: this is why experienced designers favor tiny interfaces. The Go community states it most sharply - "the bigger the interface, the weaker the abstraction" - and builds from one-method interfaces up. The same instinct shows up in idiomatic Go: small interfaces are more reusable, easier to implement, and easier to test. ISP is the principle that keeps the abstractions used by the next principle small and honest.
D - Dependency Inversion Principle
The statement: high-level modules should not depend on low-level modules; both should depend on abstractions. And abstractions should not depend on details - details should depend on abstractions. In plain terms: depend on an interface, not on a concrete class.
The violation is a high-level policy class that reaches down and hard-wires a concrete dependency:
class EmailSender {
send(msg: string) { /* SMTP... */ }
}
class NotificationService {
private sender = new EmailSender(); // can't swap, can't test in isolation
notify(msg: string) { this.sender.send(msg); }
}
NotificationService now is email. You cannot send an SMS without editing it, and you cannot unit-test it without actually sending email. Invert the dependency: define the abstraction the high-level code needs, and inject a concrete implementation from outside:
interface Notifier { send(msg: string): void; }
class EmailNotifier implements Notifier { send(msg: string) { /* SMTP */ } }
class SmsNotifier implements Notifier { send(msg: string) { /* SMS */ } }
class NotificationService {
constructor(private notifier: Notifier) {} // depends on the abstraction
notify(msg: string) { this.notifier.send(msg); }
}
const service = new NotificationService(new EmailNotifier()); // wiring at the edge
Both the high-level NotificationService and the low-level EmailNotifier now depend on the Notifier interface, and the concrete choice is made at the composition edge. In a test you pass a fake Notifier and assert on it.
The distinction to get right: DIP is the principle (depend on abstractions). Passing the dependency in through the constructor is dependency injection, a technique that helps you satisfy DIP; "inversion of control" is the broader idea of who is in charge of wiring. DIP is the one interviewers push on hardest, because it is what makes a design testable and swappable - when a class feels rigid or impossible to unit-test, an injected abstraction is almost always the cure.
The five are one idea
SOLID is not five unrelated rules; they interlock. Open/Closed is almost always achieved through polymorphism - and polymorphism is only safe when Liskov holds, so subtypes really are substitutable. The abstractions you extend under OCP are the ones Dependency Inversion tells you to depend on, and Interface Segregation keeps those abstractions small enough to implement without lying. Underneath it all, Single Responsibility keeps each class cohesive enough that clean abstractions are even possible. Read together, SOLID is a single stance: depend on small, honest abstractions so that new behavior arrives as new code.
Do not cargo-cult it
SOLID applied without judgment produces its own failure mode: a fog of one-method classes, interfaces with a single implementer, and factories building factories - complexity with no payoff, which is exactly what KISS and YAGNI warn against. The principles earn their keep where change is likely; applying them speculatively everywhere is over-engineering, not craftsmanship. Add the abstraction when a second implementation appears or is clearly coming, not on the off chance.
In an interview, this means you apply SOLID and name it when you justify a move - "I'll put pricing behind a strategy interface so we can add rules without touching checkout, that's open/closed" - rather than reciting the acronym unprompted. Reviewers are listening for whether you can spot the specific violation and make the specific fix. Here is the quick mapping from smell to principle:
| Smell in the code | Principle it violates |
|---|---|
| One class edited by several different teams / for unrelated reasons | Single Responsibility |
A growing if/switch on a type; editing tested code to add a case |
Open/Closed |
| A subtype that overrides to throw, or breaks the base's expectations | Liskov Substitution |
| Implementers forced to stub methods they cannot support | Interface Segregation |
| A class that constructs its own concrete dependencies | Dependency Inversion |
You now have SOLID as a working tool
Each principle names a concrete refactor: separate classes by actor (S), extend through polymorphism instead of editing (O), keep inheritance honest about behavior (L), split fat interfaces into roles (I), and depend on injected abstractions (D). Learn to recognize the five smells above on sight, apply the fix, and name it - that is what a machine-coding reviewer is grading. For the Go-flavored take on the same principles, SOLID Go Design is worth reading; and for the design patterns that put OCP and DIP to work, the next stop is a pattern catalog like Refactoring Guru.

