Liskov substitution

A child class must keep every promise of its parent. The square that is not a rectangle, and how to fix it.

Pattern: SOLID20 min read
Quick recall3 questions
  1. 01What does open for extension, closed for change mean?

    Add new behaviour by adding a class, not by editing a class that already works.

  2. 02An Invoice class computes totals, saves to a database and sends email. How many reasons to change does it have?

    Three: finance, the platform team and marketing. Split it into three classes.

  3. 03What does a child class get from its parent?

    Its fields and methods. It can override a method to change how that method works.

In the last lesson, Checkout worked with any Discount. That only holds if every Discount behaves the way Checkout expects.

A child class can look right and still behave differently. Code that trusts the parent then breaks, far from the child. This lesson shows how to spot that child and how to fix the family.

The problem

A square that is not a rectangle

In maths, every square is a rectangle. So Square extends Rectangle looks correct. stretch is code written for rectangles:

class Rectangle {
    protected int width, height;
    void setWidth(int w) { width = w; }
    void setHeight(int h) { height = h; }
    int area() { return width * height; }
}

class Square extends Rectangle {
    @Override void setWidth(int w) { width = w; height = w; }
    @Override void setHeight(int h) { width = h; height = h; }
}

int stretch(Rectangle r) {
    r.setWidth(5);
    r.setHeight(4);
    return r.area(); // the caller expects 5 x 4 = 20
}

stretch(new Rectangle()) returns 20. stretch(new Square()) returns 16, with no error.

stretch()written for Rectangle
Squarepassed as a Rectangle
  1. stretch() to Square:
    setWidth(5)
  2. stretch() to Square:
    setHeight(4)
  3. stretch() to Square:
    area()
  4. Square to stretch():
    16, not 20
Read it top to bottom. setHeight(4) also changed the width, so the area is 4 x 4.

The compiler is happy and no test of Square fails. The bug shows up in stretch, which never mentions squares.

Cash that cannot be refunded

The same bug in a food delivery app. Every payment method can pay and refund, and cancelOrder refunds the customer:

interface PaymentMethod {
    void pay(int amount);
    void refund(int amount);
}

class Upi implements PaymentMethod {
    public void pay(int amount) { System.out.println("UPI paid Rs " + amount); }
    public void refund(int amount) { System.out.println("UPI refunded Rs " + amount); }
}

class CashOnDelivery implements PaymentMethod {
    public void pay(int amount) { System.out.println("Cash collected Rs " + amount); }
    public void refund(int amount) {
        throw new UnsupportedOperationException("no online refund for cash");
    }
}

void cancelOrder(PaymentMethod method, int amount) {
    method.refund(amount);
    System.out.println("Order cancelled");
}
  • cancelOrder(new Upi(), 400) prints UPI refunded Rs 400, then Order cancelled.
  • cancelOrder(new CashOnDelivery(), 400) crashes before the order is cancelled. Java throws UnsupportedOperationException, Python NotImplementedError, TypeScript an Error.

cancelOrder trusted the interface: every PaymentMethod can refund. One child broke that promise.

The idea

Code that works with a parent must keep working, unchanged, when you pass it any child.

Barbara Liskov stated this rule in a 1987 talk, so it carries her name. "Is a" in real life is not enough. The child must also behave like the parent, for every caller.

Three promises a child must keep

PromiseBroken when the childExample
Accept what the parent acceptsrejects input the parent takesvolume 0 to 100, child only to 50
Return what the parent returnsbreaks the parent's resultSquare.setWidth also sets height
No new surprisesthrows where the parent does notCashOnDelivery.refund throws
  1. Accept. A music app's Speaker takes any volume from 0 to 100. A CarSpeaker child that throws above 50 breaks a caller that sets 80.
  2. Return. Rectangle.setWidth promises that only the width changes. Square changes the height too, so stretch gets 16.
  3. No surprises. PaymentMethod.refund never said it might fail. CashOnDelivery throws anyway, and the caller has no catch for it.

The fix is in the family tree

You do not patch the caller. You redesign the hierarchy, so each parent promises only what all its children can do:

Only methods that can really refund sit under RefundablePayment. Cash implements the smaller interface.
  • PaymentMethod promises only what every method can do: pay.
  • RefundablePayment extends it with refund, for methods that can really refund.
  • Upi implements RefundablePayment. CashOnDelivery implements only PaymentMethod.
  • cancelOrder asks for a RefundablePayment, so cash can never reach it.

The fix

Shapes: share only what is true for both

A square and a rectangle both have an area. They do not resize the same way. So the shared parent offers only area(), and neither class has setters.

interface Shape {
    int area();
}

record Rectangle(int width, int height) implements Shape {
    public int area() { return width * height; }
}

record Square(int side) implements Shape {
    public int area() { return side * side; }
}

Rectangle(5, 4) has area 20 and Square(4) has area 16. Any code that takes a Shape gets exactly what area() promises. To resize, you create a new shape.

Payments: split the promise

interface PaymentMethod {
    void pay(int amount);
}

interface RefundablePayment extends PaymentMethod {
    void refund(int amount);
}

class Upi implements RefundablePayment {
    public void pay(int amount) { System.out.println("UPI paid Rs " + amount); }
    public void refund(int amount) { System.out.println("UPI refunded Rs " + amount); }
}

class CashOnDelivery implements PaymentMethod {
    public void pay(int amount) { System.out.println("Cash collected Rs " + amount); }
}

void cancelOrder(RefundablePayment method, int amount) {
    method.refund(amount);
    System.out.println("Order cancelled");
}
  • Both classes still pay: a list of PaymentMethod can hold a Upi and a CashOnDelivery.
  • cancelOrder(new CashOnDelivery(), 400) now fails to compile in Java and TypeScript, because CashOnDelivery has no refund.
  • In Python, a type checker such as mypy reports the same call as an error before the code runs.

The crash moved from a customer's phone to your editor. Cash orders take their own path: nothing was charged online, so there is nothing to refund online.

How it runs

Orders pagecustomer taps cancel
cancelOrdertakes a RefundablePayment
Upikeeps the refund promise
  1. Orders page to cancelOrder:
    cancel(order)
  2. cancelOrder to Upi:
    refund(400)
  3. Upi to cancelOrder:
    refunded
  4. cancelOrder to Orders page:
    cancelled
Read it top to bottom. Any RefundablePayment can stand where Upi stands.
  1. The orders page asks cancelOrder to cancel a UPI order.
  2. cancelOrder calls refund without checking the class. Every RefundablePayment keeps that promise.
  3. A card class added next month slots in with no change to cancelOrder. That is open-closed, and it only works because of Liskov.

When to use it

Signals in the code

  • A child overrides a method to throw "not supported", or to do nothing.
  • Callers check the type first: instanceof in Java, isinstance in Python, before they call a method.
  • "Is a" holds in real life, but the behaviour differs: a square and a rectangle, a penguin and a bird that flies.
  • The parent's tests fail on the child. Run every test written for the parent with each child. A failure means a broken promise.

When it is too much

Do not split a hierarchy for a difference no caller can see. A child that logs more, or runs faster, keeps every promise. Split only when a caller would get a wrong answer or a crash.

Liskov or interface segregation?

Liskov substitutionInterface segregation
Looks ata child classan interface
The smella child breaks a promisea class fakes a method
Typical fixredesign the hierarchysplit the interface

The two meet often. CashOnDelivery broke a promise because PaymentMethod asked for a method cash cannot do.

Remember4 points
  1. 01

    Code that works with a parent must keep working with any child, unchanged.

  2. 02

    A child keeps three promises: accept what the parent accepts, return what it returns, add no new surprises.

  3. 03

    A child that throws "not supported", or a caller that checks instanceof, signals a broken promise.

  4. 04

    Fix the family tree, not the caller: each parent promises only what all its children can do.

Read more

Next: interface segregation. Small, focused interfaces like PaymentMethod and RefundablePayment, so no class has to fake a method it cannot do.