Liskov Substitution Principle
Core. Expect to meet this one, and expect to be asked for it by name.
Start with the problem
A parking lot needs to know where to put a car, and it needs an answer even when no spot is free. SpotAllocator states that contract directly, in its return type.
public interface SpotAllocator {
Optional<List<String>> allocate(VehicleType type, SpotGrid grid);
int capacityFor(VehicleType type, SpotGrid grid);
}
Optional.empty() is not an error. It is the answer "no spot fits right now," and FirstFitAllocator returns it the same way it returns a real list: by handing it back, never by throwing.
@Override
public Optional<List<String>> allocate(VehicleType type, SpotGrid grid) {
// ... scans the grid
return Optional.empty();
}
The caller reads that contract once and writes code that trusts it.
Optional<List<String>> spots = allocator.allocate(type, grid);
if (spots.isEmpty()) {
return Ticket.declined(type);
}
For one allocator this is fine, and there is only one way to read allocate's return type: as the whole answer, never as a partial one that might also throw.
Watch where it goes
A second allocator arrives: spots near the entrance are reserved for members, so a non-member gets turned away from those spots specifically, not from the lot. Somebody writes it fast, under time pressure, and reaches for the exception type that is already sitting in the file for everything else that goes wrong.
@Override
public Optional<List<String>> allocate(VehicleType type, SpotGrid grid) {
if (!grid.hasReservedCapacity(type)) {
throw new IllegalStateException("no member spots configured");
}
// ... scans the grid
return Optional.empty();
}
It compiles. @Override is satisfied, because the method signature is identical to FirstFitAllocator's. The parking lot code already trusts allocate to return an empty Optional instead of throwing. It now crashes the first time this allocator meets a type it was not configured for, and nothing in the type system said that would happen.
That is the cost, and it is worse than a compile error, because a compile error would have stopped the swap before anyone parked a car. The caller wrote code against one contract and received an object that honours a narrower one. Every future caller of SpotAllocator now has to find out, by reading the source of whichever implementation it was handed, whether Optional.empty() is still safe to expect or whether this one throws instead.
The principle
FirstFitAllocator and the new allocator both satisfy the compiler. Only one of them satisfies the caller. A subtype is substitutable for its supertype only when a caller written against the supertype keeps working without any change, no matter which subtype it actually received. The same inputs must be accepted, the same kind of answer given for the same kind of question, and nothing new introduced for the caller to catch.
That is the Liskov Substitution Principle. Wherever the program expects a SpotAllocator, any implementation must be safe to use the same way, without the caller checking which one it received.
What modern Java changes here
javac checks that an override's signature is compatible with what it overrides. It does not check, and cannot check, that the override's behaviour is. @Override on the throwing allocate method above compiles cleanly, because Java's type system has no way to express "this method may return an empty Optional, but it may never throw for a normal miss." That gap is exactly where Liskov violations live in real Java code. One shape is a strengthened precondition: this implementation demands a configured grid, where the interface asked nothing of the kind. The other is a new exception where the documented contract promised a value instead. Reading past @Override to the javadoc on the method being overridden is the only way to catch this before a test does.
The version of this that is wrong
The tempting fix, once the crash is reported, is to patch the caller instead of the allocator.
Optional<List<String>> spots;
if (allocator instanceof MemberOnlyAllocator member && !grid.hasReservedCapacity(type)) {
spots = Optional.empty();
} else {
spots = allocator.allocate(type, grid);
}
This works for this one caller, at the cost of the entire reason SpotAllocator existed. The interface was supposed to let a caller treat every allocator alike. Now one caller knows about one implementation's quirk, and the next caller who forgets to add the same guard hits the same crash. The failure tag for this shape is seam-bypassed: the seam is already there and correct, and the code went around it instead of fixing the implementation that broke the contract. The actual fix is smaller and lives in the allocator, not the caller: return Optional.empty() for an unconfigured grid, the same way FirstFitAllocator returns it for a full one.
Where this lives in the app
Syllabus item C6 is the full lesson on this principle, still being written alongside this page. Expect it to cover an override that strengthens a precondition or throws where the parent did not, which is the shape almost every real Liskov violation in Java takes.