Liskov Substitution Principle: When Inheritance Lies

SOLID Principles Series #3


In 1987, Barbara Liskov stated the following: “If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of the program.” That’s a mouthful. Let’s break it down into plain English.

T is your base class. S is a subclass. LSP says: anywhere your code uses T, you should be able to drop in S instead, and everything should still work correctly. No exceptions thrown where none were expected. No behaviour silently ignored. No surprises.
A subclass is not just a class that inherits fields and methods. It’s a class that honors the contract of its parent. That contract includes not just the method signatures, but the expected behaviour behind them.


What Is the Liskov Substitution Principle?

LSP is the third of the five SOLID principles. It defines the rules for correct inheritance: a subclass must be substitutable for its base class without the caller knowing the difference.

Three things a subclass must never do:

  • Throw exceptions for methods the base class handles without throwing.
  • Return values outside the range the base class promised.
  • Silently ignore behaviour the caller expects to happen.

If any of these happen, the subclass is not a proper substitute. It only looks like one.


The Problem in Code

The classic example exists for a reason: it’s simple enough to see the violation clearly, and realistic enough to appear in production code written by well-meaning developers.

We start with a Bird base class. A bird can fly. That’s the contract.

// Bird.java
public class Bird {
    protected String name;

    public Bird(String name) {
        this.name = name;
    }

    public void fly() {
        System.out.println(name + " is flying.");
    }
}

We add a few birds. Everything works as expected.

// Eagle.java / Sparrow.java
public class Eagle extends Bird {
    public Eagle() { super("Eagle"); }

    @Override
    public void fly() {
        System.out.println("Eagle soaring at high altitude.");
    }
}

public class Sparrow extends Bird {
    public Sparrow() { super("Sparrow"); }

    @Override
    public void fly() {
        System.out.println("Sparrow fluttering between branches.");
    }
}

Now someone adds a penguin. A penguin is a bird, right? It has feathers, it lays eggs, it’s in every ornithology textbook under Aves. So the developer extends Bird.

// Penguin.java - LSP violation
public class Penguin extends Bird {
    public Penguin() { super("Penguin"); }

    @Override
    public void fly() {
        throw new UnsupportedOperationException("Penguins can't fly!");
    }
}

And here’s the code that uses these birds:

// BirdHandler.java
public class BirdHandler {

    public void sendToSky(List<Bird> birds) {
        for (Bird bird : birds) {
            bird.fly();  // works for Eagle, works for Sparrow
                         // throws at runtime for Penguin
        }
    }
}

// Caller has no idea what's inside the list
List<Bird> birds = List.of(new Eagle(), new Sparrow(), new Penguin());
handler.sendToSky(birds); // RuntimeException. Surprise.

BirdHandler works with Bird. It has every right to call fly(). The contract says birds fly. But Penguin breaks that contract at runtime, and the caller has no way to know it until the exception hits.


The Solution, Step by Step

The fix is a redesign of the hierarchy. The mistake was not adding Penguin. The mistake was putting fly() in Bird when not all birds fly. The base class made a promise it could not keep for all its subtypes.

1. Split the hierarchy by actual capability

Bird defines only what every bird can do. Flying is a capability, not a universal bird trait. It gets its own interface.

// Bird.java - honest base class
public abstract class Bird {
    protected String name;

    public Bird(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    // every bird can do this
    public abstract void eat();
}
// Flyable.java - capability interface
public interface Flyable {
    void fly();
}

2. Each bird implements only what it actually can do

Flying birds implement Flyable. Penguins don’t. No exceptions, no silent ignoring, no broken contracts.

// Eagle.java / Sparrow.java - implement Flyable
public class Eagle extends Bird implements Flyable {
    public Eagle() { super("Eagle"); }

    @Override
    public void eat() {
        System.out.println("Eagle hunting for prey.");
    }

    @Override
    public void fly() {
        System.out.println("Eagle soaring at high altitude.");
    }
}

public class Sparrow extends Bird implements Flyable {
    public Sparrow() { super("Sparrow"); }

    @Override
    public void eat() {
        System.out.println("Sparrow pecking at seeds.");
    }

    @Override
    public void fly() {
        System.out.println("Sparrow fluttering between branches.");
    }
}
// Penguin.java - honest about its capabilities
public class Penguin extends Bird {
    public Penguin() { super("Penguin"); }

    @Override
    public void eat() {
        System.out.println("Penguin diving for fish.");
    }

    // no fly() - and that's correct
}

3. The handler works with the right abstraction

Code that needs flying birds asks for Flyable. Code that needs any bird asks for Bird. No runtime surprises, no defensive instanceof checks.

// BirdHandler.java - no more surprises
public class BirdHandler {

    // only accepts birds that can actually fly
    public void sendToSky(List<Flyable> flyingBirds) {
        for (Flyable bird : flyingBirds) {
            bird.fly();  // safe. every Flyable can fly. that's the contract.
        }
    }

    // accepts any bird
    public void feedAll(List<Bird> birds) {
        for (Bird bird : birds) {
            bird.eat();  // safe for Eagle, Sparrow, and Penguin alike
        }
    }
}

// Now the types enforce what's allowed
List<Flyable> flyers   = List.of(new Eagle(), new Sparrow()); // Penguin won't compile here
List<Bird>    allBirds = List.of(new Eagle(), new Sparrow(), new Penguin());

handler.sendToSky(flyers);   // works
handler.feedAll(allBirds);   // works

Why This Actually Works

The compiler becomes your safety net. In the broken version, adding a Penguin to a list of flying birds compiles without complaint and fails at runtime. In the fixed version, passing a Penguin where a Flyable is expected is a compile-time error. The bug moves from production to your IDE.

Substitutability is restored. Any Flyable can replace any other Flyable without the caller knowing. Any Bird can replace any other Bird without the caller knowing. That’s LSP: the promise the type makes is a promise every instance keeps.

The real lesson is about inheritance. The penguin example looks like it’s about birds. It’s actually about the limits of “is-a” thinking. A penguin is a bird in biology. But in code, “is-a” means “can do everything the base class promises.” When those two definitions conflict, the hierarchy is wrong, not the penguin.


How to Spot LSP Violations

Watch for these signals in your own code:

  • A subclass throws UnsupportedOperationException in an overridden method.
  • You find yourself writing instanceof checks before calling a method.
  • A subclass overrides a method to do nothing (empty body, silent ignore).
  • You describe a subclass as a base class “but without feature X”.

Any of these usually means the subclass is not a true substitute. The hierarchy needs to be reconsidered, not patched.


Bonus: Preconditions and Postconditions

LSP goes deeper than just “don’t throw exceptions in overridden methods.” The full picture comes from Bertrand Meyer’s Design by Contract — the idea that every method has a contract with its caller, made up of two parts:

Precondition is what must be true before a method runs. It’s the method’s requirements from the caller. For example: “I will only fly if the wind speed is provided and is a positive number.”

Postcondition is what the method guarantees will be true after it runs. It’s the method’s promise to the caller. For example: “After calling fly(), the bird will have moved to a new position.”

LSP adds one rule for each:

  • A subclass must not tighten preconditions. If the base class accepts any wind speed above 0, the subclass cannot demand wind speed above 50. The caller already passed something valid by the base class rules — the subclass has no right to reject it.
  • A subclass must not weaken postconditions. If the base class promises the bird moved after flying, the subclass cannot silently skip the movement. The caller is counting on that guarantee.

In code:

// Flyable.java - base contract
// precondition: windSpeed > 0
// postcondition: bird has moved to a new position
public interface Flyable {
    void fly(double windSpeed);
}

// Eagle.java - honors the contract
public class Eagle extends Bird implements Flyable {
    @Override
    public void fly(double windSpeed) {
        if (windSpeed <= 0) throw new IllegalArgumentException("Wind speed must be positive.");
        System.out.println("Eagle soaring, wind: " + windSpeed + " km/h");
        // postcondition honored: eagle moved
    }
}

// StormEagle.java - tightened precondition: LSP violation
public class StormEagle extends Bird implements Flyable {
    @Override
    public void fly(double windSpeed) {
        // hardened precondition: now requires windSpeed > 50
        if (windSpeed <= 50) throw new IllegalArgumentException("Not enough wind for StormEagle.");
        System.out.println("StormEagle riding the storm.");
    }
}
// Caller plays by the base class rules
Flyable bird = new StormEagle();
bird.fly(10);  // valid by Flyable's contract, throws in StormEagle. LSP violated.

The caller passed 10 — perfectly valid according to the Flyable contract. StormEagle rejected it anyway. That’s a tightened precondition, and it breaks substitutability just as effectively as throwing UnsupportedOperationException.

The rule of thumb: accept at least as much as the base class, guarantee at least as much as the base class.


Wrapping Up

LSP is the principle that keeps inheritance honest. It’s not enough for a subclass to share a parent’s fields and method signatures. It has to share the behaviour those signatures promise. When it doesn’t, every caller that works with the base type becomes a potential crash site.

The penguin is a useful reminder that real-world “is-a” relationships don’t always translate cleanly into code. When they don’t, the right move is to redesign the hierarchy, not to override methods with exceptions and hope no one notices.

Next up: the Interface Segregation Principle, where we look at what happens when interfaces grow too large and how splitting them keeps implementations clean.


Have you ever been burned by a broken substitution? Drop a comment below. LSP violations tend to hide until the worst possible moment, and the instanceof check that “fixed” it is usually still somewhere in the codebase. I’m curious how you spotted it and whether the hierarchy got fixed or just patched.

Posted in solidTags:
Write a comment