Liskov substitution principle (LSP)
Open in the interactive app: practice, quiz, mock interview →In one minute
- If code works with a type, it must keep working with any subtype or any implementation of that interface, without knowing which one it got. That is the Liskov Substitution Principle.
- "Works" means the behaviour the caller relies on, not just matching method signatures. Compilers check signatures; nobody checks behaviour unless you write tests for it.
- Precisely: an implementation may accept more inputs than promised and may promise more in its results, but must never demand more or deliver less, and must keep the type's invariants.
- The tell-tale sign of a violation is a caller that checks the concrete type (
isinstance, a Go type switch,instanceof) to work around one implementation's quirks. - The practical defence is a contract test suite that every implementation must pass. It matters more now that agents write adapters quickly, and often stub the hard methods.
How it works
A realistic "before"
An export service writes customer reports to blob storage through an interface:
class BlobStore(Protocol):
def put(self, key: str, data: bytes) -> None: ...
def get(self, key: str) -> bytes | None: ... # None if missing
def delete(self, key: str) -> None: ... # no-op if missing
def list_keys(self, prefix: str) -> list[str]: ... # sortedThere is an S3 implementation for production and a local-disk one for development. Over a year, three more arrive. An archive store for cold data raises NotImplementedError from put, because archives are written by a batch job. A second cloud provider's adapter raises on delete of a missing key, because that is what its API does. A caching wrapper keeps serving a deleted report for five minutes. Each class has the right method names, so every type checker is happy.
The callers are not. The retention job crashes on the second provider when a report was already deleted. A user deletes a report, refreshes and sees it again. The export service grows this:
if isinstance(store, ArchiveStore):
raise ExportError("archive is read-only")
if isinstance(store, OtherCloudStore):
try: store.delete(key)
except NotFound: passNow every new implementation means editing callers. An LSP violation has turned into an Open/Closed violation, which is how it usually shows up in a codebase.
The "after"
Three fixes, in order of importance:
- Write the contract down and test it. "
getafterputreturns the same bytes;putoverwrites;getof a missing key returnsNone;deleteof a missing key is a no-op;list_keysreturns matching keys in ascending order." Then run one shared test suite against every implementation, including fakes used in other tests. - Make adapters absorb provider quirks. The second cloud adapter catches the provider's 404 on delete. The cache wrapper invalidates on delete. The interface does not bend to the provider; the adapter does.
- Split interfaces that some implementations cannot honour. The archive is not a
BlobStore. It is aBlobReader. Callers that only read take aBlobReader; the type system now stops anyone handing an archive to the export writer.
What the principle actually says
Barbara Liskov stated the idea in a 1987 keynote, and Liskov and Jeannette Wing made it precise in 1994: a subtype must satisfy its supertype's specification. Concretely, for each operation:
- Preconditions cannot be strengthened. If the interface accepts any key, an implementation cannot reject keys longer than 64 characters.
- Postconditions cannot be weakened. If
list_keyspromises sorted output, returning keys in insertion order is a violation, even if it is "faster". - Invariants must be preserved. If a store promises read-your-writes, a cache that serves stale data breaks it.
- History constraint. A subtype cannot allow state changes the supertype forbids. An "immutable" config that has a mutable subtype is not immutable to anyone holding it.
The famous square-and-rectangle puzzle is the history constraint in miniature: a Square that keeps width and height equal breaks a caller that sets the width and expects the height to stay put. In backend code the same thing appears as read-only collections, eventually consistent replicas and "lite" API clients.
When following it too zealously hurts
- Over-specified contracts. If the contract says "
putcompletes in under 5 ms" or "errors areS3Error", only one implementation can ever satisfy it. Specify what callers need; leave room for variation in latency, consistency details and error types. Hyrum's law says callers will depend on unspecified behaviour anyway, which is a reason to state what is promised and test exactly that. - Forcing everything into one hierarchy. If two things are only similar, do not make one a subtype of the other to reuse code. Compose instead.
- Interface explosion. Splitting every interface into one-method pieces to avoid any possible violation makes signatures unreadable. Split along what callers actually use: readers and writers is a common, useful line.
- Rejecting known, documented compromises. Java's
Listdeclaresaddas an "optional operation" that may throwUnsupportedOperationException. That is a weak contract, but a stated one. Sometimes a stated weakness beats a type hierarchy with forty interfaces.
Reviewing agent-written code
Ask an agent to "add a Google Cloud implementation of BlobStore" and it will produce a class with every method present. Look for the methods that were hard: raise NotImplementedError, return nil with a // TODO, an empty list where pagination was needed, an error swallowed to make a test pass. Each is a substitution failure waiting for production traffic. Also check the fakes an agent writes for tests: a fake store that never fails and always returns sorted results makes the caller's tests pass against behaviour no real store has. The review rule is simple: every implementation, including fakes, must run the shared contract suite in CI, and any new isinstance or type switch on an implementation type needs a written justification.
The same shape elsewhere
LSP is "trust but verify" applied to types. HTTP has the same rule at protocol level: RFC 9110 says GET is safe and idempotent, so caches, proxies and crawlers repeat GETs freely. A server that deletes data on GET is a substitution violation, and the bug report arrives when a link prefetcher deletes a customer's records. Payment gateway adapters, message queue clients and database drivers all carry the same risk.
Down the stack
Layer 1The contract
For the store interface, the contract is a list of testable statements: round trip (get(k) returns what put(k, v) wrote), overwrite, missing keys return "not found" rather than an error, delete is idempotent, and listing returns exactly the matching keys in sorted order. Each implementation may add guarantees (stronger consistency, versioning) but may not drop one. Concurrency is part of the contract too: Go's standard library says when a type is "safe for concurrent use by multiple goroutines", and an implementation that is not safe breaks callers that rely on that sentence.
Layer 2The mechanism
Type systems check only the structural part of substitutability. Go and TypeScript use structural typing: any type with the right method set satisfies the interface, with no declaration of intent. Python's typing.Protocol does the same for mypy, and mypy also flags overrides whose signatures are incompatible with the base class. Signature rules follow variance: parameters may be widened (contravariance) and return types narrowed (covariance). TypeScript deliberately relaxes this: methods declared with method syntax are checked bivariantly in their parameters even under --strict, so an implementation that accepts a narrower argument type compiles. None of these checkers know that list_keys must be sorted. Behavioural rules live in documentation and in contract tests, the way Go's io.Reader docs spell out how n and err may combine and every reader in the standard library is tested against that.
Layer 3The machine
A call through an interface reaches the implementation by data, not by name. In C++ and Java the object header points to a vtable and the call reads a fixed slot. In Go an interface value is two machine words: a pointer to an itab (the concrete type plus its method pointers, built once per type and interface pair and cached in a global hash table) and a pointer to the data. Assigning a non-pointer struct to an interface may copy it to the heap. A call costs a couple of dependent loads plus an indirect branch, around 1 to 2 ns when predicted. In V8 the call site's inline cache records object shapes; a site that only sees one implementation is monomorphic and fast. In CPython the method is found through the type's MRO and a per-type cache.
Violations add cost the dispatch never had. A caller that works around a quirk with a type switch adds a comparison of type pointers per call and, more expensively, a branch that only one implementation exercises and that tests rarely cover. A cache wrapper that breaks read-your-writes typically does so for a performance reason: invalidation across processes needs a network round trip (0.5 ms or more in a data centre), and skipping it is the shortcut. The fix makes that cost explicit in the contract instead of hiding it.
Why it's built this way
- Large systems are assembled from parts written at different times by different people (and now agents), joined by interfaces.
- Callers are written once against the interface and cannot be updated each time an implementation appears.
- So each implementation must honour everything the callers were promised, or the callers break in production.
- Compilers verify only signatures, because behaviour is undecidable in general.
- So the behavioural promise has to be written down and enforced by a shared test suite every implementation runs.
Diagram
flowchart TB CT["Contract test suite"] --> M["MemoryStore (tests)"] CT --> S["S3Store"] CT --> G["OtherCloudStore adapter"] CT --> P["PrefixedStore wrapper"] X["Export service"] -->|"BlobStore"| S X -->|"BlobStore"| G R["Report viewer"] -->|"BlobReader"| A["ArchiveStore (read-only)"]
Worked example
A SaaS company stores 30 million report files across two clouds behind one BlobStore interface. Over six months it has four storage-related incidents: a retention job crash (delete raised on a missing key), stale reports after deletion (cache wrapper), a failed migration (one adapter's list_keys returned the first 1,000 keys only, because the agent that wrote it never handled pagination), and a dev environment that passed tests a production adapter would have failed (the in-memory fake sorted keys; the real adapter did not).
The team writes a contract suite of 14 tests and runs it against all five implementations. On the first run it fails 9 assertions across three of them. Fixes take two days: catch 404 on delete, invalidate the cache on delete and overwrite, implement pagination in list_keys, and sort in the adapter. Two isinstance checks in the export service are deleted. The archive store moves to a separate BlobReader interface, and the compiler finds one caller that was passing it where writes were expected.
Cost: the suite runs in 0.3 seconds against the in-memory store and about 20 seconds against real cloud buckets in a nightly job. In the following six months, storage incidents drop to zero, and a third provider's adapter (written mostly by an agent) passes the suite on its second attempt.
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| One wide interface, documented optional operations | Few types, simple signatures | Callers must handle "unsupported" at runtime | Optional capabilities are rare and clearly documented |
| Split interfaces (reader, writer, lister) | Compiler prevents invalid substitution | More types; signatures get longer | Some implementations genuinely cannot honour part of the contract |
| Contract test suite for all implementations | Catches behavioural violations, including in fakes | Must be maintained; slow against real backends | Any interface with more than one implementation |
| Type checks in callers | Quick local fix | Every new implementation edits callers; hidden coupling | Almost never; only as a temporary, ticketed workaround |
| Inheritance for code reuse | Less code | Creates subtype promises nobody intended | Rarely; prefer composition and delegation |
Pitfalls in production
- Stubbing hard methods with
NotImplementedErroror a silent no-op so the class "satisfies" the interface. Fix: split the interface, or implement the method properly before merging. - Test fakes that are better behaved than production (never fail, always sorted, always consistent). Fix: run the fake through the same contract suite, and add failure injection to it.
- Wrappers (caches, retries, metrics) that quietly change semantics, such as serving stale reads or retrying non-idempotent writes. Fix: wrappers run the contract suite too, with the inner store underneath.
- Adapters that leak provider errors, so callers start matching on
S3Errorstrings. Fix: map provider errors to the interface's own error values, such as a not-found sentinel. - Pagination, limits and size caps missing in one implementation, discovered only on a large tenant. Fix: contract tests that include more items than one page.
Leadership lens
- Treat interfaces with several implementations as products: a written contract, an owner and a shared test suite that every new implementation must pass before it merges.
- Count type checks on implementations in the codebase. Each one is an LSP debt with a known cost: the next implementation will need another.
- When agents generate adapters, make the contract suite the acceptance criterion. It turns "looks complete" into "behaves like the others", which is the property you actually need.
- Decide explicitly what is allowed to vary between implementations (latency, consistency window, error detail) and put it in the contract, so teams do not over-specify around one vendor.
Check yourself
An implementation of
BlobStoreaccepts keys of any length, where the interface only promised keys up to 1,024 bytes. Is that a violation?Show answer
No. It weakens a precondition (accepts more), which is allowed; callers that respect the 1,024-byte limit still work.
A caching wrapper returns a deleted object for up to 60 seconds. Which part of the contract does it break, and what are the fixes?
Show answer
It breaks the postcondition that
getafterdeletereturns not found (read-your-writes). Fix it by invalidating on delete, or state a weaker consistency contract that callers are designed for.Why does an LSP violation tend to become an Open/Closed violation?
Show answer
Callers compensate with type checks for the misbehaving implementation, so every new implementation forces edits in the callers instead of being purely additive.
Why can't the Go or TypeScript compiler catch the "list must be sorted" violation?
Show answer
They check method names and signatures structurally; ordering is a behavioural promise, which only tests or documentation can express.
Watch
- The Power of Abstraction (Barbara Liskov, NC State): Liskov on data abstraction, its history and where the substitution idea came from.
- Breaking Dependencies: The SOLID Principles (Klaus Iglberger, CppCon 2020): LSP explained as behavioural expectations, with examples beyond the square and rectangle.
Free resources
- A Behavioral Notion of Subtyping (Liskov and Wing, 1994): the precise definition, including the history constraint.
- Data Abstraction and Hierarchy (Barbara Liskov, 1987): the keynote where the principle first appeared.
- Contract Test (Martin Fowler): testing that a substitute behaves like the real thing.
- io.Reader documentation (Go): a real interface whose behavioural contract is spelled out in its docs.
- Hyrum's Law: why callers end up depending on behaviour you never promised.
- Liskov substitution principle (Wikipedia): the variance and pre/postcondition rules in summary.