01What does open for extension, closed for change mean?
Add new behaviour by adding a class, not by editing a class that already works.
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.
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() to Square:setWidth(5)
- stretch() to Square:setHeight(4)
- stretch() to Square:area()
- Square to stretch():16, not 20
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)printsUPI refunded Rs 400, thenOrder cancelled.cancelOrder(new CashOnDelivery(), 400)crashes before the order is cancelled. Java throwsUnsupportedOperationException, PythonNotImplementedError, TypeScript anError.
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
| Promise | Broken when the child | Example |
|---|---|---|
| Accept what the parent accepts | rejects input the parent takes | volume 0 to 100, child only to 50 |
| Return what the parent returns | breaks the parent's result | Square.setWidth also sets height |
| No new surprises | throws where the parent does not | CashOnDelivery.refund throws |
- Accept. A music app's
Speakertakes any volume from 0 to 100. ACarSpeakerchild that throws above 50 breaks a caller that sets 80. - Return.
Rectangle.setWidthpromises that only the width changes.Squarechanges the height too, sostretchgets 16. - No surprises.
PaymentMethod.refundnever said it might fail.CashOnDeliverythrows anyway, and the caller has nocatchfor 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:
┌───────────────────────────────────────────────────┐ │ │ │ ┌───────────────────────┐ │ │ │ <<interface>> │ │ │ │ PaymentMethod │ │ │ ├───────────────────────┤ │ │ │ + pay(amount) │ │ │ └───────────────────────┘ │ │ △ △ │ │ ╎ │ │ │ ┌─────────┴───────┐ ┌───────┴───────────────┐ │ │ │ CashOnDelivery │ │ <<interface>> │ │ │ └─────────────────┘ │ RefundablePayment │ │ │ ├───────────────────────┤ │ │ │ + refund(amount) │ │ │ └───────────────────────┘ │ │ △ │ │ ╎ │ │ ┌─────────┴─────────────┐ │ │ │ Upi │ │ │ └───────────────────────┘ │ │ ──▷ extends ╌╌▷ implements │ │ │ └───────────────────────────────────────────────────┘
PaymentMethodpromises only what every method can do: pay.RefundablePaymentextends it withrefund, for methods that can really refund.UpiimplementsRefundablePayment.CashOnDeliveryimplements onlyPaymentMethod.cancelOrderasks for aRefundablePayment, 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
PaymentMethodcan hold aUpiand aCashOnDelivery. cancelOrder(new CashOnDelivery(), 400)now fails to compile in Java and TypeScript, becauseCashOnDeliveryhas norefund.- 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 page to cancelOrder:cancel(order)
- cancelOrder to Upi:refund(400)
- Upi to cancelOrder:refunded
- cancelOrder to Orders page:cancelled
- The orders page asks
cancelOrderto cancel a UPI order. cancelOrdercallsrefundwithout checking the class. EveryRefundablePaymentkeeps that promise.- 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:
instanceofin Java,isinstancein 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 substitution | Interface segregation | |
|---|---|---|
| Looks at | a child class | an interface |
| The smell | a child breaks a promise | a class fakes a method |
| Typical fix | redesign the hierarchy | split the interface |
The two meet often. CashOnDelivery broke a promise because PaymentMethod asked for a method cash cannot do.
- 01
Code that works with a parent must keep working with any child, unchanged.
- 02
A child keeps three promises: accept what the parent accepts, return what it returns, add no new surprises.
- 03
A child that throws "not supported", or a caller that checks instanceof, signals a broken promise.
- 04
Fix the family tree, not the caller: each parent promises only what all its children can do.
Read more
- Liskov substitution principle on Wikipedia, with its history and the formal rules
- A behavioral notion of subtyping by Barbara Liskov and Jeannette Wing, 1994, the original paper
- List in the Java 21 docs: see "Unmodifiable Lists"
- The ReadonlyArray type in the TypeScript handbook
Next: interface segregation. Small, focused interfaces like PaymentMethod and RefundablePayment, so no class has to fake a method it cannot do.