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.

Watch it evolve1 / 10
The problem

Playlists and a save menu

A Playlist has a name, tracks and add(). SaveMenu adds the current song to the playlist the listener picks.

80
Start
Stage 2
Final

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.

The first versionJava
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);
    }
}
**«record»Track− title: String− seconds: int− plays: intPlaylist− name: String− tracks: List<Track>~ name(): String~ add(Track)~ tracks(): List<Track>~ seconds(): intSaveMenu− playlists: List<Playlist>~ choices(): String~ save(Track, int): String

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:

The subclass, and the method it refusesJava
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().

Java
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;
    }
}
***«record»Track− title: String− seconds: int− plays: int«abstract»Playlist− name: String~ name(): String~ tracks(): List<Track>~ seconds(): intEditablePlaylist− tracks: List<Track>~ add(Track)~ tracks(): List<Track>SmartPlaylist− library: List<Track>− minPlays: int~ tracks(): List<Track>SaveMenu− playlists: List<EditablePlaylist>~ choices(): String~ save(Track, int): String

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 SaveMenu constructor matches

JavaScript

TypeError: playlist.add is not a function, when the listener saves

Python

AttributeError: 'SmartPlaylist' object has no attribute 'add', when the listener saves

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.

***«record»Track− title: String− seconds: int− plays: int«abstract»Playlist− name: String~ name(): String~ tracks(): List<Track>~ seconds(): intEditablePlaylist− tracks: List<Track>~ add(Track)~ tracks(): List<Track>SmartPlaylist− library: List<Track>− minPlays: int~ tracks(): List<Track>SaveMenu− playlists: List<EditablePlaylist>~ choices(): String~ save(Track, int): String

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

SmartPlaylist, whose add() always throws

Preconditions can't be strengthened

A subtype accepts every input the parent accepts

A kids' playlist whose add() rejects explicit tracks

Postconditions can't be weakened

A subtype delivers everything the parent promises

A shuffled playlist whose tracks() comes back in a new order on each call, when callers rely on the order added

Invariants must hold

What's always true of the parent stays true

A playlist that caches its length and stops updating it after add()

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

add() exists only where it works

Nobody: the compiler or the missing method catches misuse

An optional operation

add() may throw, and the documentation says so

Every caller, who must avoid it or handle the exception

A capability check

Call canAdd() before add()

Every caller, who must remember to ask

A silent refusal

Nothing: add() just does 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 Playlist that 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 UnsupportedOperationException

C++

With override, that a virtual function with that signature exists; a narrower return type for pointers and references

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 NotImplementedError, which means "not written yet"

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

1. After the fix, the app adds a podcast queue that listeners can add episodes to. It shows in the sidebar. Where does it fit?
2. Why isn't a type check in SaveMenu a good fix for the smart playlist?
3. A subclass overrides add() to do nothing, silently. Does it meet the Liskov substitution principle?
4. Java's List.of() returns a list whose add() throws. Why doesn't that break the principle?

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.