Interface Segregation Principle
Overview
The Interface Segregation Principle (`ISP`) states that no client should be forced to depend on methods it does not use. In Python, this usually means designing small, focused `Protocol`s or abstract base classes rather than massive "god interfaces". Think of it like a universal remote control: if you only want to change the volume, a small remote with just 3 buttons is much better than a massive remote where you might accidentally press the wrong button. For example, instead of a single `CloudStorage` interface that requires implementing multiple operations, you might split it into a `Reader` and a `Writer`. A massive interface often includes unrelated methods: - `read`: Retrieves data from a specific path, returning `bytes`, or raises `FileNotFoundError` if missing. - `write`: Saves data to a path, returning `None`, or raises `PermissionError` on failure. - `delete`: Removes a file at the given path, returning `None`, or raises `FileNotFoundError` if missing. - `set_permissions`: Modifies access rights, returning `None`, or raises `PermissionError` on failure. class CloudStorage(Protocol): def read(self, path: str) -> bytes: ... def write(self, path: str, data: bytes) -> None: ... def delete(self, path: str) -> None: ... def set_permissions(self, path: str, perms: int) -> None: ... When a class implements a massive interface but only supports read operations, it is forced to implement the other methods by raising `NotImplementedError`. This violates `ISP`, creating a fragile codebase where changes to unused methods break unrelated clients. Instead, we segregate the interface into smaller, focused protocols. class Reader(Protocol): def read(self, path: str) -> bytes: ... class Writer(Protocol): def write(self, path: str, data: bytes) -> None: ... Now, a client that only needs to write logs can accept a `Writer` without worrying about reading or deleting files. def flush_logs(destination: Writer, logs: bytes) -> None: destination.write("/var/log/app.log", logs) A common trap is providing dummy implementations that raise `NotImplementedError` just to satisfy the type checker for a "god interface". When you do this, type checkers like `mypy` assume the method is perfectly safe to call, leading to unexpected runtime crashes. class ReadOnlyFile(CloudStorage): def read(self, path: str) -> bytes: return b"data" def write(self, path: str, data: bytes) -> None: raise NotImplementedError("Cannot write") def backup_file(storage: CloudStorage) -> None: # Mypy says this is fine, but it crashes at runtime! storage.write("/backup.zip", b"data") By segregating interfaces into smaller `Protocol`s, you prevent clients from accidentally calling unsupported methods. This strategy of depending on small abstractions rather than concrete implementations is covered fully in `dip`.
It prevents sprawling, fragile interfaces where changing a method breaks clients that didn't even use that method. Small interfaces make your classes easier to mock in tests and highly reusable. When an interface is segregated, you avoid forcing subclasses to implement methods they don't need.
Where used: FastAPI dependencies, Unit testing mocks, Domain-driven design
Why learn this
- Writing smaller, composable `Protocol`s instead of massive base classes
- Making unit testing easier by requiring narrower mocks
Code walkthrough
from typing import Protocol
class Writer(Protocol):
def write(self, text: str) -> None:
...
def log_message(logger: Writer, msg: str) -> None:
logger.write(f'LOG: {msg}')
class Console:
def write(self, text: str) -> None:
print(text)
log_message(Console(), 'Hello')
Focus: The `log_message` function only demands a `Writer` (an object with a `write` method), not a full `FileStorage` or `Database`.
Aha moment
class BaseDevice:
def read(self):
raise NotImplementedError
def write(self):
raise NotImplementedError
class ReadOnlyDevice(BaseDevice):
def read(self):
return 'data'
def write(self):
raise Exception('Cannot write to ReadOnlyDevice!')
dev = ReadOnlyDevice()
print(dev.read())
try:
dev.write()
except Exception as e:
print(e)
Prediction: What happens when you call `write` on the read-only device?
Common guess: It silently fails or does nothing.
It crashes. Forcing a read-only device to implement a `write` method violates `ISP`. The device shouldn't even have a `write` method. If we segregated the interfaces, `ReadOnlyDevice` would simply not implement `Writer`.
Common mistakes
- The God Interface: Creating a massive base class with dozens of methods because "all cloud providers do these things", forcing every subclass to implement methods that don't make sense for them. This is often "solved" by raising `NotImplementedError`, which tricks type checkers and leads to runtime crashes.
Glossary
- client
- In programming, a piece of code, class, or function that uses the services or methods provided by another class. Example: a function `def save(repo: Repository):` is a client of the `Repository` interface.
- composable
- Designing small, independent parts of code that can be easily combined together to build more complex features. Example: combining a `Reader` protocol and a `Writer` protocol into a single class rather than inheriting from one giant interface.
Recall questions
- What is the core idea of the Interface Segregation Principle (`ISP`)?
Understanding checks
What is the primary consequence of violating the Interface Segregation Principle (`ISP`)?
Classes are forced to implement methods they don't actually need, often resulting in dummy methods or `NotImplementedError` exceptions.
When an interface is too broad (a "god interface"), subclasses must provide implementations for every method defined in the interface, even if those methods don't make logical sense for the specific subclass.
A developer argues that having a single `CloudProvider` protocol with 50 methods is better than 10 small protocols because "all cloud providers eventually do everything". Why is this reasoning flawed according to `ISP`?
Because clients consuming the protocol rarely use all 50 methods, and changing one method forces unrelated clients or providers to update.
`ISP` focuses on the clients using the interface. Even if the provider implements everything, a function that only uploads a file shouldn't depend on an interface that also defines database backup methods.
What happens when a type checker analyzes code that calls a method on an object where the method's implementation simply raises `NotImplementedError`?
The type checker will approve the code, but it will crash at runtime.
Type checkers only verify that the method signature matches the interface. They do not execute the method to see if it raises an exception, which is why raising `NotImplementedError` to satisfy a massive interface is a dangerous trap.
Practice tasks
Split the Protocol
The provided code defines a massive `MultiFunctionDevice` protocol that forces read-only devices to implement a `write_doc` method they can't use. Modify the code to split this into two smaller protocols: `Reader` (with `read_doc`) and `Writer` (with `write_doc`), and make `ReadOnlyDevice` only inherit from `Reader`.
Challenge
Refactor a God Class
Create two `Protocol`s, `Worker` and `Eater`. Then create a `Human` class that implements both, and a `Robot` class that implements only `Worker`.
Continue learning
Previous: Structural Subtyping with Protocols