Interface Segregation Principle (ISP)
The Interface Segregation Principle states:
"Clients should not be forced to depend on interfaces they do not use."
Fat interfaces—interfaces with many methods—force implementers to provide methods they don't need. Clients end up depending on methods they never call.
ISP says: split fat interfaces into smaller, focused ones.
The Problem
Consider this interface:
interface Worker {
work(): void;
eat(): void;
sleep(): void;
attendMeeting(): void;
writeCode(): void;
reviewCode(): void;
manageTeam(): void;
conductInterview(): void;
}
Now implement it for different types of workers:
class JuniorDeveloper implements Worker {
work() { this.writeCode(); }
eat() { /* lunch */ }
sleep() { /* rest */ }
attendMeeting() { /* stand-up */ }
writeCode() { /* coding */ }
reviewCode() { throw new Error("Juniors don't review"); } // Unused
manageTeam() { throw new Error("Juniors don't manage"); } // Unused
conductInterview() { throw new Error("Juniors don't interview"); } // Unused
}
class Robot implements Worker {
work() { /* robot work */ }
eat() { throw new Error("Robots don't eat"); } // Unused
sleep() { throw new Error("Robots don't sleep"); } // Unused
attendMeeting() { throw new Error("Robots don't attend meetings"); } // Unused
writeCode() { /* generates code */ }
reviewCode() { throw new Error("Robots don't review"); } // Unused
manageTeam() { throw new Error("Robots don't manage"); } // Unused
conductInterview() { throw new Error("Robots don't interview"); } // Unused
}
This is a mess. Implementers are forced to provide methods they can't actually support.
The Solution: Role Interfaces
Split the fat interface into focused, role-based interfaces:
interface Workable {
work(): void;
}
interface Feedable {
eat(): void;
}
interface Restable {
sleep(): void;
}
interface MeetingAttendee {
attendMeeting(): void;
}
interface Developer {
writeCode(): void;
}
interface CodeReviewer {
reviewCode(): void;
}
interface Manager {
manageTeam(): void;
}
interface Interviewer {
conductInterview(): void;
}
Now classes implement only what they support:
class JuniorDeveloper implements Workable, Feedable, Restable, MeetingAttendee, Developer {
work() { this.writeCode(); }
eat() { /* lunch */ }
sleep() { /* rest */ }
attendMeeting() { /* stand-up */ }
writeCode() { /* coding */ }
}
class Robot implements Workable, Developer {
work() { /* robot work */ }
writeCode() { /* generates code */ }
}
class SeniorDeveloper implements Workable, Feedable, Restable, MeetingAttendee, Developer, CodeReviewer, Interviewer {
// ... all relevant methods
}
Clean. Each class declares exactly what it can do.
Real-World Example: CRUD Operations
Before: Fat Interface
interface Repository<T> {
create(entity: T): Promise<T>;
read(id: string): Promise<T>;
update(id: string, entity: T): Promise<T>;
delete(id: string): Promise<void>;
list(): Promise<T[]>;
search(query: string): Promise<T[]>;
bulkCreate(entities: T[]): Promise<T[]>;
bulkDelete(ids: string[]): Promise<void>;
archive(id: string): Promise<void>;
restore(id: string): Promise<void>;
}
A read-only view only needs read, list, search. Why force it to depend on delete, bulkDelete, archive?
After: Segregated Interfaces
interface Readable<T> {
read(id: string): Promise<T>;
}
interface Listable<T> {
list(): Promise<T[]>;
}
interface Searchable<T> {
search(query: string): Promise<T[]>;
}
interface Writable<T> {
create(entity: T): Promise<T>;
update(id: string, entity: T): Promise<T>;
}
interface Deletable {
delete(id: string): Promise<void>;
}
interface BulkOperations<T> {
bulkCreate(entities: T[]): Promise<T[]>;
bulkDelete(ids: string[]): Promise<void>;
}
interface Archivable {
archive(id: string): Promise<void>;
restore(id: string): Promise<void>;
}
// Full repository composes all
interface FullRepository<T> extends
Readable<T>, Listable<T>, Searchable<T>,
Writable<T>, Deletable, BulkOperations<T>, Archivable {}
// Read-only client only needs these
type ReadOnlyRepository<T> = Readable<T> & Listable<T> & Searchable<T>;
Now clients declare minimal dependencies.
Benefits of ISP
Clearer Dependencies
// This function clearly only needs reading capability
function displayUser(repo: Readable<User>, id: string) {
const user = await repo.read(id);
render(user);
}
// This function needs full write access
function importUsers(repo: Writable<User> & BulkOperations<User>, data: User[]) {
await repo.bulkCreate(data);
}
Easier Testing
// Only mock what's needed
const mockReadable: Readable<User> = {
read: jest.fn().mockResolvedValue(testUser),
};
await displayUser(mockReadable, '123');
No need to mock delete, archive, bulkCreate, etc.
Flexible Implementation
// Read-only cache implementation
class CachedUserReader implements Readable<User>, Listable<User> {
read(id: string) { return this.cache.get(id); }
list() { return this.cache.values(); }
}
// Doesn't need to implement write operations it can't support
Identifying ISP Violations
Signs of a Fat Interface
- Methods that throw "not implemented"
- Methods that return null/undefined "because this type doesn't support it"
- Clients that only use a subset of methods
- Implementers that leave methods empty
The Interface Dependency Test
For each client of an interface, ask: "Which methods does this client actually call?"
If the answer is "a subset," the interface might be too fat.
Practical Application
Start Coarse, Refine When Needed
You don't need to preemptively split every interface. Start with reasonable groupings:
interface UserService {
getUser(id: string): User;
createUser(data: CreateUserInput): User;
updateUser(id: string, data: UpdateUserInput): User;
}
When you find yourself needing just getUser, split:
interface UserReader {
getUser(id: string): User;
}
interface UserWriter {
createUser(data: CreateUserInput): User;
updateUser(id: string, data: UpdateUserInput): User;
}
interface UserService extends UserReader, UserWriter {}
Composition Over Inflation
Instead of adding methods to an interface, compose interfaces:
// Instead of growing this
interface Service {
methodA();
methodB();
methodC(); // New!
methodD(); // New!
}
// Compose
interface ServiceA { methodA(); methodB(); }
interface ServiceC { methodC(); }
interface ServiceD { methodD(); }
type FullService = ServiceA & ServiceC & ServiceD;
Key insight: Fat interfaces force clients to depend on things they don't need. Split interfaces by client usage. Each interface should represent a cohesive role. Clients declare minimal dependencies, implementations provide exactly what they support.