SOLID Reconsidered: Which Principles Actually Bind
The five SOLID principles are of very unequal value — Liskov substitution is a checkable property with a formal definition, dependency inversion is a structural rule about module boundaries, and the other three are heuristics that routinely get applied backwards.
SOLID is taught as five equally-weighted commandments. They are not equal. One (LSP) is a precisely-defined property you can check a subclass against; one (DIP) is a structural claim about which way dependencies should point between modules; the remaining three are heuristics whose meaning depends entirely on interpretation, and whose common interpretations cause as much damage as they prevent. Worth taking them one at a time, with the weakest claims marked as such.
L — Liskov substitution, the one with a definition #
Liskov and Wing's 1994 formulation is the real content: subtyping should preserve behaviour, not just signatures. Their requirement is that any property provable about supertype objects also holds for subtype objects.
Concretely, an override may:
| Requirement | Direction | |
|---|---|---|
| Preconditions | may be weakened, never strengthened | the subtype must accept everything the supertype accepted |
| Postconditions | may be strengthened, never weakened | the subtype must deliver at least what the supertype promised |
| Invariants | must be preserved | |
| History rule | the subtype must not permit state changes the supertype's interface forbids | this is what breaks immutable→mutable subclassing |
| Exceptions | only those the supertype declares, or subtypes of them |
The history rule is the one usually omitted, and it is why Square extends Rectangle fails while an immutable Square implementing an immutable Rectangle interface is fine — the problem was never the geometry, it was allowing a state change (independent width and height) that the square's invariant forbids.
class Rectangle { void setWidth(int w); void setHeight(int h); int area(); }
// Client code that is correct for every Rectangle:
r.setWidth(5); r.setHeight(4); assert r.area() == 20; // fails for SquareThe client is not doing anything unreasonable — it is relying on a documented property. That is the test: LSP is violated when correct client code breaks after you substitute the subtype, and the violation belongs to the subtype, not the client.
D — Dependency inversion, the structural one #
The claim: high-level policy should not depend on low-level detail; both should depend on an abstraction, and the abstraction should be owned by the high-level side.
The important part is the last clause, which people drop. Putting an interface between two modules changes nothing if the interface ships with the low-level module — the compile-time dependency still points the same way. Inversion happens when the consumer declares the abstraction (interfaces and abstract types):
before: billing ──────────────► postgres_rates (billing can't build without postgres)
after: billing ◄── RateSource ──── postgres_rates (postgres depends on billing's contract)
That is a genuine architectural property with a mechanical test: can you compile and test the high-level module without the low-level one on the classpath? See dependency injection and inversion for the wiring, which is a separate and much smaller question.
S — Single responsibility, the most-misread #
"A class should have one reason to change" is not operational, because "reason" is not defined. Martin later restated it as one actor: a module should be answerable to one stakeholder group, so that a change requested by finance cannot break something reporting has come to rely on. That version is useful — it is really a statement about cohesion under change, and the evidence for it is in the commit history: files that keep changing for unrelated reasons are the ones to split.
The common misreading — "a class should do one thing" — produces the opposite of good design. It licenses splitting until every class has one method, which maximizes the number of interfaces a reader must hold while removing nothing from anyone's head. Ousterhout's name for the result is classitis, and his counter-principle is that a module should be deep: a simple interface over substantial functionality (encapsulation).
O — Open/closed, the most speculative #
Meyer's original (1988) formulation was about inheritance: a class should be usable-as-is and extendable-without-editing. Martin's version is about polymorphism: add behaviour by adding a type, not by editing a switch.
Where the set of variants is genuinely open and known to grow — payment methods, export formats, protocol codecs — this is right, and it is just the strategy pattern. Where it is applied pre-emptively, it is speculative generality: an abstraction built for a second implementation that never arrives, which now costs indirection on every read. Fowler lists it as a code smell for that reason, and it is in direct tension with YAGNI.
The honest resolution: do not build the extension point until the second case exists. Adding it later is a mechanical refactor; removing an unused one after code has grown around it is not. The one exception is a genuinely external boundary, where the seam has value even with one implementation.
Note too that the modern alternative inverts the trade deliberately: a sealed hierarchy with exhaustive pattern matching is closed on purpose, so that adding a variant produces compile errors at every place that must be updated. That is often what you want — loud breakage beats a silently-unhandled case.
I — Interface segregation #
Clients should not depend on methods they do not use. Almost entirely subsumed by preferring role interfaces over header interfaces. The one non-obvious payoff is at recompilation and deployment boundaries: in large builds, a fat interface means unrelated modules rebuild when it changes.
What SOLID does not cover #
This is the strongest criticism of using it as a checklist: a codebase can satisfy all five and still be bad, because the principles say nothing about the things that dominate real maintenance cost.
- Coupling and cohesion in general — SOLID touches them obliquely; Parnas 1972 addresses them directly and is a better foundation.
- Data and lifetime — ownership, aliasing, mutability (value semantics).
- Error handling and failure modes (error handling).
- Concurrency — nothing in SOLID prevents a race.
- Performance and data layout — the abstraction SOLID encourages can be exactly what destroys locality.
- Deployability and change granularity — what can be released independently.
| Verdict | Why | |
|---|---|---|
| Liskov substitution | binding | formally defined, mechanically testable, violations are real bugs |
| Dependency inversion | binding | structural, with a compile-time test |
| Interface segregation | useful | correct, but "prefer role interfaces" says it better |
| Single responsibility | useful if read as cohesion | harmful if read as "one thing" — produces classitis |
| Open/closed | conditional | right for genuinely open sets; speculative generality otherwise |
Sources
- paperLiskov & Wing, A Behavioral Notion of Subtyping (ACM TOPLAS 16(6), 1994)
- paperLiskov & Wing — abstract and paper (MIT PMG)
- paperParnas, On the Criteria To Be Used in Decomposing Systems into Modules (CACM, 1972)
- bookA Philosophy of Software Design, 2nd Edition
- bookRefactoring: Improving the Design of Existing Code, 2nd Edition