intermediate~1.5h

Liskov Substitution Principle

Why a subclass that compiles cleanly can still be an unsafe substitute for its parent, and how to design hierarchies where every subtype genuinely honors its parent's contract.

Learning objectives

  • State the Liskov Substitution Principle and distinguish structural from behavioral compatibility
  • Recognize an LSP violation caused by a subclass throwing on an inherited method
  • Explain why 'Square extends Rectangle' is a classic LSP trap despite being mathematically valid
  • Fix an LSP violation by splitting a bloated contract along real capability instead of overriding to throw

◆ Story

A replacement part advertised as compatible with your car should genuinely work the moment you install it — no surprise behavior, nothing that suddenly breaks something else that used to work fine. If installing the "compatible" part means the brakes stop working correctly, it was never actually compatible, whatever the label said.

The Liskov Substitution Principle (LSP) says exactly this about subclasses: a subclass should be genuinely substitutable for its parent class, anywhere the parent is expected, without breaking the correctness of the program.

A quick, classic warm-up before the worked example: a Bird class with a fly() method seems reasonable — until Ostrich extends Bird. An ostrich is unmistakably a bird, but it cannot fly. Leave Ostrich.fly() empty and callers silently get nothing; throw an exception and any code trusting Bird.fly() to be safe now crashes the moment it happens to be handed an ostrich. Either way, Ostrich is not a safe substitute for Bird, despite being one in the real, biological sense. This is the seed of every LSP violation: a parent class that promises something not every subclass can actually keep.

The worked example: a small file system where some files support both reading and writing, and some files are read-only — a log archive, a shipped configuration template, anything the system deliberately shouldn't let you overwrite.

Start with one class supporting both operations, and a read-only variant that inherits from it. File provides read() and write(); ReadOnlyFile extends File and is forced to override write(), since File already provides it — and the only thing it can honestly do inside that override is throw.

It compiles cleanly. Calling code that holds a File reference has no way to know, just from the type, that this particular instance will blow up on write(). The exception only shows up the moment something actually calls it — which could be far away from where the object was created, deep inside code that has no reason to suspect this particular File is any different from any other.

💻 Code example

class File { void read() { System.out.println("Reading from file"); } void write() { System.out.println("Writing to file"); } } class ReadOnlyFile extends File { @Override void write() { throw new UnsupportedOperationException("Cannot write to a read-only file"); // forced to override } } // calling code that trusted File's contract File file = new ReadOnlyFile(); file.read(); // "Reading from file" — fine file.write(); // throws at RUNTIME, with no compile-time warning at all

Every caller holding a File reference has been implicitly promised, by the class's own public contract, that write() is safe to call. ReadOnlyFile silently breaks that promise, and does it in a way the compiler cannot catch, because write()'s signature is identical whether it actually works or always throws.

This is precisely why LSP is described as being about behavioral compatibility, not just structural ("has the same method signatures") compatibility — ReadOnlyFile passes every structural check and still isn't a valid substitute for File.

The mistake happened one step earlier than the write() override — it happened when File was defined to promise both read and write, unconditionally, for every file that would ever exist. Some files can only be read, and that capability should be its own contract, promised only to callers who genuinely need it. Some files can be read and written — a genuinely larger contract, not a variant of the first with one method disabled. No file should ever be forced to implement a capability it can't honestly support, which means the real fix isn't a smarter override, it's not promising the capability in the first place for types that can't deliver it.

Split "file" into two focused interfaces — Readable and Writable — and let a class declare only the ones it genuinely supports. A ReadableFile base class implements read(), since every file, read-only or not, can genuinely do that. WritableFile extends it and adds write() honestly. ReadOnlyFile also extends ReadableFile, but — this is the important difference from the earlier attempt — it doesn't override write() to throw. It simply never has a write() method to call in the first place. The compiler now enforces what used to only be a runtime surprise; calling readOnly.write() is a compile error, not an exception three layers deep in production.

This is the actual test LSP asks for: write one method that genuinely needs only read access, and pass it anything readable. A method typed to accept ReadableFile and calling only read() works correctly whether it's handed a ReadOnlyFile or a WritableFile — every real subtype behaves exactly as the contract promises, with zero surprises. That's substitutability, proven, not just claimed.

💻 Code example

interface Readable { void read(); } interface Writable { void write(); } class ReadableFile implements Readable { public void read() { System.out.println("Reading from file"); } // every file can genuinely do this } class WritableFile extends ReadableFile implements Writable { // gets read() for free, adds write() honestly public void write() { System.out.println("Writing to file"); } } class ReadOnlyFile extends ReadableFile { // no write() at all. not overridden, not disabled, not present. the type itself says "cannot write." } // proving substitutability static void readAnyFile(ReadableFile file) { file.read(); } readAnyFile(new ReadOnlyFile()); // substitutable — ReadOnlyFile IS-A ReadableFile, fully, honestly readAnyFile(new WritableFile()); // also substitutable ReadableFile readOnly = new ReadOnlyFile(); // readOnly.write(); // COMPILE ERROR — the method doesn't exist on this type at all

▲ Edge case — a mathematically true "is-a" can still be a bad hierarchy

The classic textbook trap is Square extends Rectangle: mathematically, a square genuinely is a special rectangle. But if Rectangle has independent setWidth()/setHeight(), Square has to override both to keep its sides equal — silently changing behavior a caller reasonably relied on, since setting width to 5 and height to 10 on a "Rectangle" that's secretly a Square doesn't give you a 5×10 rectangle. The lesson generalizes past shapes: "is-a" in the real world doesn't automatically mean "safely substitutable" in code — the same trap the read-only file fell into.

▲ Edge case — strengthening a precondition is also a violation

LSP isn't only about missing methods. A subclass that narrows what inputs it accepts — say, a write() override that throws on negative-sized writes when the parent's write() never had that restriction — is just as much a violation, even though the method exists and compiles. The rule is symmetric: a subclass can only ask for the same or less, and promise the same or more, never the reverse.

Both edge cases point at the same underlying discipline: before writing extends, check not just whether this thing is genuinely a kind of the parent, but whether every caller of the parent's methods can trust this subclass exactly as much.

Java's own collections framework has a known, often-cited LSP wrinkle: List.of(...) returns an immutable list that still implements the same List interface as ArrayList — calling .add() on it compiles fine and throws UnsupportedOperationException at runtime, the exact shape of violation the initial ReadOnlyFile attempt fell into. It's a deliberate, documented trade-off in the JDK, not an oversight, but it's still the textbook example engineers point to when explaining LSP violations in a real, widely used library.

File-permission systems, read replicas in databases, and any "read vs. read-write" API design face this identical fork, which is exactly why this topic's example is a file system rather than an abstract shape. Database drivers that expose a read-only connection object are solving the same problem this topic solved — by giving the read-only variant a narrower type, not a full-featured type with methods that throw.

Q: Why does ReadOnlyFile extends File (throwing from write()) violate LSP, even though it compiles?

A: File's public contract implicitly promises write() is safe to call on any File. ReadOnlyFile silently breaks that promise at runtime — a violation the compiler can't catch, because the method's signature looks identical whether it works or always throws.

Q: What's the actual fix, and why is it better than overriding write() to throw?

A: Split File into Readable and Writable interfaces, and only let ReadOnlyFile implement Readable. It never has a write() method at all, so the compiler — not a runtime exception — prevents misuse.

Q: Why does "Square extends Rectangle" violate LSP even though a square is mathematically a rectangle?

A: Square must override setWidth/setHeight to keep its sides equal, silently changing behavior callers could reasonably rely on from Rectangle — a real-world "is-a" doesn't guarantee behavioral substitutability in code.

Q: What kind of compatibility does LSP actually require — structural, or behavioral?

A: Behavioral — a subclass must preserve the behavior callers can rely on from the parent, not just have matching method signatures.

Q: Besides missing or broken methods, what's another way a subclass can violate LSP?

A: By strengthening a precondition (accepting a narrower range of valid input than the parent promised) or weakening a postcondition (guaranteeing less than the parent did) — both break substitutability even when every method is present and compiles.

Want a visual for this concept?

Generate a diagram tailored to “Liskov Substitution Principle” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to Interface Segregation Principle← Back to all Low-Level Design & Design Patterns chapters