Most software doesn't die from a single catastrophic bug — it slowly rots. A small change in one place breaks something unrelated three files away; a class everyone is afraid to touch grows to a thousand lines; adding a new feature means editing code that already works and praying. SOLID is a set of five principles, popularized by Robert C. Martin, that push back against this rot.
The goal is simple: write object-oriented code that is maintainable — easy to understand, safe to change, and open to new features without rewriting the old ones. SOLID is mostly about managing coupling and cohesion: keeping the things that change together close, and the things that don't loosely connected.
Why these principles exist
Code is read and changed far more often than it is written. A class that does too much, or that everyone has to edit for every new requirement, becomes a magnet for bugs — every change risks breaking something nobody was thinking about.
Each SOLID principle attacks one source of that fragility. None of them are clever tricks; they're habits that, applied at the right moment, make a codebase that bends instead of breaking when requirements shift.
The five principles
SOLID is an acronym, and each letter names one principle: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion. The animation below steps through them one at a time, in that order, so you can see each idea on its own before we dig into concrete examples.
S · Single Responsibility — A class should have one reason to change.
Don't try to memorize all five at once. The thread running through every principle is the same: isolate the things that change. If you keep that idea in mind, each letter is just a different angle on it.
S — Single Responsibility
A class should have only one reason to change. Put another way, it should do one job and own one slice of the system's behavior.
Imagine an Invoice class that calculates totals, formats itself as a PDF, and emails itself to the customer. That's three jobs glued together. When the finance team changes the tax math, the marketing team tweaks the email wording, and design redoes the PDF layout, all three pull on the same file — and any of them can break the others. Splitting it into InvoiceCalculator, InvoiceRenderer, and InvoiceMailer gives each concern its own home, so a change in one doesn't ripple into the rest.
O — Open/Closed
Software should be open for extension but closed for modification. You should be able to add new behavior without editing code that already works.
Say you have a shipping cost calculator with a long if/else chain: standard, express, overnight. Every new shipping option means cracking open that method and risking the existing cases. The open/closed approach instead defines a ShippingMethod interface and lets each option be its own class. Adding "international" is now a new file that plugs in — the tested code you already trust stays untouched.
L — Liskov Substitution
Subtypes must be usable anywhere their base type is expected, without surprising the caller. If code works with a Bird, it should keep working when you hand it any specific bird.
The classic trap: a Bird class has a fly() method, and you add a Penguin subclass that throws an error or does nothing when asked to fly. Now any code that loops over birds and calls fly() breaks the moment a penguin shows up. The subclass technically inherits the type but violates the promise the base type made. The fix is to model the hierarchy honestly — perhaps only FlyingBird has fly() — so a substitute never betrays the caller's expectations.
I — Interface Segregation
Many small, focused interfaces beat one big general-purpose one. No class should be forced to implement methods it doesn't need.
Picture a single Machine interface with print(), scan(), and fax(). A modern all-in-one printer is happy to implement all three — but a simple office printer that can only print is now forced to provide empty or error-throwing scan() and fax() methods. Splitting that fat interface into Printer, Scanner, and Fax lets each device implement exactly the capabilities it actually has, and nothing more.
D — Dependency Inversion
Depend on abstractions, not concretions. High-level code that holds your business logic shouldn't be wired directly to low-level details like a specific database or email provider.
If your OrderService creates a MySqlDatabase itself, the two are welded together — you can't swap the storage or test the service in isolation without dragging a real database along. Instead, have OrderService depend on a Repository interface and receive a concrete implementation from outside. That hand-off is exactly what dependency injection provides, and it's the practical mechanism that makes this principle work day to day.
SOLID is easy to overdo. Wrapping every class in an interface and inverting every dependency "just in case" buries simple code under layers of indirection. Apply a principle when you feel real pressure — a class growing two jobs, an if/else chain you keep editing — not preemptively for change that may never come.
When it helps (and when it doesn't)
SOLID pays off most in code that is large, long-lived, and changing — the parts of a system where new requirements keep arriving and many people touch the same modules. There, the small upfront cost of clean boundaries saves you from the slow rot of tangled dependencies.
For a throwaway script, a quick prototype, or a stable corner of the code that hasn't changed in years, rigidly applying all five principles is usually a waste — you pay for flexibility you'll never use. SOLID is a set of guidelines, not laws. The real skill is reading where change pressure actually lives and applying just enough structure to absorb it, without drowning simple code in abstraction.