SOLID: Object-Oriented Design That Ages Well

Five principles for code that survives contact with the future: one job per class, extend without rewriting, substitutes that keep their promises, small interfaces, and dependencies that point at abstractions — told through a home kitchen.

Beginner · 17 min read

Why this matters

Every senior engineer carries a scar from a class called something like Manager, Utils, or God. Ten thousand lines. Touched by forty people. Nobody dares delete a line, because nobody knows which three features that line is secretly holding up. You change the tax calculation and the email templates break. You fix the emails and login breaks. The code works — right up until you need it to do anything new.

SOLID is five principles for never building that class in the first place. The principles come from Robert C. Martin's writing on object-oriented design — first collected in Agile Software Development (2002); the SOLID acronym itself was coined a couple of years later by Michael Feathers, who noticed the initials spelled a handy word. Martin revisited them in Clean Architecture (2017). You've probably seen the acronym before. This lesson is about what the letters actually buy you — not as rules to memorize, but as instincts for splitting code into pieces that stay easy to change.

For this lesson, your code is a home kitchen. Yours — the one you'll cook in for the next ten years.

Video: Uncle Bob's SOLID Principles Made Easy - In Python! — ArjanCodes
All five SOLID principles with practical Python examples and companion code — the "why bother" case from a veteran educator.

What good design actually buys you

Before the five letters, the payoff. Object-oriented design earns its keep in three ways, and every principle below serves at least one of them:

That's the whole sales pitch. A codebase where change is cheap, testing is easy, and nothing is secretly holding up three other things.

Now the five principles. Each one gets one kitchen analogy, then the precise definition underneath.

Video: SOLID Principles Explained in 8 Minutes | Software Design Basics — Knowledge Thrusters
Shows what SOLID buys you — maintainable, scalable code — with real-world examples and common mistakes.

S — Single responsibility: one job per gadget

Somewhere in a catalog there's an all-in-one breakfast station: toaster on the left, griddle in the middle, coffee maker on the right, one power cord. It looks efficient until the toaster element dies — and takes your morning coffee down with it. Three jobs, one box, one shared fate.

Classes work the same way. Martin's phrasing is exact: a class should have one, and only one, reason to change. Not "do one thing" — change for one reason. A ReportGenerator that also sends emails and writes to the database has three reasons to change: the report format, the email policy, the schema. Every change risks the other two.

// Three reasons to change, one class to blame
class OrderManager {
  takeOrder() { /* ... */ }   // changes when ordering changes
  cookFood() { /* ... */ }    // changes when the menu changes
  printBill() { /* ... */ }   // changes when billing changes
}

The fix is the split you'd expect:

flowchart LR
    G[KitchenGod<br/>cooks, serves, bills]:::service --> C[Cook]:::service
    G --> S[Server]:::service
    G --> B[Cashier]:::service

Cook, Server, Cashier. The menu changes? You touch the Cook. Billing changes? The Cashier. Nothing else flinches. That's maintainability, bought with a sketch you could draw on a napkin.

See it side by side:

Interactive diagram: VsToggle (loads in the app)

And your smoke detector? It has exactly one job — scream when there's smoke — and it has never once tried to also be a clock radio. Be like the smoke detector. (It'll be back.)

Video: Ignacy Sokołowski: Single Responsibility Principle - PyWaw Summit 2015 — PyWaw Summit
Conference talk on how to spot mixed responsibilities and refactor code toward single-purpose, testable classes.

O — Open/closed: the stand mixer

Your stand mixer is a motor with a socket on top. Pasta roller, meat grinder, grain mill, ice cream churn — every attachment snaps into the same socket. You've never once opened the motor housing to add a new one.

That's the open/closed principle, and the phrase comes from Bertrand Meyer (Object-Oriented Software Construction, 1997): software entities should be open for extension, but closed for modification. You add behavior by adding new code, not by editing old code.

The usual violation is the switch statement that grows a new case every sprint:

function charge(order) {
  switch (order.paymentType) {
    case 'card': /* ... */ break;
    case 'cash': /* ... */ break;
    case 'crypto': /* ... */ break;  // this month's edit
    // next month: crack this function open again
  }
}

Every new payment method means opening a function that already works — and risking every payment method that already works. The fix is the mixer socket: depend on a PaymentMethod interface, and let each new method arrive as a new attachment. New code gets added; old code stays shut. That's changeability, and it's the reason the mixer outlives every attachment trend.

Video: Open/Closed Principle (OCP) Explained with Java | SOLID Principles with Real-World Example — asknehru
Uses a payment-service example to show adding new behavior via polymorphism instead of editing existing code.

L — Liskov substitution: the cream test

A recipe calls for cream. You're out, so you reach for oat cream. Fine — if it behaves like cream everywhere the recipe needs cream: it shouldn't curdle under heat, and it should whip. If your substitute curdles in the pan, it wasn't really a substitute. It just looked like one on the shelf.

That's Liskov substitution, from Barbara Liskov's 1987 keynote "Data Abstraction and Hierarchy": objects of a subtype must be usable anywhere the supertype is expected, without breaking the program. It's about behavior, not labels. The classic violation: Square extends Rectangle, but Rectangle lets you set width and height independently — and a square that silently changes its height when you set its width just broke every recipe written for rectangles.

The kitchen version: if the recipe says "any pan," your non-stick pan had better survive the broiler the way the cast iron would. A substitute that can't keep the contract isn't a substitute — it's a surprise. LSP is the principle that says surprises are bugs.

Liskov violations in the wild

This isn't a textbook curiosity — two of the most-used classes in Java history violate it. java.util.Stack extends Vector, so a stack inherits methods like insertElementAt that let you jam an element into the middle — shattering the last-in-first-out contract every stack recipe depends on. java.util.Properties extends Hashtable, inheriting put(Object, Object), which happily accepts non-string keys that getProperty will then never find. In both cases the subclass looks substitutable and quietly isn't: the oat cream that curdles in the pan. Joshua Bloch holds both up in Effective Java as cautionary tales for the same verdict — inheritance models "is-a," and when the "is-a" is a lie, every caller pays.

The test is always behavioral: can a caller use the subtype without knowing it isn't the supertype? If answering that requires reading the subclass's source code, the contract is already broken.

Video: Liskov Substitution Principle (LSP) Explained in Java | SOLID Principles Made Simple — Byte & Beyond with Uday
Uses Bird, Sparrow, and Ostrich to demonstrate substitutable subclasses and fix inheritance that breaks expectations.

I — Interface segregation: the right menu

Your TV remote has eighty buttons. You use nine. The other seventy-one are a maze you navigate every time you want the volume up — and every one of them is a button some client is forced to depend on.

Interface segregation says: no client should be forced to depend on methods it doesn't use. Hand the vegan the vegan menu, not the steakhouse menu with a lecture. In code: prefer several small, focused interfaces over one fat one.

// The 80-button remote
interface Worker { work(): void; eat(): void; sleep(): void; }

class Robot implements Worker {
  work() { /* ... */ }
  eat() { /* throw? */ }   // robots don't eat. Now what?
  sleep() { /* ... */ }
}

The robot is forced to implement eat() — a method it can never honestly provide — because the interface was designed for humans. Split the interface and the problem evaporates:

interface Workable { work(): void; }
interface Eatable { eat(): void; }

class Human implements Workable, Eatable { /* ... */ }
class Robot implements Workable { /* ... */ }  // no fake eat() required

Small interfaces, honest clients. Nobody implements a method just to throw "not supported" into the void.

Video: Interface Segregation Principle | Design Principles - SOLID | Low Level Design — Your Tech Buddy
Explains why fat interfaces cause trouble and how splitting them into focused contracts improves flexibility.

D — Dependency inversion: plug, don't hardwire

Your lamp plugs into a wall outlet. It is not hardwired to the power plant. When you move apartments, the lamp comes with you — because it depends on the socket, an abstraction, not on any particular grid.

Dependency inversion says the same about code: high-level modules should depend on abstractions, not on low-level details — and the details should depend on the abstractions too. The recipe app shouldn't name your hard drive. It should name a Storage interface, and let the hard drive (or the cloud bucket, or the test fake) plug into that.

flowchart TB
    App[Recipe App]:::service --> I[Storage<br/>interface]:::service
    Disk[Local Disk]:::data -. implements .-> I
    Cloud[Cloud Bucket]:::cloud -. implements .-> I

Notice the arrows: the low-level details point at the abstraction. That's the "inversion" — in naive code, the app would point straight at the disk, and swapping storage would mean rewriting the app.

Watch a swap happen, step by step:

Interactive diagram: StepThrough (loads in the app)

Video: Dependency Inversion Principle (D) - Software Design Patterns — The Software Mentor
Depend on abstractions, not concretions — a working engineer walks through the DIP with TypeScript code, when it pays off, and when it is overkill.

The junk drawer tour: violations you'll meet in the wild

You now have names for the messes. A quick field guide:

And the smoke detector? Still on the ceiling. Still one job. Still never broken a build.

Video: 6 Minutes to Finally Understand SOLID Principles — Neural Download
Walks through classic violations like the 2000-line God class plus pitfalls where over-applying SOLID backfires.

Takeaways

  1. SOLID is about cheap change. Maintainability (one place to edit), testability (swap the real thing for a fake), changeability (add without rewriting) — every principle serves at least one.
  2. Single responsibility: a class should have one, and only one, reason to change. Split the breakfast station into a toaster, a griddle, and a coffee maker.
  3. Open/closed: open for extension, closed for modification (Meyer). Add attachments to the mixer; never open the motor housing.
  4. Liskov substitution: a subtype must be usable anywhere its supertype is expected, without breaking the program. The oat cream must survive the pan.
  5. Interface segregation: no client should depend on methods it doesn't use. Hand the vegan the vegan menu.
  6. Dependency inversion: depend on abstractions, not details. Plug the lamp into the outlet; don't hardwire it to the power plant.

Check your understanding

  1. Your ReportGenerator class also sends the report by email and saves a copy to the database. Which principle is it violating?

    • The open/closed principle — it can't be extended with new report types
    • The Liskov substitution principle — it isn't substitutable for its parent
    • The dependency inversion principle — it depends on low-level details
    • The single responsibility principle — it has three reasons to change
  2. In the stand-mixer analogy, what does the mixer's attachment socket represent?

    • The stable interface that new behavior plugs into without modifying existing code
    • A base class that every new attachment is forced to inherit from
    • The database where the mixer stores the recipes it knows
    • A performance trick for making the motor run faster
  3. Square extends Rectangle, but setting a square's width also changes its height — breaking code written for rectangles. What does this violate?

    • Interface segregation — clients are forced to depend on methods they don't use
    • Liskov substitution — the subtype can't stand in for the supertype
    • The open/closed principle — someone modified the rectangle class
    • Dependency inversion — the square depends on a concrete class
  4. A Robot class is forced to implement eat() even though robots don't eat. What's the interface-segregation fix?

    • Make eat() throw an error — at least that's honest about it
    • Delete the Robot class entirely and only build for humans
    • Split the fat interface into smaller ones, so Robot only implements what it uses
    • Merge eat() and work() into one doEverything() method
  5. Why should the recipe app depend on a Storage interface instead of directly on LocalDisk?

    • Interfaces execute faster than concrete classes ever could
    • So the app can swap in a cloud bucket or a test fake without changing its own code
    • Because the compiler requires every class to declare an interface
    • To make the class diagram symmetrical, which impresses reviewers

Go deeper

Want to keep pulling this thread? These talks and tutorials go further than we did here:

Sources & further reading