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.
Hand-copyable revision notes. Terse rules, arrows, doodles. Everything here is derived from the Detailed Notes below.
| Principle | One-liner | Fix |
|---|---|---|
| ISP | fat interface | split contracts |
| DIP | hard-wired deps | depend on abstraction + inject |
| LSP | broken "is-a" | use composition instead |
The full lesson: what each principle is, why it exists, where it bites you in real code, and how to fix it.
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.
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.
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.
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.
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.
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
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.
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.
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?
UserService — it holds business logic and orchestrates
what should happen.EmailService / the Twilio client — the mechanical
how, the concrete detail.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.
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
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.
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.
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?
fly(), so any code expecting bird.fly() breaks. The inheritance was never
truly behavioural.[+] 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.
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.
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 |
The real follow-ups this material invites. Click to reveal each answer, then say it aloud.
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." [!]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.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.
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.Flyable) and have only the classes that genuinely support it
implement that interface, rather than forcing it down through the base class.instanceof checks that
unravel the other principles.new Concrete() in high-level code and inject the dependency
instead.