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. It needs an answer even when nothing is free.
SpotAllocator states that contract 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);
}
Read the return type carefully, because Optional.empty() is not an error here — it is the answer "nothing fits".
FirstFitAllocator returns it the same way it returns a real spot, by handing it back rather than 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));
}
}
Now a caller reads that contract once and 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
With 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. So you write a second allocator.
You are under time pressure. The exception type is already sitting right there, used for everything else that goes wrong. You reach for it.
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)
Stop and work out what just happened.
javac accepted @Override without a word, because the signature matches exactly. ParkingLot was never edited to know about MemberOnlyAllocator. It received one through the same constructor that FirstFitAllocator used.
And it crashed the first time it met a configuration this allocator dislikes.
Nothing in the type system warned anybody. ParkingLot.park already trusts allocate to return an empty Optional instead of throwing. Your second allocator honours every rule the compiler enforces and breaks the one the caller actually depends on.
3. The principle: a promise, not a signature
Both allocators satisfy javac. Only one satisfies the caller.
Here is the rule. A subtype is substitutable for its supertype when a caller written against the supertype keeps working, unmodified, whichever subtype it actually receives.
Three things have to hold. The same inputs must be accepted. The same kind of answer must come back for the same kind of question. Nothing new may 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, with no caller checking which one it got.
The formal version has three parts, and each part has a name worth carrying into an interview.
A subtype may only weaken preconditions. Accept everything the supertype accepted, possibly more, never less.
It may only strengthen postconditions. Promise everything the supertype promised, possibly more, never less.
And it must preserve every invariant the supertype guaranteed.
Which one did MemberOnlyAllocator break? The middle one. SpotAllocator.allocate promises never to throw for a normal miss, and that implementation throws for one.
4. The fix that looks reasonable and is wrong
The crash gets reported. Now watch the tempting move: 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
It works, for this one caller.
It also costs you the entire reason SpotAllocator existed. The interface was there so ParkingLot could treat every allocator alike. Now one caller knows one implementation by name, and the next caller who forgets that instanceof check hits the identical crash.
This failure has a name in the app: seam-bypassed. The seam was already there and already 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.
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 and 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.
Notice what stayed still. ParkingLot did not change at all, in the broken version or the fixed one. The bug and the fix both belonged to the class that made a 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 it can ever trigger. And ParkingLot already had that branch.
That is what substitutable looks like when it works. A third implementation slots in with nothing else touched, even a degenerate one.
Going deeper
@Override checks exactly one thing: that a method's signature matches what it overrides. It says nothing about behaviour.
It cannot. 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 where every Liskov violation in real Java code lives.
Look back at section 2. That bug compiled cleanly. It passed a type checker built by people far more careful than you or me. It broke a caller the first time it ran.
So do not read a silent compiler as evidence of anything here. The check that actually catches this is reading the javadoc on the method you are overriding, and asking one question: does my version still keep every promise the original made? Sometimes not even a test catches it, if the failing case is rare enough.
The three formal rules 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. Suppose SpotAllocator had promised "the returned spot is always numerically the lowest free one." An override that returned any free spot satisfies the signature and breaks the promise. No exception. Nothing for a test to catch.
Why this matters in an interview
When an interviewer hands you a second implementation of an interface you did not write, they are testing this chapter.
The question is never whether it compiles, because every wrong answer here compiles.
The question is what the interface promises, whether the new class keeps every part of that promise, and which clause it breaks if it does not. Say that out loud and you separate yourself from every candidate who has only memorised the principle's name.
Then reach for the fix inside the broken implementation rather than a guard in every caller. That is what proves your 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 →