Cruxly › Low-level design › Principles & Patterns
Liskov Substitution
a subtype must not surprise the caller
Open the interactive lessonThe principle: if S is a subtype of T, objects of type T should be replaceable with objects of type S without altering the correctness of the program.
Not "compiles" — correctness. LSP is about behaviour, and it is the SOLID principle most often broken by code that type-checks perfectly.
Where it comes from. This one is Barbara Liskov's, from her 1987 OOPSLA keynote "Data Abstraction and Hierarchy". She and Jeannette Wing stated it formally in 1994 (A Behavioral Notion of Subtyping): if some property can be proven about objects of type T, it must also hold for objects of any subtype S. Martin's contribution was putting it into the SOLID set as a design rule for everyday inheritance.
The key word in the 1994 version is property. A subtype that keeps every method signature but changes what a caller can rely on — a result that used to be non-null, an operation that used to succeed, a value that used to stay independent of another — breaks LSP even though nothing fails to compile.
The canonical violation — Square extends 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 {
@Override void setWidth(int w) { width = height = w; } // must keep width==height
@Override void setHeight(int h) { width = height = h; }
}
This compiles. It is a real is-a relationship geometrically. And it breaks any code written against Rectangle's contract:
void resize(Rectangle r) {
r.setWidth(5);
r.setHeight(10);
assert r.area() == 50; // FAILS for a Square: area() is 100
}
resize is correct for every Rectangle and wrong for the Square subtype — which is precisely what LSP forbids. The bug is not in resize; it is in modelling Square as a Rectangle at all, because Rectangle's implicit contract ("width and height vary independently") is not one Square can honour.
The fix — model the actual relationship
interface Shape { int area(); }
class Rectangle implements Shape {
private final int width, height;
Rectangle(int w, int h) { width = w; height = h; }
public int area() { return width * height; }
}
class Square implements Shape {
private final int side;
Square(int side) { this.side = side; }
public int area() { return side * side; }
}
Neither is a subtype of the other. Both satisfy Shape's actual contract — "has an area" — and neither promises independent width/height mutation that the other cannot honour. Immutability sidesteps the entire problem class: if a shape cannot be resized after construction, there is no setWidth to violate.
The version of this bug that actually appears in interviews
It rarely shows up as literal squares and rectangles. It shows up as:
class ReadOnlyList<T> extends ArrayList<T> {
@Override public boolean add(T item) {
throw new UnsupportedOperationException(); // "read-only," so... throw?
}
}
Any code that holds an ArrayList<T> and calls .add() — which ArrayList fully supports — now crashes at runtime for this specific subtype, with no compiler warning anywhere. (The List interface itself marks add as an optional operation that may throw, as List.of lists do; the broken promise here is ArrayList's, which never throws.) This is the pattern to watch for: a subtype that throws, silently no-ops, or narrows the precondition of a method its parent promises to support. The fix is usually the same as the Square/Rectangle fix — model "read-only" and "mutable" as separate interfaces (ReadableList / MutableList) rather than one extending the other.
Where the contract actually breaks
The compiler sees a valid override. The contract sees a promise being withdrawn. Those are different things, and the picture makes the gap visible.
The four rules a subtype must not break
"Don't surprise the caller" is the intuition. These are the four specific ways subtypes break it — worth knowing by name, because naming the exact rule being violated is much stronger than "that feels wrong."
@@ 1 | Preconditions may be weakened, never strengthened
If the parent accepts any non-null string, the child must not demand a non-empty one. A caller holding the parent type has no way to know the extra restriction exists.
class Parent { void send(String msg) { ... } } // any non-null
class Child extends Parent {
@Override void send(String msg) {
if (msg.isEmpty()) throw new IllegalArgumentException(); // stricter!
super.send(msg);
}
}
@@ 2 | Postconditions may be strengthened, never weakened
If the parent guarantees the returned list is sorted, the child must also return it sorted. Promising more is fine; promising less is a broken contract.
@@ 3 | Invariants of the parent must be preserved
Rectangle's unstated invariant is that width and height move independently. Square preserves a different invariant (width equals height) at the cost of the parent's — which is exactly the violation above.
@@ 4 | No new exceptions the parent did not declare
ReadOnlyList.add() throwing UnsupportedOperationException is this rule breaking. Callers wrote list.add(x) against ArrayList, whose contract says that call succeeds.
:::key The one-sentence test Ask: could I replace every instance of the parent with this child, in code I have never read, without anything breaking? If answering requires knowing which concrete type is in play, the substitution is not safe — and needing to know the concrete type defeats the entire point of programming to the parent. :::
How to fix it, and the case for composition
When a subtype cannot honour the parent's contract, there are three honest moves and one dishonest one.
1. Make both siblings under a narrower interface. The Shape fix above. Correct when neither type is really a specialisation of the other — they just share a capability.
2. Remove the mutation that caused the conflict. Square is only a problem because Rectangle is mutable. Immutable value types cannot violate LSP through setters, because there are none. This is the reason so much modern design defaults to immutability, and it is worth saying as the general fix rather than a trick specific to shapes.
3. Use composition instead of inheritance. If Square genuinely needs rectangle behaviour internally, let it hold one rather than be one:
class Square {
private final Rectangle inner; // has-a, not is-a
Square(int side) { inner = new Rectangle(side, side); }
int area() { return inner.area(); } // expose only what makes sense
}
Now Square cannot be passed where a Rectangle is expected, which is exactly right — that substitution was never safe. Composition lets you reuse the implementation without inheriting a contract you cannot keep.
The dishonest move is a type check inside the parent's method (if (this instanceof Square) ...). That is the violation, rewritten so it is harder to find.
:::warn Why this shows up so often in real codebases Inheritance is the first reuse mechanism most people learn, so it gets reached for whenever two things look similar. But "similar" is not the test — "substitutable in every context the parent is valid in" is. The gap between those two is where most LSP violations live, and it widens as the parent gains methods over time: a subtype that was substitutable in 2019 can quietly stop being so when someone adds a method to the base class. :::
How to say it in the interview
"The classic tell is a subtype that throws or does nothing on a method its parent promises to actually perform — the type system says the substitution is fine, and the behaviour says it very much is not."
Checklist
- Checked substitutability by behaviour, not by whether the code compiles
- Identified the base type's implicit contract before deciding a subtype honours it
- Named which of the four rules is broken, rather than saying it feels wrong
- Recognised 'subtype throws/no-ops on an inherited method' as the practical LSP smell
- Modelled the two variants as separate types rather than one extending the other
- Offered immutability as the general fix, not a trick specific to shapes
- Used composition to reuse the implementation without inheriting the contract
- Rejected an instanceof check inside the parent as a fix