A class that saves a user to the database, sends them a welcome email, and formats a receipt is one change away from breaking two features it has nothing to do with. Touch the email template and you risk the database write. Touch the receipt layout and you risk the email. SOLID is five rules aimed at that kind of entanglement, each from a different angle, and they share one idea: a class should change for one reason, and the rest of the code should depend on what something promises to do rather than how it does it.
The principles come from Robert C. Martin’s 2000 paper “Design Principles and Design Patterns”. Michael Feathers later put them in the order that spells SOLID. None of the five is about syntax or a specific language. They are about where the seams in your code go, so a change in one place stays in that place.
What SOLID stands for, and why it’s five rules and not one
- S, Single Responsibility: a class has one reason to change.
- O, Open/Closed: add behavior by adding code, not by editing code that already works.
- L, Liskov Substitution: a subclass has to behave like its parent everywhere the parent is used.
- I, Interface Segregation: don’t make a class implement methods it has no use for.
- D, Dependency Inversion: depend on an abstraction, not on a specific concrete class.
They overlap on purpose. Dependency Inversion is what makes Open/Closed work in practice: you can only add a new implementation without touching old code if the old code was written against an interface. Interface Segregation is Single Responsibility applied to interfaces instead of classes. Treat the five as views of one goal, not five boxes to tick.
Single Responsibility Principle: one reason to change
Here is a class with two reasons to change hiding in one body:
| |
A schema change and an email-template change now both touch User. Testing save() drags in a database connection (or a mock of one), and testing send_welcome_email() drags in an SMTP client, even though neither test cares about the other method. This is also where the tangling described in What Is Spaghetti Code and How to Refactor It starts: a class keeps absorbing unrelated jobs because it was already there.
Split the jobs out:
| |
Each class now changes for its own reason, and each can be tested with a fake that covers one small job. Note that “one reason to change” is not “one method”. UserRepository could grow find_by_email and delete and still have a single responsibility, because every method serves the same job: persisting users.
Open/Closed Principle: add behavior without editing working code
A calculator that branches on a type tag:
| |
Adding a triangle means editing area() again. Worse, every other function that branches on shape["type"] has to be found and edited the same way. Each new shape is a change to tested code, with a chance of breaking the circle and rectangle cases while you’re in there.
Define the contract once and add new shapes as new classes:
| |
Adding Triangle is a new class that implements area(). total_area never changes, and neither do Circle and Rectangle. That is all “open for extension, closed for modification” means: new behavior arrives as new code, and the code that already has test coverage stays untouched.
Liskov Substitution Principle: a subclass must keep the parent’s promises
The classic failure is a Square built as a subclass of Rectangle:
| |
| |
resize_and_check works for any Rectangle. Pass it a Square and the assertion fails: setting the height silently changed the width too, so the area is 16, not 20. Square type-checks as a Rectangle, so mypy has nothing to flag, yet it breaks the behavior every caller of Rectangle was written to expect. Keep that distinction. Liskov violations are rarely about method signatures, which tools already catch. They are about a subclass quietly changing what callers are allowed to assume.
Don’t fix this with a special case for squares inside resize_and_check. Fix it by not making Square a subclass of Rectangle at all. A square does not have two independently resizable dimensions, so it cannot honor a Rectangle’s contract. Both can implement the Shape interface from the Open/Closed example, with no inheritance between them.
Interface Segregation Principle: don’t force unused methods on a class
An interface that bundles unrelated capabilities:
| |
Every caller holding a Printer now has to know, outside the type system, which methods are safe on which instance. The interface promises three capabilities and delivers one, plus two landmines.
Split the interface by capability instead of by device:
| |
A function that only needs to print takes a Printable, and there is no .scan() to call by mistake on a device that never had one. The contract comes from what the object implements, not from a comment warning you which methods will raise.
Dependency Inversion Principle: depend on abstractions, not concrete classes
A service built directly on one database library:
| |
UserService is welded to SQLite. Moving to Postgres, or handing it a fake repository in a test, means editing UserService itself instead of swapping a dependency.
| |
UserService now depends on the UserRepository abstraction. Building a SQLiteUserRepository(), or a FakeUserRepository() for a test, and passing it in is a separate step, and that step is dependency injection. People use the two terms interchangeably, but they are different things: dependency inversion is the design rule (depend on abstractions), and dependency injection is the technique that supplies the concrete object. What Is Dependency Injection (With Examples) covers the injection side, including what a DI container adds once a class needs more than one or two dependencies wired in.
When SOLID becomes over-engineering
Splitting a twenty-line script into five interfaces buys you nothing if there is exactly one implementation and no plan for a second. Interface Segregation and Dependency Inversion both add a layer of indirection. That layer pays off when you actually swap implementations, such as production versus test or SQLite versus Postgres, or when you need to isolate a class behind a test double as described in Unit vs Integration vs End-to-End Tests. If nothing is ever swapped, the abstraction is pure overhead.
More abstractions don’t mean better design. A constructor that takes eight injected interfaces is not more SOLID than one that takes three. It means the class behind it still has too many responsibilities, which sends you back to the first letter.
SOLID is also object-oriented vocabulary. Plenty of good code has no classes at all. The habits underneath carry over anyway: small functions with one job, and dependencies passed in rather than reached for globally. Those matter in a functional style even when none of the five letters applies literally.
How to start applying SOLID to an existing codebase
Don’t retrofit a whole codebase against a checklist. Apply Single Responsibility first, and only where a class already hurts to change: a file that causes merge conflicts every sprint, or a test that needs three unrelated mocks before it can run. Pull a dependency behind an abstraction only when a second implementation or a test fake is a current need, not a hypothetical one. Most Open/Closed and Liskov problems start life as a Single Responsibility problem that grew a subclass, so the other three principles tend to follow once those two habits stick.