Object-Oriented Design · Session Notes

SOLID PrinciplesInterface Segregation · Dependency Inversion · Liskov Substitution

The three SOLID principles that keep large object-oriented systems from calcifying, taught from the ground up with diagrams, analogies, and the traps that catch people in interviews.

Scope: a focused reference on the three SOLID principles that fight coupling hardest — ISP, DIP, LSP (plus a recap of SRP & OCP). Items marked [+] are added for completeness; [!] flags an inconsistency worth noting.
Section 1

Notebook Summary

Hand-copyable revision notes. Terse rules, arrows, doodles. Everything here is derived from the Detailed Notes below.

⭐ trick / shortcut ⚠ pitfall / gotcha 💡 tip / interview aside → leads to / implies

SOLID — big picture

  • SOLID = 5 OO design principles (Uncle Bob / R. C. Martin)
  • SRP · OCP · LSP · ISP · DIP
  • Why: clean + maintainable → loose coupling → reusable + testable
  • 💡Goal in one word = decoupling. Every principle removes a reason to change one class when another changes.

Recap — S&O

  • SRP → one class = one reason to change (single purpose)
  • OCP → open for extension, closed for modification (add new code, don't edit old)

ISP — Interface Segregation

  • Rule: clients not forced to depend on methods they don't use
  • Fix: split fat interface → many small role interfaces
  • ⚠Symptom = classes forced to throw / stub empty methods they can't support
  • Doodle: one big "car-boat-plane" blob → split into 3 clean shapes.
  • "It's a car! No, a boat! No… a plane?" = interface trying to be everything
  • ⭐ISP is SRP for interfaces — same idea, applied to contracts

DIP — Dependency Inversion

  • Rule: high-level modules ≠ depend on low-level; both → depend on abstraction
  • Also: abstractions ≠ depend on details; details → depend on abstractions
  • high-level = UserService · low-level = EmailService
  • swap Twilio → SendGrid should touch 0 high-level code
  • Doodle: A→B arrow, then flip both to point up at "«interface»" in the middle.
  • 💡DIP = principle. DI (injection) = the technique that delivers it. [!] slide agenda mislabels it "Dependency Injection"
  • ⭐"new is glue" — every new LowLevel() inside a high-level class welds them together
  • Takeaways → separate impl from abstraction · code to interfaces · forget who constructs it · enables unit testing (mocks)

LSP — Liskov Substitution

  • Rule: subtype must be usable anywhere its base type is — no surprises
  • Use inheritance only where it's TRULY "is-a"
  • ⚠Classic trap: Square extends Rectangle → setWidth breaks height
  • Doodle: Penguin under "Bird{fly()}" with a red ✗ on the fly arrow.
  • ⭐Smell test: subclass throws / no-ops an inherited method → LSP violated → prefer composition

Quick contrast

Principle One-liner Fix
ISP fat interface split contracts
DIP hard-wired deps depend on abstraction + inject
LSP broken "is-a" use composition instead
§ · · · §
Section 2

Detailed Study Notes

The full lesson: what each principle is, why it exists, where it bites you in real code, and how to fix it.

The Hook

Your team ships a working app. One afternoon, finance asks to switch the text-message provider from Twilio to a cheaper vendor. It should be a one-line config change. Instead, a developer spends the afternoon grepping the codebase, because the phrase new TwilioClient() is hand-typed inside twelve different classes that have nothing to do with texting. Change one, miss another, ship a bug.

That afternoon is what happens when code is tightly coupled — when everything is welded directly to everything else. SOLID is the set of habits that keeps the welds from forming, so a change stays where you make it. This guide covers the three principles that do the heaviest lifting on coupling: Interface Segregation, Dependency Inversion, and Liskov Substitution.

SOLID, in one breath

SOLID is five design principles for object-oriented code, proposed by Robert C. "Uncle Bob" Martin, that together make a system easier to change without breaking it.

Think of a well-organized workshop where every tool hangs in one labelled spot. You can grab, swap, or replace any tool without disturbing the rest of the bench. SOLID is that labelling system for code: the payoff here is clean, maintainable code, loose coupling, and better reusability and testability.

Letter Principle Plain-English rule
S Single Responsibility A class should have only one reason to change.
O Open/Closed Open to extension, closed to modification.
L Liskov Substitution A subtype must work anywhere its base type does.
I Interface Segregation Don't force clients to depend on methods they don't use.
D Dependency Inversion Depend on abstractions, not concrete details.

This guide is the "continued" half. The first two — SRP and OCP — are recapped here in one line each so the rest connects.

Recap: Single Responsibility (S)

A class should have exactly one reason to change — a single purpose. If a User class both validates login and formats invoices, two unrelated teams can force edits to the same file. Split it.

Recap: Open/Closed (O)

Software should be open for extension but closed for modification. You should be able to add new behaviour by writing new code (a new subclass, a new plug-in), not by reopening and editing code that already works and is already tested.

💡 Tip — the unifying idea Every SOLID principle is a different attack on the same enemy: coupling. Keep asking "if this changes, what else is forced to change?" A good design keeps that answer as small as possible. That single question is often enough to reason your way to the right principle in an interview.

Interface Segregation ISP

Clients should not be forced to depend upon interface members they do not use. In plain terms: don't make a class implement methods it can't meaningfully support.

Analogy — the do-everything remote. Picture a universal remote with 60 buttons where you only ever use 4. Most buttons are noise; some do nothing on your TV and just confuse you. A clean design gives you a small remote with exactly the buttons your device supports. An interface is a remote for a class: it should expose only the buttons that actually work.

A joke about a vehicle frames this: "It's a car! No, it's a boat! No… maybe airplane?" That is a single interface trying to describe a car, a boat, and a plane at once. A car forced to implement fly() and sail() has to do something dishonest with those methods — usually throw an error or leave them empty. That is the smell ISP removes.

BEFORE — one fat interface «Vehicle» drive() fly() sail() Car must stub fly() + sail() ✗ split AFTER — focused interfaces «Drivable» drive() «Flyable» fly() «Sailable» sail() Car → Drivable Boat → Drivable+Sailable
One "Vehicle" interface forces every implementer to support flying and sailing. Splitting it lets each class implement only the roles it actually fills.

Where it shows up (worked example)

This guide uses a running example: an app that was refactored into separate Cart, User, and Order classes to honour SRP. The activity asks you to spot the problem the refactor introduced and propose an ISP fix. The typical shape: a shared interface (say IOperations with addToCart(), placeOrder(), updateProfile()) that all three classes are made to implement — so User ends up with a meaningless placeOrder() and Order gets a stray updateProfile(). [+] The fix: give each responsibility its own narrow interface and let each class implement only what fits.

// ✗ ISP violation — fat interface
interface OrderOps {
  addToCart();
  placeOrder();
  updateProfile();   // Order has no business doing this
}

// ✓ Segregated — small role interfaces
interface Cartable  { addToCart(); }
interface Orderable { placeOrder(); }
interface Profile   { updateProfile(); }
// Cart implements Cartable, Order implements Orderable, User implements Profile
⭐ Trick — ISP is just SRP for interfaces If you already understand Single Responsibility, you understand ISP: it's the same "one job" rule applied to contracts instead of classes. Whenever an interface would need "and" to describe it ("edits profiles and places orders"), split it. This reframing makes ISP questions trivial.
⚠ Pitfall — the empty-method tell The unmistakable sign of an ISP violation is a class implementing a method with throw new UnsupportedOperationException() or an empty body. If a class has to lie about a method to satisfy an interface, the interface is too fat. Don't "fix" it by leaving the method blank — split the interface.
⚠ Pitfall — don't over-segregate [+] The opposite failure exists too: shattering everything into one-method interfaces creates its own noise and ceremony. Group methods that genuinely change together and are used together. ISP is about cohesive roles, not maximum fragmentation.

Dependency Inversion DIP

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.
⚠ Name clash — read this first [!] Some references label this topic "Dependency Injection," but the principle and the SOLID acronym are about Dependency Inversion. These are two different (related) things, and the body version is the correct one for SOLID's "D". Inversion is the principle (depend on abstractions). Injection is a common technique for achieving it (hand a class its dependency from outside instead of letting it build its own). Don't conflate them in an interview.

Analogy — wall sockets. Your laptop charger doesn't care which power plant made the electricity — coal, solar, nuclear, all the same. It depends on the socket (a standard abstraction), not on the plant (the concrete detail). Swap the entire generation source and your charger is untouched. DIP asks your high-level code to plug into an interface the same way, so the concrete provider behind it can change freely.

Worked example

Here's a concrete scenario. In UserService, the team uses Twilio [!] [+](the deck writes "Twillio" — auto-typo; the real product is Twilio) to send messages to users. Then finance decides to switch to SendGrid because the subscription is cheaper. The question: is that easy to do in the code?

If UserService contains new TwilioClient(), the two are welded together. Switching to SendGrid means editing the high-level business class — and every other class that did the same. That's the pain the principle removes.

BEFORE — direct dependency UserService (high) TwilioClient (low) welded ✗ AFTER — both depend on abstraction UserService (high) «IMessenger» TwilioClient SendGridClient
Left: the high-level class points straight at a concrete class. Right: the arrow is "inverted" — both the high-level class and every concrete provider now point up at a shared interface, so providers swap freely.

Why it's called "inversion"

Normally, dependencies flow downhill: high-level code reaches down and grabs the low-level tool. DIP inverts the direction of that arrow. You introduce an interface (say IMessenger) that the high-level module owns and depends on. The low-level Twilio and SendGrid classes now both point up at that interface by implementing it. The high-level module no longer knows they exist.

// ✗ Inverted-away: high-level welded to low-level
class UserService {
  private client = new TwilioClient();   // hard-wired
  notify(u) { this.client.sendSms(u.phone, "hi"); }
}

// ✓ DIP: depend on an abstraction, injected from outside
interface IMessenger { send(to, msg); }
class UserService {
  constructor(private msg: IMessenger) {}   // injected
  notify(u) { this.msg.send(u.phone, "hi"); }
}
// new UserService(new SendGridClient())  ← swap here, UserService untouched
⭐ Trick — "new is glue" Any time a high-level class calls new SomeConcreteThing() inside itself, it has just glued itself to that concrete thing. Scan for the new keyword on low-level dependencies — each one is a coupling point. Move construction to the edge of the app (the "composition root") and inject the finished object in. This one heuristic catches most DIP violations on sight.
◆ Why it matters — testing A key takeaway is unit testing, and this is the biggest practical payoff. If UserService depends on IMessenger, your test can inject a fake messenger that records calls instead of actually texting anyone. A hard-wired new TwilioClient() would fire real SMS during tests. "Depends on an interface" and "is easy to unit-test" are almost the same statement.

Another prompt — "can any other classes be refactored to follow DIP?" — is a reminder that the same welding usually appears in more than one place. The key takeaways: separate implementations from abstractions, make coding to interfaces the default, stop caring which class constructs the dependency, and improve maintainability while encouraging unit testing.

💡 Tip — DIP vs. DI vs. IoC [+] Three terms interviewers love to blur: Dependency Inversion (the principle — depend on abstractions), Dependency Injection (the technique — pass dependencies in via constructor/setter/parameter), and Inversion of Control (the broader idea — a framework or container decides when your objects are created and wired). Injection is one way to satisfy inversion; a DI container automates the wiring.

Liskov Substitution LSP

Objects of a subclass must be usable anywhere objects of the parent class are expected, without breaking the program. In short: use inheritance only where it is truly an "is-a" relationship.

Analogy — a rental car. If a booking says "you'll get a car," any car should slot in: sedan, hatchback, SUV — you can drive them all the same way. But if the rental hands you a toy car that looks like a car yet can't actually drive, the substitution fails. A subtype that can't stand in for its parent without surprises breaks LSP.

LSP is the discipline behind honest inheritance. Just because two things seem related doesn't mean one should extend the other. The test is behavioural: can the child be dropped in for the parent everywhere, with no caller needing to know the difference?

Bird fly() Sparrow fly() ✓ really flies Penguin fly() ✗ throws / no-op LSP broken
A Sparrow substitutes for a Bird cleanly. A Penguin "is-a" Bird by taxonomy but can't fly(), so any code expecting bird.fly() breaks. The inheritance was never truly behavioural.

The textbook trap: Square extends Rectangle

[+] The most famous LSP violation isn't on the slides but is the one interviewers ask. A Square "is-a" Rectangle mathematically, so you make it a subclass. But Rectangle lets you set width and height independently. A Square must keep them equal, so setWidth(5) secretly changes the height too. Any code written for a rectangle — "set width to 5, expect height unchanged" — now breaks when handed a square. The types pass the compiler; the behaviour violates the contract.

⭐ Trick — the "throws or no-ops" smell test Fastest way to spot an LSP violation: does any subclass override an inherited method to throw an exception, do nothing, or weaken what callers rely on? (Penguin's fly(), a read-only list's add().) If yes, that "is-a" is a lie. The fix is almost always composition over inheritance — model the shared capability as a separate interface (Flyable) instead of forcing it down the hierarchy.
⚠ Pitfall — "is-a" in English ≠ "is-a" in code Natural-language taxonomy tricks you. "A penguin is a bird," "a square is a rectangle" — both true in speech, both dangerous as inheritance. LSP is about substitutable behaviour, not dictionary categories. Always ask: "can callers use the child exactly as they'd use the parent, with no special cases?"
◆ Why it matters — it protects OCP [+] LSP and Open/Closed reinforce each other. OCP lets you extend behaviour by adding subtypes; that only works if the new subtypes are substitutable. The moment a subtype needs an if (x instanceof Penguin) special case at the call site, you've broken both LSP and OCP at once.
Principle Guards against Telltale smell Usual fix
ISP fat interfaces empty / thrown method impls split into role interfaces
DIP hard-wired concrete deps new Concrete() in high-level code depend on interface + inject
LSP dishonest inheritance subclass throws / no-ops / needs instanceof composition over inheritance
§ · · · §

Interview & Practice Q&A

The real follow-ups this material invites. Click to reveal each answer, then say it aloud.

What problem does Interface Segregation actually solve?
It stops a class from being forced to implement methods it can't meaningfully support. When one fat interface serves many unrelated clients, some clients must stub or throw on methods they don't use, which is fragile and misleading. ISP splits the fat interface into small, role-specific ones so each class implements only what genuinely applies to it.
How is ISP different from the Single Responsibility Principle?
They're the same "one job" idea applied to different targets. SRP says a class should have one reason to change; ISP says an interface should serve one cohesive role. If you can describe an interface only by using the word "and," it's doing too much — split it, just as you would a class that has two responsibilities.
Explain Dependency Inversion in one sentence, then say why it's called "inversion."
High-level modules and low-level modules should both depend on a shared abstraction rather than the high-level depending directly on the low-level. It's "inversion" because it flips the normal dependency arrow: instead of business logic reaching down to a concrete tool, both the business logic and the tool point up at an interface the high-level module owns.
What's the difference between Dependency Inversion and Dependency Injection?
Inversion is the principle — depend on abstractions, not concrete classes. Injection is a technique that helps achieve it — instead of a class constructing its own dependency with new, the finished dependency is passed in from outside (via constructor, setter, or parameter). You can follow the inversion principle using injection, but they aren't the same thing. Note that some references mislabel the principle as "Dependency Injection." [!]
In the Twilio-to-SendGrid scenario, what makes the switch hard or easy?
It's hard when UserService constructs Twilio directly (new TwilioClient()), because switching means editing the high-level business class and every other place that did the same. It's easy when UserService depends on an IMessenger interface and the concrete client is injected — then swapping to SendGrid is a one-line change at the composition root, and UserService is never touched.
How does Dependency Inversion make code easier to test?
Because the high-level class depends on an interface, a test can inject a fake or mock implementation instead of the real thing. You can verify that UserService called send() without ever hitting the real Twilio API or sending an SMS. Hard-wired concrete dependencies would force real network calls during tests, which is slow, flaky, and sometimes costly.
Give a concrete example of a Liskov Substitution violation.
The classic one is Square extends Rectangle. A rectangle lets you set width and height independently, but a square must keep them equal, so setWidth(5) silently changes the height. Code written to work with any rectangle — "set the width and expect the height unchanged" — breaks when passed a square. The Penguin extends Bird case is the same shape: the subclass can't honour the parent's fly() contract.
You spot a subclass that overrides a method just to throw an exception. Which principle is at risk and what do you do?
That's the signature smell of a Liskov Substitution violation — the subclass can't truly stand in for its parent. The fix is usually composition over inheritance: pull the offending capability into its own interface (e.g. Flyable) and have only the classes that genuinely support it implement that interface, rather than forcing it down through the base class.
How do these three principles reinforce each other?
They all reduce coupling from different angles. ISP keeps interfaces small so DIP has clean abstractions to depend on; DIP lets high-level code stay stable when implementations change; LSP guarantees the substitutes behind those abstractions actually behave, which is also what makes Open/Closed extension safe. Break LSP and your polymorphism sprouts instanceof checks that unravel the other principles.

Key Takeaways

  1. The whole game is coupling. Every SOLID principle answers one question — "if this changes, what else is forced to change?" — and shrinks the answer.
  2. ISP = don't force clients to depend on methods they don't use. Symptom: empty or throwing method bodies. It's SRP applied to interfaces — split the fat contract.
  3. DIP = high-level and low-level modules both depend on an abstraction, not on each other. "New is glue" — hunt down new Concrete() in high-level code and inject the dependency instead.
  4. Inversion ≠ Injection. Inversion is the principle; injection is a technique that delivers it. The "Dependency Injection" label sometimes seen is really Dependency Inversion. [!]
  5. LSP = a subtype must be usable anywhere its base type is, with no surprises. English "is-a" is not code "is-a"; when a subclass throws or no-ops an inherited method, prefer composition.
  6. DIP's practical payoff is testability — depending on interfaces lets you inject mocks, so "well-decoupled" and "easy to unit-test" end up meaning the same thing.