Loading episodes…
0:00 0:00

Cracking the Code: The Liskov Substitution Principle (LSP) Visually Explained

00:00
BACK TO HOME

Cracking the Code: The Liskov Substitution Principle (LSP) Visually Explained

10xTeam December 21, 2025 9 min read

The Liskov Substitution Principle (LSP) is the “L” in the SOLID acronym and a cornerstone of robust object-oriented design. It provides a critical guideline for creating reliable inheritance hierarchies.

At its core, the principle states:

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 that program (correctness, task performed, etc.).

In simpler terms, a subclass should be able to stand in for its parent class without causing errors or unexpected behavior. If your “child” class can’t do everything its “parent” can, you have a flawed abstraction that will lead to fragile code.

The Analogy: Transportation Breakdown

Imagine a base class, SeaTransport, designed to move passengers over water. It has a method travelAcrossWater().

Now, let’s look at two potential subclasses:

  1. Speedboat: A speedboat is a type of sea transport. It can seamlessly implement travelAcrossWater().
  2. Car: A car is a form of transport, but it’s not sea transport. It cannot fulfill the travelAcrossWater() contract.

If we try to force Car to inherit from SeaTransport, we violate LSP. A function expecting to move passengers across the sea will fail if you substitute a Car for a Speedboat.

This diagram illustrates the core conflict: while both are forms of transport, they don’t share the same fundamental capabilities required by the parent class.

graph TD;
    subgraph LSP Violation
        A[SeaTransport <br> travelAcrossWater()] --> B[Speedboat];
        A --> C{Car};
    end

    subgraph LSP Compliant
        D[SeaTransport] --> E[Speedboat];
        D --> F[Sailboat];
        G[LandTransport] --> H[Car];
        G --> I[Bus];
    end

    style C fill:#f9f,stroke:#333,stroke-width:2px

The Code Problem: A Real-World LSP Violation

Let’s examine a more practical example with shipping providers. We have an abstract ShippingProvider class that defines the contract for all shipping companies. It includes methods to ship a package, track it, and cancel the shipment.

We have three implementers:

  • DHL and Aramex: Global carriers that support all three operations.
  • LocalCompany: A small, local delivery service that can ship and track packages but does not have a cancellation feature.

Here’s the initial, flawed structure:

flawed-design/
└── src/
    ├── providers/
    │   ├── ShippingProvider.java
    │   ├── DHL.java
    │   ├── Aramex.java
    │   └── LocalCompany.java
    └── Main.java

ShippingProvider.java

// Abstract Parent Class
public abstract class ShippingProvider {
    public abstract void shipPackage();
    public abstract void trackPackage();
    public abstract void cancelShipment();
}

LocalCompany.java (The Problem Child)

public class LocalCompany extends ShippingProvider {
    @Override
    public void shipPackage() {
        System.out.println("LocalCompany is shipping the package.");
    }

    @Override
    public void trackPackage() {
        System.out.println("LocalCompany is tracking the package.");
    }

    @Override
    public void cancelShipment() {
        // This violates LSP!
        throw new UnsupportedOperationException("LocalCompany does not support shipment cancellation.");
    }
}

When a client uses this code, they operate under the assumption that any ShippingProvider can handle all three methods. This leads to a runtime disaster.

Main.java

public class Main {
    public static void main(String[] args) {
        ShippingProvider provider = new LocalCompany();

        provider.shipPackage(); // Works fine
        provider.trackPackage(); // Works fine
        
        try {
            // This line will crash the program!
            provider.cancelShipment(); 
        } catch (UnsupportedOperationException e) {
            System.err.println("Error: " + e.getMessage());
        }
    }
}

[!WARNING] Throwing a new, unexpected exception in a subclass method is a classic LSP violation. The client code is written against the parent class’s contract, which promises a cancelShipment capability. The subclass breaks that promise, making it an imperfect substitute.

The Solution: Segregating Interfaces

To fix this, we must remodel our abstractions to be more precise. Not all providers are cancellable, so we shouldn’t force that behavior on them. The solution is to segregate the interfaces based on capability.

  1. Create a base ShippingProvider with only the truly universal methods.
  2. Create a new, more specific CancellableShippingProvider that extends the base and adds the cancellation functionality.

Here is the new, robust project structure:

lsp-compliant-design/
└── src/
    ├── providers/
    │   ├── ShippingProvider.java           // Base contract
    │   ├── CancellableShippingProvider.java // Extended contract
    │   ├── DHL.java
    │   ├── Aramex.java
    │   └── LocalCompany.java
    └── Main.java

This class diagram shows the new, compliant hierarchy. LocalCompany now correctly inherits from the base provider, while DHL and Aramex inherit from the more feature-rich cancellable provider.

classDiagram
  class ShippingProvider {
    <<abstract>>
    +shipPackage()
    +trackPackage()
  }
  class CancellableShippingProvider {
    <<abstract>>
    +cancelShipment()
  }
  class LocalCompany
  class DHL
  class Aramex

  ShippingProvider <|-- CancellableShippingProvider
  ShippingProvider <|-- LocalCompany
  CancellableShippingProvider <|-- DHL
  CancellableShippingProvider <|-- Aramex

Code Refactoring (Before vs. After)

Let’s see the code changes using diffs.

ShippingProvider.java Refactoring We remove the cancelShipment method from the base contract.

--- a/ShippingProvider.java
+++ b/ShippingProvider.java
 public abstract class ShippingProvider {
     public abstract void shipPackage();
     public abstract void trackPackage();
-    public abstract void cancelShipment();
 }

CancellableShippingProvider.java (New File) This new abstract class extends the base and adds the specific functionality.

// New Abstract Class for cancellable providers
public abstract class CancellableShippingProvider extends ShippingProvider {
    public abstract void cancelShipment();
}

LocalCompany.java Refactoring LocalCompany now extends the base ShippingProvider and no longer needs to deal with a method it can’t implement.

--- a/LocalCompany.java
+++ b/LocalCompany.java
-public class LocalCompany extends ShippingProvider {
+public class LocalCompany extends ShippingProvider { // Correct parent
     @Override
     public void shipPackage() {
         System.out.println("LocalCompany is shipping the package.");
     }
 
     @Override
     public void trackPackage() {
         System.out.println("LocalCompany is tracking the package.");
     }
 
-    @Override
-    public void cancelShipment() {
-        throw new UnsupportedOperationException("LocalCompany does not support shipment cancellation.");
-    }
 }

DHL.java Refactoring DHL now extends the more specific CancellableShippingProvider to signal that it supports the full feature set.

--- a/DHL.java
+++ b/DHL.java
-public class DHL extends ShippingProvider {
+public class DHL extends CancellableShippingProvider { // More specific parent
     @Override
     public void shipPackage() {
         System.out.println("DHL is shipping the package.");
     }
 
     @Override
     public void trackPackage() {
         System.out.println("DHL is tracking the package.");
     }
 
     @Override
     public void cancelShipment() {
         System.out.println("DHL has cancelled the shipment.");
     }
 }

The Safe Client Code

Now, the client code is inherently safe. The compiler itself prevents you from making a mistake.

public class Main {
    public static void main(String[] args) {
        // This object's type only exposes ship() and track()
        ShippingProvider local = new LocalCompany();
        local.shipPackage();
        local.trackPackage();
        // local.cancelShipment(); // COMPILE ERROR! Method does not exist.

        // To cancel, you must use a type that guarantees the feature.
        CancellableShippingProvider dhl = new DHL();
        dhl.shipPackage();
        dhl.trackPackage();
        dhl.cancelShipment(); // Works perfectly!
    }
}

[!TIP] The Benefit of LSP: By adhering to LSP, you create predictable and reliable code. The type system becomes your safety net. You no longer need instanceof checks or try-catch blocks to handle subtype-specific behavior, leading to cleaner, more maintainable, and truly polymorphic code.

Best Practices & Deeper Insights

The Liskov Substitution Principle is about more than just method signatures; it’s about behavioral subtyping. A subtype must not only have the same methods but also behave in a way that is consistent with the parent’s contract.

Here are some rules to follow:

  1. Preconditions cannot be strengthened in a subtype. A subclass method shouldn’t be more restrictive about its input parameters than the superclass method.
  2. Postconditions cannot be weakened in a subtype. A subclass method must meet or exceed the guarantees made by the superclass method.
  3. Invariants of the superclass must be preserved. An invariant is a condition that is always true for an object. A subclass should not change this state in a way that violates the superclass’s assumptions.
  4. Don’t throw new, unexpected exceptions. As seen in our example, this is a direct violation.
Quiz: Test Your LSP Knowledge

Question: You have a `Rectangle` class with `setWidth(w)` and `setHeight(h)` methods. You create a `Square` subclass that inherits from `Rectangle`. To maintain the property of a square (where width always equals height), you override both setters:

  • setWidth(w) also sets the height to w.
  • setHeight(h) also sets the width to h.

Does this design violate the Liskov Substitution Principle? Why or why not?

Answer: Yes, this is a classic LSP violation. A function that takes a `Rectangle` object might do the following:


void resizeRectangle(Rectangle r) {
  r.setWidth(5);
  r.setHeight(10);
  assert(r.getWidth() == 5); // This assertion will fail if a Square is passed in!
}

The `Square` subclass changes the behavior of the setters in an unexpected way, breaking the client's reasonable assumptions about how a `Rectangle` works. It is not a valid substitute.


Join the 10xdev Community

Subscribe and get 8+ free PDFs that contain detailed roadmaps with recommended learning periods for each programming language or field, along with links to free resources such as books, YouTube tutorials, and courses with certificates.

Audio Interrupted

We lost the audio stream. Retry with shorter sentences?