| Principle | Gist | Verdict |
|---|---|---|
| SRP | One reason to change (one stakeholder) | Not operationalizable; rhetoric |
| OCP | Extend with new code, not by editing old code | Conflicts with YAGNI when applied prematurely |
| LSP | A subtype is substitutable without breaking the contract | The only verifiable one; violations are real bugs |
| ISP | A client does not depend on methods it doesn't use | Portable anywhere; useful |
| DIP | Dependencies point toward the abstraction; the interface belongs to the consumer | Useful, but degenerates into single-implementation interfaces |
A “reason to change” is people, not requirements. Martin's later formulation: a module is responsible to one, and only one, actor — a group of people who originate requirements. A class that stores a shopping list and computes its cost violates SRP not because it “does two things”, but because different people own the list's contents and the pricing rules, and their requirements change independently. The actor is a criterion for where to cut, not a design driver: you don't create one class per stakeholder; you check the converse — that no single artifact serves two masters with mismatched change schedules.
None of this makes it operationalizable: granularity is arbitrary, the principle gives no stopping criterion for decomposition — a violation is recognized after the fact, and no design can be derived from it. The practical test is file history: commits from unrelated tasks by different people systematically converging on one class. The guard against the opposite extreme, splitting for the sake of splitting, is cohesion (13): what changes together lives together. The operationalizable replacement is information hiding (12): the question is not “how many responsibilities are here” but “what breaks outside if this internal decision is revisited”.
A contract has two layers. Signatures — which methods, which types — are checked by the compiler. Behavior — what a method promises to do — is checked by nobody but reviews and tests. LSP requires holding both: a subtype that matches the signatures but breaks the promise breaks everyone who wrote code against the supertype. The canonical example: Rectangle has setWidth and setHeight; Square, inheriting from it, must keep its sides equal — its setWidth(5) quietly changes the height too, and code that relied on “set the width, the height stays untouched” breaks precisely on the square. The compiler is happy; the contract is broken. The cure in the examples below is not to fix the inheritance but to remove it: a closed set of variants with no mutating setWidth (3), plus contract tests run against every implementation.
An LSP violation and its fix: the only SOLID principle that deserves examples.
// sealed instead of inheritance — the contract is closed and compiler-checked
sealed interface Shape permits Rect, Square {}
record Rect(double w, double h) implements Shape {}
record Square(double side) implements Shape {}
// Square extends Rectangle is impossible by construction — no mutating setWidth// LSP is expressed through trait contracts; a violation is a panic instead of Err
trait Storage {
fn get(&self, k: &str) -> Result<Option<Vec<u8>>, StorageErr>; // contract: never panics
}
// an impl that calls unwrap() inside get violates LSP,
// caught by code review + proptest// io.Reader's contract lives in its docs; an implementation that panics instead of
// returning an error violates LSP, even though the compiler is happy
type SafeReader struct{ inner io.Reader }
func (r SafeReader) Read(p []byte) (int, error) {
n, err := r.inner.Read(p)
if err != nil && err != io.EOF {
return n, fmt.Errorf("read: %w", err) // not panic
}
return n, err
}# Protocol + a contract test run against every implementation
from typing import Protocol
class Storage(Protocol):
def get(self, key: str) -> bytes | None: ...
def storage_contract_suite(impl: Storage): # one test suite for all implementations
assert impl.get("missing") is None # not KeyError — that's the contract