Give me a subclass that the compiler accepts but that still breaks the Liskov substitution principle. What rule does it break?
Substitutability is a claim about behaviour, and signatures carry none of it. A subclass that rejects an argument its supertype accepted has strengthened a precondition, which the compiler cannot see: the code type-checks, and it breaks callers that were written correctly against the base type.
What the interviewer is scoring
- Whether the direction of the rules is stated correctly, that preconditions may only weaken and postconditions only strengthen
- Does the example break behaviour rather than merely change a signature or a return type
- That they can say why the compiler cannot detect it, given what a type declaration actually asserts
- Whether they can point to a real library where the supertype was widened to legalise the behaviour
- Can they describe a test arrangement that would have caught the violation before release
Answer
The compiler checks shape, not promises
When you declare that a class extends another, the compiler verifies a small set of structural facts: the overriding method exists, its parameter types match, its return type is the same or narrower, its access is not more restrictive, and it declares no new checked exceptions. That is the entire assurance. Nothing in the type system records what the supertype's method promised to do, which values it accepted, or what had to be true afterwards, so nothing can check that the subtype still honours any of it.
Liskov substitution is the missing half. It says code written against the supertype must keep working when handed any subtype, without knowing which one it has, and it decomposes into four rules that can be checked by reading:
- Preconditions may be weakened, never strengthened. A subtype must accept every input the supertype accepted, and may accept more.
- Postconditions may be strengthened, never weakened. It must guarantee everything the supertype guaranteed, and may guarantee more.
- Invariants of the supertype must be preserved, including ones the supertype never wrote down.
- No new exception types, and nothing thrown where the supertype promised a result.
The direction of the first two is the part candidates reverse under pressure, and getting it right is most of the signal in this question.
A subclass that type-checks and lies
class Account {
/** Withdraws any amount up to the balance. */
void withdraw(BigDecimal amount) { ... }
}
class RestrictedAccount extends Account {
private static final BigDecimal CAP = new BigDecimal("500");
@Override
void withdraw(BigDecimal amount) {
// Rejects inputs the supertype accepted: the precondition has been
// strengthened, and no compiler will tell you.
if (amount.compareTo(CAP) > 0) throw new IllegalArgumentException("over cap");
super.withdraw(amount);
}
}
This compiles cleanly. IllegalArgumentException is unchecked, so the exception rule is not enforced either. And it is entirely reasonable-looking code that somebody will write in a sprint, because the domain genuinely has restricted accounts.
The damage lands on a caller that never mentions RestrictedAccount. A batch payment routine holding an Account reference, correct against the documented contract, now fails on some accounts and not others, for a reason that appears nowhere in its own source. The subclass has narrowed the set of inputs the type accepts, so the base type no longer describes what you can do with a value of that type. That is the practical meaning of a substitution violation: the abstraction has stopped being usable without knowing the concrete class, and every caller must now do exactly what polymorphism was supposed to remove.
The other classic breaks the invariant rule rather than the precondition rule. A Square extending a mutable Rectangle must keep width and height equal, so setting the width has to change the height, and any caller that sets both dimensions and then asserts the area is now wrong. The invariant the subclass added is incompatible with a postcondition the supertype implied but never stated.
What the JDK did instead
The interesting comparison is a library that faced the same problem and chose differently. Collections.unmodifiableList and List.of return objects that throw UnsupportedOperationException from add, which looks exactly like the violation above. The difference is that List documents mutation as an optional operation, so the throw is written into the supertype's contract rather than smuggled in by the subtype. Every caller of List.add is on notice that it may throw, and no substitution has been broken because the base promise was never as strong as it appeared.
That is the legitimate escape route, and it has a real price: the supertype's contract is now weaker for everybody, so all callers must handle a case that most implementations never produce. Widening a base contract to accommodate one subtype is a decision to make every consumer's code more defensive, and it is only worth it when the weaker contract genuinely describes the abstraction. Where it does not, the right answer is that the inheritance relationship is wrong — RestrictedAccount is not an Account in the sense callers rely on, and the cap belongs in a policy the caller consults before deciding, or in a separate type that does not claim substitutability.
Java's own arrays show that even the language ships a hole here. Arrays are covariant, so a String[] can be assigned to an Object[], and storing an Integer through that reference compiles and then throws ArrayStoreException at runtime. Generics were deliberately made invariant to avoid repeating it, which is why List<String> is not a List<Object>, and why wildcards exist to recover the flexibility safely.
Catching it before a caller does
Because the violation is behavioural, only behavioural checks find it. The arrangement that works is a test suite written entirely against the supertype, parameterised over every implementation, asserting the contract and nothing else: that any input the base accepts is accepted, that the documented postconditions hold, that the invariants survive a sequence of operations rather than a single call. When someone adds an implementation that narrows the contract, an existing test fails against the new class, and the failure names the rule rather than the symptom.
Two habits reduce how often you need it. Prefer composition when the relationship is "uses" or "is constrained like", not "is a": the restricted account holds an account and applies a policy, and no caller is misled. And when you do use inheritance, write the contract down in the supertype — what it accepts, what it guarantees, what may throw — because a rule that was never stated cannot be broken deliberately and will therefore be broken accidentally.
Substitutability is a promise about behaviour, and the compiler checks none of it: an override may only widen what it accepts and only strengthen what it guarantees. When a subtype needs to accept less, either the base contract was wrong, or the inheritance was.
Likely follow-ups
- How do you choose between widening the supertype's contract and abandoning the inheritance relationship?
- Why does adding a field and overriding equals break symmetry, and what are the two ways out of it?
- Java's arrays are covariant. Where does that surface at runtime, and what did generics do differently?
- What would a contract test suite for an interface with optional operations have to assert?
Related questions
- How do you design the error contract for an API that other teams have to program against?hardAlso on api-design and contracts6 min
- Where does encapsulation or polymorphism change a design, and how do you decide between composition and inheritance?mediumAlso on polymorphism and inheritance6 min
- What are the four pillars of OOP, and what is the difference between abstraction and encapsulation?easyAlso on polymorphism and inheritance4 min
- This interface has twelve methods and every implementation throws UnsupportedOperationException for four of them. What went wrong, and how would you fix it?mediumAlso on liskov-substitution5 min
- The same domain has to be exposed to a mobile app, a partner integration and internal service-to-service traffic. Where does GraphQL fit, where does gRPC, and where does neither?hardAlso on api-design7 min
- Clients are asking for page 4,000 of your /orders collection. How is that endpoint paginated, and what would you change?mediumAlso on api-design4 min
- When would you use the strategy pattern instead of inheritance?mediumAlso on inheritance5 min
- Service A needs something from service B. When should that be a synchronous call and when should it be an event?mediumAlso on api-design3 min