Syllabus · C6
Liskov substitution: a subtype keeps the supertype's contract
The idea
Liskov substitution: a subtype must keep every promise its supertype made
A tag list for a search UI needs one operation: add a word, and get every word added back out, oldest first. WordList.add(String word) does exactly that, and there is nothing wrong with it as it stands. No interface, no pattern, one class holding a growing list of strings.
The product team's first change is small: a preview widget can only show five tags, so cap the list at five. The natural first move is a subclass. CappedWordList extends WordList overrides add, and once five tags are in, a sixth call throws IllegalStateException. This compiles, and javac -Xlint:all says nothing about it, because nothing about it breaks a syntax rule. A test that adds five tags or fewer passes too.
The cost shows up in a caller that was written before CappedWordList existed: TagImporter.importAll loops over a batch of tags and calls add on whichever WordList it is handed. It has no branch for the case where a sixth tag arrives, because WordList.add never said a sixth tag might be a problem. Hand it a CappedWordList and a batch of seven tags, and it breaks on the sixth call. TagImporter did nothing wrong. The subclass answers fewer calls successfully than the type it claims to be.
A second change looks unrelated: an exported report needs a frozen snapshot of the tags, one nobody can edit afterward. Subclassing again seems like the same kind of fix. FrozenWordList extends WordList overrides add to throw UnsupportedOperationException every time, for every word. This also compiles, and TagImporter breaks on it too, immediately, on the first call, for a different reason than before.
Both breaks share one shape once the two exceptions are set aside. Something inherited WordList's type and then honored less of WordList's behavior than the type promised. CappedWordList narrows which calls succeed; FrozenWordList removes success outright. Neither the declared type WordList list nor the compiler checking it can tell a subtype that keeps every promise the parent made from one that keeps only some. That gap is what the Liskov substitution principle names. A subtype must be usable wherever its supertype is used, by every caller already written against the supertype, with none of them noticing a change.
This is not a made-up hazard. List.of("a", "b").add("c") throws UnsupportedOperationException from a method java.util.List declares and documents as mutating the list, for the identical reason FrozenWordList does. An interviewer who has read that exception before will recognize the shape immediately.
The fix keeps WordList's promise instead of trading it away. A bounded tag list can satisfy the five-tag limit without ever refusing an add. When it is full, it forgets the oldest tag to make room for the newest one. The limit becomes a property of what words() returns, not of what add is allowed to reject. TagImporter never notices the difference, because nothing it relies on changed.
Here is the test that separates a safe subtype from a broken one. Can every caller already written against the parent call every method on the child, with every input the parent accepted? Does the result still satisfy what the parent promised? A yes to both means the child is safe to hand out in the parent's place. A caller should never have to learn something new about the child: not a narrower set of accepted inputs, not a new exception, not a null where a value was promised. Any one of those means the two types are not substitutable, whatever the class hierarchy claims.
Composition over inheritance (C4) is the usual way out once a subclass is tempted to remove capability rather than add it. Placing an invariant correctly (A5) applies the same discipline to constructors instead of overrides. A rule belongs where it holds for every caller and every value, not wherever it is convenient to check.
Worked walkthrough
What each line guarantees, and what breaks without it
Five files, one caller, three word lists. Run it:
.toolchain/jdk-21/bin/javac -Xlint:all -d out lessons/C6/worked/src/*.java
.toolchain/jdk-21/bin/java -cp out Main
javac -Xlint:all prints nothing. java -cp out Main prints this, unedited:
--- Plain WordList - the base contract, honored
imported all 7 tags: [java, python, rust, go, kotlin, scala, swift]
--- CappedWordList(5) - a stronger precondition
TagImporter broke: java.lang.IllegalStateException: preview widget holds at most 5 tags
--- FrozenWordList - a broken postcondition
TagImporter broke: java.lang.UnsupportedOperationException: this word list is a read-only export
WordList — the contract, stated once
void add(String word) {
Objects.requireNonNull(word, "word");
words.add(word);
}
List<String> words() {
return List.copyOf(words);
}
add refuses a null word and nothing else. Every non-null word a caller was ever allowed to pass keeps being allowed, no matter how many words came before it. words() never returns null; an empty list already says "nothing here" without needing a second way to say it. That is the whole contract, and everything below is a subtype that keeps it or breaks it.
TagImporter — the caller that never changes
static void importAll(WordList list, List<String> words) {
for (String word : words) {
list.add(word);
}
}
This method was written and would have passed review against WordList alone. It has no branch for "unless the list is one of the two subclasses below" because it was never told those subclasses would exist. That is what a contract buys a caller: the freedom to be written once, before every implementation exists.
CappedWordList — a precondition the parent never had
@Override
void add(String word) {
if (count >= cap) {
throw new IllegalStateException("preview widget holds at most " + cap + " tags");
}
super.add(word);
count++;
}
javac -Xlint:all says nothing about this override, because nothing is syntactically wrong with it. CappedWordList is a WordList as far as the compiler is concerned, and every test that adds five tags or fewer passes. The failure needs a sixth tag to show up at all.
The run above supplies one: TagImporter breaks on IllegalStateException at the moment count reaches cap, on a call that WordList.add would have accepted without comment. The precondition "a non-null word is always accepted" held for the parent and does not hold for the child.
FrozenWordList — a postcondition the parent never gave up
@Override
void add(String word) {
throw new UnsupportedOperationException("this word list is a read-only export");
}
No count to exceed here. Every call fails, immediately, for a different reason: WordList.add promises to return normally, and this override promises nothing of the sort. The run above shows TagImporter breaking on the very first tag, with UnsupportedOperationException, which is the exact shape java.util.List.of(...).add(...) takes in the JDK. FrozenWordList satisfies the method signature void add(String) and refuses the one thing the signature was supposed to guarantee alongside it, which is that calling it succeeds.
Both breaks share one cause
Neither class fails to compile. Neither class fails a test that only exercises the case its author had in mind — five tags for the cap, zero further calls for the frozen export. Both fail the one caller that was written before either subclass existed, because both subclasses promise to be a WordList and then decline part of what that promise means. That is what the faded exercise asks you to avoid: keep the shape a caller can rely on, even when the implementation underneath has to change.
Worked source
The 5 files of the worked design
Every file below is the one the app opens, verbatim. This is the part worth reading slowly: the prose above argues for a shape, and these are the lines that have it.
worked/src/CappedWordList.java32 linesworked/src/FrozenWordList.java25 linesworked/src/TagImporter.java21 linesworked/src/WordList.java29 linesworked/src/Main.java27 lines
worked/src/CappedWordList.java32 lines
/**
* A preview widget can only show 5 tags, so this subclass refuses a 6th.
*
* This is the naive fix for a real requirement, and it type-checks perfectly: {@code javac}
* has no rule that says an override may not add a restriction the parent never had. It
* compiles, and it passes any test that only ever adds 5 words or fewer. It is also wrong,
* because it strengthens {@link WordList#add(String)}'s precondition — the parent accepts
* any non-null word in any state; this override starts rejecting some of those same calls
* once {@code count} reaches {@code cap}. See {@code TagImporter} and {@code Main} for the
* caller that breaks.
*/
class CappedWordList extends WordList {
private final int cap;
private int count;
CappedWordList(int cap) {
if (cap < 1) {
throw new IllegalArgumentException("a preview widget needs room for at least 1 tag");
}
this.cap = cap;
}
@Override
void add(String word) {
if (count >= cap) {
throw new IllegalStateException("preview widget holds at most " + cap + " tags");
}
super.add(word);
count++;
}
}
worked/src/FrozenWordList.java25 lines
import java.util.List;
/**
* A read-only export of a tag list, for a report that must not change once generated.
*
* This is the second naive fix, and it also compiles clean. It is the identical shape
* {@code java.util.List.of(...).add(...)} takes in the JDK: a type that satisfies
* {@link WordList}'s method signature and then throws from inside it, because the parent's
* postcondition — {@code add} always returns normally — was never something this class
* actually meant to keep. Nothing about the type system stops a subclass from declaring
* a promise its body refuses to honor.
*/
class FrozenWordList extends WordList {
FrozenWordList(List<String> snapshot) {
for (String word : snapshot) {
super.add(word);
}
}
@Override
void add(String word) {
throw new UnsupportedOperationException("this word list is a read-only export");
}
}
worked/src/TagImporter.java21 lines
import java.util.List;
/**
* A caller written once, against {@link WordList}'s contract, and never touched again.
*
* It has no idea whether {@code list} is the plain list or one of the two subclasses this
* lesson builds. It only knows what {@link WordList#add(String)} promises: every non-null
* word is accepted. That promise is exactly what {@link CappedWordList} and
* {@link FrozenWordList} each take back.
*/
final class TagImporter {
private TagImporter() {
}
static void importAll(WordList list, List<String> words) {
for (String word : words) {
list.add(word);
}
}
}
worked/src/WordList.java29 lines
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* A growing collection of tags, oldest first.
*
* <h2>The contract every subtype must keep</h2>
* {@link #add(String)} never refuses a non-null word: it always returns normally, and it
* never throws for any word a caller was already allowed to pass. {@link #words()} never
* returns {@code null} — an empty list means no words are held right now, not an error. A
* subtype is free to change what "held right now" means (see {@code BoundedWordList} in the
* faded exercise, which forgets the oldest word to make room), but it may never turn an add
* that used to succeed into one that throws, and it may never hand back {@code null} where an
* empty list would say the same thing correctly.
*/
class WordList {
private final List<String> words = new ArrayList<>();
void add(String word) {
Objects.requireNonNull(word, "word");
words.add(word);
}
List<String> words() {
return List.copyOf(words);
}
}
worked/src/Main.java27 lines
import java.util.List;
/**
* Runs the same caller, {@code TagImporter.importAll}, against three word lists that all
* type-check as {@code WordList}. See NOTES.md for the annotated version of this output.
*/
public class Main {
public static void main(String[] args) {
List<String> tags = List.of("java", "python", "rust", "go", "kotlin", "scala", "swift");
tryImport("Plain WordList - the base contract, honored", new WordList(), tags);
tryImport("CappedWordList(5) - a stronger precondition", new CappedWordList(5), tags);
tryImport("FrozenWordList - a broken postcondition",
new FrozenWordList(List.of("legacy")), tags);
}
private static void tryImport(String label, WordList list, List<String> tags) {
System.out.println("\n--- " + label);
try {
TagImporter.importAll(list, tags);
System.out.println(" imported all " + tags.size() + " tags: " + list.words());
} catch (RuntimeException e) {
System.out.println(" TagImporter broke: " + e.getClass().getName() + ": " + e.getMessage());
}
}
}
The faded stage is not here, on purpose
In the app, the third stage of a lesson hands you the worked design with a few lines
replaced by // GAP: markers, then compiles your completion and runs a JUnit suite
against it. That needs javac, and a static site has no compiler — so rather than show a
control that cannot work, this page stops at the worked source.
Run the app for the drill: it is the download in the header, and it works offline once unpacked.