Low-level designCore11 min
Liskov substitution principle
A subtype must work anywhere its parent is expected. If code written for the parent can break when it's handed a subclass, the subclass is a subtype in name only, and the hierarchy needs to change.
Playlists and a save menu
A Playlist has a name, tracks and add(). SaveMenu adds the current song to the playlist the listener picks.
1 · The situation
Playlists, and a menu that saves songs to them
A music app lists the listener's playlists in a sidebar. A Playlist has a name and its tracks, adds up its length in seconds(), and puts a track at the end with add(). A "Save to playlist" menu, SaveMenu, lists the playlists it's given and adds the current track to the one the listener picks.
The names are the same in all four languages. A track is a small value: a record in Java, a struct in C++, a plain object in JavaScript and a frozen dataclass in Python.
import java.util.ArrayList;
import java.util.List;
record Track(String title, int seconds, int plays) {}
class Playlist {
private final String name;
private final List<Track> tracks = new ArrayList<>();
Playlist(String name) {
this.name = name;
}
String name() {
return name;
}
void add(Track track) {
tracks.add(track);
}
List<Track> tracks() {
return List.copyOf(tracks);
}
int seconds() {
int total = 0;
for (Track track : tracks()) total += track.seconds();
return total;
}
}
class SaveMenu {
private final List<Playlist> playlists;
SaveMenu(List<Playlist> playlists) {
this.playlists = List.copyOf(playlists);
}
String choices() {
List<String> names = new ArrayList<>();
for (Playlist p : playlists) names.add(p.name());
return "Save to: " + String.join(", ", names);
}
String save(Track track, int choice) {
Playlist playlist = playlists.get(choice);
playlist.add(track);
return "Saved " + track.title() + " to " + playlist.name();
}
}
public class Main {
static void show(Playlist p) {
int s = p.seconds();
System.out.printf("%s: %d tracks, %d:%02d%n", p.name(), p.tracks().size(), s / 60, s % 60);
}
public static void main(String[] args) {
Track nightDrive = new Track("Night drive", 245, 1);
Track paperPlanes = new Track("Paper planes", 198, 2);
Track northbound = new Track("Northbound", 230, 0);
Playlist roadTrip = new Playlist("Road trip");
roadTrip.add(paperPlanes);
roadTrip.add(northbound);
List<Playlist> sidebar = List.of(roadTrip);
for (Playlist p : sidebar) show(p);
SaveMenu menu = new SaveMenu(sidebar);
System.out.println(menu.choices());
System.out.println(menu.save(nightDrive, 0));
show(roadTrip);
}
}The class diagram of this version, drawn from its code: the menu holds the playlists it offers.
The listener saves Night drive to Road trip:
Road trip: 2 tracks, 7:08 Save to: Road trip Saved Night drive to Road trip Road trip: 3 tracks, 11:13
2 · The change
A smart playlist that can't take a song
The app adds smart playlists, filled from the listening history. On repeat holds every track played at least three times. It sits in the sidebar and has a name, tracks and a length, so the natural move is a subclass: SmartPlaylist extends Playlist and overrides tracks() to pick its tracks from the library.
That leaves add(). A listener can't add a song to a list the app chooses, so the subclass refuses it:
class SmartPlaylist extends Playlist {
private final List<Track> library;
private final int minPlays;
SmartPlaylist(String name, List<Track> library, int minPlays) {
super(name);
this.library = List.copyOf(library);
this.minPlays = minPlays;
}
@Override
List<Track> tracks() {
return library.stream().filter(t -> t.plays() >= minPlays).toList();
}
@Override
void add(Track track) {
throw new UnsupportedOperationException(
name() + " is a smart playlist; it picks its own tracks");
}
}Everything that only reads a playlist works with On repeat: the sidebar shows its tracks and its length. The save menu doesn't. It offers every playlist it's given, and the listener tries both:
Road trip: 2 tracks, 7:08 On repeat: 2 tracks, 7:55 Save to: Road trip, On repeat Saved Night drive to Road trip Error: On repeat is a smart playlist; it picks its own tracks Road trip: 3 tracks, 11:13
The menu offers both playlists, and saving to the smart one fails.
SaveMenu didn't change. It was written for Playlist, and for a Playlist calling add() is correct. To the compiler a SmartPlaylist is a Playlist, but it doesn't keep a promise Playlist makes, so code that was correct for every playlist is wrong for this one.
The quick patch is a type check in the menu: skip any playlist that is a SmartPlaylist. It works today, and it spreads:
- Every caller needs it. Each piece of code that adds to a playlist, such as drag and drop or a share link, must make the same check.
- The next refusal slips past. A new playlist type that also can't take songs isn't a
SmartPlaylist, so none of the checks catch it. - The callers now know the subclasses. Code written against the parent must name its children, which is what the parent type was meant to avoid.
The practice checks flag the subclass instead: SmartPlaylist.add always throws.
3 · The fix
Give the method only to the playlists that keep it
Start from the smart playlist version. The hierarchy says every playlist can take a song, and one can't. Change the hierarchy so it says what's true:
Playlist becomes abstract: a name, tracks() for each subclass to supply, and seconds() built on it. It has no add(). JavaScript has no abstract classes, so a comment says that subclasses supply tracks().
abstract class Playlist {
private final String name;
Playlist(String name) {
this.name = name;
}
String name() {
return name;
}
abstract List<Track> tracks();
int seconds() {
int total = 0;
for (Track track : tracks()) total += track.seconds();
return total;
}
}The sidebar still shows both playlists, because it only needs the base type. The menu offers only Road trip, and the error is gone:
Road trip: 2 tracks, 7:08 On repeat: 2 tracks, 7:55 Save to: Road trip Saved Night drive to Road trip Road trip: 3 tracks, 11:13
Handing the smart playlist to the menu is now a mistake each language reports, and none of them reaches inside a method that only pretends to exist:
Language | Giving the menu a smart playlist |
|---|---|
Java | A compile error: the list's types are incompatible |
C++ | A compile error: no |
JavaScript |
|
Python |
|
4 · The principle
A subtype keeps its parent's promises
The Liskov substitution principle says that an object of a subtype must work wherever an object of its supertype is expected, without the code that uses it knowing the difference. Whatever holds for the parent's objects must hold for the subtype's. A type that meets this is a behavioral subtype; a class that only compiles as a subclass is a subtype in name only.
After the fix: every subclass keeps the promises of Playlist, and only EditablePlaylist promises add().
A compiler checks the shape of an override: its name, its parameters, its return type. It can't check what the override does, and Java lets an override throw any unchecked exception, which is how SmartPlaylist.add() compiled. The rest of the contract is behavior, and it comes down to a few rules:
Rule | What it means | A playlist that breaks it |
|---|---|---|
No new failures | A subtype doesn't throw where the parent's callers expect success |
|
Preconditions can't be strengthened | A subtype accepts every input the parent accepts | A kids' playlist whose |
Postconditions can't be weakened | A subtype delivers everything the parent promises | A shuffled playlist whose |
Invariants must hold | What's always true of the parent stays true | A playlist that caches its length and stops updating it after |
No new kinds of change | A subtype can't be changed in ways the parent can't | A subclass of a fixed, read-only album that lets tracks be added |
"Is a" isn't enough. To a listener, a smart playlist is a playlist. It doesn't behave like the playlist this code describes, one you can add to. The question for a subclass isn't whether it's a kind of the parent in the real world, but whether every caller of the parent can use it without a surprise.
5 · When breaking it is fine
Optional operations, and contracts that say so up front
Some well-known libraries refuse methods on purpose. Java's collection interfaces mark methods such as add() as optional operations: an implementation that doesn't support one throws UnsupportedOperationException, and the unmodifiable lists from List.of() do. That keeps the principle, because the contract says so up front: code written against List is told that add() may throw. The cost moves to every caller, who must know where a list came from or be ready for the exception.
Approach | What the contract says | Who carries the cost |
|---|---|---|
Split the type, as in the fix |
| Nobody: the compiler or the missing method catches misuse |
An optional operation |
| Every caller, who must avoid it or handle the exception |
A capability check | Call | Every caller, who must remember to ask |
A silent refusal | Nothing: | The listener, whose song vanishes; this one breaks the principle |
Bending the rule is reasonable when:
- The contract already allows it, as with optional operations, and the callers are written for it.
- The hierarchy isn't yours to change, such as a framework's base class. Document the refusal, and keep the subclass away from code that needs the method.
- It's a test double. A fake that throws from methods a test must never call breaks substitution on purpose, in one place.
6 · Getting it right
Design the hierarchy around what callers rely on
- Write down what each method promises, in a comment or a test, and ask of every override whether it keeps the promise.
- Run the parent's tests against every subclass. A test written for
Playlistthat fails for a subclass has found a substitution bug. - Watch for the signs: an override that throws, does nothing, or narrows what it accepts, and a caller that checks a type before it calls.
Two fixes come up again and again:
- Keep the parent small. Put in the base type only what every subtype can do, and add the rest in subtypes or in separate interfaces, as Interface segregation shows next.
- Hold instead of extend. If a class needs another's code but can't keep its promises, keep one as a field, as in Composition over inheritance.
Each language checks a different part of an override for you:
Language | What it checks in an override | What it leaves to you |
|---|---|---|
Java | The parameters; a return type that's the same or narrower; no new checked exceptions; no narrower access | Behavior, and unchecked exceptions such as |
C++ | With | Behavior and exceptions |
JavaScript | Nothing: any method can replace any other | Everything, including the parameters |
Python | Nothing at run time; a type checker such as mypy checks that the signature is compatible with the parent's | Behavior. For a method a subclass doesn't support, the docs advise not raising |
7 · Look-alikes
Subtyping and its neighbors
Several ideas meet at the subclass. What tells them apart is the question each one asks.
Idea | The question it asks |
|---|---|
Liskov substitution | Can every user of the parent use this subtype without a surprise? |
Inheritance | Does this class reuse the parent's code? A mechanism, and it can make a subtype that breaks the rule |
Polymorphism | Which method runs is decided by the object; it's only safe when the rule holds |
Interface segregation | Does a caller depend on methods it doesn't use? Small types leave no method to refuse |
Design by contract | What does each method require, promise and keep true? The rules above, written down |
Open/closed | Can a new case be added without editing working code? Only if the new case can stand in for the old |
Two neighbors matter most. A subclass that refuses a method usually means the parent type is too wide, which is Interface segregation's subject. And Open/closed depends on this principle: when a new kind can't stand in for the old ones, the callers grow type checks again, and every new kind edits them.
8 · In practice
Any vehicle, any level
In the parking-lot practice problem, a spot decides whether any Vehicle fits by asking it for size(), and never checks which kind it has. That works only while every car, motorbike and truck answers the question the way Vehicle promises.
The checks flag a subclass method that always throws where its parent's method works, reported as "Truck.size always throws" for a truck that refuses to say. Design the parking lot so that any vehicle can stand in for any other.
Check yourself
4 questions
Takeaways
Remember this
- A subtype must work wherever its parent is expected, without the caller knowing the difference.
- The compiler checks the shape of an override, not what it does; behavior is your job.
- An override that throws, does nothing, or accepts less than its parent breaks the parent's promise.
- When a subclass can't keep a promise, fix the hierarchy: move the method to the types that keep it.
- "Is a" in the real world isn't enough; the test is whether every caller can use the subtype without a surprise.