Objects that hold their shape · chapter 16 of 33
Liskov substitution: keeping a promise a caller relies on
Chapter 2.6 · Part 2, Objects that hold their shape · about 30 minutes
What you need before this chapter: chapters 2.1 through 2.5, plus Optional and exceptions from Part 1.
When you finish this chapter you will be able to:
- Write an interface whose return type states a contract, and a caller that trusts it
- Produce a second implementation that compiles, satisfies
@Override, and crashes the caller anyway - State the principle as a promise about behaviour, not about method signatures
- Recognise the wrong fix, patching the caller instead of the implementation, and say why it fails
1. Working code, and the contract it states
A parking lot needs to know where a vehicle should go, and it needs an answer even when nothing is free. SpotAllocator states that contract directly, in its return type.
import java.util.List;
import java.util.Optional;
public interface SpotAllocator {
/** The spot to occupy, or empty if nothing fits right now. Never throws for a normal miss. */
Optional<String> allocate(String vehicleType, List<String> freeSpots);
}
Optional.empty() is not an error. It is the answer "nothing fits," and FirstFitAllocator returns it the same way it returns a real spot: by handing it back, never by throwing.
public final class FirstFitAllocator implements SpotAllocator {
@Override
public Optional<String> allocate(String vehicleType, List<String> freeSpots) {
return freeSpots.isEmpty() ? Optional.empty() : Optional.of(freeSpots.get(0));
}
}
A caller reads that contract once and writes code that trusts it completely.
public final class ParkingLot {
private final SpotAllocator allocator;
public ParkingLot(SpotAllocator allocator) { this.allocator = allocator; }
public String park(String vehicleType, List<String> freeSpots) {
Optional<String> spot = allocator.allocate(vehicleType, freeSpots);
if (spot.isEmpty()) {
return "declined: no spot for " + vehicleType;
}
return "parked " + vehicleType + " at " + spot.get();
}
}
parked CAR at S1
declined: no spot for CAR
For one allocator, there is only one way to read allocate's return type: as the whole answer, never as a partial one that might also throw.
2. A second implementation, and the crash it hides
A members' lounge reserves a few spots for members only. A second allocator handles it, written under time pressure, reaching for the exception type already sitting nearby for everything else that goes wrong.
public final class MemberOnlyAllocator implements SpotAllocator {
private final List<String> reservedSpots;
public MemberOnlyAllocator(List<String> reservedSpots) { this.reservedSpots = reservedSpots; }
@Override
public Optional<String> allocate(String vehicleType, List<String> freeSpots) {
if (reservedSpots.isEmpty()) {
throw new IllegalStateException("no member spots configured");
}
for (String spot : freeSpots) {
if (reservedSpots.contains(spot)) {
return Optional.of(spot);
}
}
return Optional.empty();
}
}
ParkingLot memberLot = new ParkingLot(new MemberOnlyAllocator(List.of()));
System.out.println(memberLot.park("CAR", List.of("S1", "S2")));
Exception in thread "main" java.lang.IllegalStateException: no member spots configured
at MemberOnlyAllocator.allocate(MemberOnlyAllocator.java:14)
at ParkingLot.park(ParkingLot.java:12)
at Main.main(Main.java:11)
javac accepted @Override without complaint, because the method signature matches exactly. ParkingLot was never edited to know about MemberOnlyAllocator; it just received one through the same constructor FirstFitAllocator used. It crashes the moment it meets a configuration this allocator does not like, and nothing in the type system said that would happen. ParkingLot.park already trusts allocate to return an empty Optional instead of throwing. The second allocator honours the compiler's rules and breaks the caller's anyway.
3. The principle: a promise, not a signature
FirstFitAllocator and MemberOnlyAllocator both satisfy javac. Only one of them satisfies the caller. A subtype is substitutable for its supertype only when a caller written against the supertype keeps working, unmodified, no matter which subtype it actually receives. The same inputs must be accepted. The same kind of answer must come back for the same kind of question. Nothing new should appear for the caller to catch.
That is the Liskov Substitution Principle: wherever a program expects a SpotAllocator, any implementation must be safe to use the same way, without the caller checking which one it got.
The formal version states it in three parts, and each one has a name worth knowing. A subtype may only weaken preconditions (accept everything the supertype accepted, and possibly more, never less). It may only strengthen postconditions (promise everything the supertype promised, and possibly more, never less). And it must preserve every invariant the supertype guaranteed. MemberOnlyAllocator breaks the middle one: SpotAllocator.allocate promises never to throw for a normal miss, and this implementation throws for one.
4. The fix that looks reasonable and is wrong
Once the crash is reported, the tempting move is to patch the caller instead of the allocator.
public String park(String vehicleType, List<String> freeSpots) {
Optional<String> spot;
if (allocator instanceof MemberOnlyAllocator member && !member.hasReservedCapacity()) {
spot = Optional.empty();
} else {
spot = allocator.allocate(vehicleType, freeSpots);
}
if (spot.isEmpty()) {
return "declined: no spot for " + vehicleType;
}
return "parked " + vehicleType + " at " + spot.get();
}
declined: no spot for CAR
This works, for this one caller. It costs the entire reason SpotAllocator existed. The interface was supposed to let ParkingLot treat every allocator alike; now one caller knows one implementation's quirk by name, and the next caller who forgets the same instanceof check hits the same crash. The failure has its own name: seam-bypassed. The seam is already there and correct. The code went around it instead of fixing the implementation that broke the contract.
The real fix is smaller, and it lives in the allocator, not the caller.
public final class MemberOnlyAllocator implements SpotAllocator {
private final List<String> reservedSpots;
public MemberOnlyAllocator(List<String> reservedSpots) { this.reservedSpots = reservedSpots; }
@Override
public Optional<String> allocate(String vehicleType, List<String> freeSpots) {
for (String spot : freeSpots) {
if (reservedSpots.contains(spot)) {
return Optional.of(spot);
}
}
return Optional.empty();
}
}
parked CAR at S1
declined: no spot for CAR
parked CAR at S2
No configuration check, no thrown exception. An unconfigured lot simply has nothing to offer a member, the same way a full lot has nothing to offer anyone, and Optional.empty() says exactly that. ParkingLot did not change at all, in either the broken version or the fixed one. The bug and the fix both belonged entirely to the class that made the promise and then broke it.
Your turn
Write a third allocator, NoOpAllocator, that never parks anyone: allocate always returns Optional.empty(), whatever it is given. Confirm, by reading ParkingLot.park, that no line of it needs to change to accept this allocator too.
The answer.
public final class NoOpAllocator implements SpotAllocator {
@Override
public Optional<String> allocate(String vehicleType, List<String> freeSpots) {
return Optional.empty();
}
}
ParkingLot.park reads spot.isEmpty() and returns the decline message. NoOpAllocator never returns anything else, so that branch is the only one this allocator can ever trigger, and it is a branch ParkingLot already had. This is what "substitutable" looks like when it works: a third, even a degenerate, implementation slots in with nothing else touched.
Going deeper
@Override checks one thing: that a method's signature matches what it overrides. It says nothing about behaviour. It cannot, because Java's type system has no way to write down "this method may return an empty Optional, but must never throw for a normal miss." That gap is exactly where every Liskov violation in real Java code lives. Section 2's bug compiled cleanly, passed a type checker built by people far more careful than any of us, and still broke a caller the first time it ran. The compiler staying silent here is not proof of anything. Reading the javadoc on the method you are overriding is the only check that catches this before a test does. Ask whether your version still keeps every promise the original made, and sometimes not even a test catches a case rare enough to miss.
The three formal rules from section 3 give you a checklist for that reading. A strengthened precondition looks like MemberOnlyAllocator demanding a configured list where the interface demanded nothing. A broken postcondition looks like a new exception where the contract promised a value. A broken invariant is subtler still. Suppose SpotAllocator had promised "the returned spot is always numerically the lowest free one." An override that returned any free spot would satisfy the signature and break that promise, with no exception anywhere for a test to catch.
Why this matters in an interview
An interviewer who hands you a second implementation of an interface you did not write is testing exactly this chapter. The move is not "does it compile," which every wrong answer here already does. It is reading what the interface promises, checking whether the new class keeps every part of that promise, and naming the one clause it breaks if it does not. Diagnosing this out loud is what separates a candidate who has memorised the principle's name from one who can catch it live. Reaching for the fix inside the broken implementation, rather than a guard in every caller, is what proves the diagnosis was real.
Next: chapter 2.7, Interface segregation: the smallest useful contract — where an interface that promises too much starts costing every class that has to implement all of it.
← 2.5 Open for extension, closed for modification · All chapters · 2.7 Interface segregation: the smallest useful contract →