Loading learning content...
The Interface Segregation Principle states: "Clients should not be forced to depend on methods they do not use." This deceptively simple statement carries profound implications for how we design interfaces.
The key word is clients. ISP doesn't say "implementations should have small interfaces." It says clients—the code that uses interfaces—should not be burdened with methods they don't need. This client-centric perspective leads us to a powerful design approach: client-specific interfaces.
Client-specific interfaces are tailored to what each client actually requires, creating precise contracts that eliminate unnecessary dependencies and maximize flexibility.
By the end of this page, you will understand how to identify what clients truly need, how to design interfaces that serve specific client categories, how to balance granularity with practicality, and how client-specific interfaces create systems that are easier to test, maintain, and evolve.
To design client-specific interfaces, we must first understand what we mean by "client." In the context of ISP:
A client is any code that depends on an interface.
This includes:
The question to ask is not "What can this interface do?" but rather "What does this client need from its collaborators?"
Let's examine a concrete scenario where multiple clients have different needs:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
// Consider an inventory system with multiple clients // THE FAT INTERFACE (ISP violation)interface InventoryManager { // Stock operations getStockLevel(productId: string): number; addStock(productId: string, quantity: number): void; removeStock(productId: string, quantity: number): void; reserveStock(productId: string, quantity: number): ReservationId; releaseReservation(reservationId: ReservationId): void; // Reporting getStockReport(): StockReport; getLowStockAlerts(): LowStockAlert[]; getInventoryValue(): Money; getStockMovementHistory(productId: string): StockMovement[]; // Warehouse operations transferStock(productId: string, fromWarehouse: string, toWarehouse: string, quantity: number): void; locateStock(productId: string): WarehouseLocation[]; optimizeStockPlacement(): void; // Import/Export importFromCsv(data: string): ImportResult; exportToCsv(): string; syncWithErp(erpConnection: ErpConnection): SyncResult;} // CLIENT 1: Order Service// Needs: Check stock, reserve stock, release reservationclass OrderService { constructor(private inventory: InventoryManager) {} placeOrder(items: OrderItem[]): Order { // Only uses 3 of 14 methods! for (const item of items) { const stock = this.inventory.getStockLevel(item.productId); if (stock < item.quantity) throw new InsufficientStockError(); } const reservations = items.map(item => this.inventory.reserveStock(item.productId, item.quantity) ); return new Order(items, reservations); } cancelOrder(order: Order): void { // Uses 1 method for (const reservationId of order.reservations) { this.inventory.releaseReservation(reservationId); } }} // CLIENT 2: Reporting Dashboard// Needs: Reports, alerts, value calculationsclass ReportingDashboard { constructor(private inventory: InventoryManager) {} generateDailyReport(): DashboardData { // Only uses 4 of 14 methods! return { stockReport: this.inventory.getStockReport(), alerts: this.inventory.getLowStockAlerts(), totalValue: this.inventory.getInventoryValue(), // Doesn't need stock operations, warehouse ops, or import/export }; }} // CLIENT 3: Warehouse Manager App// Needs: Transfer, locate, optimizeclass WarehouseApp { constructor(private inventory: InventoryManager) {} handleTransferRequest(request: TransferRequest): void { // Only uses 3 of 14 methods! const locations = this.inventory.locateStock(request.productId); // ... find best source warehouse this.inventory.transferStock( request.productId, sourceWarehouse, request.toWarehouse, request.quantity ); } runOptimization(): void { // Uses 1 method this.inventory.optimizeStockPlacement(); }} // CLIENT 4: Data Integration Service// Needs: Import, export, syncclass DataIntegrationService { constructor(private inventory: InventoryManager) {} syncWithExternalSystems(): void { // Only uses 3 of 14 methods! const csvExport = this.inventory.exportToCsv(); this.inventory.syncWithErp(this.erpConnection); // Backup import capability }}The analysis is clear:
| Client | Methods Needed | Methods Forced to Depend On |
|---|---|---|
| OrderService | 3 | 14 |
| ReportingDashboard | 4 | 14 |
| WarehouseApp | 4 | 14 |
| DataIntegrationService | 3 | 14 |
Each client uses less than 30% of the interface but is coupled to 100% of it. This creates:
Every method in an interface that a client doesn't use is a source of unnecessary coupling. It's a point where changes can propagate, tests can fail, and confusion can arise. Client-specific interfaces eliminate this waste by ensuring clients depend only on what they actually need.
Now let's apply client-specific interface design to our inventory example. The process is:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
// Client-Specific Interfaces for the Inventory System // Interface for clients that need to check and reserve stock (OrderService, etc.)interface StockChecker { getStockLevel(productId: string): number; isInStock(productId: string, quantity: number): boolean;} interface StockReserver { reserveStock(productId: string, quantity: number): ReservationId; releaseReservation(reservationId: ReservationId): void;} // Interface for clients that need stock reports (Dashboard, Analytics, etc.)interface StockReporter { getStockReport(): StockReport; getLowStockAlerts(): LowStockAlert[]; getInventoryValue(): Money; getStockMovementHistory(productId: string): StockMovement[];} // Interface for clients that manage warehouse operationsinterface WarehouseManager { transferStock(productId: string, from: string, to: string, qty: number): void; locateStock(productId: string): WarehouseLocation[]; optimizeStockPlacement(): void;} // Interface for clients that need stock modification (receiving, adjustments)interface StockModifier { addStock(productId: string, quantity: number): void; removeStock(productId: string, quantity: number): void;} // Interface for clients that integrate with external systemsinterface InventoryDataExchange { importFromCsv(data: string): ImportResult; exportToCsv(): string; syncWithErp(connection: ErpConnection): SyncResult;} // The implementation fulfills all rolesclass InventoryService implements StockChecker, StockReserver, StockReporter, WarehouseManager, StockModifier, InventoryDataExchange { // Implementation of all methods... getStockLevel(productId: string): number { /* ... */ } isInStock(productId: string, quantity: number): boolean { /* ... */ } reserveStock(productId: string, quantity: number): ReservationId { /* ... */ } releaseReservation(reservationId: ReservationId): void { /* ... */ } getStockReport(): StockReport { /* ... */ } getLowStockAlerts(): LowStockAlert[] { /* ... */ } getInventoryValue(): Money { /* ... */ } getStockMovementHistory(productId: string): StockMovement[] { /* ... */ } transferStock(productId: string, from: string, to: string, qty: number): void { /* ... */ } locateStock(productId: string): WarehouseLocation[] { /* ... */ } optimizeStockPlacement(): void { /* ... */ } addStock(productId: string, quantity: number): void { /* ... */ } removeStock(productId: string, quantity: number): void { /* ... */ } importFromCsv(data: string): ImportResult { /* ... */ } exportToCsv(): string { /* ... */ } syncWithErp(connection: ErpConnection): SyncResult { /* ... */ }} // NOW: Clients depend only on what they need! class OrderService { constructor( private stockChecker: StockChecker, private stockReserver: StockReserver ) {} placeOrder(items: OrderItem[]): Order { for (const item of items) { if (!this.stockChecker.isInStock(item.productId, item.quantity)) { throw new InsufficientStockError(); } } const reservations = items.map(item => this.stockReserver.reserveStock(item.productId, item.quantity) ); return new Order(items, reservations); }} class ReportingDashboard { constructor(private reporter: StockReporter) {} generateDailyReport(): DashboardData { return { stockReport: this.reporter.getStockReport(), alerts: this.reporter.getLowStockAlerts(), totalValue: this.reporter.getInventoryValue(), }; }} class WarehouseApp { constructor(private warehouseManager: WarehouseManager) {} handleTransferRequest(request: TransferRequest): void { const locations = this.warehouseManager.locateStock(request.productId); // ... }} class DataIntegrationService { constructor(private dataExchange: InventoryDataExchange) {} syncWithExternalSystems(): void { this.dataExchange.syncWithErp(this.erpConnection); }}The transformation is complete:
| Client | Now Depends On | Methods |
|---|---|---|
| OrderService | StockChecker, StockReserver | 4 methods |
| ReportingDashboard | StockReporter | 4 methods |
| WarehouseApp | WarehouseManager | 3 methods |
| DataIntegrationService | InventoryDataExchange | 3 methods |
Each client now depends only on what it uses. If we add a method to WarehouseManager, OrderService is completely unaffected—it doesn't even know that interface exists.
Client-specific interfaces deliver immediate benefits: cleaner dependency injection, simpler mocking in tests, reduced cognitive load when reading code, and elimination of phantom dependencies that caused unnecessary coupling.
Determining where to split interfaces requires understanding your clients' needs. Here are practical techniques for identifying client boundaries:
Technique 1: Method Usage Analysis
Examine which methods each client actually calls. Group methods that are always used together.
1234567891011121314151617181920212223242526272829
// Method Usage Matrix// List all clients and which methods they use /*Method | OrderSvc | Dashboard | Warehouse | DataSync--------------------|----------|-----------|-----------|----------getStockLevel | ✓ | | |reserveStock | ✓ | | |releaseReservation | ✓ | | |getStockReport | | ✓ | |getLowStockAlerts | | ✓ | |getInventoryValue | | ✓ | |transferStock | | | ✓ |locateStock | | | ✓ |optimizeStockPlacement| | | ✓ |importFromCsv | | | | ✓exportToCsv | | | | ✓syncWithErp | | | | ✓ Analysis:- Rows that share the same column pattern belong in the same interface- Rows with different patterns suggest interface boundaries*/ // This analysis reveals 4 natural interface groupings:// 1. StockChecker/Reserver: getStockLevel, reserveStock, releaseReservation// 2. StockReporter: getStockReport, getLowStockAlerts, getInventoryValue// 3. WarehouseManager: transferStock, locateStock, optimizeStockPlacement// 4. DataExchange: importFromCsv, exportToCsv, syncWithErpTechnique 2: Actor Analysis
Different actors (people or systems) in your domain often need different interfaces. Each actor's perspective suggests an interface boundary.
| Actor | What They Do | Suggested Interface |
|---|---|---|
| Customer Service Rep | Checks stock for customer inquiries | StockChecker |
| Order Fulfillment System | Reserves stock for orders | StockReserver |
| Finance Team | Values inventory for accounting | InventoryValuer |
| Warehouse Staff | Physically moves stock | WarehouseOperations |
| Purchasing Team | Tracks what to reorder | ReorderAlerts |
| External ERP | Syncs data bidirectionally | DataExchange |
Technique 3: Change Reason Analysis
Methods that change for the same reason belong together. Methods that change for different reasons should be in different interfaces.
12345678910111213141516171819
// Change Reason Analysis /*Reason for Change | Affected Methods--------------------------------------|------------------------------------------Stock algorithm changes | getStockLevel, reserveStock, releaseReservationReporting format requirements | getStockReport, getLowStockAlertsWarehouse layout changes | locateStock, optimizeStockPlacement, transferStockERP vendor switch | syncWithErpCSV format standardization | importFromCsv, exportToCsvAccounting rule changes | getInventoryValue Insight: Methods affected by the same change reason form natural cohesive groups. These groups should be separate interfaces.*/ // Result: Interfaces aligned with change reasons are more stable// When the ERP vendor changes, only InventoryDataExchange clients recompile// When warehouse layout changes, only WarehouseManager clients are affectedUse all three techniques together. If method usage analysis, actor analysis, and change reason analysis all suggest the same boundary, you've found a stable interface. If they conflict, dig deeper—there may be a domain insight you're missing.
A common question arises: How small should interfaces be? The answer lies on a spectrum, and finding the right granularity requires balancing competing concerns.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
// ❌ TOO COARSE: One interface for everythinginterface InventoryManager { // 15 methods across 5 concerns getStock(): number; reserveStock(): ReservationId; getReport(): Report; transfer(): void; syncWithErp(): void; // ... 10 more methods} class OrderService { constructor(private inventory: InventoryManager) {} // Too much!} // ❌ TOO FINE: One interface per methodinterface StockLevelGetter { getStockLevel(id: string): number; }interface StockChecker { isInStock(id: string, qty: number): boolean; }interface StockReserver { reserveStock(id: string, qty: number): ReservationId; }interface ReservationReleaser { releaseReservation(id: ReservationId): void; }// ... and so on for every method class OrderService { constructor( private stockLevelGetter: StockLevelGetter, private stockChecker: StockChecker, private stockReserver: StockReserver, private reservationReleaser: ReservationReleaser // Ridiculous! Too many dependencies ) {}} // ✅ JUST RIGHT: Cohesive role-based interfacesinterface StockChecker { getStockLevel(productId: string): number; isInStock(productId: string, quantity: number): boolean;} interface StockReserver { reserveStock(productId: string, quantity: number): ReservationId; releaseReservation(reservationId: ReservationId): void;} class OrderService { constructor( private stockChecker: StockChecker, private stockReserver: StockReserver // Just right: Two cohesive role interfaces ) {}}The Right Granularity Heuristics:
Cohesion Test: Do all methods in the interface serve the same purpose? If yes, they belong together.
Client Test: Would a typical client use most methods, or just a few? Aim for interfaces where clients use most methods.
Name Test: Can you give the interface a clear, specific name? If you can only call it IDoesThreeUnrelatedThings, split it.
Constructor Test: If injecting the interface leads to 6+ dependencies, you may be too fine-grained.
Stability Test: Methods that change together should be in the same interface; methods that change independently can be separated.
The ideal interface has 2-5 related methods. One-method interfaces are occasionally justified (like Runnable or Comparable), but they should be the exception. Interfaces with more than 7 methods usually indicate multiple mixed concerns.
One of the most immediate benefits of client-specific interfaces is dramatically simplified testing. When clients depend only on what they use, mocking becomes trivial.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
// BEFORE: Fat interface makes testing painful interface IInventoryManager { getStockLevel(productId: string): number; isInStock(productId: string, quantity: number): boolean; reserveStock(productId: string, quantity: number): ReservationId; releaseReservation(reservationId: ReservationId): void; getStockReport(): StockReport; getLowStockAlerts(): LowStockAlert[]; getInventoryValue(): Money; transferStock(from: string, to: string, qty: number): void; locateStock(productId: string): WarehouseLocation[]; importFromCsv(data: string): ImportResult; exportToCsv(): string; // ... more methods} // Testing OrderService requires mocking ALL 11+ methods!describe('OrderService', () => { it('should place an order when stock is available', () => { const mockInventory: IInventoryManager = { getStockLevel: jest.fn().mockReturnValue(100), isInStock: jest.fn().mockReturnValue(true), reserveStock: jest.fn().mockReturnValue('res-123'), releaseReservation: jest.fn(), // Still must provide these even though OrderService never uses them: getStockReport: jest.fn(), getLowStockAlerts: jest.fn(), getInventoryValue: jest.fn(), transferStock: jest.fn(), locateStock: jest.fn(), importFromCsv: jest.fn(), exportToCsv: jest.fn(), }; const orderService = new OrderService(mockInventory); // ... test });}); // AFTER: Client-specific interfaces make testing elegant interface StockChecker { getStockLevel(productId: string): number; isInStock(productId: string, quantity: number): boolean;} interface StockReserver { reserveStock(productId: string, quantity: number): ReservationId; releaseReservation(reservationId: ReservationId): void;} // Testing OrderService requires mocking only 4 methods!describe('OrderService', () => { it('should place an order when stock is available', () => { const mockStockChecker: StockChecker = { getStockLevel: jest.fn().mockReturnValue(100), isInStock: jest.fn().mockReturnValue(true), }; const mockStockReserver: StockReserver = { reserveStock: jest.fn().mockReturnValue('res-123'), releaseReservation: jest.fn(), }; const orderService = new OrderService(mockStockChecker, mockStockReserver); // ... test }); // Can even test with only the interfaces needed for each test it('should release reservations when order is cancelled', () => { const mockStockReserver: StockReserver = { reserveStock: jest.fn(), releaseReservation: jest.fn(), }; // Don't even need StockChecker for this test! const orderService = new OrderService(null as any, mockStockReserver); orderService.cancelOrder(existingOrder); expect(mockStockReserver.releaseReservation).toHaveBeenCalled(); });});The testing benefits are substantial:
| Metric | Fat Interface | Client-Specific |
|---|---|---|
| Methods to mock | 11+ | 2-4 |
| Lines of mock setup | ~25 | ~8 |
| False positives from mocks | High | Low |
| Test clarity | Obscured | Clear |
| Mock maintenance burden | High | Low |
Additional testing advantages:
If your mock setup requires more than 5-6 method stubs, it's a sign that the interface is too fat. Let testing pain guide you toward better interface segregation—it's often the first place ISP violations become obvious.
Let's examine how client-specific interfaces appear in well-designed real-world systems and libraries.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
// EXAMPLE 1: Node.js Streams// The stream API uses client-specific interfaces // For producers (things that generate data)interface Readable { read(size?: number): Buffer | null; pipe<T extends Writable>(destination: T): T; on(event: 'data', listener: (chunk: Buffer) => void): this; on(event: 'end', listener: () => void): this;} // For consumers (things that receive data)interface Writable { write(chunk: Buffer, callback?: (error?: Error) => void): boolean; end(callback?: () => void): this; on(event: 'drain', listener: () => void): this;} // For transformers (things that modify data in transit)interface Transform extends Readable, Writable { _transform(chunk: Buffer, encoding: string, callback: TransformCallback): void;} // A client that reads files needs only Readableclass FileProcessor { process(source: Readable): Promise<void> { // Works with files, HTTP responses, sockets, etc. }} // EXAMPLE 2: Repository Pattern with Client-Specific Interfaces // Read-only clients get a read-only interfaceinterface ReadRepository<T> { findById(id: string): T | null; findAll(): T[]; findBy(criteria: Criteria<T>): T[]; count(): number;} // Clients that need to write get a separate interfaceinterface WriteRepository<T> { save(entity: T): void; saveAll(entities: T[]): void; delete(entity: T): void; deleteById(id: string): void;} // Clients that need both compose themtype CrudRepository<T> = ReadRepository<T> & WriteRepository<T>; // Query service only needs read accessclass QueryService { constructor(private users: ReadRepository<User>) {} findActiveUsers(): User[] { return this.users.findBy({ active: true }); }} // Command service needs write accessclass UserRegistrationService { constructor(private users: WriteRepository<User>) {} registerUser(userData: UserData): void { const user = User.create(userData); this.users.save(user); }} // EXAMPLE 3: Payment Processing // Interface for clients that initiate paymentsinterface PaymentInitiator { createPaymentIntent(amount: Money, customer: CustomerId): PaymentIntent; confirmPayment(intentId: string, paymentMethod: PaymentMethodId): PaymentResult;} // Interface for clients that need refundsinterface RefundProcessor { createRefund(paymentId: string, amount?: Money): Refund; getRefundStatus(refundId: string): RefundStatus;} // Interface for clients that query payment historyinterface PaymentQuerier { getPayment(paymentId: string): Payment; listPayments(customerId: CustomerId, options: ListOptions): Payment[]; getPaymentsByDateRange(start: Date, end: Date): Payment[];} // Interface for webhook handlinginterface PaymentWebhookHandler { handleWebhook(payload: WebhookPayload, signature: string): void;} // Checkout flow only needs PaymentInitiatorclass CheckoutService { constructor(private payments: PaymentInitiator) {}} // Customer support needs PaymentQuerier and RefundProcessorclass SupportDashboard { constructor( private paymentQuerier: PaymentQuerier, private refundProcessor: RefundProcessor ) {}}Notice how client-specific interfaces appear across diverse domains—streams, repositories, payments. The pattern is universal: identify distinct client needs and create focused interfaces that serve those needs precisely.
A practical question arises: If one class implements multiple interfaces, how do we wire things up in dependency injection?
The answer is straightforward: the DI container manages a single instance but provides it to different clients through different interface types.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
// Example using a typical DI pattern // The multi-role implementationclass InventoryService implements StockChecker, StockReserver, StockReporter, WarehouseManager { // Implementation...} // DI Container Configurationclass CompositionRoot { configureServices(container: Container): void { // Register the concrete implementation once const inventoryService = container.registerSingleton( InventoryService, InventoryService ); // Alias it to each interface it implements // All these resolve to the SAME instance container.alias(StockChecker, InventoryService); container.alias(StockReserver, InventoryService); container.alias(StockReporter, InventoryService); container.alias(WarehouseManager, InventoryService); // Now clients can depend on just what they need container.register(OrderService, { // OrderService constructor: (stockChecker: StockChecker, stockReserver: StockReserver) // Both resolve to the same InventoryService instance }); container.register(ReportingDashboard, { // Dashboard constructor: (reporter: StockReporter) // Resolves to InventoryService, same instance }); }} // Alternative: Factory-based wiringclass AppFactory { createApp(): Application { // Single instance const inventoryService = new InventoryService(/* dependencies */); // Pass it through different interface "views" const orderService = new OrderService( inventoryService, // as StockChecker inventoryService // as StockReserver ); const dashboard = new ReportingDashboard( inventoryService // as StockReporter ); const warehouseApp = new WarehouseApp( inventoryService // as WarehouseManager ); return new Application(orderService, dashboard, warehouseApp); }} // TypeScript interface perspective// The same object, viewed through different lenses:const inventoryService = new InventoryService(); const asStockChecker: StockChecker = inventoryService; // ✓const asStockReserver: StockReserver = inventoryService; // ✓const asStockReporter: StockReporter = inventoryService; // ✓const asWarehouseManager: WarehouseManager = inventoryService; // ✓ // Each "view" sees only its interface's methods// asStockChecker.getStockLevel(...) ✓// asStockChecker.transferStock(...) ✗ Type error!The key insight: Client-specific interfaces don't require multiple implementations or duplicate code. A single class can implement many interfaces and be injected into different clients through different interface "views." The DI container or factory handles the wiring; clients remain blissfully unaware of the full implementation.
This is the beauty of interface-based design: the same object can wear different masks depending on who's looking at it.
Clients get exactly the dependency they asked for, even though behind the scenes it's the same object. This separation of interface from implementation is what makes ISP practical in real systems.
Client-specific interfaces are the practical application of ISP. By designing interfaces from the client's perspective—focusing on what each client needs rather than what implementations provide—we create systems with minimal coupling and maximum flexibility.
What's next:
We've seen how to design interfaces around client needs. Next, we'll explore the Minimal Interface Principle—how to determine the smallest possible interface that still serves its purpose, and why minimalism in interface design leads to more robust and flexible systems.
You now understand how to design client-specific interfaces—interfaces tailored to what each client actually requires. This client-first approach ensures no code depends on methods it doesn't use, creating systems that are easier to test, maintain, and evolve. Next, we'll see how to apply the minimal interface principle to achieve even greater design clarity.