📐 Section 18 · Question #5

What is Liskov Substitution Principle

The Liskov Substitution Principle (LSP) is the third principle of the SOLID design principles, introduced by Barbara Liskov in 1987:


🟢 Junior Level

The Liskov Substitution Principle (LSP) is the third principle of the SOLID design principles, introduced by Barbara Liskov in 1987:

“Subtypes must be substitutable for their base types without altering the correctness of the program.”

Plain English Meaning

If class B is a subtype of class A, any program component expecting an object of type A must be able to accept an object of type B without producing unexpected runtime exceptions, broken assumptions, or erratic behavior:

  • A subclass must not break contracts established by the parent type.
  • A subclass must not reject inputs that the parent accepts.
  • A subclass must not throw unexpected exceptions when base methods are called.
European Standard Electrical Wall Socket (Schuko) Analogy:
+-------------------------------------------------------------------------+
| • The socket expects any standard 230V Schuko plug (Base Contract).     |
| • A kettle, laptop charger, or vacuum cleaner (Subtypes) plug in and    |
|   operate reliably without blowing the building's circuit breaker.      |
| • A defective "smart plug" that causes short-circuits or trips fuses    |
|   violates the physical contract (LSP Violation).                       |
+-------------------------------------------------------------------------+

Classic Anti-Pattern: Ostriches and Birds

// ❌ VIOLATION: Ostrich breaks the behavioral contract of Bird
public class Bird {
    public void fly() {
        System.out.println("Flying in the sky!");
    }
}

public class Ostrich extends Bird {
    @Override
    public void fly() {
        // Ostriches are biologically birds, but cannot fly!
        throw new UnsupportedOperationException("Ostriches cannot fly!");
    }
}

// Client service crashes unexpectedly:
public class SanctuaryService {
    public void migrateAll(List<Bird> birds) {
        for (Bird bird : birds) {
            bird.fly(); // 💥 CRASHES with UnsupportedOperationException when an Ostrich is passed!
        }
    }
}

Compliant Solution: Behavioral Subtyping

Object-oriented hierarchies must model behavior, not real-world biological taxonomies:

// ✅ COMPLIANT: Hierarchy separated by behavior
public interface Bird {
    void eat();
}

public interface FlyingBird extends Bird {
    void fly();
}

public class Sparrow implements FlyingBird {
    @Override public void eat() { System.out.println("Eating seeds"); }
    @Override public void fly() { System.out.println("Flying high!"); }
}

public class Ostrich implements Bird {
    @Override public void eat() { System.out.println("Eating grass"); }
    // No fly() method; contract is preserved!
}

The migration routine now accepts List<FlyingBird>, eliminating runtime crashes at compile time.


🟡 Middle Level

1. The 4 Rules of Design by Contract (DbC)

LSP is formally anchored in Bertrand Meyer’s and Barbara Liskov’s Design by Contract:

Contract Rule Formal Definition Subclass Constraint
1. Preconditions Conditions that must hold before method execution. Cannot be strengthened in a subclass. (If parent accepts any int, child cannot throw exceptions for negative values).
2. Postconditions Guarantees that must hold after method completion. Cannot be weakened in a subclass. (If parent guarantees returning a non-null list or closed stream, child must do the same).
3. Invariants State conditions that remain true before and after every method. Must be preserved by the subclass. (If parent guarantees balance >= 0, child methods cannot leave negative balances).
4. History Constraint State properties that cannot mutate over the lifecycle. Child cannot turn immutable base properties into mutable state.

2. The Mutable Square and Rectangle Paradox

In mathematics, a square is a rectangle with equal sides. In mutable object-oriented programming, inheritance between them breaks LSP:

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 getArea() { return width * height; }
}

public class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width; // Enforcing square equality mutates height!
    }

    @Override
    public void setHeight(int height) {
        this.width = height;
        this.height = height; // Enforcing square equality mutates width!
    }
}

Breaking the Client Assumption:

public void resizeAndVerify(Rectangle r) {
    r.setWidth(5);
    r.setHeight(10);
    // For Rectangle: expected area = 5 * 10 = 50
    // For Square: setHeight(10) silently modified width to 10 -> Area is 100!
    assert r.getArea() == 50 : "LSP Violation! Expected 50, but got " + r.getArea();
}

Key Takeaway: An “IS-A” relationship must be validated by mutation semantics, not static dictionary definitions.


3. Famous LSP Violations in the Java Standard Library (JDK)

Even the Java platform contains notable historical LSP violations:

  1. Collections.unmodifiableList(list):
    • Implements List<T>, but calling add() or remove() throws UnsupportedOperationException.
    • Callers expecting a standard List<T> have their postcondition assumptions violated.
  2. java.sql.Date extends java.util.Date:
    • Represents only date components (no hours, minutes, seconds).
    • Calling date.getHours() throws IllegalArgumentException, breaking the contract of java.util.Date.
  3. java.util.Stack extends java.util.Vector:
    • A Stack is strictly a LIFO structure (push, pop).
    • Because it inherits from Vector, callers can invoke stack.add(2, element) to insert items into the middle of the stack, violating LIFO invariants.

🔴 Senior Level

1. Mathematical Formalism: Liskov and Wing (1994)

In their seminal paper “A Behavioral Notion of Subtyping”, Barbara Liskov and Jeannette Wing defined subtyping using predicate logic:

\[\forall x : T, \quad \phi(x) \implies \phi(y) \quad \text{where } y : S \text{ and } S \text{ is a subtype of } T\]

Let $\phi(x)$ be a property provable about objects $x$ of type $T$. Then $\phi(y)$ must hold true for objects $y$ of type $S$, where $S$ is a subtype of $T$.

Contract Implications:

  • Preconditions: $Pre(T) \implies Pre(S)$ (The parent’s precondition implies the child’s precondition; the child requires the same or less).
  • Postconditions: $Post(S) \implies Post(T)$ (The child’s postcondition implies the parent’s postcondition; the child guarantees the same or more).
  • Invariants: $Inv(S) \implies Inv(T)$.

2. Covariance and Contravariance in Java

Java’s compiler enforces type-level subtyping rules that mirror LSP:

  1. Return Type Covariance:
    • A subclass method can return a more specific subtype than declared in the parent: ```java public class BaseService { public CharSequence getPayload() { return “base”; } }

    public class SpecificService extends BaseService { @Override public String getPayload() { return “specific”; } // String is a subtype of CharSequence } ```

    • This complies with LSP: callers expecting CharSequence receive String, which satisfies all expected methods.
  2. Method Argument Contravariance:
    • Under pure LSP, a subclass should be able to accept wider argument types (e.g., base accepts String, subclass accepts Object).
    • Java Limitation: Java does not support contravariant method parameters in method overriding; doing so is treated as method overloading.
    • However, contravariance is supported via generic wildcards (PECS: Producer Extends, Consumer Super):
      // Contravariant consumer: can consume any supertype of User
      public void processUsers(Consumer<? super User> consumer) { ... }
      

3. Enforcing LSP with Contract Testing in CI/CD

In distributed microservices and framework architecture, LSP adherence is verified using Contract Tests (Parameterized Base Test Suites):

// Base Contract Test defining behavioral guarantees for all Repository implementations
public abstract class RepositoryContractTest<T extends Repository<Entity, String>> {

    protected abstract T createRepository();

    @Test
    void shouldSaveAndRetrieveEntity() {
        T repo = createRepository();
        Entity entity = new Entity("order-101", "PAID");
        repo.save(entity);

        Optional<Entity> found = repo.findById("order-101");
        assertTrue(found.isPresent());
        assertEquals("PAID", found.get().status());
    }

    @Test
    void shouldNotThrowExceptionWhenDeletingNonExistentId() {
        T repo = createRepository();
        // Base contract: delete is idempotent, must not throw exception!
        assertDoesNotThrow(() -> repo.deleteById("non-existent-id"));
    }
}

// Verification for Postgres implementation
class PostgresRepositoryTest extends RepositoryContractTest<PostgresRepository> {
    @Override
    protected PostgresRepository createRepository() {
        return new PostgresRepository(dataSource);
    }
}

// Verification for In-Memory cache implementation
class InMemoryRepositoryTest extends RepositoryContractTest<InMemoryRepository> {
    @Override
    protected InMemoryRepository createRepository() {
        return new InMemoryRepository();
    }
}

If InMemoryRepository throws an unexpected exception on deleting a missing key, it fails the inherited contract test, halting the CI pipeline before production deployment.


4 Tricky Questions

1. Can a subclass throw a broader or new checked exception when overriding a method?

Answer: No. The Java compiler strictly enforces that an overriding method cannot declare broader or new checked exceptions in its throws clause. If the superclass declares throws IOException, the subclass cannot declare throws Exception or throws SQLException.

However, the compiler allows overriding methods to throw any unchecked RuntimeException (NullPointerException, UnsupportedOperationException). Throwing undocumented, unexpected runtime exceptions is the most common way developers inadvertently violate LSP in production.

2. Why does immutability solve the Square / Rectangle paradox?

Answer: The paradox only occurs when state mutation methods (setWidth, setHeight) permit modifying dimensions independently, which breaks the geometric invariants of a square.

With immutable data carriers (record Rectangle(int width, int height) and record Square(int side)):

  • Neither class provides mutator methods.
  • Both can safely implement a common Shape interface (int getArea()).
  • Because state cannot change after instantiation, no hidden side effects can alter dimensions or invalidate caller assumptions.

3. Does the Null Object Pattern violate LSP?

Answer: When implemented properly, the Null Object pattern is an exemplary application of LSP. Instead of returning null (which causes NullPointerException), the Null Object returns safe default values (e.g., empty strings, empty lists) or executes no-op actions.

It violates LSP only if callers expect a method to mutate persistent external state (e.g., writing an audit trail to disk) and the Null Object silently drops the operation where callers depend on guaranteed persistence.

4. Does checking if (obj instanceof Subclass) in business code prove an LSP violation?

Answer: Almost always yes (excluding exhaustive pattern matching over Java 21 sealed hierarchies).

If a service must verify the runtime type of an object to execute specific logic or avoid invoking an unsupported method, the abstraction is deficient:

  • The base interface does not adequately represent the capabilities of its subtypes.
  • Subtypes are not transparently substitutable. The proper fix is to encapsulate the variant behavior into the base interface or apply the Visitor Pattern.

🎯 Interview Cheat Sheet

Core Concepts:

  • LSP Definition: Subtypes must be transparently substitutable for their base types without altering system correctness.
  • Design by Contract: Preconditions cannot be strengthened; postconditions cannot be weakened; class invariants must be preserved.
  • Primary Code Smells: UnsupportedOperationException in overrides, empty no-op methods, and defensive instanceof checks.
  • Classic Traps: Square extends Rectangle (mutation side effects); Collections.unmodifiableList() (throws on add()).
  • Remedy: Model abstractions by behavior, prefer Composition over Inheritance, and enforce contracts via CI/CD contract tests.

Red Flags to Avoid:

  • ❌ “If the code compiles without errors, LSP is respected.” (LSP is a behavioral and semantic principle; the compiler cannot detect logic regressions or thrown unchecked exceptions).
  • ❌ “Subclasses can override methods to do whatever they want, including throwing UnsupportedOperationException.” (Throwing unexpected exceptions directly violates LSP).
  • ❌ “Real-world ‘IS-A’ relationships always justify inheritance in OOP.” (A square is mathematically a rectangle, but mutability makes inheritance invalid).