Start Learning
Javaneer
Back to stage
Stage 0·SOLID in Practice

Liskov Substitution

Subtypes must be usable through the base type without surprises. The classic square/rectangle trap, behavioral subtyping, and why 'is-a' isn't enough.

14 min readAdvanced
On this page

The Liskov Substitution Principle is the one that makes polymorphism safe. Named after Barbara Liskov, it says: objects of a subtype must be usable anywhere the base type is expected, without breaking the program's correctness. If code works with a FeePolicy, it must work with every FeePolicy - no exceptions, no "but this one throws if you call it wrong." Violate LSP and Open/Closed collapses, because you can no longer trust the abstraction.

Substitutability, not just "is-a"

Inheritance in Java gives you "is-a": a Square is a Rectangle, a PenguinAccount is an Account. But LSP demands more than the type checker's approval - it demands behavioral compatibility. The subtype must honor the contract the base type promised: same or weaker preconditions, same or stronger postconditions, no surprising exceptions, no strengthened requirements.

The famous counterexample is the square/rectangle:

class Rectangle {
    protected int width, height;
    void setWidth(int w)  { this.width = w; }
    void setHeight(int h) { this.height = h; }
    int area() { return width * height; }
}
class Square extends Rectangle {          // a square IS-A rectangle, right?
    void setWidth(int w)  { this.width = w; this.height = w; }   // keep it square
    void setHeight(int h) { this.width = h; this.height = h; }
}

Now this perfectly reasonable code, written against Rectangle, breaks:

void resizeAndCheck(Rectangle r) {
    r.setWidth(5);
    r.setHeight(4);
    assert r.area() == 20;   // holds for Rectangle... FAILS for Square (area == 16)
}

Square passes the compiler but violates the behavioral contract: Rectangle promised width and height are independent, and Square broke that promise. Any code relying on the base type's contract is now unsafe. "Is-a" was true; substitutable was not.

Tells of an LSP violation

You've probably smelled these without naming them:

  • A subtype throwing on an inherited method - UnsupportedOperationException in an override (the classic List.of(...) immutable list that throws on add).
  • instanceof checks before using a subtype - code that must ask "which subclass is this?" is admitting the subtypes aren't truly substitutable.
  • Overrides that do nothing or weaken behavior - an override that silently ignores a call the base type honored.
  • Strengthened preconditions - a subtype that rejects inputs the base type accepted.

In ledger-legacy, a ReadOnlyAccount extends Account that throws on deposit() is an LSP landmine: any method taking an Account and calling deposit will blow up on that one subtype.

Fixing it: model the real relationship

The fix is rarely "make the subtype behave" - it's usually that inheritance was the wrong tool. A square and a rectangle are both shapes with an area(), but neither is a safe subtype of the other under mutation. Prefer:

  • Composition over inheritance - Square has dimensions, it isn't a mutable Rectangle.
  • A common abstraction - both implement a Shape interface with area(), neither extends the other.
  • Immutability - if Rectangle were immutable (no setters), Square could safely extend it, because the broken promise was about mutation.

LSP violations poison Open/Closed

The whole point of OCP was that FeeCalculator can trust any FeePolicy. If one FeePolicy secretly throws or returns nonsense for certain inputs, the calculator must special-case it - and you're back to modifying working code. LSP is what lets the other principles' abstractions actually be trusted. A leaky subtype forces instanceof checks that unravel the whole design.

A job description and the temp who can't do it

A base type is a job description: 'this role answers phones and processes refunds.' Substitutability means any person you send fills that role without the caller noticing who showed up. LSP is violated when the temp agency sends someone who answers phones but throws a tantrum at refunds - technically 'an employee,' but now every manager must check who they got before assigning work (instanceof), and code written to the job description breaks. The fix isn't to force the temp to fake it; it's to realize this person was hired for a different job - model them under the role they can actually fulfill.

Spot and fix the violation

ledger-legacy has class SavingsAccount extends Account where the base Account.withdraw(amount) promises to reduce the balance, but SavingsAccount.withdraw throws IllegalStateException if more than 3 withdrawals were made this month. A reporting job iterates all Accounts calling withdraw during reconciliation and now crashes on savings accounts. Explain the LSP violation and propose a fix.

What does the Liskov Substitution Principle require of a subtype?

Key takeaways

  • LSP: a subtype must be substitutable for its base type without breaking the base type's behavioral contract - more than the compiler's 'is-a'.
  • The subtype must not strengthen preconditions, weaken postconditions, or throw surprising exceptions on inherited methods.
  • Tells: an override that throws UnsupportedOperationException, instanceof checks before use, or do-nothing/weakening overrides (the square/rectangle trap).
  • LSP violations poison Open/Closed - abstractions become untrustworthy and callers must special-case subtypes, unraveling the design.
  • The fix is usually that inheritance was wrong: prefer composition, a shared interface, or immutability so the real relationship is modeled honestly.
Was this lesson helpful?
Edit this page on GitHub