Table of contents
- LSP — Subtypes Must Be Substitutable for Their Base Types
- ISP — Clients Should Not Be Forced to Depend on Methods They Don’t Use
- Where LSP and ISP Meet
- Key Takeaways
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:
- Precondition: A condition that must be satisfied before the method is called. Subtypes can weaken (make more lenient) but must not strengthen it
- Postcondition: A condition guaranteed after the method executes. Subtypes can strengthen (make stricter) but must not weaken it
- Invariant: A condition that the object must always satisfy. Subtypes must maintain it
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 subclass overrides a parent method and throws an exception — The moment you throw
UnsupportedOperationException, you’re likely breaking the parent’s contract instanceofchecks keep multiplying — This means you’re bypassing with type checks what should be solved through polymorphism- Existing tests fail when you substitute a subclass — If tests written for the parent type break when a child is substituted, that’s direct evidence of an LSP violation
// 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.
- Admin panel:
findById,findAll,create,update,delete - Auth module:
login,logout,validateToken - Password management:
changePassword,resetPassword
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
| Principle | Key Question | Violation 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




Loading comments...