All chapters in this track
- 01Chapter 1: Why Object Orientation Exists
- 02Chapter 2: Classes, Objects, and Instances, Precisely Defined
- 03Chapter 3: The Four Pillars I: Encapsulation
- 04Chapter 4: The Four Pillars II: Abstraction
- 05Chapter 5: The Four Pillars III: Inheritance
- 06Chapter 6: The Four Pillars IV: Polymorphism
- 07Chapter 7: Composition vs. Inheritance
- 08Chapter 8: Interfaces and Abstract Classes: When to Use Which
- 09Chapter 9: SOLID, Part 1: Single Responsibility & Open/Closed
- 10Chapter 10: SOLID, Part 2: Liskov Substitution, Interface Segregation, Dependency Inversion
- 11Chapter 11: Basic UML for Communicating Design
- 12Chapter 12: Anti-Patterns: God Objects, Tight Coupling, Deep Hierarchies, the Fragile Base Class, Anemic Domain Models
- 13Chapter 13: OOP Idioms Across TypeScript and Python — and Where OOP Is the Wrong Tool
- 14Chapter 14: When OOP Breaks Down: Real-World Failure Modes
- 15Chapter 15: Design Exercise: Parking Lot System
- 16Chapter 16: Design Exercise: Library Management System
- 17Chapter 17: Design Exercise: Elevator Controller
- 18Chapter 18: OOP in Interviews: What a Strong Answer Looks Like
Chapter 10 of 18
Chapter 10: SOLID, Part 2: Liskov Substitution, Interface Segregation, Dependency Inversion
Why this matters
These three principles are less quoted than SRP and OCP but catch bugs that are harder to spot in review: a subclass that technically compiles but breaks caller assumptions (LSP), an interface so bloated that implementers are forced to fake support for methods they don't need (ISP), and a design where high-level business logic is welded directly to a low-level implementation detail (DIP). Each has already been foreshadowed earlier in this book — this chapter makes each one explicit and formal.
Liskov Substitution Principle (LSP)
[BEGINNER] Subtypes must be substitutable for their base types without altering the correctness of the program. This is the formal version of the substitutability test introduced in Chapter 5 and Chapter 6.
Before — a subclass that violates callers' expectations:
class Bird {
fly(): string { return "Flying"; }
}
class Penguin extends Bird {
fly(): string {
throw new Error("Penguins can't fly"); // breaks any code written against Bird
}
}
function makeBirdsFly(birds: Bird[]): void {
for (const bird of birds) {
console.log(bird.fly()); // crashes if a Penguin sneaks in — Bird never promised this could happen
}
}
Every caller of makeBirdsFly was written trusting Bird's contract ("can fly"). Penguin is a Bird in plain English, but it cannot honor that contract, so substituting it in breaks the program — an LSP violation.
After — the hierarchy reflects actual shared behavior:
interface Bird {
eat(): string;
}
interface FlyingBird extends Bird {
fly(): string;
}
class Sparrow implements FlyingBird {
eat(): string { return "Eating seeds"; }
fly(): string { return "Flying"; }
}
class Penguin implements Bird {
eat(): string { return "Eating fish"; }
// No fly() — Penguin was never forced to promise something it can't do.
}
function makeBirdsFly(birds: FlyingBird[]): void {
for (const bird of birds) {
console.log(bird.fly()); // only ever called on birds that can actually fly
}
}
The fix isn't a workaround inside Penguin — it's recognizing that "can fly" was never a valid part of every bird's contract in the first place. Splitting the interface so it only promises what's universally true fixes the violation at its root.
from typing import Protocol
class Bird(Protocol):
def eat(self) -> str: ...
class FlyingBird(Bird, Protocol):
def fly(self) -> str: ...
class Sparrow:
def eat(self) -> str: return "Eating seeds"
def fly(self) -> str: return "Flying"
class Penguin:
def eat(self) -> str: return "Eating fish"
Interface Segregation Principle (ISP)
[BEGINNER] No client should be forced to depend on methods it doesn't use. A large, "do everything" interface forces every implementer to either support all of it or fake support for the parts it doesn't need — usually with a method that throws or silently does nothing, which is itself a form of the LSP violation above.
Before — a bloated interface:
interface Worker {
work(): void;
eat(): void;
sleep(): void;
}
class HumanWorker implements Worker {
work(): void { console.log("Working"); }
eat(): void { console.log("Eating lunch"); }
sleep(): void { console.log("Sleeping"); }
}
class RobotWorker implements Worker {
work(): void { console.log("Working"); }
eat(): void { throw new Error("Robots don't eat"); } // forced to fake a method it doesn't need
sleep(): void { throw new Error("Robots don't sleep"); } // same problem
}
After — segregated by actual capability:
interface Workable { work(): void; }
interface Eatable { eat(): void; }
interface Sleepable { sleep(): void; }
class HumanWorker implements Workable, Eatable, Sleepable {
work(): void { console.log("Working"); }
eat(): void { console.log("Eating lunch"); }
sleep(): void { console.log("Sleeping"); }
}
class RobotWorker implements Workable {
work(): void { console.log("Working"); } // only implements what actually applies
}
from typing import Protocol
class Workable(Protocol):
def work(self) -> None: ...
class Eatable(Protocol):
def eat(self) -> None: ...
class RobotWorker: # only needs to satisfy Workable
def work(self) -> None:
print("Working")
ISP and LSP reinforce each other: an interface that's too broad is exactly what tempts implementers into LSP-violating fake methods. Splitting the interface by actual capability (as Chapter 8 also recommends: "interfaces model capabilities") fixes both at once.
Dependency Inversion Principle (DIP)
[BEGINNER] High-level modules should not depend on low-level modules; both should depend on abstractions. This is the principle behind why PaymentProcessor was written as an interface back in Chapter 4 — and it's worth stating precisely, because "depend on abstractions" is easy to recite and easy to get backwards in practice.
Before — high-level logic depends directly on a low-level detail:
class SqlOrderRepository {
save(order: object): void { console.log("Saving to SQL database"); }
}
class OrderService {
private repository = new SqlOrderRepository(); // hard-wired to one specific low-level implementation
placeOrder(order: object): void {
// business logic
this.repository.save(order);
}
}
OrderService — the high-level policy ("how orders get placed") — is welded directly to SqlOrderRepository — a low-level detail (a specific database technology). Testing OrderService now means standing up a real SQL database, or nothing runs. Switching databases means editing OrderService itself, even though the business rule for placing an order hasn't changed at all.
After — both depend on an abstraction:
interface OrderRepository {
save(order: object): void;
}
class SqlOrderRepository implements OrderRepository {
save(order: object): void { console.log("Saving to SQL database"); }
}
class InMemoryOrderRepository implements OrderRepository {
save(order: object): void { console.log("Saving in memory (for tests)"); }
}
class OrderService {
constructor(private repository: OrderRepository) {} // depends on the abstraction, injected in
placeOrder(order: object): void {
this.repository.save(order);
}
}
// Production:
const service = new OrderService(new SqlOrderRepository());
// Tests:
const testService = new OrderService(new InMemoryOrderRepository());
from typing import Protocol
class OrderRepository(Protocol):
def save(self, order: dict) -> None: ...
class SqlOrderRepository:
def save(self, order: dict) -> None:
print("Saving to SQL database")
class OrderService:
def __init__(self, repository: OrderRepository) -> None:
self._repository = repository
def place_order(self, order: dict) -> None:
self._repository.save(order)
[SENIOR INSIGHT] The word "inversion" refers to the direction of the dependency arrow: without DIP, the high-level OrderService depends downward on the low-level SqlOrderRepository. With DIP, both depend on an abstraction (OrderRepository) that sits between them — the low-level module now depends upward on the interface, inverting the naive direction. This is also the mechanism behind dependency injection (passing OrderRepository into the constructor rather than constructing it internally) — DIP is the principle, dependency injection is a common technique for satisfying it.
Common mistakes
- Treating a
throw new Error("not supported")method as an acceptable way to satisfy an interface. This is almost always both an LSP and an ISP violation at once — the type promises something it can't deliver, because the interface was too broad to begin with. - Applying DIP by injecting an interface that only has one real implementation and no test double, "because DIP says so." If there's no second implementation and no testing need, the abstraction may be premature (Chapter 14) — DIP is valuable specifically because it enables swapping implementations (including test doubles), not as an end in itself.
- Confusing "depend on abstractions" with "add an interface to everything." DIP applies where a high-level policy would otherwise be hard-wired to a low-level, volatile detail (a database, an external API, a file system) — not to every internal collaboration between classes.
- Building one giant interface instead of segregating by capability, then discovering half the implementers need to fake methods — the exact ISP failure mode above.
Checklist: applying LSP, ISP, DIP
- For LSP: before adding a subclass or interface implementation, ask "can every existing caller use this instead, without special-casing it?" If the answer requires a caller to check "is this the exception type" first, that's a violation.
- For ISP: if an implementer would need to throw or no-op on part of an interface, split the interface by capability instead of forcing a fake implementation.
- For DIP: identify the volatile, low-level detail (a specific database, a third-party API, the file system) and put an interface between it and the high-level policy that uses it — especially anywhere tests currently require a real instance of that low-level thing to run.
- Don't apply DIP reflexively to stable, non-volatile collaborations that have no realistic second implementation or testing need.
- When reviewing a PR that adds a subclass or interface implementation, explicitly check LSP and ISP together — they usually fail as a pair (a too-broad interface produces LSP-violating fake implementations).
Discussion / interview questions
-
"What does the Liskov Substitution Principle actually require, precisely?" Strong answer: Any subtype must be usable anywhere its base type is expected, without breaking the correctness of code written against the base type — not just "it compiles," but "callers' assumptions still hold." Uses the Penguin/Bird example or equivalent.
-
"What's the connection between a fat interface and LSP violations?" Strong answer: An interface that promises more than every implementer can deliver forces some implementers into fake methods (throwing/no-op), which is exactly what breaks substitutability — ISP violations and LSP violations often come from the same root cause.
-
"Explain Dependency Inversion — what's actually being 'inverted'?" Strong answer: Without DIP, a high-level module depends directly downward on a low-level module's concrete implementation; DIP introduces an abstraction that both depend on, so the low-level module now depends upward on the interface — inverting the naive dependency direction. Connects this to why it enables testing with substitutable implementations.
-
"Is adding an interface always 'applying DIP' correctly?" Strong answer: No — DIP pays off specifically when it decouples a high-level policy from a volatile, low-level detail (external API, database, file system); wrapping a stable internal collaboration in an interface "because DIP says so," with no second implementation ever expected, is unnecessary indirection.
-
"You inherit a codebase where an interface has a method that half its implementers throw
NotImplementedExceptionon. What's the underlying design problem, and how would you fix it?" Strong answer: Identifies it as an ISP violation causing an LSP violation, and proposes segregating the interface by actual capability so implementers only commit to what they can genuinely support — theWorkable/Eatable/Sleepablefix.
Chapter summary / cheat sheet
- LSP: a subtype must be fully substitutable for its base type — if callers need special-case handling for a specific subtype, that subtype violates the contract.
- ISP: don't force implementers to depend on methods they don't use — a bloated interface produces fake/no-op implementations, which is usually also an LSP violation.
- DIP: high-level policy and low-level detail should both depend on an abstraction between them, not have the high-level module hard-wired to the low-level one. This is the principle; dependency injection is the common technique that satisfies it.
- All three, together with SRP and OCP (Chapter 9), aim at the same outcome: code that can change in one place without unpredictable ripple effects elsewhere.
- None of the five principles are absolute rules — each is judged against a real, current need (a real second implementation, a real testing requirement, a real volatile dependency), not applied reflexively everywhere.