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.

java
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 Square

The 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):

text
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.
VerdictWhy
Liskov substitutionbindingformally defined, mechanically testable, violations are real bugs
Dependency inversionbindingstructural, with a compile-time test
Interface segregationusefulcorrect, but "prefer role interfaces" says it better
Single responsibilityuseful if read as cohesionharmful if read as "one thing" — produces classitis
Open/closedconditionalright for genuinely open sets; speculative generality otherwise

Sources

  1. paperLiskov & Wing, A Behavioral Notion of Subtyping (ACM TOPLAS 16(6), 1994)
  2. paperLiskov & Wing — abstract and paper (MIT PMG)
  3. paperParnas, On the Criteria To Be Used in Decomposing Systems into Modules (CACM, 1972)
  4. bookA Philosophy of Software Design, 2nd Edition
  5. bookRefactoring: Improving the Design of Existing Code, 2nd Edition

Searches titles, aliases, headings, summaries, and full text.↑↓ move ↵ open esc close