Skip to content
ioob.dev
Go back

OOP Design Principles Part 2 — LSP and ISP

· 6 min read
OOP & SOLID Series (2/5)
  1. OOP Design Principles Part 1 — SRP and OCP
  2. OOP Design Principles Part 2 — LSP and ISP
  3. OOP Design Principles Part 3 — DIP
  4. OOP Design Principles Part 4 — TDA, Law of Demeter, CQS
  5. OOP Design Principles Part 5 — Composition over Inheritance
Table of contents

Table of contents

LSP — Subtypes Must Be Substitutable for Their Base Types

LSP (Liskov Substitution Principle) was proposed by Barbara Liskov in 1987.

If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering the correctness of the program. — Barbara Liskov

This might sound complex, but the core idea is simple. If you replace a parent type with a child type in code that uses the parent, the program must still behave correctly. It’s not about whether it compiles, but whether it works semantically.

Classic Violation — Rectangle and Square

This is the most frequently cited example when explaining LSP. Mathematically, a square is a special form of a rectangle. So expressing this through inheritance seems natural.

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;
    }
}

A square must always have equal width and height. So the setters are overridden.

public class Square extends Rectangle {

    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width;  // Changing width also changes height
    }

    @Override
    public void setHeight(int height) {
        this.width = height;  // Changing height also changes width
        this.height = height;
    }
}

It compiles without issues. But what happens when you put a Square where Rectangle is expected?

public void resize(Rectangle rect) {
    rect.setWidth(5);
    rect.setHeight(4);
    assert rect.getArea() == 20;  // Should be 5 x 4 = 20
}

Passing a Rectangle yields an area of 20. As expected. But passing a Square? When setHeight(4) is called, width also changes to 4, making the area 16. The assertion fails.

flowchart TB
    subgraph violation["LSP Violation — Square cannot substitute Rectangle"]
        Client["resize method"] -->|"Expects Rectangle"| Rect["Rectangle<br/>setWidth → only width changes<br/>setHeight → only height changes"]
        Client -->|"Passes Square"| Sq["Square<br/>setWidth → width + height change<br/>setHeight → width + height change"]
        Rect -->|"5 x 4 = 20"| OK["Works correctly"]
        Sq -->|"4 x 4 = 16"| FAIL["Behavior broken!"]
        style FAIL fill:#ff6b6b,color:#fff
        style OK fill:#51cf66,color:#fff
    end

The type system guarantees that Square is a child of Rectangle. But there’s no behavioral compatibility. The child broke the parent’s contract. This is an LSP violation.

Design by Contract

To understand LSP more precisely, you need to know Design by Contract. Proposed by Bertrand Meyer along with the Eiffel language, it states that methods have three kinds of contracts:

The postcondition of Rectangle.setWidth() is that width changes to the given value and height remains unchanged. Square changes height when setWidth() is called, weakening the postcondition. Contract violation.

Proper Design — Common Abstraction

The solution to the rectangle-square problem is to use a common abstraction instead of inheritance.

public interface Shape {
    int getArea();
}

public class Rectangle implements Shape {

    private final int width;
    private final int height;

    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

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

public class Square implements Shape {

    private final int side;

    public Square(int side) {
        this.side = side;
    }

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

Through the Shape interface, both classes provide getArea(). Since they’re not in an inheritance relationship, there’s no room for contract conflicts like setWidth()/setHeight(). Making them immutable objects makes them even safer.

Signals That Detect LSP Violations

There are a few signals to quickly spot LSP violations in practice.

// A classic signal of LSP violation — the child refuses the parent's behavior
open class Bird {
    open fun fly(): String = "Flying"
}

class Penguin : Bird() {
    override fun fly(): String {
        throw UnsupportedOperationException("Penguins cannot fly")
    }
}

fun makeBirdFly(bird: Bird) {
    println(bird.fly())  // Throws exception if Penguin is passed
}

A penguin is a bird. But it can’t fulfill the contract of being a “flying bird.” In this case, you need to separate the hierarchy into FlyableBird and NonFlyableBird, or extract the fly() method into a separate interface. This naturally leads us into ISP.

ISP — Clients Should Not Be Forced to Depend on Methods They Don’t Use

ISP (Interface Segregation Principle) was proposed by Robert C. Martin.

No client should be forced to depend on methods it does not use.

If a client depends on an interface that contains methods it doesn’t need, it will be affected when those unnecessary methods change. Interfaces should be split into small, specific units.

Violation Example — The Fat Interface

Suppose we define an interface for a multifunction printer.

public interface MultiFunctionDevice {

    void print(Document doc);

    void scan(Document doc);

    void fax(Document doc);

    void staple(Document doc);
}

A high-end multifunction device implementing this interface has no problems.

public class OfficeDevice implements MultiFunctionDevice {

    @Override
    public void print(Document doc) { /* print */ }

    @Override
    public void scan(Document doc) { /* scan */ }

    @Override
    public void fax(Document doc) { /* fax */ }

    @Override
    public void staple(Document doc) { /* staple */ }
}

The problem arises when implementing a simple printer.

public class SimplePrinter implements MultiFunctionDevice {

    @Override
    public void print(Document doc) { /* print */ }

    @Override
    public void scan(Document doc) {
        throw new UnsupportedOperationException("Scan not supported");
    }

    @Override
    public void fax(Document doc) {
        throw new UnsupportedOperationException("Fax not supported");
    }

    @Override
    public void staple(Document doc) {
        throw new UnsupportedOperationException("Staple not supported");
    }
}

Exceptions are being thrown for methods that can’t be implemented. This is also an LSP violation signal, as we saw earlier. SimplePrinter cannot fully honor the MultiFunctionDevice contract.

flowchart TB
    subgraph fat["ISP Violation — Fat interface"]
        MFD["MultiFunctionDevice<br/>print + scan + fax + staple"]
        MFD --> Office["OfficeDevice<br/>Can implement all"]
        MFD --> Simple["SimplePrinter<br/>Only print works<br/>Rest throw UnsupportedOperationException"]
        style Simple fill:#ff6b6b,color:#fff
    end

Interface Segregation

The solution is to split the fat interface by role.

public interface Printable {
    void print(Document doc);
}

public interface Scannable {
    void scan(Document doc);
}

public interface Faxable {
    void fax(Document doc);
}

public interface Stapleable {
    void staple(Document doc);
}

Now each class implements only the capabilities it supports.

public class SimplePrinter implements Printable {

    @Override
    public void print(Document doc) { /* print */ }
}

public class OfficeDevice implements Printable, Scannable, Faxable, Stapleable {

    @Override
    public void print(Document doc) { /* print */ }

    @Override
    public void scan(Document doc) { /* scan */ }

    @Override
    public void fax(Document doc) { /* fax */ }

    @Override
    public void staple(Document doc) { /* staple */ }
}

SimplePrinter only implements Printable. There’s no longer any reason to throw UnsupportedOperationException. Even if the Faxable interface changes, SimplePrinter is unaffected.

flowchart TB
    subgraph segregated["ISP Compliant — Segregated interfaces"]
        P["Printable"]
        S["Scannable"]
        F["Faxable"]
        ST["Stapleable"]
        P --> Simple2["SimplePrinter"]
        P --> Office2["OfficeDevice"]
        S --> Office2
        F --> Office2
        ST --> Office2
        style Simple2 fill:#51cf66,color:#fff
        style Office2 fill:#51cf66,color:#fff
    end

Practical Example — Splitting a User Service

The multifunction printer example is good for explaining the principle but feels distant from real-world practice. Let’s look at a more realistic example.

Here’s a case where all user-related functionality is gathered in a single interface.

public interface UserService {

    User findById(Long id);
    List<User> findAll();
    User create(CreateUserRequest request);
    User update(Long id, UpdateUserRequest request);
    void delete(Long id);
    void changePassword(Long id, String oldPassword, String newPassword);
    void resetPassword(Long id);
    LoginResult login(String email, String password);
    void logout(Long userId);
    boolean validateToken(String token);
}

Assuming multiple clients use this interface, each needs different methods.

The auth module has zero interest in findAll() or delete(). Yet because it depends on UserService, a change to a CRUD-related method signature requires recompilation of the auth module too.

Apply ISP to segregate the interfaces.

public interface UserQueryService {
    User findById(Long id);
    List<User> findAll();
}

public interface UserCommandService {
    User create(CreateUserRequest request);
    User update(Long id, UpdateUserRequest request);
    void delete(Long id);
}

public interface AuthService {
    LoginResult login(String email, String password);
    void logout(Long userId);
    boolean validateToken(String token);
}

public interface PasswordService {
    void changePassword(Long id, String oldPassword, String newPassword);
    void resetPassword(Long id);
}

Implementation classes implement only the interfaces they need. A single class can implement multiple interfaces, or they can be split into separate classes.

public class UserServiceImpl implements UserQueryService, UserCommandService {

    @Override
    public User findById(Long id) { /* ... */ }

    @Override
    public List<User> findAll() { /* ... */ }

    @Override
    public User create(CreateUserRequest request) { /* ... */ }

    @Override
    public User update(Long id, UpdateUserRequest request) { /* ... */ }

    @Override
    public void delete(Long id) { /* ... */ }
}

public class AuthServiceImpl implements AuthService {

    @Override
    public LoginResult login(String email, String password) { /* ... */ }

    @Override
    public void logout(Long userId) { /* ... */ }

    @Override
    public boolean validateToken(String token) { /* ... */ }
}

The auth module depends only on AuthService. Even if methods are added to UserCommandService or signatures change, the auth module is unaffected. Each client now depends only on the interface it needs.

The Relationship Between ISP and SRP

ISP and SRP are similar. While SRP limits the responsibilities of an implementation class to one, ISP says to split the responsibilities of an interface into small pieces. They point in the same direction. However, the perspective differs. SRP asks “why would this class change?”, while ISP asks “who are the clients of this interface?”

In practice, the best starting point for applying ISP is to look at the clients first. “Who uses this interface?” If the interface contains methods that a user doesn’t use, it’s time to segregate.

Where LSP and ISP Meet

Recall the penguin example from the LSP section. The Bird class had a fly() method, and Penguin inherited it and threw UnsupportedOperationException. This is both an LSP violation and an ISP violation. Penguin was forcefully shoehorned into the “flyable bird” interface.

Applying ISP to segregate the interfaces also resolves the LSP violation.

interface Bird {
    fun eat(): String
    fun sleep(): String
}

interface Flyable {
    fun fly(): String
}

class Eagle : Bird, Flyable {
    override fun eat() = "Hunts for food"
    override fun sleep() = "Sleeps in high places"
    override fun fly() = "Soars through the sky"
}

class Penguin : Bird {
    override fun eat() = "Catches and eats fish"
    override fun sleep() = "Sleeps standing up"
    // No need to implement fly()
}

Penguin doesn’t implement Flyable, so there’s no need to throw or ignore fly(). It can fully honor the Bird interface’s contract — eat() and sleep(). LSP is maintained while ISP is also satisfied.

flowchart TB
    BirdI["Bird interface<br/>eat + sleep"]
    FlyI["Flyable interface<br/>fly"]

    BirdI --> Eagle["Eagle<br/>Bird + Flyable"]
    FlyI --> Eagle
    BirdI --> PenguinC["Penguin<br/>Implements only Bird"]

    style Eagle fill:#51cf66,color:#fff
    style PenguinC fill:#51cf66,color:#fff

Key Takeaways

PrincipleKey QuestionViolation Signals
LSP”Does the program work correctly when you substitute a child for the parent?”Excessive instanceof, UnsupportedOperationException, tests fail with subclasses
ISP”Do all clients of this interface use every method?”Empty implementations, exception throwing, different clients use different methods

If LSP ensures the correctness of polymorphism, ISP minimizes the scope of dependencies. Applying both together results in clean class hierarchies and reduced ripple effects from changes.

In the next part, we’ll cover SOLID’s final principle: DIP. We’ll explore through code and diagrams why it’s problematic for high-level modules to depend on low-level modules, and what it concretely means to invert the direction of dependencies.


-> Part 3: DIP


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
OOP Design Principles Part 1 — SRP and OCP
Next Post
OOP Design Principles Part 3 — DIP