Loading learning content...
When most developers think about interfaces, they think about APIs—a collection of methods that define what an object can do. This implementation-centric view leads to a natural but problematic tendency: creating interfaces that mirror class implementations rather than expressing meaningful contracts.
The Interface Segregation Principle invites us to a paradigm shift: stop thinking of interfaces as implementation contracts and start thinking of them as roles. This seemingly subtle change in perspective transforms how we design systems, dramatically improving flexibility, testability, and maintainability.
By the end of this page, you will understand how to conceptualize interfaces as roles rather than implementation blueprints. You'll learn why this mental model is central to ISP, how it prevents fat interfaces before they form, and how role-based thinking enables the compositional flexibility that distinguishes expert object-oriented design from merely functional code.
To appreciate role-based interface design, we must first understand what it replaces. The conventional view of interfaces treats them primarily as abstraction mechanisms for implementations. Under this view, we create interfaces by:
This approach seems logical—after all, we're "programming to interfaces, not implementations," right? But there's a critical flaw in this reasoning.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
// The Conventional Approach: Interface mirrors implementation // Step 1: Design the class with all its functionalityclass UserService { // Authentication methods login(email: string, password: string): User { /* ... */ } logout(userId: string): void { /* ... */ } resetPassword(email: string): void { /* ... */ } changePassword(userId: string, newPassword: string): void { /* ... */ } // Profile management methods getProfile(userId: string): UserProfile { /* ... */ } updateProfile(userId: string, profile: UserProfile): void { /* ... */ } uploadAvatar(userId: string, image: Buffer): string { /* ... */ } // User administration methods listAllUsers(): User[] { /* ... */ } banUser(userId: string): void { /* ... */ } deleteUser(userId: string): void { /* ... */ } promoteToAdmin(userId: string): void { /* ... */ } // Notification methods sendWelcomeEmail(userId: string): void { /* ... */ } sendPasswordResetEmail(email: string): void { /* ... */ } notifyLoginFromNewDevice(userId: string, device: DeviceInfo): void { /* ... */ }} // Step 2: Extract an interface that "abstracts" this classinterface IUserService { login(email: string, password: string): User; logout(userId: string): void; resetPassword(email: string): void; changePassword(userId: string, newPassword: string): void; getProfile(userId: string): UserProfile; updateProfile(userId: string, profile: UserProfile): void; uploadAvatar(userId: string, image: Buffer): string; listAllUsers(): User[]; banUser(userId: string): void; deleteUser(userId: string): void; promoteToAdmin(userId: string): void; sendWelcomeEmail(userId: string): void; sendPasswordResetEmail(email: string): void; notifyLoginFromNewDevice(userId: string, device: DeviceInfo): void;} // Step 3: Use the interface everywhereclass LoginController { constructor(private userService: IUserService) {} handleLogin(email: string, password: string) { // Uses only 2 of 14 methods! const user = this.userService.login(email, password); this.userService.notifyLoginFromNewDevice(user.id, this.getDeviceInfo()); return user; }}The problem is now visible: The LoginController depends on IUserService, but it only uses 2 of the 14 methods. This means:
IUserService for LoginController tests requires stubbing all 14 methodsThe fundamental mistake? We designed the interface around the implementation, not around the clients' needs.
When interfaces are derived by "extracting" from implementations, they inevitably inherit the structure and scope of those implementations. This creates fat interfaces that force clients to depend on methods they never use—the precise violation ISP warns against. The solution isn't to add more discipline to extraction; it's to change how we think about interfaces entirely.
Role-based interface design inverts the conventional approach. Instead of asking "What does this class do?" and extracting an interface, we ask "What role does this object play in this particular context?" and define interfaces from the client's perspective.
A role is a coherent set of related responsibilities that a client expects from a collaborator. Roles are:
Let's revisit our example with role-based thinking:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
// Role-Based Approach: Interfaces defined by what clients need // Role: Something that can authenticate usersinterface Authenticator { login(email: string, password: string): User; logout(userId: string): void;} // Role: Something that can manage passwordsinterface PasswordManager { resetPassword(email: string): void; changePassword(userId: string, newPassword: string): void;} // Role: Something that provides user profile accessinterface ProfileProvider { getProfile(userId: string): UserProfile; updateProfile(userId: string, profile: UserProfile): void;} // Role: Something that can manage avatarsinterface AvatarManager { uploadAvatar(userId: string, image: Buffer): string;} // Role: Something with administrative powers over usersinterface UserAdministrator { listAllUsers(): User[]; banUser(userId: string): void; deleteUser(userId: string): void; promoteToAdmin(userId: string): void;} // Role: Something that can send user notificationsinterface UserNotifier { sendWelcomeEmail(userId: string): void; sendPasswordResetEmail(email: string): void; notifyLoginFromNewDevice(userId: string, device: DeviceInfo): void;} // The implementation can fulfill multiple rolesclass UserService implements Authenticator, PasswordManager, ProfileProvider, AvatarManager, UserAdministrator, UserNotifier { // Implementation of all methods...} // Now LoginController depends only on the roles it needs!class LoginController { constructor( private authenticator: Authenticator, private notifier: UserNotifier ) {} handleLogin(email: string, password: string) { const user = this.authenticator.login(email, password); this.notifier.notifyLoginFromNewDevice(user.id, this.getDeviceInfo()); return user; }}Notice the transformation:
This is the essence of role-based interface design: interfaces are discovered from client needs, not extracted from implementations.
In role-based design, the 'arrows' of discovery point from clients toward providers. We don't start with an implementation and ask 'What interface should I extract?' We start with clients and ask 'What role do I need to fill this collaboration?' This client-first thinking prevents fat interfaces from ever forming.
Role-based interface design isn't just a theoretical nicety—it's how well-designed real-world systems are structured. Let's examine how this pattern appears across different domains.
Example 1: The File System Abstraction
A file on disk can play many roles. Different clients need different subsets of its capabilities:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
// Different clients need different views of a "file" // Role: Something whose content can be readinterface Readable { read(buffer: Buffer, offset: number, length: number): number;} // Role: Something whose content can be writteninterface Writable { write(buffer: Buffer, offset: number, length: number): number;} // Role: Something that can be positionedinterface Seekable { seek(position: number, whence: SeekMode): number; tell(): number;} // Role: Something that can be closed/releasedinterface Closeable { close(): void;} // Role: Something with metadatainterface FileMetadata { readonly size: number; readonly createdAt: Date; readonly modifiedAt: Date; readonly permissions: FilePermissions;} // A file descriptor in the OS implements multiple rolesclass FileDescriptor implements Readable, Writable, Seekable, Closeable, FileMetadata { // Full implementation...} // A log writer only needs Writable + Closeableclass LogWriter { constructor( private output: Writable & Closeable ) {} log(message: string): void { const buffer = Buffer.from(message + ''); this.output.write(buffer, 0, buffer.length); } finish(): void { this.output.close(); }} // A file searcher only needs Readable + Seekableclass FileSearcher { constructor( private file: Readable & Seekable ) {} findPattern(pattern: Buffer): number | null { // Can read and seek, doesn't need to write or know metadata }} // A backup system needs FileMetadata + Readableclass BackupManager { constructor( private source: FileMetadata & Readable ) {} shouldBackup(): boolean { // Uses metadata.modifiedAt return this.source.modifiedAt > this.lastBackupTime; }}Example 2: E-Commerce Order System
An Order object participates in many different business processes. Each process views the order through a different role:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
// Role: Something that can be pricedinterface Priceable { readonly subtotal: Money; readonly tax: Money; readonly total: Money; applyDiscount(discount: Discount): void;} // Role: Something that can be shippedinterface Shippable { readonly shippingAddress: Address; readonly weight: Weight; readonly dimensions: Dimensions; calculateShippingCost(carrier: Carrier): Money;} // Role: Something that can be paid forinterface Payable { readonly paymentDue: Money; recordPayment(payment: Payment): void; readonly isFullyPaid: boolean;} // Role: Something with line itemsinterface LineItemContainer { readonly items: ReadonlyArray<LineItem>; addItem(product: Product, quantity: number): void; removeItem(itemId: string): void;} // Role: Something with order lifecycleinterface OrderLifecycle { readonly status: OrderStatus; submit(): void; cancel(reason: string): void; refund(): void;} // Role: Something that can be tracked by customerinterface CustomerTrackable { readonly orderId: string; readonly estimatedDelivery: Date; readonly trackingNumber: string | null; readonly statusHistory: ReadonlyArray<StatusEvent>;} // The Order aggregate implements all these rolesclass Order implements Priceable, Shippable, Payable, LineItemContainer, OrderLifecycle, CustomerTrackable { // Full implementation...} // Payment processor only sees Payableclass PaymentProcessor { process(payable: Payable, paymentMethod: PaymentMethod): PaymentResult { // Works with any payable thing, not just Orders // Could also process invoices, subscriptions, etc. }} // Shipping calculator only sees Shippableclass ShippingCalculator { calculateOptions(shippable: Shippable): ShippingOption[] { // Works with any shippable thing // Doesn't know or care about payment status }} // Customer portal only sees CustomerTrackableclass CustomerOrderView { constructor(private order: CustomerTrackable) {} render(): CustomerViewDTO { // Can only access tracking info // Cannot cancel, modify, or see internal order details }}Notice that roles like 'Priceable' or 'Shippable' can apply to many different types, not just Order. An Invoice might be Priceable. A Product might be Shippable. By defining interfaces around roles rather than implementations, we enable polymorphism across unrelated types that share behaviors—one of the most powerful aspects of role-based design.
Role-based interfaces do more than improve coupling—they enhance communication. Well-named role interfaces form a ubiquitous language that makes code self-documenting and discussions more precise.
Compare these two method signatures:
sendNotification(userService: IUserService)sendNotification(notifier: Notifier)The naming of roles carries semantic weight. Consider these role names and what they communicate:
| Role Name | Semantic Meaning |
|---|---|
Authenticator | Validates identity, establishes sessions |
Authorizer | Checks permissions, enforces access control |
Encryptor | Transforms data into secure, unreadable form |
Validator | Checks data against rules, reports violations |
Transformer | Converts data from one form to another |
Publisher | Broadcasts events to interested parties |
Subscriber | Receives and reacts to published events |
Repository | Provides collection-like access to persistent objects |
Factory | Creates complex objects without exposing construction logic |
Observer | Watches for changes, reacts when notified |
Each name is a promise about behavior, not a description of implementation. This is why role-based interfaces are often named with "-er" or "-or" suffixes—they describe what something does, not what something is.
Good role names typically: (1) Use agent nouns (Validator, not IValidation), (2) Describe capability, not structure (Readable, not IHasReadMethod), (3) Are context-appropriate (UserAuthenticator vs just Authenticator when disambiguation is needed), and (4) Avoid implementation hints (Notifier, not EmailNotifier—that's for the class name).
One of the most powerful consequences of role-based interface design is role composition. When interfaces are small and focused, clients can combine them to express exactly what they need—no more, no less.
This is enabled by intersection types (in TypeScript), multiple interface inheritance (in Java/C#), or protocols (in Swift). The ability to compose roles multiplicatively expands our design vocabulary.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
// Individual rolesinterface Identifiable { readonly id: string;} interface Timestamped { readonly createdAt: Date; readonly updatedAt: Date;} interface Versionable { readonly version: number; incrementVersion(): void;} interface SoftDeletable { readonly isDeleted: boolean; softDelete(): void; restore(): void;} interface Auditable { readonly createdBy: UserId; readonly lastModifiedBy: UserId; recordModification(userId: UserId): void;} // Composed roles for specific needstype Trackable = Identifiable & Timestamped;type VersionedEntity = Identifiable & Versionable;type AuditedEntity = Identifiable & Timestamped & Auditable;type FullyManaged = Identifiable & Timestamped & Versionable & SoftDeletable & Auditable; // Repository that works with any identifiable, timestamped entityinterface Repository<T extends Trackable> { findById(id: string): T | null; findByCreatedAfter(date: Date): T[]; save(entity: T): void;} // Audit service works with anything auditableclass AuditService { recordChange<T extends AuditedEntity>(entity: T, userId: UserId): void { entity.recordModification(userId); this.auditLog.append({ entityId: entity.id, timestamp: new Date(), user: userId, previousVersion: entity.version }); }} // Soft delete handler works with anything soft-deletable and identifiableclass SoftDeleteHandler { delete<T extends Identifiable & SoftDeletable>(entity: T): void { entity.softDelete(); this.eventBus.publish(new EntitySoftDeleted(entity.id)); } restore<T extends Identifiable & SoftDeletable>(entity: T): void { entity.restore(); this.eventBus.publish(new EntityRestored(entity.id)); }}The mathematics of role composition:
With 5 orthogonal role interfaces (Identifiable, Timestamped, Versionable, SoftDeletable, Auditable), we can express 32 different combinations (2^5). Each combination represents a distinct set of capabilities that a client might require.
With a single fat interface that combines all these capabilities, we have 1 option: all or nothing.
This multiplicative power is why role-based design scales so well. As systems grow, the number of ways to compose existing roles grows exponentially, while the number of new interfaces needed grows only linearly.
| Approach | 10 Capabilities | 20 Capabilities | 30 Capabilities |
|---|---|---|---|
| Fat Interfaces | 1 way to use | 1 way to use | 1 way to use |
| Role Composition | 1,024 combinations | ~1 million combinations | ~1 billion combinations |
Role-based interfaces buy us exponential flexibility with linear effort. Each new role interface multiplies the expressiveness of our type vocabulary. This is why experienced designers prefer many small interfaces over few large ones—the composition possibilities compound.
Polymorphism, at its core, means "many forms"—the ability to treat different objects uniformly based on shared behavior. Role-based interfaces enable genuine polymorphism across objects that may have nothing else in common.
Consider: What do these have in common?
File on diskHttpResponse from a serverDatabaseResultSet from a queryGeneratorFunction that yields valuesMockDataSource in a testThey're completely different types with different implementations. But they can all be Readable—something that provides data when asked. This shared role enables polymorphic code:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
// Role interface for anything that can provide datainterface DataSource { read(): AsyncIterable<Buffer>; readonly metadata: SourceMetadata;} // All these unrelated types implement the same roleclass FileSource implements DataSource { /* ... */ }class HttpResponseSource implements DataSource { /* ... */ }class DatabaseSource implements DataSource { /* ... */ }class InMemorySource implements DataSource { /* ... */ }class S3BucketSource implements DataSource { /* ... */ } // Polymorphic processor works with ANY data sourceclass DataProcessor { // This single method works with 5+ completely different types! async process(source: DataSource): Promise<ProcessedResult> { const results: ProcessedChunk[] = []; for await (const chunk of source.read()) { results.push(this.processChunk(chunk)); } return { metadata: source.metadata, chunks: results, processedAt: new Date() }; }} // Usage: same processor, different sourcesconst processor = new DataProcessor(); // Process a local fileconst fileResult = await processor.process(new FileSource('/data/input.csv')); // Process an HTTP responseconst httpResult = await processor.process(new HttpResponseSource(apiUrl)); // Process database resultsconst dbResult = await processor.process(new DatabaseSource(query)); // Process test data in unit testsconst testResult = await processor.process(new InMemorySource(mockData)); // Process data from S3const s3Result = await processor.process(new S3BucketSource(bucketName, key));This is polymorphism working as intended:
DataProcessor.process()) works with many typesInMemorySource with controlled dataWithout role-based interfaces, we'd be forced into awkward alternatives:
Role-based interfaces give us polymorphism without inheritance, composition without boilerplate.
Role-based interfaces formalize the intuition behind 'duck typing': if it walks like a duck and quacks like a duck, we can treat it as a duck. Role interfaces make the duck's expected behavior explicit and type-safe, capturing the benefits of duck typing without the runtime surprises.
Some developers wonder: "Why not just use abstract base classes for shared behavior?" The answer reveals a critical distinction between role-based design and inheritance-based design.
Base classes express what something is. A Dog extends Mammal because a dog is a mammal. This is an ontological claim about identity and shared characteristics.
Interfaces express what something can do. A Dog implements Runnable because a dog can run. This is a behavioral claim about capabilities that may be shared with completely unrelated types.
The problems with using base classes for roles:
| Aspect | Abstract Base Class | Role Interface |
|---|---|---|
| Relationship type | IS-A (identity) | CAN-DO (capability) |
| Inheritance count | Single (one base) | Multiple (many roles) |
| Coupling | Tight (inherits implementation) | Loose (only contract) |
| State sharing | Yes (can inherit fields) | No (only behavior contract) |
| Runtime cost | Virtual dispatch + state | Virtual dispatch only |
| Testing | May need to handle base class behavior | Mock only the role's methods |
| Evolution | Dangerous (changes affect all children) | Safe (implementations are independent) |
123456789101112131415161718192021222324252627282930313233343536373839404142
// PROBLEMATIC: Using base class for roleabstract class Serializable { abstract serialize(): string; abstract deserialize(data: string): void;} class User extends Serializable { /* ... */ }class Order extends Serializable { /* ... */ } // Problem: What if Order needs to extend DomainEntity?class DomainEntity { readonly id: string; readonly createdAt: Date;} // CAN'T DO THIS in single-inheritance languages:// class Order extends DomainEntity, Serializable { } // SOLUTION: Role interfacesinterface Serializable { serialize(): string; deserialize(data: string): void;} interface DomainEntity { readonly id: string; readonly createdAt: Date;} // Now Order can be both!class Order implements Serializable, DomainEntity { readonly id: string; readonly createdAt: Date; serialize(): string { /* ... */ } deserialize(data: string): void { /* ... */ }} // And we can combine them freelyclass User implements Serializable, DomainEntity { /* ... */ }class Configuration implements Serializable { /* ... */ } // No DomainEntity neededclass AuditLog implements DomainEntity { /* ... */ } // No Serializable neededIf you find yourself creating an abstract base class with no fields—only abstract methods—you almost certainly want an interface instead. Base classes are for sharing implementation; interfaces are for sharing contracts. Conflating them leads to inflexible hierarchies.
We've explored a fundamental paradigm shift in interface design—from implementation-centric to role-centric thinking. This isn't merely a technique; it's a change in perspective that transforms how we model systems.
What's next:
Now that we understand role-based thinking, we'll explore how to design client-specific interfaces—interfaces tailored to what each particular client actually uses. This is the practical application of ISP: ensuring that no client is forced to depend on methods it doesn't need.
You now understand the foundational concept of role-based interface design. This mental model—thinking of interfaces as roles rather than implementation contracts—is the key insight that makes ISP not just a rule to follow, but a natural consequence of good design thinking. Next, we'll see how to apply this by designing interfaces around specific client needs.