InterviewKata
Low-level design · Module 03c of 11 · Level 1: Foundations

Liskov substitution in depth

Module 03 showed one subclass that couldn't do its parent's job. This module gives the full rule: the four promises every subtype must keep, why the dangerous violations don't crash, how a single contract test catches them, and two ways to repair a hierarchy that lies.

1. The idea

A hire-car company promises "any car in this class takes four passengers and standard fuel". If one car in the class only seats two, or needs a special fuel card, every booking system that trusted the promise breaks, even though the car is still, technically, a car.

Barbara Liskov's rule: code written against a type must keep working, unchanged, with any subtype. A compiler checks that a subclass has the right methods. It can't check that they keep the parent's promises. Those promises are the contract, and a subclass has four rules to follow:

RuleThe subclass may……but may notLibrary example that breaks it
Preconditions (what callers must provide)Accept moreDemand moreA parent promises loans of 1–21 days; a subclass refuses anything over 7
Postconditions (what it guarantees back)Guarantee moreGuarantee lessA parent returns "due in exactly the days asked"; a subclass quietly pushes weekend due dates to Monday
Invariants (what's always true)Add its ownBreak the parent's"Copies on loan never exceed copies owned"; a subclass lends the last copy twice
Exceptions (how it can fail)Throw fewer, or narrower onesThrow kinds callers weren't told aboutA subclass throws PaymentRequired from borrow(), which callers never catch

Notice that two of the four never crash: a shortened loan or a moved due date just gives callers wrong answers. That's why the reliable check is a contract test: one test suite, written against the parent type, run against every subclass.

2. How it works

A reference atlas can be looked up but never lent. Putting it under Book because "it's a book" looks harmless. Follow a checkout loop that trusts the type.

Two ways to fix a hierarchy that lies: split it by what things can do (Item for everything in the catalogue, LendableItem only for things that can be lent), or move the varying behaviour into a part the item has (a LendingPolicy: one copy, unlimited copies), so subclasses don't need to override the promise at all. The code below does both.

3. Try it yourself

Does this subclass keep the contract?

The parent, LendableItem, promises: loans of 1–21 days accepted; due date = today + days; never more copies out than owned; fails only with NotAvailable. Judge each subclass.

4. In code

Library items with a written contract. A paper book and an e-book share one LendableItem class and differ only in the lending policy they hold; both pass the same contract test. The atlas is an Item but never a LendableItem, so checkout can't be handed one.

from __future__ import annotations
from abc import ABC, abstractmethod


class NotAvailable(Exception):
    """The only failure callers of borrow() are promised."""


# How copies are lent: a separate object, so items can differ without breaking the contract.
class LendingPolicy(ABC):
    @abstractmethod
    def available(self) -> bool: ...

    @abstractmethod
    def take(self) -> None: ...


class SingleCopy(LendingPolicy):
    def __init__(self) -> None:
        self.on_loan = False

    def available(self) -> bool:
        return not self.on_loan

    def take(self) -> None:
        self.on_loan = True


class UnlimitedCopies(LendingPolicy):          # e-books: lending one never runs out
    def available(self) -> bool:
        return True

    def take(self) -> None:
        pass


class Item:                                    # everything in the catalogue can be looked up
    def __init__(self, title: str) -> None:
        self.title = title


class LendableItem(Item):
    """Contract: accepts any loan of 1-21 days (precondition); returns today + days (postcondition);
    never lends more copies than it has (invariant); fails only with NotAvailable (exceptions)."""

    def __init__(self, title: str, policy: LendingPolicy) -> None:
        super().__init__(title)
        self.policy = policy

    def borrow(self, today: int, days: int) -> int:
        if not 1 <= days <= 21:
            raise ValueError("loans are 1-21 days")
        if not self.policy.available():
            raise NotAvailable(self.title)
        self.policy.take()
        return today + days


class ReferenceBook(Item):                     # in the catalogue, never lent: not a LendableItem at all
    pass


def contract_holds(item: LendableItem) -> bool:
    """The same test runs against every lendable item: that is how substitution is checked."""
    due = item.borrow(today=10, days=21)       # the longest loan the contract allows must be accepted
    if due != 31:
        return False
    try:
        item.borrow(today=11, days=7)          # a second loan either succeeds or raises NotAvailable
    except NotAvailable:
        pass
    return True


if __name__ == "__main__":
    shelf = [LendableItem("Dune (paper)", SingleCopy()), LendableItem("Dune (e-book)", UnlimitedCopies())]
    for item in shelf:
        print(f"{item.title}: contract {'holds' if contract_holds(item) else 'broken'}, available now: {item.policy.available()}")
    catalogue: list[Item] = shelf + [ReferenceBook("World Atlas")]
    print("can borrow:", ", ".join(i.title for i in catalogue if isinstance(i, LendableItem)))
class NotAvailable extends Error {} // the only failure callers of borrow() are promised

// How copies are lent: a separate object, so items can differ without breaking the contract.
interface LendingPolicy { available(): boolean; take(): void; }

class SingleCopy implements LendingPolicy {
  private onLoan = false;
  available(): boolean { return !this.onLoan; }
  take(): void { this.onLoan = true; }
}

class UnlimitedCopies implements LendingPolicy { // e-books: lending one never runs out
  available(): boolean { return true; }
  take(): void {}
}

class Item { constructor(readonly title: string) {} } // everything in the catalogue can be looked up

// Contract: accepts any loan of 1-21 days (precondition); returns today + days (postcondition);
// never lends more copies than it has (invariant); fails only with NotAvailable (exceptions).
class LendableItem extends Item {
  constructor(title: string, readonly policy: LendingPolicy) { super(title); }
  borrow(today: number, days: number): number {
    if (days < 1 || days > 21) throw new Error("loans are 1-21 days");
    if (!this.policy.available()) throw new NotAvailable(this.title);
    this.policy.take();
    return today + days;
  }
}

class ReferenceBook extends Item {} // in the catalogue, never lent: not a LendableItem at all

// The same test runs against every lendable item: that is how substitution is checked.
function contractHolds(item: LendableItem): boolean {
  const due = item.borrow(10, 21); // the longest loan the contract allows must be accepted
  if (due !== 31) return false;
  try { item.borrow(11, 7); } catch (e) { if (!(e instanceof NotAvailable)) throw e; } // succeed or NotAvailable
  return true;
}

const shelf = [new LendableItem("Dune (paper)", new SingleCopy()), new LendableItem("Dune (e-book)", new UnlimitedCopies())];
for (const item of shelf) console.log(`${item.title}: contract ${contractHolds(item) ? "holds" : "broken"}, available now: ${item.policy.available() ? "True" : "False"}`);
const catalogue: Item[] = [...shelf, new ReferenceBook("World Atlas")];
console.log("can borrow: " + catalogue.filter((i) => i instanceof LendableItem).map((i) => i.title).join(", "));
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

// The only failure callers of borrow() are promised.
class NotAvailable extends RuntimeException {
    private static final long serialVersionUID = 1L;
    NotAvailable(String title) { super(title); }
}

// How copies are lent: a separate object, so items can differ without breaking the contract.
interface LendingPolicy {
    boolean available();
    void take();
}

class SingleCopy implements LendingPolicy {
    private boolean onLoan = false;
    public boolean available() { return !onLoan; }
    public void take() { onLoan = true; }
}

class UnlimitedCopies implements LendingPolicy { // e-books: lending one never runs out
    public boolean available() { return true; }
    public void take() {}
}

class Item { // everything in the catalogue can be looked up
    final String title;
    Item(String title) { this.title = title; }
}

// Contract: accepts any loan of 1-21 days (precondition); returns today + days (postcondition);
// never lends more copies than it has (invariant); fails only with NotAvailable (exceptions).
class LendableItem extends Item {
    final LendingPolicy policy;
    LendableItem(String title, LendingPolicy policy) { super(title); this.policy = policy; }

    int borrow(int today, int days) {
        if (days < 1 || days > 21) throw new IllegalArgumentException("loans are 1-21 days");
        if (!policy.available()) throw new NotAvailable(title);
        policy.take();
        return today + days;
    }
}

class ReferenceBook extends Item { // in the catalogue, never lent: not a LendableItem at all
    ReferenceBook(String title) { super(title); }
}

public class Main {
    // The same test runs against every lendable item: that is how substitution is checked.
    static boolean contractHolds(LendableItem item) {
        int due = item.borrow(10, 21); // the longest loan the contract allows must be accepted
        if (due != 31) return false;
        try { item.borrow(11, 7); } catch (NotAvailable e) { /* allowed */ }
        return true;
    }

    public static void main(String[] args) {
        List<LendableItem> shelf = List.of(new LendableItem("Dune (paper)", new SingleCopy()), new LendableItem("Dune (e-book)", new UnlimitedCopies()));
        for (LendableItem item : shelf)
            System.out.println(item.title + ": contract " + (contractHolds(item) ? "holds" : "broken") + ", available now: " + (item.policy.available() ? "True" : "False"));
        List<Item> catalogue = new ArrayList<>(shelf);
        catalogue.add(new ReferenceBook("World Atlas"));
        System.out.println("can borrow: " + catalogue.stream().filter(i -> i instanceof LendableItem).map(i -> i.title).collect(Collectors.joining(", ")));
    }
}

Output (the same in all three languages)

Dune (paper): contract holds, available now: False
Dune (e-book): contract holds, available now: True
can borrow: Dune (paper), Dune (e-book)

5. In the interview

6. Remember it

Memory hook: "Keep the parent's promises"A subtype may accept more and guarantee more, never demand more or deliver less.
How this hook works: what “Keep the parent's promises” means

What the slogan means, phrase by phrase

“Keep”
Callers written for the parent must work unchanged.
“the parent's promises”
Preconditions, postconditions, invariants and the exceptions it may throw.

Why this picture fits: The hire-car class: any car in it seats four and takes standard fuel, or the booking system breaks.

The story → the concept

Hire carsThe code
The car classLendableItem
Seats four, standard fuel1–21 days, due = today + days
A two-seater in the classA subclass that refuses long loans
A different class of carReferenceBook: an Item, not a LendableItem

Watch it on an example

  1. A paper copy and an e-book both pass the same contract test.
  2. The atlas is in the catalogue but never offered for loan.
  3. Copy rules differ through a policy object, not overridden promises.

The trap it saves you from

Inheriting because the nouns sound related, then overriding methods to refuse the parent's job.

It still works when…

In interfaces too: every implementation of an interface must keep the interface's contract, not just its method names.

7. Common mistakes

Overrides that throw "not supported"The subclass can't do the parent's job, so it shouldn't be one.
instanceof checks in callersCallers working around a subclass is a sign it doesn't substitute.
Silent changesChanging what a method returns or when it succeeds is a violation even if nothing crashes.
Square extends Rectangle style modelsA real-world "is a" isn't the same as substitutable behaviour.

8. Practice exercise (25 minutes)

  1. Write the contract (5 min). For the vending machine's PaymentMethod, write its four promises in one line each.
  2. Break it on purpose (5 min). Write a subclass that breaks one rule without throwing. Would your tests notice?
  3. Write a contract test (10 min). One test function that takes any PaymentMethod and checks the promises. Run it on two implementations.
  4. Fix with composition (5 min). Move one varying behaviour into an object the class holds instead of an override.

9. Self-check quiz

The parent accepts loans of 1–21 days. A subclass accepts 1–28. Is that allowed?

Preconditions may be weakened; callers who pass 1–21 still work.

A subclass returns a later due date than the parent promises, without any error. Which rule is broken?

It guarantees less than the parent; callers get wrong answers silently.

ReferenceBook can't be lent. What's the best model?

Split the hierarchy by capability so no subclass has to refuse its parent's job.

How do you catch violations that never throw?

One suite written against the parent, run on each subtype.

10. Go deeper

ResourceTimeWhy
Module 03d: Interface segregation in depthNextSmall interfaces make substitution easier to keep.
Module 02: OOP essentialsReviewIs-a vs has-a, the start of the composition fix.
P09: Library management45 minThe full problem this module's example comes from.
A behavioral notion of subtyping (Liskov and Wing, 1994)PaperThe formal version of the four rules.