Liskov Substitution Principle (LSP)

Subtypes must honour their parent's contract. The Square-Rectangle problem and why it isn't pedantry.

Concept Foundational
9 min read
solid lsp inheritance contracts

Summary#

The Liskov Substitution Principle is the L in SOLID and the one most often dismissed as pedantry until the first production incident proves otherwise. Barbara Liskov’s formulation, simplified: if S is a subtype of T, then objects of type T may be replaced with objects of type S without altering the desirable properties of the program.

Said plainly: a subclass must honour its parent’s contract — the promises the parent makes to its callers — not just its method signatures. Java’s compiler enforces the signatures. The compiler cannot enforce the promises. LSP is the discipline that the compiler will not give you.

The principle’s most famous example is the Square-Rectangle problem. A square is-a rectangle in geometry. A Square is-a Rectangle in inheritance. The two statements look identical and are not — and the gap between them is the gap LSP names.

Why it matters#

LSP is the principle that makes inheritance pay rent. Without it, a base class is a liability — every caller has to know which subtype it actually received, what surprising override behaviour to guard against, and which methods to avoid. With it, a caller writes against the base type and never asks the runtime type.

Three concrete reasons interviewers care:

  • Polymorphism stops being useful when LSP breaks. The whole point of “code to the interface” is that the implementation is replaceable. If SortedList cannot be passed where List is expected because its add reorders elements unexpectedly, polymorphism is degraded to “code to whichever subtype you happen to have.”
  • It catches false is-a relationships. Inheritance gets reached for casually. LSP forces the question is the contract honoured? — and the answer reveals when inheritance is being used as a shortcut for code reuse rather than as a real specialisation.
  • It is the predicate that makes OCP work. OCP says “code closes over a strategy interface and accepts any implementation.” That promise is only honest if every implementation honours the contract — which is to say, if LSP holds.

The principle’s value is measured at the bug-report level: how often does a bug describe behaviour that is correct for one subtype but surprising for another that should have been interchangeable? LSP is what shrinks that count to zero.

How it works#

Spotting the violation#

The most reliable detector is the unexpected exception or unexpected side-effect when a subtype is passed where a parent is expected. A subtype that throws UnsupportedOperationException from a method the parent declared, or that silently mutates state the parent does not document mutating, is the smell LSP names.

A worked example. The Square-Rectangle problem, stripped to its essentials:

public class Rectangle {
protected int width;
protected int height;
public void setWidth(int w) { this.width = w; }
public void setHeight(int h) { this.height = h; }
public int getWidth() { return width; }
public int getHeight() { return height; }
public int area() { return width * height; }
}
public class Square extends Rectangle {
@Override
public void setWidth(int w) {
super.setWidth(w);
super.setHeight(w);
}
@Override
public void setHeight(int h) {
super.setWidth(h);
super.setHeight(h);
}
}

This looks fine. A square is a rectangle whose width equals height; the overrides keep that invariant. But now consider a caller that holds a Rectangle reference:

public final class AreaCalculator {
public int growAndArea(Rectangle r) {
r.setWidth(5);
r.setHeight(10);
// The caller expects 5 * 10 = 50.
return r.area();
}
}

If r is a Rectangle, the result is 50. If r is a Square, setHeight(10) also set the width to 10, so the result is 100. The caller wrote against Rectangle’s contract — width and height are independent dimensions — and Square broke that contract while compiling cleanly. The bug shows up in a system that worked before someone added the new subtype, with no compiler warning.

This is the gap between the geometric is-a (a square is a special rectangle) and the behavioural is-a (a square’s API for mutation is incompatible with a rectangle’s). LSP says the second is the only one that matters in code.

The refactor#

There are three honest fixes; pick whichever matches the design intent.

1. Drop the inheritance. If Square and Rectangle are both mutable shapes with conflicting mutation contracts, they are not in an is-a relationship — they are sibling implementations of a higher concept.

public interface Shape {
int area();
}
public final class Rectangle implements Shape {
private final int width;
private final int height;
public Rectangle(int w, int h) { this.width = w; this.height = h; }
@Override public int area() { return width * height; }
}
public final class Square implements Shape {
private final int side;
public Square(int side) { this.side = side; }
@Override public int area() { return side * side; }
}

Both are Shape. Neither claims to be the other. The mutation problem disappears because the values are immutable, and every caller that needs an area gets one without surprise.

2. Make the parent’s invariants explicit and make the subtype honour them. If the rectangle’s contract is “width and height are independent,” document it and refuse to derive any subtype that violates it.

3. Replace inheritance with composition. A Square has-a Rectangle internally, but exposes only side and area() — never setWidth/setHeight. Callers that wanted a square get a square’s API; callers that wanted a rectangle get a rectangle.

The first fix is usually the cleanest and the one to lead with in interviews.

The contract rules LSP formalises#

LSP is more than “subtypes don’t break callers” — it gives precise rules for how a subtype’s contract may legally differ from its parent’s:

  • Preconditions may not be strengthened. If the parent accepts any non-null String, the subtype may not require that the string be non-empty. A caller that wrote against the parent’s looser rule will be broken by the stricter one.
  • Postconditions may not be weakened. If the parent promises to return a list sorted ascending, the subtype may not return an unsorted list. A caller relying on the order will be broken.
  • Invariants must be preserved. If the parent’s invariant is “size never decreases after add(),” a subtype that compacts on add violates LSP.
  • Exceptions thrown by the subtype’s methods must be of types thrown by the parent’s methods, or be unchecked. A subtype that throws a new checked exception breaks every caller.

Said one way, be liberal in what you accept, conservative in what you return. Said another, the subtype’s API must be at least as forgiving on the way in and at least as strong on the way out as the parent’s.

A second, more practical, violation#

java.util.List declares add(E e). Collections.unmodifiableList(...) returns a list whose add throws UnsupportedOperationException. This is a famous LSP violation in the JDK — and the JDK accepts it as a pragmatic trade.

LSP-correct. List and ImmutableList are sibling interfaces. Read operations live on a ReadableList parent; add lives only on the mutable child. The compiler refuses to let you call add on an immutable list.

JDK’s choice. One List interface for everything. Immutable lists throw at runtime. The cost is real LSP violations; the benefit is one type that flows everywhere.

The JDK’s choice is a reminder that LSP is a principle, not a law — it can be traded for ergonomics. The trade is honest only if everyone knows the trade was made.

Variants and trade-offs#

A few useful refinements:

  • Behavioural subtyping is the formal name. Liskov and Wing’s 1994 paper Behavioral Subtyping Using Invariants and Constraints is the rigorous version; the informal LSP is the principle distilled to one sentence.
  • Generics tighten things. List<? extends Number> and List<? super Integer> are Java’s encoding of LSP at the type-parameter level — covariance for reads, contravariance for writes. Wildcards exist because the language wants to help callers stay LSP-correct.
  • Composition sidesteps LSP entirely. A class that does not extend any base class cannot violate LSP for any caller — there is no contract to break. This is one reason “favour composition over inheritance” is the default modern advice.
Inheritance useLSP statusRecommendation
Specialising a fully abstract interface (no shared state)Easy to keep correctDefault to this
Extending a concrete class to add behaviourEasy to violate by accidentCompose instead unless the contract is explicit
Implementation reuse via protected fieldsAlmost always violates LSP eventuallyExtract a helper class; pass it in

When this is asked in interviews#

Three moves carry LSP discussion in an LLD round:

  • Lead with contracts, not signatures. When asked “is this a good use of inheritance?”, do not look at method names — describe the parent’s contract aloud (preconditions, postconditions, invariants) and check the child’s override against each. That framing telegraphs that you know LSP is behavioural, not syntactic.
  • Know the Square-Rectangle story. It is the canonical example and interviewers reach for it. Be able to spot it, explain why it is a violation (not just “it’s a Liskov thing”), and refactor to a Shape interface in two minutes.
  • Defend a real composition fix. Many candidates correctly identify the bug and incorrectly fix it by adding a runtime instanceof check in the caller. That makes the design worse — it pushes the smell to every caller. The fix is to remove the false is-a, not to patch around it.

The principle’s one durable test, after the meeting: can a caller written against the parent type pass any subtype, with no special-casing and no surprise? If not, the inheritance is the wrong tool.

Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.