Appearance
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
- "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
Squaredoes not behave like aRectangleif it has a conflicting invariant. Prefer "behaves-like-a" as your inheritance criterion. - 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. - Subclasses may weaken preconditions — If the parent requires
amount > 0, a subclass may acceptamount >= 0. Callers designed for the parent will always satisfy the looser requirement. - 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.
- 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.
- 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.
- Sealed classes in Java 17+ — When you control the full hierarchy, use
sealedandpermitsto make the substitution set explicit and prevent unexpected subclasses.
Related Concepts
Other SOLID Principles:
- SRP — Single Responsibility Principle: Classes with single responsibilities are easier to subtype correctly because their contracts are narrower and clearer.
- OCP — Open/Closed Principle: OCP extensions must satisfy LSP — every new implementation added via extension must be substitutable for the interface it implements.
- ISP — Interface Segregation Principle: Narrow interfaces have narrower contracts, making LSP compliance easier to achieve and verify.
- DIP — Dependency Inversion Principle: DIP works only because LSP guarantees that any implementation of an abstraction is a valid substitute.
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.