Engineering Atlas › Low-Level Design

Liskov substitution principle (LSP)

13 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

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]: ...  # sorted

There 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: pass

Now 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:

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:

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

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

  1. Large systems are assembled from parts written at different times by different people (and now agents), joined by interfaces.
  2. Callers are written once against the interface and cannot be updated each time an implementation appears.
  3. So each implementation must honour everything the callers were promised, or the callers break in production.
  4. Compilers verify only signatures, because behaviour is undecidable in general.
  5. 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

OptionStrengthsWeaknessesChoose when
One wide interface, documented optional operationsFew types, simple signaturesCallers must handle "unsupported" at runtimeOptional capabilities are rare and clearly documented
Split interfaces (reader, writer, lister)Compiler prevents invalid substitutionMore types; signatures get longerSome implementations genuinely cannot honour part of the contract
Contract test suite for all implementationsCatches behavioural violations, including in fakesMust be maintained; slow against real backendsAny interface with more than one implementation
Type checks in callersQuick local fixEvery new implementation edits callers; hidden couplingAlmost never; only as a temporary, ticketed workaround
Inheritance for code reuseLess codeCreates subtype promises nobody intendedRarely; prefer composition and delegation

Pitfalls in production

Leadership lens

Check yourself

  1. An implementation of BlobStore accepts 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.

  2. 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 get after delete returns not found (read-your-writes). Fix it by invalidating on delete, or state a weaker consistency contract that callers are designed for.

  3. 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.

  4. 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

Free resources

Open in the interactive app: practice, quiz, mock interview →