Skip to content

LSP - The Liskov Substitution Principle

If code works with a base type, it must keep working with any subtype. No surprise exceptions, no stricter inputs, no weaker promises.

Builds on Abstract classes and inheritance and Method overriding and polymorphism

Stage 2core11 min read

What it says

If code works with a base type, it should keep working when you hand it any subtype, without knowing the difference.

A base type is a parent class or an interface. A subtype is a subclass, or a class that implements that interface. Substitution happens when a method asks for the base type and gets one of these objects instead.

Barbara Liskov introduced the principle in a 1987 keynote, and she and Jeannette Wing later wrote it as a formal rule. For everyday Java, the idea is simpler: a subtype must keep every promise its base type makes about behavior.

Follow one call step by step:

  1. A method accepts PaymentMethod, not a particular payment class.
  2. The method trusts what PaymentMethod promises.
  3. At runtime it receives CardPayment, UpiPayment, or another implementation.
  4. The method should not need to check which one it got.
  5. If one implementation rejects an input that was fine before, returns a surprise value, or throws an unexpected exception, substitution has failed.

The important word is behavior. Java checks names, parameter types, return types, and some exception rules. It cannot check that pay() returns a real transaction id or that write() actually writes. LSP covers those promises.

That is why the everyday "is-a" test is too weak. A square is a rectangle in maths, and a penguin is a bird in biology. Neither fact means the class can keep the parent's promises in code. Ask instead: does it behave like the base type when a caller uses it?

An everyday analogy

Think of a spare tyre (stepney). If it fits and lets you drive the same way as the regular tyre, it is a true substitute. A thin space-saver spare fits the wheel but comes with a rule: "maximum 80 km/h." It works in fewer driving conditions than the tyre it replaces.

The shape matches, but the deal is worse. A subtype can fail the same way: the method signatures fit, but the behavior adds limits or drops guarantees.

The rules

A contract is the deal between a caller and a type. It is more than a Java method signature. It has three parts:

  • A precondition is what the caller must provide before a call. Example: "the amount must be positive."
  • A postcondition is what the method guarantees after a successful call. Example: "the returned transaction id is not null."
  • An invariant is a rule that stays true for as long as the object is valid. Example: "the balance never becomes negative."

Now compare a parent and subtype one rule at a time:

RulePlain meaningBroken by
Preconditions can't be stricterA subtype cannot demand more from the caller than the base type didParent accepts any positive amount; subtype throws above ₹1,000
Postconditions can't be weakerA subtype must provide at least what the base type promisedParent promises a receipt id; subtype sometimes returns null
Invariants must holdRules that are always true for the base type must remain trueAccount keeps balance at 0 or above; subtype allows -5,000
No new exceptionsA subtype should not surprise callers with a new failureUnsupportedOperationException from an override
History constraintA subtype must not allow state changes the parent ruled outMutable subclass of an immutable Money class

The direction can feel backward at first. A subtype may accept more inputs, but not fewer. It may promise more about its result, but not less. The caller must get at least the deal the base type advertised.

Java enforces only part of that deal. An override cannot throw a broader checked exception or make the method less visible (for example, public to protected). Parameter types must match exactly; change one and you get an overload, not an override. A return type may become a more specific type. Unchecked exceptions, null results, empty methods and hidden state changes still compile, so contract tests and code review must catch them.

How to spot a violation

  • Overrides that throw UnsupportedOperationException or "not implemented". The most common one by far.
  • Empty overrides that silently do nothing. void fly() { } in Penguin. Worse than throwing, because nobody notices.
  • instanceof checks ("is this object of that class?") before calling a method. if (!(file instanceof ReadOnlyFile)) file.write(); The caller is patching a broken hierarchy.
  • Overrides that return dummy values. null, -1, an empty list where the parent always returned real data.
  • Extra validation in the subclass. if (amount > 1000) throw ... that the parent never had.
  • Javadoc on an override that says "don't call this".
  • Tests for the parent fail when you run them against the subclass. This is the best test for LSP. Run the base class test suite with each subclass.
  • Inheritance used only to reuse code. class OrderList extends ArrayList<Order> so you can "get add() for free". Now every ArrayList method is part of your contract, even ones you never meant to offer.

Even the JDK bends this rule. List.of(...) and Collections.unmodifiableList(...) return a List whose add() throws. The Collection docs call add an "optional operation". It is a known, documented trade-off, not something to copy in your own designs.

Example 1: the read-only file in this repo

Before

BadCode/File.java promises two things, read and write. BadCode/ReadOnlyFile.java takes one of them back:

public class File {
    public void read()  { System.out.println("reading from file..."); }
    public void write() { System.out.println("writing a file...."); }
}

public class ReadOnlyFile extends File {
    @Override
    public void write() {
        throw new UnsupportedOperationException("Can't write to a read-only file");
    }
}
File file = new ReadOnlyFile();
file.read();   // fine
file.write();  // blows up at runtime

Any method that takes a File and writes to it is now a time bomb. The compiler is happy. The bug only shows up when a ReadOnlyFile happens to reach that method, maybe months later. The usual "fix" is an instanceof check in the caller, which spreads the problem to every caller.

Loading diagram…

After

GoodCode only promises what each type can actually do:

public interface Readable { void read(); }
public interface Writable { void write(); }

public class ReadableFile implements Readable {
    @Override
    public void read() { System.out.println("Reading from a file"); }
}

public class ReadOnlyFile extends ReadableFile { }

public class WritableFile extends ReadableFile implements Writable {
    @Override
    public void write() { System.out.println("Writing to a file ...."); }
}
Loading diagram…

Why it works:

  • readAnyFile(ReadableFile file) accepts both subclasses, and both really can read.
  • Code that needs to write asks for a Writable. You can't pass a ReadOnlyFile there. The compiler stops you, so the runtime bug is gone.
  • WritableFile adds write() on top. Adding behaviour is fine. Removing it is what breaks LSP.

Small gotcha: java.lang.Readable already exists. Inside the same package the local Readable wins, so the repo code compiles, but in a real project pick a name that doesn't clash with java.lang, like ReadableSource.

Example 2: Rectangle and Square

The classic. Interviewers love it because the bad version looks so reasonable.

Before

public class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int width)   { this.width = width; }
    public void setHeight(int height) { this.height = height; }
    public int area()                 { return width * height; }
}

public class Square extends Rectangle {
    @Override
    public void setWidth(int side)  { this.width = side; this.height = side; }

    @Override
    public void setHeight(int side) { this.width = side; this.height = side; }
}

Square stays a valid square by changing both sides in each setter. Now look at a caller that only knows about rectangles, say a banner layout on a home screen:

void resizeBanner(Rectangle banner) {
    banner.setWidth(5);
    banner.setHeight(4);
    assert banner.area() == 20;   // Square gives 16
}

Rectangle.setWidth had a promise (a postcondition) that nobody wrote down: "after this call the width is the new value and the height is unchanged". Square broke it. No exception, no warning, just a wrong number.

After

Stop pretending one is a kind of the other. Give them a shared interface and make both immutable (a value never changes after it is created):

public interface Shape {
    int area();
}

public record Rectangle(int width, int height) implements Shape {
    public int area() { return width * height; }
    public Rectangle withWidth(int w) { return new Rectangle(w, height); }
}

public record Square(int side) implements Shape {
    public int area() { return side * side; }
}
Loading diagram…

Anyone asking for a Shape only relies on area(), and both keep that promise. Anyone who needs a separate width and height asks for a Rectangle, and a Square can't sneak in. A record is a short way to write a class that only holds fixed values. Records are also final, so nobody can repeat the old mistake by extending Rectangle later.

In an interview, the immutable version is the answer they want. In real code, ask first whether anything actually needs to treat squares as rectangles. Usually nothing does, and a single Rectangle record with an isSquare() method is all you need.

The same story applies to Bird and Penguin. If Bird has fly(), then Penguin either throws or does nothing, and both break callers. The fix is the same: Bird has what every bird does (eat(), layEgg()), and a separate FlyingBird or Flyer interface adds fly() for the ones that can.

Example 3: payment methods that quietly change the rules

A checkout has a base type for payment methods:

public abstract class PaymentMethod {
    /** Charges the amount. Returns a transaction id. */
    public abstract String pay(double amount);

    /** Refunds a previous payment, fully or partly. */
    public abstract void refund(String txnId, double amount);
}

Over time, three subclasses show up:

public class CardPayment extends PaymentMethod {
    public String pay(double amount) { return "CARD-1001"; }
    public void refund(String txnId, double amount) { /* ... */ }
}

public class UpiLitePayment extends PaymentMethod {
    public String pay(double amount) {
        if (amount > 1000) {                                   // stricter precondition
            throw new IllegalArgumentException("UPI Lite limit is Rs 1000");
        }
        return "UPI-1001";
    }
    public void refund(String txnId, double amount) { /* ... */ }
}

public class CashOnDelivery extends PaymentMethod {
    public String pay(double amount) {
        return null;                                           // weaker postcondition
    }
    public void refund(String txnId, double amount) {
        throw new UnsupportedOperationException();             // new exception
    }
}

Three rules broken in one hierarchy:

  • UpiLitePayment demands amount <= 1000. A ₹1,499 order that worked with a card now crashes at checkout. (Say the wallet has a ₹1,000 cap; the exact real limit doesn't matter here.)
  • CashOnDelivery.pay() returns null where callers were promised a transaction id. The order service saves null and the support dashboard throws a NullPointerException a week later.
  • CashOnDelivery.refund() throws. The cancellation flow that refunds "any payment method" now fails for COD orders.

Fixing it

Make the hidden rules part of the contract, and split the capability that not everyone has. This is a bigger redesign than the prose suggests: PaymentMethod becomes an interface, pay returns PaymentResult instead of a String, and refund moves out to Refundable.

public interface PaymentMethod {
    boolean canPay(double amount);
    PaymentResult pay(double amount);           // never null
}

public interface Refundable {
    void refund(String txnId, double amount);
}

public sealed interface PaymentResult {
    record Paid(String txnId) implements PaymentResult { }
    record PayOnDelivery(double amountDue) implements PaymentResult { }
}

public class UpiLitePayment implements PaymentMethod, Refundable {
    private static final double LIMIT = 1000;

    public boolean canPay(double amount) { return amount <= LIMIT; }
    public PaymentResult pay(double amount) { return new PaymentResult.Paid("UPI-1001"); }
    public void refund(String txnId, double amount) { /* ... */ }
}

public class CashOnDelivery implements PaymentMethod {
    public boolean canPay(double amount) { return amount <= 5000; }
    public PaymentResult pay(double amount) { return new PaymentResult.PayOnDelivery(amount); }
}

PaymentResult is sealed, so only the two listed records can implement it. pay now returns one of them, never null.

The limit has not disappeared. It moved into the base contract: pay may only be called after canPay returned true. Every implementation lives under that same rule, so no subclass is stricter than the parent, and callers know to check.

The checkout now asks first, instead of finding out by crashing:

List<PaymentMethod> options = allMethods.stream()
        .filter(m -> m.canPay(cart.total()))
        .toList();

And cancellation only refunds what can be refunded:

if (order.paymentMethod() instanceof Refundable r) {
    r.refund(order.txnId(), order.total());
} else {
    cashRefundQueue.add(order);
}

Wait, isn't that an instanceof? Yes, but it is a different kind. It tests for a capability that is part of the design (Refundable), not for one subclass that broke the parent's promise. The difference is that no caller of PaymentMethod is ever surprised.

When not to worry about it (and when to avoid inheritance)

LSP is one of the few SOLID rules that is almost never "too much". If you inherit, keep the promises. The over-engineering risk is elsewhere:

  • Don't split out an interface that no code asks for by that type. If no code ever takes "any Refundable", you don't need that interface yet.
  • If you don't need substitution, don't inherit. Inheriting from ArrayList or HashMap to reuse code is a common trap. Use composition: hold a list as a field and expose only what you need.
  • Documented optional operations are a trade-off, not a crime. The JDK collections do it on purpose. If you ever do something similar, document it loudly and give callers a way to check first (like canPay).
  • Utility base classes with no callers that use the base type don't really have a contract to break. LSP matters where some code says "give me any X".

How it connects to the rest

  • OCP (add new behavior without editing old code) depends on LSP. You can only add new subtypes without editing the caller if every subtype behaves. See OCP-Notes.md.
  • ISP is often the fix. A fat parent forces subclasses to fake methods, and splitting it (like Readable and Writable) removes the temptation. See ISP-Notes.md.
  • equals(): the equals and hashCode note compares getClass() instead of using instanceof. That is an LSP story too, with no clean answer. With instanceof, a subclass that adds fields to equals breaks symmetry with its parent (a.equals(b) and b.equals(a) give different answers). With getClass(), a subclass object can never equal a parent object, which is its own substitution problem. Joshua Bloch's advice in Effective Java is to use composition instead of extending a class that has a meaningful equals.
  • Overriding basics: 07-OverridingAndPolymorphism.md covers what Java checks for you at compile time.

Patterns where LSP matters most:

  • Strategy and State: the context swaps implementations freely, so every one must honour the same contract.
  • Template Method: subclasses fill in steps, and a step that throws or returns junk breaks the whole algorithm.
  • Decorator and Proxy: a wrapper must be usable anywhere the wrapped object is.

Before you commit, ask

  • Does any override throw an exception the parent never threw?
  • Does any override do nothing, or return null or -1 where the parent returned real data?
  • Does the subclass reject inputs the parent accepted?
  • Would the parent's unit tests pass if I ran them against this subclass?
  • Is any caller checking instanceof on this specific subclass to avoid a crash?
  • Am I inheriting for substitution, or just to reuse some code?

Interview questions

"What is the Liskov Substitution Principle in one line?"

Any code that works with a base type must keep working when given a subtype, without knowing or checking which subtype it is. In practice it means a subclass must keep every promise the parent made about behaviour, not just match its method signatures.

"Why is Square extending Rectangle a violation if a square is a rectangle?"

Because Rectangle promises that width and height can be set independently. Square has to link them, so code that sets width 5 and height 4 expects area 20 but gets 16. The maths "is-a" holds, but the behaviour doesn't. Making both immutable shapes under a common Shape interface fixes it.

"What are the rules a subclass has to follow?"

It can't demand more from callers (no stricter preconditions), it can't deliver less (no weaker postconditions), it must keep the parent's invariants, and it shouldn't throw new exceptions callers don't expect. There is also the history constraint: a subclass can't allow state changes the parent never allowed, like a mutable subclass of an immutable class. Java only checks part of this, like checked exceptions and visibility, so the rest is on you.

"Is throwing UnsupportedOperationException from an override always wrong?"

It is an LSP violation, because callers of the parent didn't expect it. The JDK does it for unmodifiable collections and documents add as an optional operation, so it is a known trade-off. In your own code, prefer splitting the capability into its own interface so the compiler stops the bad call.

"How can you test for LSP?"

Write the tests against the base type and run the same suite against every subclass. If a test passes for the parent and fails for a subclass, that subclass is not a valid substitute. Callers adding instanceof checks for one subclass is another strong signal.

"How do LSP and OCP relate?"

OCP says you should add new subtypes without changing the code that uses them. That is only safe if each new subtype behaves like the base type, which is what LSP guarantees. Break LSP and the caller has to change again, which breaks OCP too.

"How would you model Penguin if Bird has fly()?"

Take fly() out of Bird, since not every bird can do it. Put shared behaviour like eat() in Bird, and add a Flyer interface or a FlyingBird subclass for birds that can fly. Code that needs flying asks for a Flyer, so a penguin can never reach it.

In this topic

Done with this topic?

How well do you know it?

PreviousOCP - The Open/Closed Principle
Practise 7 questions