Low-Level Design (Object-Oriented Design) Module 4 – Design principles: SOLID and beyond
Liskov Substitution Principle (LSP)
The Liskov Substitution Principle as behavioural subtyping: contract tests that catch a subtype breaking its parent, and fixes that change the hierarchy.
What you will learn
- State behavioural subtyping in terms of preconditions, postconditions and invariants
- Detect substitution failures with contract tests run against every implementation
- Fix a violation by changing the hierarchy instead of adding type checks
- Recognise a history constraint and how an extra method in a subtype can break it
Before you start
On this page
The previous lesson added variants by plugging new classes in where old code expected a common type. That only works if each new class behaves the way the old code expects. The Liskov Substitution Principle (LSP) is that condition, and this lesson turns it into tests you can run against every implementation.
Behavioural subtyping
Barbara Liskov and Jeannette Wing made the idea precise in a paper of 1994. A type checker can confirm that a subtype has the right methods with the right signatures, but a program can still go wrong when the methods behave differently. So they defined subtyping by behaviour, in what they called the Subtype Requirement:
Let φ(x) be a property provable about objects x of type T. Then φ(y) should be true for objects y of type S where S is a subtype of T.
In other words: whatever a caller can rely on for the parent type must still be true when it is given the child. Robert C. Martin named his principle of class design after Liskov, in the form “derived classes must be substitutable for their base classes”. Later he widened it from inheritance to interfaces of every kind:
A program that uses an interface must not be confused by an implementation of that interface.
In Python that includes duck typing. An object that you pass where a file, an iterable or your own protocol is expected counts as a subtype of that unwritten interface, and the same rules apply to it.
The rules, in terms of contracts
A contract says what a method needs (its precondition), what it promises (its postcondition) and what stays true of the object in between (its invariant). Liskov and Wing’s definition gives one rule for each, and Bertrand Meyer’s Design by Contract states the same rules for methods that a subclass redefines. A subtype’s methods:
- Accept at least what the parent’s accept. A subtype may weaken a precondition, never strengthen it: if
withdraw()works for any amount up to the balance, a subtype cannot add “only once it has matured” or “only below 100”. - Promise at least what the parent’s promise. A subtype may strengthen a postcondition, never weaken it: if
withdraw()returns the new balance, a subtype cannot returnNoneinstead. - Keep the parent’s invariants. What is always true of the parent’s objects, such as “the balance is never negative”, stays true of the subtype’s.
- Raise no new kinds of error. Callers handle the exceptions the parent documents; an exception they have never heard of is a broken promise.
- Keep the parent’s history properties. Some promises are about change over time, such as “a price never changes”. The subtype’s own extra methods must not break them. This last rule is the one Liskov and Wing added to earlier work, and the one most easily missed.
Symptoms in code
Three patterns usually mean a subtype is not a real subtype:
- an override that raises “not supported”, including
NotImplementedErrorin a class that is meant to be used; - an override that quietly does nothing, so callers believe something happened;
- callers that check
isinstance()before calling a method, because one subtype must be treated differently.
A fixed deposit that refuses withdrawals
A bank’s Account lets you pay money in and take it out, and its docstring says exactly what withdraw() promises.
FixedDeposit is locked until it matures, so it overrides withdraw() to refuse. To find out whether each
subclass keeps the promise, write the promise once as tests in a mixin class, AccountContract, and run those tests
against every implementation:
broken/check_accounts.py
import unittest
from accounts import FixedDeposit, SavingsAccount
from contract import AccountContract, run
class SavingsAccountTest(AccountContract, unittest.TestCase):
def make(self, balance):
return SavingsAccount(balance)
class FixedDepositTest(AccountContract, unittest.TestCase):
def make(self, balance):
return FixedDeposit(balance)
broken = []
for test_class in (SavingsAccountTest, FixedDepositTest):
name = test_class.__name__.removesuffix("Test")
print(name)
if run(test_class):
broken.append(name)
print(f"Breaks its contract: {', '.join(broken) or 'nothing'}") Output
SavingsAccount ok deposit adds to the balance ok withdrawing too much is refused ok withdrawing within the balance succeeds FixedDeposit ok deposit adds to the balance FAILED withdrawing too much is refused: accounts.WithdrawalNotAllowed: a fixed deposit cannot be withdrawn before it matures FAILED withdrawing within the balance succeeds: accounts.WithdrawalNotAllowed: a fixed deposit cannot be withdrawn before it matures Breaks its contract: FixedDeposit
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_accounts.py
broken/accounts.py
class InsufficientFunds(Exception):
pass
class WithdrawalNotAllowed(Exception):
pass
class Account:
"""Money you can pay in and take out.
withdraw(amount): for 0 < amount <= balance, the balance falls by amount and the new balance is returned;
for a larger amount, InsufficientFunds is raised and the balance stays as it was.
"""
def __init__(self, balance=0):
self._balance = balance
@property
def balance(self):
return self._balance
def deposit(self, amount):
self._balance += amount
return self._balance
def withdraw(self, amount):
if amount > self._balance:
raise InsufficientFunds(f"balance {self._balance}, asked for {amount}")
self._balance -= amount
return self._balance
class SavingsAccount(Account):
pass
class FixedDeposit(Account):
"""Locked until it matures, so it refuses every withdrawal."""
def withdraw(self, amount):
raise WithdrawalNotAllowed("a fixed deposit cannot be withdrawn before it matures") broken/contract.py
"""The behaviour every Account promises, as tests. Each implementation gets the same tests through make()."""
import unittest
from accounts import InsufficientFunds
class AccountContract:
def make(self, balance):
raise NotImplementedError
def test_deposit_adds_to_the_balance(self):
account = self.make(100)
self.assertEqual(account.deposit(50), 150)
def test_withdrawing_within_the_balance_succeeds(self):
account = self.make(100)
self.assertEqual(account.withdraw(30), 70)
self.assertEqual(account.balance, 70)
def test_withdrawing_too_much_is_refused(self):
account = self.make(100)
with self.assertRaises(InsufficientFunds):
account.withdraw(500)
self.assertEqual(account.balance, 100)
def run(test_class):
"""Runs one implementation's tests; prints one line per test and returns how many did not pass."""
bad = 0
for test in unittest.defaultTestLoader.loadTestsFromTestCase(test_class):
result = unittest.TestResult()
test.run(result)
name = test.id().rpartition(".")[2].removeprefix("test_").replace("_", " ")
problems = result.failures + result.errors
if problems:
bad += 1
reason = problems[0][1].strip().splitlines()[-1]
print(f" FAILED {name}: {reason}")
else:
print(f" ok {name}")
return bad Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
SavingsAccount passes. FixedDeposit fails twice. It strengthens the precondition, because no amount at all can
be withdrawn, and it raises WithdrawalNotAllowed, an exception that code written for Account has never heard of.
Any function that pays a bill from an Account will crash when it is given a fixed deposit.
Common mistake
The quick fix is in the callers: if isinstance(account, FixedDeposit): ... before every withdrawal. It works until
the next account type that cannot withdraw, and then every one of those checks must be found and extended. That is
the switch statement of the previous lesson again, now spread across the callers. Fix the hierarchy instead.
Contract tests
The test class AccountContract is an ordinary class with test_ methods and one factory method, make(). Each
implementation gets a test class that inherits from both the contract and unittest.TestCase and says how to make
one of its objects. The contract is then written once, and a new implementation inherits all of its tests by
adding a three-line class. That is the mechanical way to find substitution failures: if a subtype fails a test that
its parent passes, it is not a subtype in Liskov and Wing’s sense, whatever its class statement says.
Test what callers rely on
A contract test checks the documented promise, not the details of one implementation. “Withdrawing 30 from 100
leaves 70” belongs in the contract; “the balance is stored in _balance” does not, because another
implementation may store it differently and still keep the promise.
Fix the hierarchy, not the callers
The parent promised something not every child can do, so move the promise down. Account keeps what all accounts
share, a balance and deposits. A new WithdrawableAccount adds withdraw() and its promise, and SavingsAccount
inherits from it. FixedDeposit inherits from Account alone and adds what it really does, close() once it has
matured. (If the bank’s fixed deposits also refuse top-ups, deposit() has the same problem and moves down as well,
leaving Account with only the balance.) The contract splits the same way:
split/check_accounts.py
import unittest
from accounts import FixedDeposit, SavingsAccount
from contract import AccountContract, WithdrawableContract, run
class SavingsAccountTest(WithdrawableContract, unittest.TestCase):
def make(self, balance):
return SavingsAccount(balance)
class FixedDepositTest(AccountContract, unittest.TestCase):
def make(self, balance):
return FixedDeposit(balance)
def test_closing_pays_out_once_matured(self):
deposit = FixedDeposit(100, matured=True)
self.assertEqual(deposit.close(), 100)
self.assertEqual(deposit.balance, 0)
broken = []
for test_class in (SavingsAccountTest, FixedDepositTest):
name = test_class.__name__.removesuffix("Test")
print(name)
if run(test_class):
broken.append(name)
print(f"Breaks its contract: {', '.join(broken) or 'nothing'}") Output
SavingsAccount ok deposit adds to the balance ok withdrawing too much is refused ok withdrawing within the balance succeeds FixedDeposit ok closing pays out once matured ok deposit adds to the balance Breaks its contract: nothing
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_accounts.py
split/accounts.py
class InsufficientFunds(Exception):
pass
class Account:
"""Every account has a balance and accepts deposits. Nothing here promises withdrawals."""
def __init__(self, balance=0):
self._balance = balance
@property
def balance(self):
return self._balance
def deposit(self, amount):
self._balance += amount
return self._balance
class WithdrawableAccount(Account):
"""withdraw(amount): for 0 < amount <= balance, the balance falls by amount and the new balance is returned;
for a larger amount, InsufficientFunds is raised and the balance stays as it was."""
def withdraw(self, amount):
if amount > self._balance:
raise InsufficientFunds(f"balance {self._balance}, asked for {amount}")
self._balance -= amount
return self._balance
class SavingsAccount(WithdrawableAccount):
pass
class FixedDeposit(Account):
"""Locked until it matures. It offers no withdraw() at all, so nobody can call it by mistake."""
def __init__(self, balance=0, matured=False):
super().__init__(balance)
self.matured = matured
def close(self):
"""Pays out the whole balance once the deposit has matured."""
if not self.matured:
raise ValueError("this fixed deposit has not matured yet")
payout, self._balance = self._balance, 0
return payout split/contract.py
"""Contracts as tests: one for every Account, and one more for accounts that allow withdrawals."""
import unittest
from accounts import InsufficientFunds
class AccountContract:
def make(self, balance):
raise NotImplementedError
def test_deposit_adds_to_the_balance(self):
account = self.make(100)
self.assertEqual(account.deposit(50), 150)
class WithdrawableContract(AccountContract):
def test_withdrawing_within_the_balance_succeeds(self):
account = self.make(100)
self.assertEqual(account.withdraw(30), 70)
self.assertEqual(account.balance, 70)
def test_withdrawing_too_much_is_refused(self):
account = self.make(100)
with self.assertRaises(InsufficientFunds):
account.withdraw(500)
self.assertEqual(account.balance, 100)
def run(test_class):
"""Runs one implementation's tests; prints one line per test and returns how many did not pass."""
bad = 0
for test in unittest.defaultTestLoader.loadTestsFromTestCase(test_class):
result = unittest.TestResult()
test.run(result)
name = test.id().rpartition(".")[2].removeprefix("test_").replace("_", " ")
problems = result.failures + result.errors
if problems:
bad += 1
reason = problems[0][1].strip().splitlines()[-1]
print(f" FAILED {name}: {reason}")
else:
print(f" ok {name}")
return bad Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The account hierarchy before and after the split
Text description of the diagram
The diagram shows two class hierarchies, one above the other. Each arrow points from a class to the class it inherits from.
- Before: SavingsAccount and FixedDeposit both inherit from Account, which offers a balance, deposit() and withdraw(), and promises that a withdrawal within the balance succeeds. FixedDeposit overrides withdraw() to always raise WithdrawalNotAllowed, so it cannot keep that promise.
- After: Account offers only a balance and deposit(). WithdrawableAccount inherits from Account and adds withdraw() with the promise, and SavingsAccount inherits from WithdrawableAccount. FixedDeposit inherits from Account directly and adds close(), which pays out once the deposit has matured; it never promised withdrawals, so it breaks nothing.
Code that takes money out now asks for a WithdrawableAccount, in its type hints and in its documentation, so a
type checker rejects a fixed deposit before the program runs, and no caller needs an isinstance() check. When
splitting the interface does not fit, there are two other honest fixes. Composition: a class can hold and use an
account without claiming to be one. Or a weaker contract: if withdraw() may be refused for reasons callers cannot
predict, say so in the parent and return or raise something every caller handles. Then every caller changes once,
on purpose, instead of failing later by surprise.
History: promises about change
The hardest rule to spot is the one about history, because no single call misbehaves. FixedPriceList promises that
its prices never change, and Quote trusts that promise, so it keeps no copy of the prices. EditablePriceList
inherits from it and adds one method, set_price():
"""A promise about history: "prices never change". A subclass that adds set_price() breaks it."""
from types import MappingProxyType
class FixedPriceList:
"""Prices for one season. Promise: once the list exists, no price in it changes."""
def __init__(self, prices):
self._prices = dict(prices)
def price(self, item):
return self._prices[item]
class EditablePriceList(FixedPriceList):
"""Answers price() like its parent, and adds set_price()."""
def set_price(self, item, price):
self._prices[item] = price
class Quote:
"""A quote given to a customer. It trusts the list's promise, so it keeps no copy of the prices."""
def __init__(self, price_list, lines):
self.price_list, self.lines = price_list, lines
def total(self):
return sum(self.price_list.price(item) * quantity for item, quantity in self.lines)
prices = EditablePriceList({"tea": 30, "toast": 45})
quote = Quote(prices, [("tea", 2), ("toast", 1)])
print("quote given to the customer:", quote.total())
prices.set_price("tea", 35)
print("the same quote, later: ", quote.total())
# The standard library promises less: a read-only view of a dict says nothing about the dict changing.
menu = {"tea": 30}
view = MappingProxyType(menu)
menu["tea"] = 35
print("read-only view after the dict changed:", view["tea"])
try:
view["tea"] = 40
except TypeError as error:
print("writing through the view:", error) Output
quote given to the customer: 105 the same quote, later: 115 read-only view after the dict changed: 35 writing through the view: 'mappingproxy' object does not support item assignment
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 price_lists.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Each price() call answers correctly, yet a quote already given to a customer changed its total from 105 to 115.
The parent’s promise was about every sequence of states, and the child’s new method made a sequence in which it is
false. Liskov and Wing give the same verdict on a mutable array posing as a sequence whose users expect it never to
change: it is not a subtype.
Python’s standard library avoids the trap by promising less. In collections.abc, Sequence and MutableSequence
are the read-only and mutable interfaces, and MutableSequence extends Sequence. That is safe because “read-only”
only means the interface has no methods for changing the sequence; it does not mean that nothing else can change it.
The last lines above show the difference: a types.MappingProxyType refuses writes made through it, yet it shows
every change made to the dictionary behind it. When your code needs “this never changes”, take a copy at the moment
you rely on it, such as a tuple or a frozen data class, instead of trusting a read-only interface.
In a design interview
Each time you draw an inheritance arrow, say what the child promises its callers. When one child cannot keep a promise of the parent (a read-only account, a vehicle that cannot be charged, a bounded queue), split the interface or use composition, and say that you are doing it to keep substitution safe.
Key takeaways
- A subtype must keep every promise its parent makes, so code written for the parent cannot tell the difference; this covers duck typing and protocols as well as inheritance.
- Subtypes may accept more and promise more, never accept less, promise less, break an invariant or raise new kinds of error; their extra methods must not break the parent’s promises about history.
- Contract tests make LSP mechanical: write the parent’s promises once as a test mixin and run them against every implementation.
- When a subtype cannot keep a promise, fix the hierarchy (split the interface, compose, or weaken the contract
honestly), never the callers with
isinstance()checks.
Exercise
Exercise · Medium · Python
Write the contract tests for a queue
A team has several queue classes, and code that is written for one of them must work with any of them. Write that promise down as checks. contract_failures(make_queue) gets a class (or any function) that makes a new, empty queue with put(item), get() and len(), and returns a list of messages, one for each rule below that those queues break. An empty list means the implementation keeps the contract:
- A new queue is empty:
len(queue) == 0. put(item)always accepts the item, however many are already queued, andlen()grows by exactly one.get()removes and returns the oldest item, andlen()shrinks by one.- Items leave in the order they arrived, also when puts and gets are mixed.
get()on an empty queue raisesIndexError, and the queue stays empty.
The sample tests run your checks against six implementations. Two keep the contract and four break it in different ways, one of them by raising an error the contract never mentions. Your function must report all four and must not crash on any of them: an unexpected exception from a queue is a broken rule, not a reason for your checks to stop.
Starter code · queue_contract.py
def contract_failures(make_queue):
"""Checks the queue contract against queues made by make_queue().
Returns a list of messages, one for each rule of the contract that the queues break. An empty list means that
the implementation keeps the contract.
"""
failures = []
# Write your checks here.
return failures The sample tests · test_queue_contract.py
from collections import deque
from queue_contract import contract_failures
class ListQueue:
def __init__(self):
self._items = []
def put(self, item):
self._items.append(item)
def get(self):
if not self._items:
raise IndexError("get from an empty queue")
return self._items.pop(0)
def __len__(self):
return len(self._items)
class DequeQueue:
def __init__(self):
self._items = deque()
def put(self, item):
self._items.append(item)
def get(self):
return self._items.popleft() # raises IndexError when empty
def __len__(self):
return len(self._items)
class RingQueue(DequeQueue):
"""Holds at most four items and silently drops the oldest one."""
def __init__(self):
self._items = deque(maxlen=4)
class ShortCountQueue(ListQueue):
"""Keeps every item, but len() never reports more than four."""
def __len__(self):
return min(len(self._items), 4)
class QuietQueue(ListQueue):
"""Returns None from an empty queue instead of raising IndexError."""
def get(self):
return self._items.pop(0) if self._items else None
class CrashingQueue(ListQueue):
"""Fails in a way the contract never allows."""
def get(self):
raise RuntimeError("storage unavailable")
def test_list_queue_keeps_the_contract():
"""reports nothing for a correct list-based queue"""
assert contract_failures(ListQueue) == []
def test_deque_queue_keeps_the_contract():
"""reports nothing for a correct deque-based queue"""
assert contract_failures(DequeQueue) == []
def test_ring_queue_breaks_it():
"""catches a queue that drops items once it holds four"""
assert contract_failures(RingQueue) != []
def test_short_count_queue_breaks_it():
"""catches a queue whose len() stops counting at four"""
assert contract_failures(ShortCountQueue) != []
def test_quiet_queue_breaks_it():
"""catches a queue that returns None when it is empty"""
assert contract_failures(QuietQueue) != []
def test_reports_instead_of_crashing():
"""reports a queue whose get() raises an unexpected error, and returns messages"""
failures = contract_failures(CrashingQueue)
assert failures != []
assert all(isinstance(message, str) for message in failures) A hint
Write each rule as a small function that builds a fresh queue with make_queue() and returns True when the rule holds. Then call every rule through one helper that appends a message when the rule returns False or raises an exception (except Exception as error:), so one broken rule cannot stop the checks of the others.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
References
- A behavioral notion of subtyping (ACM Transactions on Programming Languages and Systems (copy at Carnegie Mellon University))
- The Principles of OOD (archived copy) (Robert C. Martin, via the Internet Archive)
- Solid Relevance (Robert C. Martin, The Clean Code Blog)
- Object-Oriented Software Construction, second edition (chapter 16, inheritance and contracts) (Bertrand Meyer)
- unittest, unit testing framework (Python Software Foundation)
- collections.abc, abstract base classes for containers (Python Software Foundation)
- types.MappingProxyType (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress