SOLIDGENERALCONTESTEDLANGUAGE-SPECIFIC

Liskov Substitution, Critically

A subtype must keep every promise callers rely on from the abstraction. It is a behavioural contract, not a fact about inheritance syntax — and it is the sharpest of the five.

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

What exactly does a caller rely on when it holds a reference to an abstraction, and how would I know if an implementation broke it?

The requirement

Someone added CachingUserRepository behind the existing UserRepository interface to cut database load. It is a drop-in replacement, it passed review, and three weeks later a support ticket says a user changed their email and the confirmation screen still showed the old one.

The obvious build

It implements the interface and the compiler is satisfied, so it is a valid implementation. This is exactly how the review went, and the reasoning is not stupid: in most languages "implements the interface" is the only substitutability check available, so it becomes the check people use.

Why it breaks

The compiler checks the signature. Callers rely on the behaviour, and no compiler in mainstream use checks that — so the one property that mattered was unverified (Interface Versus Implementation).

How it breaks as requirements change
  • The compiler checks the signature. Callers rely on the behaviour, and no compiler in mainstream use checks that — so the one property that mattered was unverified (Interface Versus Implementation).
  • The undocumented promise was "returns current data". Every caller depended on it, none of them wrote it down, and the caching implementation silently withdrew it.
  • The failure is invisible at the call site. The bug appears in a confirmation screen twenty files away from the class that caused it, which is what makes these defects expensive (Local Reasoning).
  • As more implementations appear — a read-replica repository with replication lag, a test double that never fails — the set of promises callers can rely on shrinks to the intersection, and nobody recalculates that intersection.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • Twenty call sites already hold a UserRepository and none of them can be inspected for "does this one tolerate stale data" without reading each.
  • The caching implementation genuinely does reduce load and the reason it was added is real.
  • The interface has no documentation beyond its method names, which is the normal state of interfaces and is part of the problem.
  • Java, where the type system checks the signature and can check nothing about the behaviour behind it.
Invariants
  • Any code holding a UserRepository must be able to reason about what findById returns without knowing which implementation it has. That is the entire value of the abstraction.
  • If an implementation cannot honour a promise the interface makes, either the promise must change for everyone or that implementation must not be substitutable.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • The interface owns the contract, which is more than the signatures: what may be returned, what may be thrown, what must be true afterwards, and how stale anything may be.
  • Every implementation owns honouring that contract, including the parts nobody wrote down — which is why writing them down is the actual fix.
  • Callers own not relying on anything the contract does not promise. A caller that depends on findById being fast has invented a promise.
  • A shared contract test owns being the executable statement of all of it (Contract Tests).
Boundaries
  • The boundary is the abstraction's contract, and the useful move is to make it explicit enough to be violated. An unstated contract cannot be broken, which sounds reassuring and means every implementation is quietly redefining it.
  • Where an implementation genuinely cannot honour the contract, the boundary is in the wrong place: either the contract is too strong (tighten it for everyone and let callers adapt) or the implementation is not a subtype and needs its own type (Designing a Module Interface).
  • This is the one principle of the five that is about correctness rather than change cost, which is why it deserves more weight than the others (SOLID, Read Honestly).

The classic, and what it is actually showing

Square/Rectangle is the standard example and it is a poor teaching device, because it invites the conclusion "do not inherit" when the real finding is elsewhere. A Square that inherits a mutable Rectangle breaks a promise the caller relies on — set the width, and the height should not change — and that promise was never written down anywhere.

The useful lesson is not about shapes. It is that the contract callers depend on is larger than the signature, and that the extra part is usually unstated. Notice also that with immutable shapes the problem evaporates entirely, which tells you the mutable setter was the culprit rather than the type hierarchy.

The caller's expectation is the thing being broken
1void stretch(Rectangle r) {
2 r.setWidth(r.getWidth() * 2);
3 assert r.getHeight() == before; // never written down, always assumed
4}
5
6class Square extends Rectangle {
7 @Override void setWidth(int w) { super.setWidth(w); super.setHeight(w); }
8 @Override void setHeight(int h) { super.setWidth(h); super.setHeight(h); }
9}
10// stretch(new Square(4)) -> height doubled too. Compiles. Ships.
11
12// The same hierarchy, immutable:
13record Rect(int w, int h) { Rect withWidth(int w) { return new Rect(w, h); } }
14// A Square is now just a Rect where w == h. No override, no violation,
15// nothing to substitute wrongly. The setter was the problem.

Two things worth taking from this and nothing else. First, the violated promise — "setting width leaves height alone" — appears in no signature, no test and no comment, which is the normal state of affairs. Second, mutability created the violation: a design with no setters has no way to express it.

The realistic version: a cache that quietly weakens a promise

Square/Rectangle almost never happens. What does happen, in nearly every system that grows, is a new implementation added for an operational reason that silently withdraws a guarantee callers were relying on — a cache, a read replica, a batching layer, a retrying wrapper.

This is where LSP earns its place among the five, and it is why the fix is not "do not cache". It is to make the weaker promise the contract for everyone, so that every caller reasons about staleness explicitly instead of twenty callers assuming freshness and one implementation not providing it.

Adding caching behind an existing interface
Substituted silently
interface UserRepository {
  findById(id: UserId): Promise<User | null>   // no stated contract
}

class CachingUserRepository implements UserRepository {
  async findById(id: UserId) {
    const hit = this.cache.get(id)
    if (hit) return hit                        // up to 60s stale
    ...
  }
}

// Twenty call sites unchanged. Nineteen tolerate staleness.
// The one that renders "your email has been updated to X"
// does not, and there is nothing in the type, the name or
// the tests that distinguishes it.
Contract weakened for everyone, or split
interface UserRepository {
  /** May be up to `maxStalenessMs` behind the last write.
   *  Never returns a user that never existed. Never throws
   *  for an unknown id — returns null. */
  readonly maxStalenessMs: number
  findById(id: UserId): Promise<User | null>

  /** Bypasses any cache. Use after a write you must reflect. */
  findByIdFresh(id: UserId): Promise<User | null>
}

// Now the confirmation screen calls findByIdFresh, and the
// contract suite has a test for both promises that every
// implementation — including the in-memory fake — must pass.

The right-hand version does not make caching illegal; it makes the weakened promise part of the contract, so the nineteen callers that tolerate staleness keep their performance win and the one that cannot opts out explicitly. The essential move is that the difference between implementations became something a caller can see and a test can check, instead of something a support ticket discovers. Note the direction of the fix: when an implementation cannot keep a promise, the honest options are to weaken the promise for everyone or to give that implementation a different type — never to let one implementation quietly differ (Eventual Consistency in Practice carries the distributed version of this argument).

The five-part reading

SIMPLIFIEDThese five rows compress Liskov and Wing's formal treatment, which states substitutability in terms of a subtype relation preserving safety and liveness properties provable of the supertype, with an explicit constraint on history. The simplification here keeps what is checkable in a test suite and drops the machinery needed to prove it — and the dropped part matters if you are designing a type system rather than a repository interface.

Stated in the same shape as the others. LSP is the one principle in the set with a precise formal statement and a mechanical check available, which is why this lesson recommends it more strongly than any of the other four.

The matrix underneath is the practical form of Liskov and Wing's rules — four ways a subtype can break a caller, each with the version you will actually meet rather than the textbook one.

  • Problem it addresses — a caller holding an abstraction cannot reason about behaviour if implementations differ in ways the type does not express, which turns polymorphism from a simplification into a source of remote, hard-to-trace defects.
  • Useful example — the caching repository above: a real operational improvement that silently withdrew a promise nineteen callers did not need and one did.
  • Misuse — reducing it to Square/Rectangle and concluding "inheritance is bad", which is neither what it says nor a useful design rule (When Inheritance Fits).
  • Trade-off — an explicit contract plus a shared suite is real, unglamorous, feature-less work, and it constrains implementations that would otherwise be useful.
  • Counterexample — a sealed hierarchy where the "subtypes" are just data variants matched exhaustively, or an interface with exactly one implementation and one test double that is generated from it. There is nothing to substitute and the contract discipline buys nothing (Making Illegal States Unrepresentable).
The ruleWhat it meansThe violation you will actually meet
Preconditions may not be strengthenedA subtype must accept everything the supertype acceptedAn implementation that rejects null, or empty input, or ids over 36 characters, where the abstraction accepted them — usually discovered by a caller that had been passing nulls happily for a year
Postconditions may not be weakenedA subtype must guarantee everything the supertype guaranteedThe caching repository. Also read replicas, batching writers that return before durability, and any wrapper that returns early (Eventual Consistency in Practice)
Invariants must be preservedAnything true of the supertype stays trueA subclass that adds a field the parent's methods do not maintain, so the object is consistent right up until an inherited method touches it
History constraintA subtype may not allow state changes the supertype forbadeCollections.unmodifiableList throwing UnsupportedOperationException from add — a violation in the Java standard library, and a defensible one, which is itself instructive
Exceptions may not be broadenedA subtype may not throw what the supertype could notA network-backed implementation of an interface whose callers were written against an in-memory one and have no catch block at all (An Error Taxonomy That Survives Contact)

How to build it

Most important first.

  • Write the contract down as part of the interface: preconditions, postconditions, exceptions and any staleness or ordering guarantees. Three lines of comment per method is usually enough and is almost never done.
  • Encode it as a shared test suite every implementation must pass. This converts a documentation problem into a CI problem, which is the only form that survives (Contract Tests).
  • When an implementation cannot pass — as caching cannot pass "returns current data" — change the contract for everyone rather than letting one implementation quietly differ. findById returning a value that may be up to N seconds stale is a weaker promise that every caller can then reason about.
  • If weakening the contract is unacceptable to some callers, they need a different type: CurrentUserRepository and CachedUserRepository as separate interfaces, with the caller choosing (Interface Segregation, Critically).
  • Prefer making the difference visible in the type over documenting it. A return type of Cached<User> cannot be ignored; a comment can (Making Illegal States Unrepresentable).

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • Before: adding a fifth implementation costs an unbounded audit — twenty call sites read to guess what they assume — and usually the audit is skipped and a bug ships.
  • After: adding an implementation costs writing it and running the contract suite. The suite either passes, or names precisely which promise cannot be kept, which is the conversation you wanted to have anyway.
  • Changing the contract costs a change to every implementation plus a review of callers who relied on the stronger version. That is expensive and correct: the cost reflects the real coupling, which was there before and was just not visible.
  • The next debugging session is where this pays most: "which implementation was in play" stops being a plausible explanation for a bug, because all of them behave the same on everything the contract covers.
What the recommended approach costs
  • Writing and maintaining a contract suite is real work, and it is work with no visible feature attached, so it loses prioritisation battles.
  • An explicit contract constrains future implementations, which is the point and is also a genuine loss of flexibility: the caching implementation was *useful*, and the contract is what makes it inadmissible in its current form.
  • Making the difference visible in types (Cached<User>) pushes the complexity to every caller, which is honest and is also more code in more places.

What can go wrong

Failure modes
  • A subtype throws where the supertype does not — UnsupportedOperationException from an immutable collection is the canonical real-world case, and it is in the Java standard library.
  • A subtype weakens a postcondition quietly, as with staleness. The most common and least detectable form (Eventual Consistency in Practice is the distributed version of the same problem).
  • A subtype strengthens a precondition: an implementation that rejects null, or empty strings, or ids over a certain length, where the abstraction accepted them.
  • The mitigation fails when the contract test is written against the easiest implementation. A suite derived from the in-memory fake will not express the promises that only matter under concurrency or failure, and every implementation will pass it while differing where it counts.
Dependencies, and their direction
  • Every caller depends on the contract, whether or not it is written down. That is the dependency this principle is about and it is invisible in the import graph.
  • Implementations depend on the contract's promises being ones they can keep, which is a constraint on what implementations are possible at all.
  • The contract test depends on nothing but the interface, which is what lets it be run against implementations written later (Testing as Design Feedback).
Misreads
  • "LSP is about inheritance." It is about substitutability, which applies to interface implementations, duck-typed objects, trait implementations and structurally-typed records equally. A codebase with no inheritance at all can violate it on every call (Polymorphism).
  • "Square/Rectangle proves you should not use inheritance." It proves that Square is not a behavioural subtype of a *mutable* Rectangle. With immutable shapes there is no violation at all, which tells you the problem was the mutable setter contract rather than inheritance (Immutability, When Inheritance Fits).
  • "If it compiles, it substitutes." The compiler checks the signature. Every interesting violation is behavioural and passes compilation (Contract Tests).
  • "So a caching layer violates LSP and must be forbidden." It violates *this* contract. Weaken the contract for everyone, or give it its own type. The principle tells you a promise is being broken; it does not tell you which side should give way.

Testing it, and how it ages

What to test, and at which boundary
  • One contract suite, run against every implementation including the test doubles. Doubles that do not pass the suite are lying to your tests (Test Doubles, Precisely).
  • Property-based tests are unusually good here: the properties *are* the contract, and generated inputs find the precondition edges a hand-written case misses (Property-Based Testing).
  • Test the substitution directly where it matters: run a caller's test suite against each implementation, not just against the fake.
  • For staleness and ordering promises, the test needs a way to control time and to interleave, which is a strong argument for injecting the clock (Time as a Dependency).
How this design ages
  • Contracts tend to weaken over time as implementations with different capabilities are added. That is not automatically wrong — but it should be a decision, recorded, rather than the residue of whichever implementation was added last.
  • The contract suite becomes the most valuable artefact in the module, and it is the thing that makes adding the sixth implementation safe.
  • The design stops fitting when implementations diverge enough that the intersection of their promises is too weak to be useful. At that point one interface has become two (Interface Segregation, Critically).

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • GENERALSubstitutability is a property of any abstraction with more than one implementation, in any language and any paradigm — the formal statement is Liskov and Wing's, and it makes no reference to classes or inheritance.
  • CONTESTEDThe strongest opposing case is not that LSP is wrong — it is provably a coherent property — but that treating it as a design principle is misplaced effort: in most codebases abstractions have one real implementation plus a test double, so there is nothing to substitute, and the discipline of writing and maintaining contract suites costs more than the substitution bugs it prevents. Critics also note that the formal version requires reasoning about behaviour that mainstream type systems cannot express, so what teams actually adopt is a vague "do not surprise callers", which is not what Liskov proved. This is fair for codebases with single-implementation interfaces; it gets much weaker the moment a second implementation is real — a cache, a replica, a stub, a mock — because that is exactly when the intersection of promises stops being obvious.
  • LANGUAGE-SPECIFICNo mainstream language checks behavioural subtyping. Eiffel's design-by-contract came closest, enforcing pre/postconditions at runtime with inheritance rules that weaken preconditions and strengthen postconditions automatically. Rust traits check signatures and coherence but not laws — nothing stops an Ord implementation from being inconsistent — while Haskell's type-class laws are a documented convention checked by test libraries like QuickCheck, which is the contract-suite idea under another name. The gap between "compiles" and "substitutes" exists everywhere; only the size differs.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Concurrencyimmutability
Domains that do not exist yet
  • Testing & Reliability Engineering — a shared contract suite run against every implementation, doubles included, is the only mechanical check for this property, and designing one that expresses failure and timing behaviour is theirs.
  • Programming Languages & Runtime Internals — the gap between what a type system checks and what a caller relies on is the whole subject here, and it is a language-design question with a long history from Eiffel contracts to refinement types.