SOLID principles
Five design principles, collected by Robert Martin, aimed at making designs more understandable, flexible and maintainable. The letters spell a mnemonic:
Applied mindlessly, they cause more harm than good. Every one of them buys flexibility with indirection, and there is probably no successful product where all five are applied everywhere at once. Strive for them, stay pragmatic, and treat none of it as dogma - each section below ends with the cases where the principle is the wrong tool.
Single responsibility principle
A class should have just one reason to change.
Every class should own a single slice of the system's behavior, and that slice should be entirely hidden inside it. The wording matters: not "one method", not "one hundred lines" - one reason to change.
Why it exists
The goal is reducing complexity, and complexity is a problem you earn over time rather than one you start with. A 200-line program does not need architecture. Write a dozen decent methods and get on with your life.
The trouble arrives when the program keeps growing. Classes get big enough that you can no longer hold them in your head. Navigation slows to scanning. You start reading an entire file to find one thing, and the number of moving parts overflows whatever mental stack you were using to keep track. That is the moment the code stops being yours and starts being something you negotiate with.
And there is a mechanical cost on top of the cognitive one. A class that does several things has to be edited when any of them changes, and each edit risks breaking the parts you were not thinking about. Two responsibilities in one class means two teams filing changes against the same lines, which is a merge conflict factory with extra steps.
The example
An Employee class holds employee data and, somewhere near the bottom, prints the timesheet
report. Both are perfectly reasonable pieces of code. Together they give the class two
reasons to change: the way the company models employees, and the way finance wants that
report formatted this quarter.
Move the printing into a TimeSheetReport class and the seam is obvious in hindsight. As a
bonus, all the other report-shaped code scattered around the codebase now has somewhere to
go.
Where it goes wrong
The failure mode is enthusiasm. "One reason to change" is not "one method per class", and taken literally it produces a codebase where every behavior is a class, every class is injected, and understanding one feature means opening eleven files.
Skip the split when:
- The program is small enough to read in one sitting. Complexity you do not have does not need managing.
- The two "responsibilities" always change together. If nobody has ever edited one without the other, they are one responsibility wearing a disguise.
- The extraction would be a class with a single method, no state, and one caller. That is a function, and it was fine as a function.
The useful question is not "does this class do more than one thing?" - almost every class does. It is "have these things ever needed to change independently, and do I expect them to?" If the answer is no, leave it alone and revisit when reality disagrees.
Try it yourself: Split Employee into two classes, one that holds employee data and one
that owns the timesheet report, then rewrite the caller that currently calls
employee.printTimeSheetReport() so it goes through the new report class instead.
Open/closed principle
Classes should be open for extension but closed for modification.
The two halves sound like they cancel out. They do not, because they describe different activities. A class is open if you can build on it from the outside - subclass it, implement its interface, plug something new into it. A class is closed if it is complete: its interface is settled, other code relies on it, and nobody needs to reopen the file to add a feature.
The point of holding both at once is simple: new features should not be able to break old ones. If shipping a new requirement means editing a class that is already developed, tested, reviewed and depended upon, you are gambling with code that was working an hour ago.
The example
An e-commerce Order class calculates shipping costs, and every shipping method lives inside
it as a branch of an if. Ground shipping has a free-delivery threshold, air shipping is a
flat rate, each has its own delivery estimate. It works, right up until sales promises
customers same-day delivery.
Now adding a shipping method means editing Order, which is the class that also computes
totals, and which every checkout path in the application runs through. The risk is entirely
disproportionate to the feature.
Extract shipping methods into their own classes behind a common Shipping interface, and the
new method arrives as a new class. Order is not touched. Client code links the order to
whichever shipping object the user picked in the UI, and that is the only place that knows
concrete types.
Order.Order never moves.Two things worth noticing. First, this is the Strategy pattern, again arrived at by following a principle rather than reaching for a catalog. Second, moving the delivery-time calculation next to the cost calculation also fixed a single responsibility problem for free - the principles tend to pull in the same direction.
Where it goes wrong
The principle is not a ban on editing classes.
- Bugs get fixed in place. If a class is wrong, correct it. Creating a subclass to route around a defect makes the child responsible for the parent's mistakes, and now you have two problems and a confusing hierarchy.
- Speculative extension points are a tax. Every seam you carve out costs indirection: one more interface to name, one more file to open, one more hop when reading a stack trace. Pay it where variation is plausible, not everywhere.
- Two variants is not always a pattern. Ground and air, with no third on the roadmap and
no team asking, is a perfectly respectable
if. Extract on the second or third genuine change request, when the direction of variation is known rather than guessed.
The tell that you actually need this: a conditional that has grown one arm per variant, and a history of commits that all touch it.
Try it yourself: Extract Order's getShippingCost()/getShippingDate() if/else chain
into a Shipping interface with one implementing class per shipping method, then rewrite
Order so adding same-day delivery never requires touching Order again.
Liskov substitution principle
When extending a class, you should be able to pass objects of the subclass wherever the parent was expected, without breaking the client code.
Most design principles are open to interpretation and taste. This one comes with a checklist, which makes it the easiest of the five to actually verify - and the one whose violations hurt most, because they only show up at runtime, in somebody else's code.
That "somebody else" is the point. The principle matters most when you are writing libraries and frameworks, where your subclasses end up inside programs you cannot see, let alone fix.
The checklist
A subclass stays substitutable when its overrides obey all of these:
- Parameter types match or get more abstract. The base declares
feed(Cat c). Overriding it asfeed(Animal c)is fine, because callers still pass cats and cats are animals. Overriding it asfeed(BengalCat c)is not, because the client's ordinary cat no longer fits. - Return types match or get more specific. The inverse rule.
buyCat(): Catmay be narrowed tobuyCat(): BengalCat, because the caller wanted a cat and got one. Widening it tobuyCat(): Animalbreaks code that was built around cat-shaped results. In dynamically typed languages the equivalent sin is returning a number where the base returned a string. - No new exception types. Client code catches what the base method is documented to
throw. An override that raises something unrelated slips straight past the
try/catchand takes the process with it. - No stronger pre-conditions. If the base accepts any
intand your override rejects negatives, code that has been passing negative values for two years starts throwing. - No weaker post-conditions. If the base method always closed its database connections, your connection-reusing override leaves callers - who terminate right after the call because they trusted the contract - leaking connections into the void.
- Invariants are preserved. The conditions under which an object makes sense at all. Cats have four legs and a tail; your subclass does not get to remove one. This is the rule most often broken by accident, because invariants live partly in interface contracts, partly in assertions, and partly in unit tests nobody reread.
- Private state stays private. Reflection, and languages with no real access control, make it possible to reach into a superclass's private fields. Possible is not permission.
Statically typed languages such as Java and C# enforce the first three at compile time, which means the interesting failures are almost always in the last four.
The example
A Document class can be opened and saved. Somebody adds ReadOnlyDocument, and since
saving makes no sense there, the override throws. Reasonable-looking, and wrong: the client
code now has to check the concrete type before saving anything, which drags it into knowing
about document subclasses and quietly breaks the open/closed principle too. Add another
document type and the client changes again.
The fix is not a smarter override, it is a redesigned hierarchy. A subclass should add to the base behavior, so make the read-only document the base class and let a writable document extend it with the ability to save. Now the type system says exactly what is true, and the type check disappears.
Where it goes wrong
There is no "overkill" version of this one in the way there is for the other principles - substitutability is not an optional flourish. What does go wrong is over-application:
- Not every awkward subclass needs the hierarchy inverted. Sometimes the honest answer is that these two things were never in an "is a" relationship, and composition dissolves the problem entirely.
- Do not contort a design to preserve an invariant nobody depends on. The safest way to extend a class is to add fields and methods and touch nothing existing, but "safest" is not always "possible", and a heroic hierarchy built to satisfy a rule literally can be worse than the violation.
- Beware the rectangle-and-square rabbit hole. Long arguments about whether a square is a rectangle are usually a sign that the model, not the principle, needs the attention.
The practical test costs nothing: take every place the superclass is used, imagine your subclass there instead, and ask whether anything gets surprised.
Try it yourself: Rework the Document/ReadOnlyDocument hierarchy so ReadOnlyDocument
no longer overrides save() with a thrown exception, then rewrite Project.saveAll() so it
no longer needs a type check against ReadOnlyDocument before calling it.
Interface segregation principle
Clients should not be forced to depend on methods they do not use.
Keep interfaces narrow enough that the classes implementing them never have to invent behavior they do not have. A "fat" interface is one that bundles several unrelated capabilities, and its damage is contagious: change it, and even clients that never touched the changed methods have to be recompiled, retested, and possibly rewritten.
The structural argument is short. A class may extend exactly one superclass, but it can implement as many interfaces as it likes. There is no scarcity to ration, so there is no excuse for cramming unrelated methods into a single declaration. Break it into refined pieces and let the classes that genuinely do everything implement all of them.
The example
You build a library that makes integrating with cloud providers painless. Version one
supports Amazon, and since Amazon offers essentially every cloud service in existence, your
CloudProvider interface covers storage, servers and CDN addresses. It fits perfectly,
because it was traced around exactly one shape.
Then you add a second provider. It does storage and nothing else. Suddenly most of the interface is too wide, and the only way to satisfy the compiler is to implement methods that cannot work: throw here, return an empty list there, return null and look away. Every one of those is a promise the type system is making on your behalf and your object cannot keep.
Split the interface into CloudHostingProvider, CDNProvider and CloudStorageProvider.
Amazon implements all three. The storage-only provider implements one, honestly. Client code
that only backs up files asks for a CloudStorageProvider, and the mismatch that used to be
a 3am exception becomes a compile error.
Where it goes wrong
Like every principle here, this one can be taken past the point of usefulness.
- Do not split an interface that is already specific. The failure this principle prevents is an implementer forced to fake behavior. If nobody is faking anything, there is nothing to fix.
- Interface count is a real cost. Each one is a name to invent, a file to open, and an indirection to follow. Ten single-method interfaces where two would do makes the code more granular and less comprehensible at the same time.
- Do not split by aesthetics. Split along the lines that actual implementers and actual clients care about. If every implementer implements all the pieces and every client depends on all the pieces, you have distributed one interface across several files and gained nothing.
The trigger to watch for is a stub. The first throw new NotSupportedError() written to
satisfy an interface is the principle knocking.
Try it yourself: Break CloudProvider into narrower interfaces so Dropbox never has to
implement createServer(), listServers(), or getCDNAddress(), then rewrite Dropbox to
implement only the interface it can honestly support.
Dependency inversion principle
High-level classes should not depend on low-level classes. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.
Two levels of class show up in almost any system. Low-level classes do the basic mechanical work: reading a disk, pushing bytes over a network, talking to a database. High-level classes hold the business logic that directs the low-level ones.
The trouble is the order people build them in. Prototyping a new system, you write the low-level parts first, because until they exist you are not sure what the higher level can even ask for. Perfectly sensible, and it leaves your business logic shaped around the plumbing. Change the plumbing and the business logic changes, which is precisely backwards: a new database server version has no business affecting a budget report.
Inverting it
Three steps:
- Describe what the high level actually needs, in business language. The reporting code
should be able to call
openReport(file), notopenFile(x),readBytes(n),closeFile(x). That vocabulary choice is the load-bearing part - an interface phrased in low-level terms is the low-level class in disguise, and it will leak the moment the implementation changes. - Make the high-level classes depend on those interfaces instead of on concrete low-level classes. The coupling is still there, but it is now to something you control and that changes when your business changes.
- Have the low-level classes implement them. Once they do, they depend on the business layer's abstraction, and the original arrow has been turned around.
Note who owns the interface. It is declared by and for the high level, which is what distinguishes this from simply putting an interface in front of everything. This principle also pairs naturally with open/closed: new low-level implementations plug into existing business logic without touching it.
The example
A budget reporting class reads and persists its data through a MySQL database class. Any change down there - a new server version, a schema convention, a migration to something else entirely - propagates up into a class whose job description says nothing about storage.
Declare a Database interface describing the read and write operations the report needs, in
the report's own terms, and let the report depend on that. Then have the MySQL class
implement it, and add a MongoDB class beside it whenever someone wants one. The reporting
logic never learns which one it got.
Where it goes wrong
This is the SOLID principle most likely to metastasize into architecture astronautics.
- One implementation, one interface, no plausible second. An interface that exists only to be implemented once is a file tax. If the only reason for it is stubbing in tests, ask whether a purpose-built fake or an in-memory implementation would say more about your intent.
- Interfaces that mirror their implementation method for method. If
Databasehas exactly the methods MySQL happens to expose, including the ones named after MySQL concepts, nothing was inverted. You renamed the coupling. - Inverting inside a single layer. The principle is about boundaries between levels - business logic against infrastructure. Two collaborating business classes at the same altitude usually just need to know each other.
- Frameworks that inject everything. A container wiring three hundred single-use interfaces makes the dependency graph invisible rather than flexible.
Invert at the seams that genuinely move: storage, network, third-party services, the clock. Everywhere else, a direct call is a feature, because you can read it.
Try it yourself: Introduce a Database interface that BudgetReport depends on instead
of MySQLDatabase directly, then rewrite MySQLDatabase to implement it so BudgetReport
no longer names a concrete database class anywhere in its own code.
Spot the smell
Five snippets, each lifted straight from the "where it goes wrong" examples above. Pick which principle each one breaks before scrolling back up to check your work.
Check yourself
What exactly does "one responsibility" mean in practice?