Skip to content

Liskov Substitution Principle (LSP)

SOLID Series — This article is Part 3 of 5. See also: SRP · OCP · ISP · DIP

Introduction

The Liskov Substitution Principle, formulated by Barbara Liskov in her 1987 keynote "Data Abstraction and Hierarchy," states that objects of a subclass should be substitutable for objects of the parent class without altering the correctness of the program. Put simply: if S is a subtype of T, then objects of type T may be replaced with objects of type S without breaking anything. LSP ensures that inheritance is used correctly and that polymorphism remains trustworthy.

Core Concept

LSP is about behavioral subtyping, not just structural subtyping. A subclass can override a method syntactically while still violating the behavioral contract the parent class establishes. The contract includes not only the method signature but also the preconditions the caller must satisfy, the postconditions the method guarantees, and the invariants the class maintains throughout its lifetime.

A practical test for LSP: write the tests for the parent class and run them against every subclass. If any subclass fails a parent class test, LSP is violated.

Anti-Pattern — The Rectangle/Square Problem

The canonical LSP violation is the Rectangle/Square problem. Mathematically, a square is a rectangle — every square is a rectangle. But in object-oriented design, a Square that extends Rectangle and overrides dimension setters breaks the behavioral contract that Rectangle establishes.

java
// Rectangle establishes a contract: width and height are independent
public class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int width) {
        this.width = width;
        // Contract: setting width does NOT affect height
    }

    public void setHeight(int height) {
        this.height = height;
        // Contract: setting height does NOT affect width
    }

    public int getWidth() { return width; }
    public int getHeight() { return height; }

    public int area() {
        return width * height;
    }
}
java
// ANTI-PATTERN: Square violates Rectangle's contract
public class Square extends Rectangle {

    @Override
    public void setWidth(int width) {
        // Square enforces equal dimensions — BREAKS Rectangle's contract
        // that setWidth does not change height
        this.width = width;
        this.height = width; // ← postcondition violation
    }

    @Override
    public void setHeight(int height) {
        this.width = height; // ← postcondition violation
        this.height = height;
    }
}
java
// TEST: Demonstrates the LSP violation
public class LspViolationTest {

    // This test is written against Rectangle's contract
    private void assertRectangleContract(Rectangle r) {
        r.setWidth(5);
        r.setHeight(4);
        // Rectangle's contract guarantees: area = width * height = 5 * 4 = 20
        assert r.area() == 20 : "Expected area 20 but got: " + r.area();
    }

    @Test
    void rectangleContractPassesForRectangle() {
        Rectangle r = new Rectangle();
        assertRectangleContract(r); // Passes — area is 20
    }

    @Test
    void rectangleContractFailsForSquare() {
        Rectangle r = new Square(); // Using Square as Rectangle — LSP scenario
        assertRectangleContract(r);
        // FAILS: Square.setHeight(4) also sets width to 4
        // area = 4 * 4 = 16, not 20
        // Square cannot substitute for Rectangle without breaking the program
    }
}

The root problem is that Square needs a stronger invariant (width == height at all times) that directly conflicts with Rectangle's postconditions for setWidth and setHeight.

Correct Implementation 1 — Shape Hierarchy Without Shared Mutation

The solution is to not model geometric congruence through inheritance of mutable state. Instead, define a Shape interface with only the behaviors that all shapes genuinely share.

java
// CORRECT: Shape interface declares only the truly shared contract
public interface Shape {
    int area();
    String describe();
}

// CORRECT: Rectangle implements Shape — manages its own invariants independently
public class Rectangle implements Shape {
    private final int width;
    private final int height;

    public Rectangle(int width, int height) {
        if (width <= 0 || height <= 0)
            throw new IllegalArgumentException("Dimensions must be positive");
        this.width = width;
        this.height = height;
    }

    @Override
    public int area() {
        return width * height;
    }

    @Override
    public String describe() {
        return String.format("Rectangle(%d x %d)", width, height);
    }

    public int getWidth() { return width; }
    public int getHeight() { return height; }
}

// CORRECT: Square implements Shape independently — no shared mutation contract
public class Square implements Shape {
    private final int side;

    public Square(int side) {
        if (side <= 0)
            throw new IllegalArgumentException("Side must be positive");
        this.side = side;
    }

    @Override
    public int area() {
        return side * side;
    }

    @Override
    public String describe() {
        return String.format("Square(%d)", side);
    }

    public int getSide() { return side; }
}
java
// Both Rectangle and Square are safely substitutable for Shape
public class ShapeProcessor {

    public void printShapeInfo(List<Shape> shapes) {
        shapes.forEach(shape -> {
            // Works correctly for Rectangle, Square, Circle, Triangle — any Shape
            System.out.printf("%s -> area: %d%n", shape.describe(), shape.area());
        });
    }

    public int totalArea(List<Shape> shapes) {
        return shapes.stream().mapToInt(Shape::area).sum();
    }
}

// Usage demonstrating substitutability
public class Main {
    public static void main(String[] args) {
        List<Shape> shapes = List.of(
                new Rectangle(5, 4),   // area = 20
                new Square(3),          // area = 9
                new Rectangle(10, 2)   // area = 20
        );

        ShapeProcessor processor = new ShapeProcessor();
        processor.printShapeInfo(shapes);
        System.out.println("Total area: " + processor.totalArea(shapes)); // 49
    }
}

Correct Implementation 2 — Payment Service Hierarchy

LSP applies to any class hierarchy. Here is a payment service example where CreditCardService and PayPalService are genuinely substitutable for the abstract PaymentService.

java
// Abstract base establishes a clear, consistent contract
public abstract class PaymentService {

    // Template method — algorithm skeleton is fixed
    public final PaymentResult process(PaymentRequest request) {
        validate(request);
        PaymentResult result = executePayment(request);
        logTransaction(result);
        return result;
    }

    // Subclasses provide the implementation detail
    protected abstract void validate(PaymentRequest request);
    protected abstract PaymentResult executePayment(PaymentRequest request);

    protected void logTransaction(PaymentResult result) {
        System.out.println("Transaction " + result.getTransactionId() +
                " completed with status: " + result.getStatus());
    }
}

// CreditCardService honors the full contract
public class CreditCardService extends PaymentService {

    private final CardGateway gateway;

    public CreditCardService(CardGateway gateway) {
        this.gateway = gateway;
    }

    @Override
    protected void validate(PaymentRequest request) {
        // Preconditions: may validate card number format, expiry, etc.
        // Does NOT strengthen preconditions beyond what PaymentService callers expect
        if (request.getAmount() <= 0)
            throw new InvalidPaymentException("Amount must be positive");
        if (request.getCardNumber() == null)
            throw new InvalidPaymentException("Card number is required");
    }

    @Override
    protected PaymentResult executePayment(PaymentRequest request) {
        // Postcondition: always returns a non-null PaymentResult — honoring parent contract
        return gateway.charge(request.getCardNumber(), request.getAmount());
    }
}

// PayPalService is equally substitutable — same contract, different mechanism
public class PayPalService extends PaymentService {

    private final PayPalClient payPalClient;

    public PayPalService(PayPalClient payPalClient) {
        this.payPalClient = payPalClient;
    }

    @Override
    protected void validate(PaymentRequest request) {
        if (request.getAmount() <= 0)
            throw new InvalidPaymentException("Amount must be positive");
        if (request.getPayPalEmail() == null)
            throw new InvalidPaymentException("PayPal email is required");
    }

    @Override
    protected PaymentResult executePayment(PaymentRequest request) {
        return payPalClient.initiatePayment(request.getPayPalEmail(), request.getAmount());
    }
}

// Caller uses the abstraction — both services are substitutable
public class CheckoutService {

    private final PaymentService paymentService;

    public CheckoutService(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    public OrderConfirmation checkout(Cart cart, PaymentRequest paymentRequest) {
        // Works identically whether paymentService is CreditCardService or PayPalService
        PaymentResult result = paymentService.process(paymentRequest);
        return new OrderConfirmation(cart.getOrderId(), result.getTransactionId());
    }
}

Diagrams

Rectangle/Square Violation

Correct Shape Hierarchy

Substitutability Contract

LSP Check Flowchart

Behavioral Subtyping

Best Practices

  1. "Is-a" vs "behaves-like-a" — Mathematical "is-a" relationships do not automatically translate to valid OO inheritance. A square is a rectangle mathematically, but a Square does not behave like a Rectangle if it has a conflicting invariant. Prefer "behaves-like-a" as your inheritance criterion.
  2. Design by contract — Explicitly document preconditions, postconditions, and invariants for every class. This makes LSP violations immediately visible during code review. Tools like Java's @Preconditions (Guava) or formal annotations help.
  3. Subclasses may weaken preconditions — If the parent requires amount > 0, a subclass may accept amount >= 0. Callers designed for the parent will always satisfy the looser requirement.
  4. Subclasses may strengthen postconditions — If the parent guarantees a non-null return, a subclass may additionally guarantee the return is non-empty. Callers will always be satisfied by the stronger guarantee.
  5. Run parent tests against every subclass — This is the most practical LSP verification technique. Write a parameterized test suite for the parent's contract and run it against all known implementations.
  6. Prefer composition when inheritance feels wrong — If making a class extend another requires overriding methods in surprising ways, it is a signal that inheritance is the wrong tool. Use composition instead.
  7. Sealed classes in Java 17+ — When you control the full hierarchy, use sealed and permits to make the substitution set explicit and prevent unexpected subclasses.

Other SOLID Principles:

Further Reading:

  • Design by Contract (Eiffel language origin).
  • Barbara Liskov's 1987 OOPSLA keynote.
  • Covariance and Contravariance in type theory.
  • Template Method Pattern: a structured way to define overridable behavior within a fixed contract.
  • REST API Design: applying substitutability to API versioning.