Liskov Substitution Principle
Core Idea
Liskov Substitution Principle states that a subtype must preserve the contract of its base type. Client code should work with the base contract and receive consistent behavior from every subtype. If one subtype changes preconditions, postconditions, or state rules, substitution fails. Then abstraction loses value.
This principle protects trust in interfaces. A port or abstract class is a promise. Each implementation must honor that promise.
Conceptual Overview
The shared system context in this handbook is the car intelligence and marketplace platform. Examples in this page follow that context: Hypothetical Scenario.
In this platform, recommendation use cases depend on market data providers.
One provider reads a local read model.
Another provider calls an external marketplace feed.
A third provider reads cached snapshot data.
Use case code depends on one ListingProvider contract.
Substitution means this rule. Any provider can replace another provider with no change in use case logic. The caller should not add subtype checks. The caller should not catch subtype-specific failures that violate the base contract.
This idea links to Dependency Inversion. Dependency inversion says policy code should depend on abstractions. Liskov gives the quality rule for those abstractions. An abstraction without substitutable implementations is a weak boundary.
The diagram shows one contract and three implementations. Two honor the contract. One breaks it by adding stronger input limits and weaker output guarantees.
Computing History
Barbara Liskov published key work on data abstraction in the 1970s. Barbara Liskov and Jeannette Wing later defined behavioral subtyping in 1994. Their formulation established substitution as a formal contract rule in object-oriented design.
Sources: Liskov and Wing (1994)
Quote
"the behavior of P is unchanged when o1 is substituted for o2"
Source: Liskov and Wing, 1994
Generic Example: Python
Base contract:
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass(frozen=True)
class ListingQuery:
city: str
max_budget: int
class ListingProvider(ABC):
@abstractmethod
def find(self, query: ListingQuery) -> list[dict]:
"""Return listings sorted by relevance. Empty list is valid."""
raise NotImplementedError
Compliant subtypes:
class ReadModelListingProvider(ListingProvider):
def find(self, query: ListingQuery) -> list[dict]:
rows = [
{"id": "car-101", "price": 19000, "city": query.city, "relevance": 0.91},
{"id": "car-102", "price": 22000, "city": query.city, "relevance": 0.84},
]
return [row for row in rows if row["price"] <= query.max_budget]
class CachedFeedListingProvider(ListingProvider):
def find(self, query: ListingQuery) -> list[dict]:
snapshot = [
{"id": "car-201", "price": 17500, "city": query.city, "relevance": 0.87},
{"id": "car-202", "price": 26000, "city": query.city, "relevance": 0.76},
]
return [row for row in snapshot if row["price"] <= query.max_budget]
Non compliant subtype:
class PremiumOnlyListingProvider(ListingProvider):
def find(self, query: ListingQuery) -> list[dict]:
if query.max_budget < 30000:
raise ValueError("budget too low for this provider")
return [{"id": "car-901", "price": 42000, "city": query.city, "relevance": 0.95}]
Issue in this subtype:
- it adds a stronger precondition than the base contract
- it rejects valid queries that other subtypes accept
- callers now need subtype knowledge
A polymorphic caller works with the base type:
class MatchCandidatesUseCase:
def __init__(self, provider: ListingProvider):
self.provider = provider
def execute(self, query: ListingQuery) -> list[dict]:
return self.provider.find(query)
MatchCandidatesUseCase can receive ReadModelListingProvider or CachedFeedListingProvider.
Client code stays unchanged.
Generic Example: Python Protocol with mypy
typing.Protocol defines structural contracts.
A class satisfies the contract if method shape matches.
No explicit inheritance is required.
mypy checks this rule at static analysis time.
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True)
class Listing:
id: str
price: int
city: str
relevance: float
@dataclass(frozen=True)
class ListingQuery:
city: str
max_budget: int
class ListingProvider(Protocol):
def find(self, query: ListingQuery) -> list[Listing]:
...
class PartnerApiProvider:
def find(self, query: ListingQuery) -> list[Listing]:
rows = [
Listing(id="car-610", price=18000, city=query.city, relevance=0.90),
Listing(id="car-611", price=23000, city=query.city, relevance=0.81),
]
return [row for row in rows if row.price <= query.max_budget]
class SnapshotProvider:
def find(self, query: ListingQuery) -> list[Listing]:
rows = [
Listing(id="car-710", price=17500, city=query.city, relevance=0.87),
Listing(id="car-711", price=26500, city=query.city, relevance=0.73),
]
return [row for row in rows if row.price <= query.max_budget]
def select_candidates(provider: ListingProvider, query: ListingQuery) -> list[Listing]:
return provider.find(query)
Static failure case:
class WrongReturnTypeProvider:
def find(self, query: ListingQuery) -> dict:
return {"id": "car-x"}
query = ListingQuery(city="Austin", max_budget=25000)
select_candidates(WrongReturnTypeProvider(), query)
mypy flags this call:
error: Argument 1 to "select_candidates" has incompatible type "WrongReturnTypeProvider"; expected "ListingProvider"
note: Following member(s) of "WrongReturnTypeProvider" have conflicts:
note: Expected:
note: def find(self, query: ListingQuery) -> list[Listing]
note: Got:
note: def find(self, query: ListingQuery) -> dict[Any, Any]
Protocol plus mypy gives strong shape validation.
Liskov still needs behavior checks.
mypy cannot prove semantic contract details such as hidden precondition changes.
Contract tests close that gap.
Generic Example: TypeScript
type ListingQuery = {
city: string;
maxBudget: number;
};
type Listing = {
id: string;
price: number;
city: string;
relevance: number;
};
abstract class ListingProvider {
abstract find(query: ListingQuery): Promise<Listing[]>;
}
class ApiListingProvider extends ListingProvider {
async find(query: ListingQuery): Promise<Listing[]> {
const rows: Listing[] = [
{ id: "car-301", price: 18500, city: query.city, relevance: 0.9 },
{ id: "car-302", price: 24500, city: query.city, relevance: 0.82 },
];
return rows.filter((row) => row.price <= query.maxBudget);
}
}
class CachedFeedListingProvider extends ListingProvider {
async find(query: ListingQuery): Promise<Listing[]> {
const rows: Listing[] = [
{ id: "car-401", price: 19200, city: query.city, relevance: 0.88 },
{ id: "car-402", price: 21900, city: query.city, relevance: 0.83 },
];
return rows.filter((row) => row.price <= query.maxBudget);
}
}
class PremiumOnlyListingProvider extends ListingProvider {
async find(query: ListingQuery): Promise<Listing[]> {
if (query.maxBudget < 30000) {
throw new Error("budget too low for this provider");
}
return [{ id: "car-990", price: 42000, city: query.city, relevance: 0.95 }];
}
}
A calling use case stays unchanged when provider type changes:
class MatchCandidatesUseCase {
constructor(private readonly provider: ListingProvider) {}
async execute(query: ListingQuery): Promise<Listing[]> {
return this.provider.find(query);
}
}
Polymorphism and Liskov Substitution
Polymorphism gives one dispatch point through a base type. Liskov gives the rule that each subtype must honor at that dispatch point. These two ideas work as one pair.
Polymorphism without substitution produces unstable runtime behavior. Substitution without polymorphic use gives little value in design. Good design uses both:
- base type in client code
- subtype inheritance for extensions
- contract checks that verify subtype behavior
In this page, MatchCandidatesUseCase depends on ListingProvider.
That is the polymorphic boundary.
Liskov is satisfied only when each subtype can pass through that boundary with no caller change.
Link with Dependency Inversion
Dependency inversion and substitution are tightly connected. Dependency inversion defines dependency direction. Liskov defines behavior validity inside that direction.
A system can claim inversion and still fail in production. That failure appears when adapters compile against the port but break runtime behavior. Policy code then adds adapter-specific branches. That branch logic is a warning sign.
A strong architecture keeps these conditions:
- core code depends on interface contracts
- each implementation honors the same input and output rules
- contract tests run for every implementation
This model supports Onion Architecture and Hexagonal Architecture. Both styles rely on stable ports. Ports require substitutable adapters.
Trade-offs and Failure Modes
Strict substitution checks add design work. Teams need explicit contract tests and clear error semantics. Early project phases can feel slower. That cost pays back during feature growth and adapter swaps.
Common failure modes:
- subtype adds new input restrictions
- subtype returns partial shape not declared in contract
- subtype throws exceptions outside contract rules
- subtype mutates shared state in hidden ways
Good control tactics:
- write contract test suites for each port
- document preconditions and postconditions in one place
- keep side effects visible in method names and return types
- reject implementations that require caller type checks
Foundational Principle Links
This principle links to Abstraction and Boundaries. Substitution gives measurable quality for an abstraction boundary.
This principle links to Correctness and Testing. Contract tests prove subtype behavior against shared rules.
This principle links to Modularity and Composition. Substitutable modules compose with lower integration friction.
This principle links to Simplicity First. Client code stays simple when it trusts one stable contract.
Practice Checklist
- define contract preconditions and postconditions in code and docs
- run contract tests for each subtype
- reject subtype rules that narrow valid input range
- keep return shape stable across implementations
- avoid adapter-specific branches in policy code
- audit exception behavior in every implementation
Written by: Pedro Guzmán
See References for complete APA-style bibliographic entries used on this page.