Skip to content

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.

Liskov substitution contract map

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.

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

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.