Liskov Substitution Principle (LSP) in Java Explained with Easy Examples

 


1. Imagine You Have a Motorcycle and a Bicycle 🏍️🚲

Hey, developers! Today, let’s learn another important SOLID principle in Java: the Liskov Substitution Principle (LSP).

Don’t worry about the complicated name. Let’s understand it with a simple example.

Imagine you have two vehicles:

  • A motorcycle
  • A bicycle

Both vehicles can accelerate and apply brakes.

But there is one important difference.

A motorcycle has an engine, while a traditional bicycle does not.

Now imagine you create a general Bike interface that requires every bike to start and stop an engine.

A motorcycle can implement these methods easily.

But what should a bicycle do?

It doesn’t have an engine!

You might be tempted to throw an exception when someone tries to start its engine.

That creates a problem because the bicycle cannot fulfill the behavior promised by the interface.

This is the basic idea behind the Liskov Substitution Principle.

2. What Is the Liskov Substitution Principle?

The Liskov Substitution Principle states:

Objects of a superclass should be replaceable with objects of its subclasses without breaking the application.

In simple words, if a class extends another class, the child class should behave in a way that respects the expectations established by the parent class.

A subclass should extend the parent’s capabilities without breaking its expected behavior.

Let’s understand this with Java code.

3. Example That Violates LSP

Imagine we create a Bike interface.

Step 1: Create the Bike interface

public interface Bike {

    void turnOnEngine();

    void turnOffEngine();

    void accelerate();

    void applyBrakes();
}

This interface says that every bike must support four operations.

Now let’s implement it in a motorcycle.

Step 2: Create the Motorcycle class

public class MotorCycle implements Bike {

    private boolean engineOn;
    private int speed;

    public MotorCycle(int speed) {
        this.speed = speed;
    }

    @Override
    public void turnOnEngine() {
        engineOn = true;
        System.out.println("Engine is ON!");
    }

    @Override
    public void turnOffEngine() {
        engineOn = false;
        System.out.println("Engine is OFF!");
    }

    @Override
    public void accelerate() {
        speed += 10;
        System.out.println("Motorcycle speed: " + speed);
    }

    @Override
    public void applyBrakes() {
        speed = Math.max(0, speed - 5);
        System.out.println("Motorcycle speed: " + speed);
    }
}

This class works as expected.

A motorcycle has an engine, can accelerate, and can apply brakes.

Now let’s create a bicycle.

Step 3: Create the Bicycle class

public class Bicycle implements Bike {

    private int speed;

    public Bicycle(int speed) {
        this.speed = speed;
    }

    @Override
    public void turnOnEngine() {
        throw new UnsupportedOperationException(
            "A bicycle has no engine!"
        );
    }

    @Override
    public void turnOffEngine() {
        throw new UnsupportedOperationException(
            "A bicycle has no engine!"
        );
    }

    @Override
    public void accelerate() {
        speed += 10;
        System.out.println("Bicycle speed: " + speed);
    }

    @Override
    public void applyBrakes() {
        speed = Math.max(0, speed - 5);
        System.out.println("Bicycle speed: " + speed);
    }
}

Look carefully at these methods:

@Override
public void turnOnEngine() {
    throw new UnsupportedOperationException(
        "A bicycle has no engine!"
    );
}

The bicycle is forced to implement a method that doesn’t make sense for it.

The interface promises that every Bike can start an engine, but the bicycle cannot fulfill that promise.

This is a design problem.

Step 4: Test the problem

public class Main {

    public static void main(String[] args) {

        Bike bike = new MotorCycle(10);

        bike.turnOnEngine();
        bike.accelerate();

        bike = new Bicycle(10);

        bike.turnOnEngine(); // Throws exception
    }
}

When the variable refers to a motorcycle, the operation works.

When it refers to a bicycle, the operation fails.

The program cannot safely treat both implementations in the same way for every operation promised by the interface.

This violates LSP because the bicycle cannot fulfill the expected contract of Bike.

4. How to Fix the Problem and Follow LSP

Now let’s improve the design.

Ask yourself a simple question:

What can both motorcycles and bicycles do?

Both can:

  • Accelerate
  • Apply brakes

What can only a motorcycle do?

  • Turn on its engine
  • Turn off its engine

Therefore, we should separate general bike behavior from engine-specific behavior.

Step 1: Create an abstract Bike class

public abstract class Bike {

    public abstract void accelerate();

    public abstract void applyBrakes();
}

Now the Bike class contains only the operations supported by both motorcycles and bicycles.

Step 2: Create an Engine interface

public interface Engine {

    void turnOnEngine();

    void turnOffEngine();
}

This interface contains engine-specific operations.

Only vehicles that actually have an engine need to implement it.

Step 3: Modify the Motorcycle class

public class MotorCycle extends Bike implements Engine {

    private boolean engineOn;
    private int speed;

    public MotorCycle(int speed) {
        this.speed = speed;
    }

    @Override
    public void turnOnEngine() {
        engineOn = true;
        System.out.println("Engine is ON!");
    }

    @Override
    public void turnOffEngine() {
        engineOn = false;
        System.out.println("Engine is OFF!");
    }

    @Override
    public void accelerate() {
        speed += 10;
        System.out.println("Motorcycle speed: " + speed);
    }

    @Override
    public void applyBrakes() {
        speed = Math.max(0, speed - 5);
        System.out.println("Motorcycle speed: " + speed);
    }
}

The motorcycle extends Bike and implements Engine.

It supports all the operations required by both types.

Step 4: Modify the Bicycle class

public class Bicycle extends Bike {

    private int speed;

    public Bicycle(int speed) {
        this.speed = speed;
    }

    @Override
    public void accelerate() {
        speed += 10;
        System.out.println("Bicycle speed: " + speed);
    }

    @Override
    public void applyBrakes() {
        speed = Math.max(0, speed - 5);
        System.out.println("Bicycle speed: " + speed);
    }
}

Notice that the bicycle no longer needs to implement engine methods.

It supports the behavior promised by its parent class without throwing unnecessary exceptions.

Step 5: Test the improved design

public class Main {

    public static void main(String[] args) {

        Bike motorcycle = new MotorCycle(10);
        Bike bicycle = new Bicycle(10);

        motorcycle.accelerate();
        motorcycle.applyBrakes();

        bicycle.accelerate();
        bicycle.applyBrakes();

        Engine engine = new MotorCycle(10);
        engine.turnOnEngine();
        engine.turnOffEngine();
    }
}

Output:

Motorcycle speed: 20
Motorcycle speed: 15
Bicycle speed: 20
Bicycle speed: 15
Engine is ON!
Engine is OFF!

Now both objects can be used wherever a Bike is expected.

Engine operations are available separately through the Engine interface.

This is a better design because every class can fulfill the contract of its parent abstraction.

5. Another Example: Vehicle and Bicycle 🚗

Let’s understand LSP with one more example.

Imagine we create a general Vehicle class.

public class Vehicle {

    public Integer getNumberOfWheels() {
        return 2;
    }

    public Boolean hasEngine() {
        return true;
    }
}

Now we create a motorcycle, a car, and a bicycle.

public class MotorCycle extends Vehicle {
}
public class Car extends Vehicle {

    @Override
    public Integer getNumberOfWheels() {
        return 4;
    }
}
public class Bicycle extends Vehicle {

    @Override
    public Boolean hasEngine() {
        return null;
    }
}

The bicycle returns null for hasEngine() because it doesn’t have an engine.

But this creates a problem.

Consider this code:

List<Vehicle> vehicles = new ArrayList<>();

vehicles.add(new MotorCycle());
vehicles.add(new Car());
vehicles.add(new Bicycle());

for (Vehicle vehicle : vehicles) {
    System.out.println(vehicle.hasEngine().toString());
}

The code works for the motorcycle and car because they return true.

When the bicycle returns null, calling .toString() throws a NullPointerException.

Why?

Because the code expects every Vehicle to return a non-null Boolean value.

The bicycle breaks that expectation.

This is another example of an LSP violation.

6. A Better Vehicle Design

Instead of assuming that every vehicle has an engine, we can create a separate abstraction for engine-powered vehicles.

Step 1: Create the Vehicle class

public class Vehicle {

    public Integer getNumberOfWheels() {
        return 2;
    }
}

This class contains behavior common to the vehicle types in our example.

Step 2: Create EngineVehicle

public class EngineVehicle extends Vehicle {

    public Boolean hasEngine() {
        return true;
    }
}

Only engine-powered vehicles extend this class.

Step 3: Create the vehicle types

public class Bicycle extends Vehicle {
}
public class MotorCycle extends EngineVehicle {
}
public class Car extends EngineVehicle {

    @Override
    public Integer getNumberOfWheels() {
        return 4;
    }
}

Now the design reflects the actual capabilities of each vehicle.

A bicycle is a Vehicle, but it is not an EngineVehicle.

A motorcycle and a car are both Vehicle and EngineVehicle.

Step 4: Test the design

public class Main {

    public static void main(String[] args) {

        List<Vehicle> vehicles = new ArrayList<>();

        vehicles.add(new MotorCycle());
        vehicles.add(new Car());
        vehicles.add(new Bicycle());

        for (Vehicle vehicle : vehicles) {
            System.out.println(
                vehicle.getNumberOfWheels()
            );
        }

        List<EngineVehicle> engineVehicles =
            new ArrayList<>();

        engineVehicles.add(new MotorCycle());
        engineVehicles.add(new Car());

        for (EngineVehicle vehicle : engineVehicles) {
            System.out.println(vehicle.hasEngine());
        }
    }
}

The first list can contain all three vehicle types.

The second list can contain only engine-powered vehicles.

Java’s type system helps prevent a bicycle from being used where an EngineVehicle is required.

This makes the design safer and easier to understand.

7. What Are the Main Benefits of LSP?

Following the Liskov Substitution Principle provides several benefits.

1. Prevents unexpected failures

Subclasses don’t break the behavior expected by code using the parent type.

2. Improves flexibility

You can introduce new subclasses without breaking existing code that relies on the parent contract.

3. Improves maintainability

Changes to subclasses are less likely to cause unexpected problems elsewhere in the application.

4. Promotes code reuse

Different implementations can be used through a common abstraction.

5. Improves testing

You can test different implementations against the expectations defined by their parent class or interface.

8. How to Identify an LSP Violation

When reviewing Java code, look for these warning signs:

  • A subclass throws UnsupportedOperationException for a method promised by the parent.
  • A subclass returns null where callers expect a valid result.
  • An overridden method behaves in a way that breaks the parent’s documented contract.
  • A subclass requires stricter input conditions than the parent method.
  • Existing code breaks when one implementation is replaced with another.

If you find these problems, reconsider the class hierarchy or split the abstraction into smaller, more accurate contracts.

The solution is not always to add another interface. The important thing is to ensure that each subtype honors the behavior expected from its parent.

9. LSP vs. ISP vs. DIP

These three SOLID principles are related, but each solves a different problem.

Principle Main idea Easy example
LSP Subtypes must honor the parent’s contract A bicycle should not pretend to support engine operations
ISP Interfaces should contain focused responsibilities A waiter shouldn’t implement chef methods
DIP High-level and low-level modules should depend on abstractions An order service depends on a payment interface

Remember:

  • ISP helps us design better interfaces.
  • LSP helps us design correct inheritance and substitution.
  • DIP helps us reduce coupling between modules.

Together, these principles help create maintainable object-oriented software.

10. Final Summary

Let’s revise everything in simple words.

Imagine you have a remote control that works with your television.

Now imagine you replace the television with another model that supports the same remote-control contract.

The remote should continue to work as expected.

Similarly, when a subclass replaces its parent type, the application should continue to behave correctly wherever the parent type is expected.

In our vehicle example:

  • Bike contains behavior common to bicycles and motorcycles.
  • Engine contains engine-specific operations.
  • MotorCycle supports both abstractions.
  • Bicycle supports the common bike behavior without pretending to have an engine.

The easiest way to remember LSP:

A child class should be a proper replacement for its parent class.

If replacing the parent object with a child object breaks the expected behavior of the application, review the design.

Happy coding! 💻

Leave a Reply