Introduction to the Liskov Substitution Principle
The Liskov Substitution Principle (LSP) works because calling code is written against the static type of a reference, not the runtime object: when you hold a Car reference and call refuel(), the compiler accepts it because Car declares refuel(), but at runtime Java dispatches to the actual object's method via the vtable. If the subclass's override silently narrows that contract — throws where the parent succeeded, or returns a result the caller never expected — the substitution that the type system promised was safe blows up at runtime. LSP is the rule that keeps that promise honest: a subtype must accept everything the supertype accepts and deliver everything the supertype guarantees, so that no caller written against the supertype can tell the difference.
The broken hierarchy, traced
Here is the classic violation. Car exposes a three-method contract; ElectricCar inherits it but cannot honour refuel(), so it throws.
public class Car {
public void start() { System.out.println("Car is starting"); }
public void stop() { System.out.println("Car is stopping"); }
public void refuel() { System.out.println("Car is refueling"); }
}
public class ElectricCar extends Car {
@Override
public void refuel() {
// Electric cars have no fuel tank — the parent's contract is impossible to keep.
throw new UnsupportedOperationException("Electric cars don't need refueling");
}
public void recharge() { System.out.println("Car is recharging"); }
}Now a perfectly ordinary client — a fleet routine that tops up every car before a trip — is written against the base type Car. It has no idea electric cars exist; it just trusts the contract.
public class Main {
// Caller depends ONLY on the Car contract. This is the substitution point.
static void prepareForTrip(Car car) {
car.start();
car.refuel(); // contract says: this returns normally
car.stop();
}
public static void main(String[] args) {
Car gasCar = new Car(); // a plain Car
Car electricCar = new ElectricCar(); // an ElectricCar, typed as Car
prepareForTrip(gasCar); // OK
prepareForTrip(electricCar); // BOOM at the refuel() line
}
}Walk the second call through the runtime, line by line. The reference type is Car, so each call compiles; the object type is ElectricCar, so each call dispatches to ElectricCar's version.
| Step | Statement (inside prepareForTrip) | Static type seen by compiler | Method actually run (dynamic dispatch) | Result |
|---|---|---|---|---|
| 1 | car.start() | Car | Car.start() (inherited) | prints "Car is starting" — fine |
| 2 | car.refuel() | Car → compiles, looks safe | ElectricCar.refuel() (override) | throws UnsupportedOperationException |
| 3 | car.stop() | Car | — | never reached; stack unwinds, exception propagates out of main |
The program crashes at step 2. Crucially, prepareForTrip did nothing wrong — it used only methods the Car type promised would work. The defect is that ElectricCar is-a Car in the type system but is not a Car in behaviour. That gap between "compiles" and "works" is exactly what LSP forbids.
Why the naive "just throw" version is wrong
Throwing from refuel() feels defensive — "I'm honestly signalling that this isn't supported." But it breaks LSP in a way the compiler can't catch. LSP, as formalised by Barbara Liskov and Jeannette Wing, requires a subtype to obey three behavioural rules relative to its supertype:
- Preconditions may not be strengthened. The subtype can't demand more than the parent to accept a call.
- Postconditions may not be weakened. The subtype must deliver at least what the parent promised. Returning normally is a postcondition of
Car.refuel(); throwing instead weakens it. - Invariants and the exception contract must be preserved. A subtype must not throw new checked/unchecked exceptions that the supertype's clients weren't told to handle.
ElectricCar.refuel() violates the second and third: it weakens the postcondition (no longer returns normally) and introduces an exception the Car contract never advertised. The real problem is upstream — refuel() was modelled as a behaviour of all cars when it is really a behaviour of fuel-powered cars. The fix (next lesson) is to break the hierarchy: pull the energy-replenishment behaviour out of Car entirely, e.g. give Car a start()/stop() core and model refuelling/recharging through a separate Refuelable / Rechargeable capability or by composing an EnergySource.
LSP, formally: the variance rules the compiler already enforces
The three prose rules above are the behavioural contract. Part of that contract is structural — the Java compiler enforces it for you on every override, and knowing exactly which part is checked (and which is not) is a common senior follow-up.
- Return types are covariant. An override may return the same type or a narrower (sub) type. This is precisely a postcondition that may only be strengthened, never weakened — safe, because any caller expecting the wider type still gets something it can use.
Number getValue()may be overridden byInteger getValue(); the reverse (widening the return toObject) does not compile. - Parameter types are invariant. Java does not allow contravariant (wider) parameters on an override. Writing a method with a wider parameter type does not override the parent method — it creates an overload, a separate method the dynamic dispatch never selects through a supertype reference. (This is why forgetting
@Overrideon an intended override with a slightly-wrong signature silently compiles as a new, never-called method.) - Thrown checked exceptions may only narrow. An override may throw the same checked exceptions, subtypes of them, or fewer — never a broader or brand-new checked exception. This is the exception contract of rule three, enforced at compile time: a caller written against the supertype only catches what the supertype declared.
Beyond types, the Liskov–Wing formulation adds one rule the compiler cannot check — the history constraint: a subtype may not permit a state change that the supertype's contract forbids. Two objects can have byte-for-byte identical method signatures and still violate substitutability through allowed mutations over time. This is exactly why an immutable or fixed-size List is not substitutable for a mutable one: every signature matches, but calling add() on Arrays.asList(...) throws UnsupportedOperationException — the fixed-size subtype forbids a state change (growth) that the mutable List contract permits, breaking the history constraint (and tying directly back to the Arrays.asList pitfall below).
Pitfalls
- The "throw
UnsupportedOperationException" trap. The JDK itself does this (Arrays.asList(...).add(), immutable collections), and it bites people constantly precisely because it is an LSP violation baked into the standard library — a fixed-sizeListis not fully substitutable for a mutable one. Treat any override whose body is "throw" as a design smell, not a solution. - Empty / no-op overrides. Overriding to do nothing (e.g. a
SquarewhosesetWidthalso mutates height) is just as much a violation as throwing — it weakens the postcondition silently, so callers get wrong results instead of a loud crash. Silent LSP breaks are worse than loud ones. - The Rectangle/Square illusion. "A square is-a rectangle" is true in geometry and false in code: a setter-bearing
Rectanglelets callers set width and height independently, an invariant aSquarecannot keep. is-a in English is not is-substitutable-for in a type system. - Type-checking to dodge the bug. Patching callers with
if (car instanceof ElectricCar) skip refuel()doesn't fix LSP — it confirms the violation and spreads knowledge of subtypes into every caller, which also breaks Open/Closed. - Covariant arrays in Java.
Object[] a = new String[2]; a[0] = 42;compiles but throwsArrayStoreExceptionat runtime — the language's own arrays are not LSP-safe, which is why generics are invariant.
How a senior engineer decides: inheritance vs the alternatives
LSP is the litmus test for whether extends is the right tool at all. The decision signal is one question: can every method the parent promises be honoured by the child for every input the parent accepts? If yes, inheritance is safe and cheap. If even one method can't (our refuel()), you've hit a fork.
- Inheritance (subclass overrides). Choose when the child is a true behavioural specialisation — it does everything the parent does, possibly more, never less. Gain: zero boilerplate, polymorphism for free. Cost: rigid coupling to the parent's whole surface; one un-honourable method poisons substitutability everywhere.
- Composition + capability interfaces (e.g.
Carhas-aEnergySource;Refuelable/Rechargeableas small interfaces). Choose when subtypes share some behaviour but diverge on others. Gain: each type implements only what it can honour, so LSP holds by construction; aligns with Interface Segregation. Cost: more classes/interfaces and explicit wiring (indirection) — you trade a few extra types for the guarantee that no caller crashes. - Restructure the hierarchy (extract a narrower base). Choose when the parent simply has too many responsibilities — split
Carso the common base only declares what all cars truly share (start/stop). Gain: clean is-a relationships. Cost: the base becomes thinner and you may need a second axis (energy type) modelled separately.
Choose inheritance when the subtype passes the substitution test on every method; prefer composition with capability interfaces when any inherited method would have to throw, no-op, or weaken its result to compile. Concretely for our case: because ElectricCar cannot honour refuel(), drop refuel() from Car, keep Car as the start/stop base, and let only fuel cars implement a Refuelable interface — composition wins here.
Takeaways
- LSP guards the gap between "compiles" and "behaves": a subtype must be usable through a supertype reference with no surprise — no new exceptions, no weakened results, no strengthened demands.
- An override whose body is
throw, a no-op, or a silent result change is the textbook signature of a violation; the JDK's ownUnsupportedOperationExceptionoverrides are cautionary, not exemplary. - English "is-a" (ElectricCar is a Car, Square is a Rectangle) does not imply "is-substitutable-for" — verify behaviour, not vocabulary, before reaching for
extends. - When even one method can't be honoured, stop subclassing: extract capability interfaces and compose, so each type implements only the contracts it can actually keep.
Re-authored and deepened for this guide. Sources: Barbara Liskov & Jeannette Wing, "A Behavioral Notion of Subtyping" (ACM TOPLAS, 1994); Robert C. Martin, Agile Software Development: Principles, Patterns, and Practices (the canonical Rectangle/Square treatment of LSP); the original Car/ElectricCar example carried over from this lesson; and the Java Language Specification on dynamic dispatch, covariant arrays, and ArrayStoreException. Worked trace, mechanism, hierarchy diagram, pitfalls, and the inheritance-vs-composition trade-off section added to meet the depth bar.
🤖 Don't fully get this? Learn it with Claude
Stuck on Introduction to the Liskov Substitution Principle? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Introduction to the Liskov Substitution Principle** (OO & Low-Level Design) and want to truly understand it. Explain Introduction to the Liskov Substitution Principle from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Introduction to the Liskov Substitution Principle** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Introduction to the Liskov Substitution Principle** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Introduction to the Liskov Substitution Principle** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.