Loading learning content...
Interfaces are contracts. They define the shape of a promise: "If you program against me, here's what you can expect." But not all contracts are created equal. Some contracts are specific and clear; others are sprawling and ambiguous.
The Interface Segregation Principle fundamentally reshapes how we think about interface design. Instead of asking "What should this abstraction be able to do?", ISP teaches us to ask "Who will use this abstraction, and what do they need from it?"
This shift from capability-centric to client-centric design is subtle but transformative. It moves the designer's perspective from the provider's side of the interface to the consumer's side. And that perspective shift changes everything.
This page explores ISP's deep relationship with interface design. You'll learn to identify natural interface boundaries, design interfaces from the client's perspective, understand role interfaces vs. capability interfaces, and apply systematic techniques for achieving ISP-compliant designs.
Traditional object-oriented design often starts with the thing being modeled. We ask: "What is a Document? What can it do?" Then we create an interface that captures those capabilities.
ISP inverts this approach. Instead of starting with the abstraction's inherent capabilities, we start with the question: "Who needs this abstraction, and for what purpose?"
This is the client-centric design paradigm.
The Discovery Process:
Client-centric design requires understanding your actual clients. This isn't guesswork — it's analysis:
Identify all consumers — Who or what will use this interface? List every service, module, or component that needs this abstraction.
Map usage patterns — For each consumer, which methods do they actually call? Create a matrix of consumers vs. methods.
Find natural groupings — Look for methods that are always used together by the same clients. These form natural interface boundaries.
Name the roles — Each grouping represents a role the abstraction plays. Name it by that role, not by the entity.
Validate independence — Ensure changes to one interface don't require changes to another. If they do, the boundary might be wrong.
Create a simple table: consumers as rows, methods as columns. Mark which methods each consumer uses. Clusters of marks reveal natural interface boundaries. Methods always used together belong to the same interface. This exercise often reveals surprising insights about how code is actually used.
## Interface Discovery Matrix: User Entity | Consumer | login | logout | getProfile | updateProfile | deleteAccount | changePassword ||--------------------|-------|--------|------------|---------------|---------------|----------------|| AuthService | ✓ | ✓ | | | | ✓ || ProfileService | | | ✓ | ✓ | | || AccountService | | | | | ✓ | || SessionManager | ✓ | ✓ | | | | || AdminDashboard | | | ✓ | | ✓ | | ## Natural Interface Boundaries Revealed: 1. **Authenticatable** (login, logout, changePassword) - Used by: AuthService, SessionManager 2. **ProfileManageable** (getProfile, updateProfile) - Used by: ProfileService, AdminDashboard 3. **AccountDeletable** (deleteAccount) - Used by: AccountService, AdminDashboard Note: AdminDashboard crosses boundaries because it's a composite view.This is normal — high-level coordinators often need multiple interfaces.Martin Fowler introduced the term Role Interface to describe interfaces designed from the client's perspective. A role interface defines the role an object plays for a specific client, rather than describing the object's complete identity.
Role Interface vs. Header Interface:
The traditional approach creates header interfaces — interfaces that mirror the public methods of a class. If your class has 20 public methods, your interface has 20 methods. This is provider-centric design.
Role interfaces are different. They answer: "What role does this object play in this specific context?" An object might play multiple roles for different clients, each defined by a separate interface.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
// ❌ HEADER INTERFACE: Mirrors the class's public APIinterface UserService { // Authentication login(credentials: Credentials): Promise<Session>; logout(session: Session): Promise<void>; changePassword(userId: string, newPassword: string): Promise<void>; resetPassword(email: string): Promise<void>; // Profile management getProfile(userId: string): Promise<UserProfile>; updateProfile(userId: string, updates: ProfileUpdates): Promise<void>; uploadAvatar(userId: string, image: Buffer): Promise<string>; // Account management createAccount(data: AccountData): Promise<User>; deleteAccount(userId: string): Promise<void>; suspendAccount(userId: string, reason: string): Promise<void>; // Preferences getPreferences(userId: string): Promise<Preferences>; updatePreferences(userId: string, prefs: Preferences): Promise<void>; // Social features followUser(followerId: string, followeeId: string): Promise<void>; unfollowUser(followerId: string, followeeId: string): Promise<void>; getFollowers(userId: string): Promise<User[]>;} // A mobile client that only shows profiles must depend on ALL of this!// It can't even import just what it needs. // ✅ ROLE INTERFACES: Designed around specific client needs // The role this object plays for authentication clientsinterface Authenticator { login(credentials: Credentials): Promise<Session>; logout(session: Session): Promise<void>; changePassword(userId: string, newPassword: string): Promise<void>; resetPassword(email: string): Promise<void>;} // The role this object plays for profile viewing/editing clientsinterface ProfileManager { getProfile(userId: string): Promise<UserProfile>; updateProfile(userId: string, updates: ProfileUpdates): Promise<void>; uploadAvatar(userId: string, image: Buffer): Promise<string>;} // The role this object plays for account administration clientsinterface AccountAdministrator { createAccount(data: AccountData): Promise<User>; deleteAccount(userId: string): Promise<void>; suspendAccount(userId: string, reason: string): Promise<void>;} // The role for preference managementinterface PreferenceStore { getPreferences(userId: string): Promise<Preferences>; updatePreferences(userId: string, prefs: Preferences): Promise<void>;} // The role for social graph operationsinterface SocialGraph { followUser(followerId: string, followeeId: string): Promise<void>; unfollowUser(followerId: string, followeeId: string): Promise<void>; getFollowers(userId: string): Promise<User[]>;} // Now each client depends only on its specific role interfaceclass MobileProfileViewer { constructor(private profiles: ProfileManager) {} // Only what it needs! async displayProfile(userId: string): Promise<void> { const profile = await this.profiles.getProfile(userId); this.render(profile); }} class AdminPanel { constructor( private auth: Authenticator, // For password resets private accounts: AccountAdministrator // For account ops ) {} // Doesn't know about profiles, preferences, or social features}The same concrete class can implement all role interfaces. A UserServiceImpl might implement Authenticator, ProfileManager, AccountAdministrator, PreferenceStore, and SocialGraph. The segregation is in the interfaces (what clients see), not necessarily in the implementation (what provides the service).
Naming Role Interfaces:
Role interfaces should be named by the role they represent, not by the entity they abstract. Compare:
| Header Interface Name | Role Interface Names |
|---|---|
User | Authenticatable, ProfileOwner, AccountHolder |
File | Readable, Writable, Deletable, Seekable |
Order | Purchasable, Shippable, Trackable, Refundable |
Payment | Chargeable, Refundable, Disputable |
Notice how role names often end in adjective suffixes like -able, -ible, or -er/-or. This isn't a strict rule, but it often naturally emerges because roles describe what something can do for a specific client, not what it is.
Just as SRP asks about class cohesion, ISP asks about interface cohesion: Do all methods in this interface belong together?
Cohesion in interfaces means that the methods form a coherent unit of functionality that clients naturally use together. High cohesion means every method in the interface relates to the same purpose; low cohesion means methods serve different, unrelated purposes that happen to share an interface.
Measuring Interface Cohesion:
Several heuristics help evaluate interface cohesion:
1234567891011121314151617181920212223242526272829303132333435363738
// ❌ LOW COHESION: "This interface handles authentication AND profile management"interface UserAccount { login(email: string, password: string): Promise<Session>; logout(session: Session): Promise<void>; getProfile(): UserProfile; updateProfile(updates: ProfileUpdates): Promise<void>;} // Analysis:// - login() and logout() operate on sessions; unrelated to profiles// - getProfile() and updateProfile() operate on profile data; unrelated to auth// - A profile viewer doesn't need login(); an auth service doesn't need getProfile()// - Sentence: "Handles logins AND profile management" — the "AND" reveals low cohesion // ✅ HIGH COHESION: Each interface has a single, coherent purpose interface SessionManager { login(email: string, password: string): Promise<Session>; logout(session: Session): Promise<void>; validateSession(session: Session): Promise<boolean>;} // Analysis:// - All methods operate on Session concept// - Clients using login() will likely need logout() and validateSession()// - Sentence: "Manages session lifecycle" — no "AND" interface ProfileEditor { getProfile(): UserProfile; updateProfile(updates: ProfileUpdates): Promise<void>; getProfileHistory(): ProfileRevision[];} // Analysis:// - All methods operate on UserProfile concept// - Clients that read profiles often also update or audit them// - Sentence: "Manages user profile data" — no "AND"Cohesion analysis can go too far. An interface with only one method isn't necessarily better than one with five. If those five methods are always used together and change together, they form a cohesive unit. ISP isn't about making interfaces as small as possible — it's about making them as focused as necessary.
Cohesion Through Domain Modeling:
Often, proper cohesion emerges from understanding the domain. Domain-Driven Design concepts help:
When interface boundaries align with domain boundaries, cohesion tends to be high naturally. Misaligned boundaries (e.g., an interface spanning two aggregates) tend toward low cohesion.
When you segregate interfaces, you need strategies for combining them. ISP doesn't mean abandoning comprehensive abstractions — it means building them from focused pieces.
Pattern 1: Interface Inheritance (Extension)
Smaller interfaces extend into larger ones when clients genuinely need the combined functionality.
12345678910111213141516171819202122232425262728293031323334353637383940414243
// Building larger interfaces from smaller ones through extension interface Readable { read(): Buffer; readLine(): string;} interface Writable { write(data: Buffer): void; writeLine(line: string): void;} interface Seekable { seek(position: number): void; getPosition(): number;} interface Closable { close(): void; isClosed(): boolean;} // Composition through extension: only combined when clients need bothinterface ReadWriteStream extends Readable, Writable {} // Full file interface for clients that need everythinginterface RandomAccessFile extends Readable, Writable, Seekable, Closable {} // Clients choose their level of abstraction:class LogReader { constructor(private source: Readable) {} // Only needs reading} class DataCopier { constructor( private source: Readable, private destination: Writable // Separate dependencies ) {}} class DatabaseFile { constructor(private file: RandomAccessFile) {} // Needs everything}Pattern 2: Intersection Types (TypeScript/Flow)
In languages with intersection types, you can combine interfaces on-demand without pre-defined composite interfaces.
12345678910111213141516171819202122232425262728293031323334353637383940
// On-demand composition using intersection types interface Identifiable { getId(): string;} interface Timestamped { getCreatedAt(): Date; getUpdatedAt(): Date;} interface Auditable { getAuditLog(): AuditEntry[];} // Functions declare exactly what they need, no pre-defined compositefunction archiveRecord(record: Identifiable & Timestamped): void { console.log(`Archiving ${record.getId()} from ${record.getCreatedAt()}`);} function fullAudit(entity: Identifiable & Timestamped & Auditable): AuditReport { return { entityId: entity.getId(), created: entity.getCreatedAt(), updated: entity.getUpdatedAt(), events: entity.getAuditLog(), };} // Any object meeting the intersection can be passedclass Order implements Identifiable, Timestamped, Auditable { getId(): string { return this.orderId; } getCreatedAt(): Date { return this.createdDate; } getUpdatedAt(): Date { return this.updatedDate; } getAuditLog(): AuditEntry[] { return this.auditEntries; }} const order = new Order();archiveRecord(order); // Works - Order satisfies Identifiable & TimestampedfullAudit(order); // Works - Order satisfies all threePattern 3: Adapter Composition
Sometimes you inherit a fat interface and need to expose a segregated view. Adapters bridge the gap.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
// Given a legacy fat interface from a third-party libraryinterface LegacyRepository { findById(id: string): Entity; findAll(): Entity[]; save(entity: Entity): void; update(entity: Entity): void; delete(id: string): void; executeQuery(sql: string): any[]; beginTransaction(): void; commit(): void; rollback(): void;} // We want segregated interfaces for our clientsinterface EntityReader { findById(id: string): Entity; findAll(): Entity[];} interface EntityWriter { save(entity: Entity): void; update(entity: Entity): void; delete(id: string): void;} // Adapters expose only the relevant sliceclass ReadOnlyRepositoryAdapter implements EntityReader { constructor(private legacy: LegacyRepository) {} findById(id: string): Entity { return this.legacy.findById(id); } findAll(): Entity[] { return this.legacy.findAll(); }} class WriteRepositoryAdapter implements EntityWriter { constructor(private legacy: LegacyRepository) {} save(entity: Entity): void { this.legacy.save(entity); } update(entity: Entity): void { this.legacy.update(entity); } delete(id: string): void { this.legacy.delete(id); }} // Clients depend on focused interfaces, not the legacy monsterclass ReportGenerator { constructor(private dataSource: EntityReader) {} // Read-only access} class ImportService { constructor(private writer: EntityWriter) {} // Write-only access}Interface extension works when composites are stable and widely used. Intersection types work for ad-hoc combinations. Adapters work for legacy integration. Choose based on your context — there's no universally 'best' approach.
ISP's principle — minimize unnecessary dependencies — applies beyond interface-based OOP designs.
ISP in Functional Programming:
In FP, "interfaces" manifest as function signatures, type classes, or protocols. The ISP equivalent: functions should require only the data they need.
-- ❌ Requires the whole User even though it only needs the email
sendWelcome :: User -> IO ()
sendWelcome user = sendEmail (userEmail user) "Welcome!"
-- ✅ Requires only the email it actually uses
sendWelcome :: EmailAddress -> IO ()
sendWelcome email = sendEmail email "Welcome!"
The refined version is easier to test (no User fixture needed), more reusable (works with any email source), and documents its actual dependencies.
ISP in API Design:
REST and GraphQL APIs face ISP considerations:
❌ Fat Endpoint: GET /users/{id}
Returns: name, email, address, payment_info, order_history, preferences, social_connections, ...
Problem: Mobile app showing user name fetches (and pays bandwidth for) payment info it never displays.
✅ Segregated Endpoints:
GET /users/{id}/profile (name, email, avatar)
GET /users/{id}/address
GET /users/{id}/payment-methods
GET /users/{id}/orders
Or: GraphQL where clients request exactly the fields they need.
ISP in Module/Package Design:
At the module level, ISP suggests splitting large modules if different clients need different subsets:
❌ Single module: utils (string utils, date utils, crypto utils, http utils)
Every importer depends on all utilities, transitively pulling in crypto and http libraries.
✅ Segregated modules:
- string-utils (no dependencies)
- date-utils (no dependencies)
- crypto-utils (depends on crypto library)
- http-utils (depends on http library)
Clients import only what they need; dependency graphs stay lean.
| Paradigm | ISP Equivalent | Violation Symptom |
|---|---|---|
| OOP Interfaces | Segregated role interfaces | Classes implement methods with 'throw NotImplemented' |
| Functional Types | Minimal function parameters | Functions receive large records but use few fields |
| REST APIs | Resource-specific endpoints | Endpoints return massive payloads with mostly unused data |
| GraphQL | Schema design, field resolvers | Over-connected schemas forcing unnecessary field resolution |
| Modules/Packages | Focused module boundaries | Importing a module pulls in heavy transitive dependencies |
| Microservices | Service API contracts | Service APIs bundling unrelated operations |
The underlying wisdom of ISP — don't force dependencies on things you don't use — transcends any particular syntax or paradigm. Whether you're designing interfaces, function signatures, APIs, or module boundaries, the principle guides you toward designs that minimize unnecessary coupling.
Let's distill the concepts into actionable guidelines for daily practice.
You don't need perfect interfaces from day one. Start with what you know. As new clients emerge with different needs, refactor to segregate. ISP-compliant design often evolves iteratively rather than being designed perfectly upfront.
We've explored how ISP fundamentally shapes the philosophy and practice of interface design.
What's Next:
With a solid understanding of how ISP guides interface design, we'll next examine the concrete problem ISP solves: fat interfaces. The next page explores what makes interfaces 'fat', why fat interfaces emerge, and the specific problems they cause in practice.
You now understand ISP's relationship with interface design philosophy. You can identify client-centric vs. provider-centric designs, recognize role interfaces, evaluate interface cohesion, and apply composition patterns. Next: Why Fat Interfaces Are Problematic.