Loading learning content...
We've spent this chapter advocating for interface segregation—splitting fat interfaces into focused, role-based contracts. But there's a danger in taking this advice too far. Extreme granularity creates its own problems: an explosion of single-method interfaces, dependency injection nightmares, and cognitive overload from tracking dozens of tiny contracts.
The goal isn't "as many interfaces as possible." The goal is the right number of interfaces—enough to avoid ISP violations, but not so many that the cure becomes worse than the disease. This page explores the art of finding that balance.
By the end of this page, you will understand the symptoms of over-segregation, develop heuristics for appropriate interface granularity, and be able to make pragmatic decisions that balance ISP purity with practical maintainability.
Interface granularity exists on a spectrum. Both extremes are problematic—the sweet spot lies somewhere in the middle, and the exact position depends on context.
The Granularity Spectrum:
| Level | Description | Trade-offs |
|---|---|---|
| Monolithic | 1 interface for entire domain (e.g., UserManager with 50 methods) | Simple to find things; impossible to decouple |
| Domain-Aggregate | 1 interface per domain aggregate (e.g., UserRepository, OrderRepository) | Clear boundaries; some role conflation |
| Role-Based | 1 interface per role (e.g., UserReader, UserWriter, UserAuthenticator) | Clean ISP; moderate interface count |
| Capability-Based | 1 interface per capability (e.g., Readable, Writable, Searchable) | Maximum reuse; requires composition |
| Single-Method | 1 interface per method (e.g., CanCreateUser, CanDeleteUser) | Ultimate ISP purity; explosion of types |
There's no universal "correct" granularity. A microservices architecture might favor finer granularity (each service has focused interfaces). A monolithic CRUD application might favor coarser granularity (repository per entity). The right answer depends on team size, change frequency, testing requirements, and architectural style.
Over-segregation is less common than under-segregation, but it happens—especially when developers take ISP advice to an extreme. Recognize these warning signs:
ICanProcessPaymentStep3After is too granular123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
// Over-segregated - each method is its own interfaceinterface UserCreator { createUser(data: CreateUserDTO): Promise<User>;} interface UserReaderById { getUserById(id: string): Promise<User | null>;} interface UserReaderByEmail { getUserByEmail(email: string): Promise<User | null>;} interface UserUpdater { updateUser(id: string, data: UpdateUserDTO): Promise<User>;} interface UserDeleter { deleteUser(id: string): Promise<void>;} interface UserExistenceChecker { userExists(id: string): Promise<boolean>;} interface UserCounter { countUsers(): Promise<number>;} // Now a service that needs basic CRUD has this nightmare:class UserProfileService { constructor( private userReaderById: UserReaderById, private userReaderByEmail: UserReaderByEmail, private userUpdater: UserUpdater, private userExistenceChecker: UserExistenceChecker, // ... and so on ) {} // 7 interface dependencies for basic profile management!} // DI container configuration becomes unreadablecontainer.bind<UserCreator>(TYPES.UserCreator).to(UserRepositoryImpl);container.bind<UserReaderById>(TYPES.UserReaderById).to(UserRepositoryImpl);container.bind<UserReaderByEmail>(TYPES.UserReaderByEmail).to(UserRepositoryImpl);container.bind<UserUpdater>(TYPES.UserUpdater).to(UserRepositoryImpl);container.bind<UserDeleter>(TYPES.UserDeleter).to(UserRepositoryImpl);container.bind<UserExistenceChecker>(TYPES.UserExistenceChecker).to(UserRepositoryImpl);container.bind<UserCounter>(TYPES.UserCounter).to(UserRepositoryImpl);// All 7 bindings point to the same implementation!The example above demonstrates ISP taken to an absurd extreme. While each interface is perfectly "segregated," the system is harder to understand, harder to configure, and provides no practical benefit—because all these interfaces are always used together and implemented by the same class.
The key to appropriate granularity is cohesion: methods that naturally belong together should stay together. ISP says "don't force unrelated methods on consumers." It doesn't say "separate every method into its own interface."
Cohesion Indicators for Keeping Methods Together:
getBalance() and deposit() both work with account balanceopenTransaction(), commit(), rollback() are a unitmethodA() produces data that methodB() consumes, they're cohesive12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
// Appropriate granularity - cohesive method groups // User identity management - cohesive because:// - Same data (user identity, credentials)// - Same stakeholder (auth team)// - Called together during login/registration flowsinterface UserIdentityManager { findByCredentials(email: string, password: string): Promise<User | null>; createUser(data: CreateUserDTO): Promise<User>; updateCredentials(userId: string, newEmail?: string, newPassword?: string): Promise<void>; verifyEmail(userId: string, token: string): Promise<boolean>;} // User profile - cohesive because:// - Different data (display info, preferences)// - Different stakeholder (product team)// - Called together during profile views/editsinterface UserProfileManager { getProfile(userId: string): Promise<UserProfile>; updateProfile(userId: string, data: UpdateProfileDTO): Promise<void>; uploadAvatar(userId: string, image: Buffer): Promise<string>; getPreferences(userId: string): Promise<UserPreferences>; updatePreferences(userId: string, prefs: UserPreferences): Promise<void>;} // User social graph - cohesive because:// - Different data (relationships)// - Different stakeholder (social features team)// - All relate to user connectionsinterface UserSocialGraph { follow(userId: string, targetUserId: string): Promise<void>; unfollow(userId: string, targetUserId: string): Promise<void>; getFollowers(userId: string): Promise<User[]>; getFollowing(userId: string): Promise<User[]>; blockUser(userId: string, blockedUserId: string): Promise<void>;} // Now services depend on what they need, but not excessivelyclass AuthService { constructor(private identity: UserIdentityManager) {} // 1 dependency for auth concerns} class ProfileController { constructor( private identity: UserIdentityManager, private profile: UserProfileManager ) {} // 2 dependencies for profile with auth} class SocialFeedService { constructor( private profile: UserProfileManager, private social: UserSocialGraph ) {} // 2 dependencies for social features}A well-designed interface typically has 3-6 methods. Fewer than 3 suggests possible over-segregation (unless it's a genuine single-capability interface like Comparable). More than 7 suggests possible under-segregation. This is a heuristic, not a rule—but it guides initial interface sizing before deeper analysis.
The most reliable granularity strategy is role-based segregation: one interface per role a consumer might play. This naturally groups cohesive methods while avoiding the extremes of monolithic or single-method interfaces.
Identify Roles, Not Individual Methods:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
// Role identification for Order domain // Role: Order Creator// Who plays this role: Checkout service, Order import tool, API endpoint// Methods needed: creating orders from various sourcesinterface OrderCreator { createFromCart(cart: Cart, customer: Customer): Promise<Order>; createFromImport(data: OrderImportData): Promise<Order>; createDraft(partial: Partial<Order>): Promise<DraftOrder>;} // Role: Order Reader// Who plays this role: Order details page, Customer dashboard, API// Methods needed: retrieving orders in various waysinterface OrderReader { getById(id: string): Promise<Order | null>; getByCustomer(customerId: string): Promise<Order[]>; search(criteria: OrderSearchCriteria): Promise<Page<Order>>;} // Role: Order State Changer// Who plays this role: Warehouse system, Admin dashboard, Background jobs// Methods needed: transitioning order statesinterface OrderStateChanger { submit(orderId: string): Promise<void>; confirm(orderId: string): Promise<void>; cancel(orderId: string, reason: CancellationReason): Promise<void>; fulfill(orderId: string, fulfillment: FulfillmentDetails): Promise<void>; complete(orderId: string): Promise<void>;} // Role: Order Financial Handler// Who plays this role: Finance system, Refund processor, Invoicing// Methods needed: financial operations on ordersinterface OrderFinancialHandler { applyDiscount(orderId: string, discount: Discount): Promise<void>; processRefund(orderId: string, amount: Money): Promise<Refund>; generateInvoice(orderId: string): Promise<Invoice>;} // Consumers declare which roles they playclass CheckoutService implements OrderCreater { // Only implements creation role} class CustomerDashboardService { constructor(private reader: OrderReader) { // Only depends on reading role }} class WarehouseIntegration { constructor( private reader: OrderReader, private stateChanger: OrderStateChanger ) { // Reads orders and changes their state }}Benefits of Role-Based Granularity:
Creator, Reader, Handler are intuitiveWhen interfaces are appropriately sized, consumers can compose them to express exactly what they need. This is preferable to creating an interface for every possible combination of capabilities.
Composition Patterns:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// Base role interfacesinterface Readable<T> { findById(id: string): Promise<T | null>; findAll(filter?: Filter): Promise<T[]>;} interface Writable<T> { save(entity: T): Promise<T>; delete(id: string): Promise<void>;} interface Searchable<T> { search(query: SearchQuery): Promise<Page<T>>; count(filter?: Filter): Promise<number>;} // Composition via intersection typestype ReadWriteRepository<T> = Readable<T> & Writable<T>;type FullRepository<T> = Readable<T> & Writable<T> & Searchable<T>; // Or explicit combined interfaces when semantically meaningfulinterface CRUDRepository<T> extends Readable<T>, Writable<T> {} // Consumers choose their compositionclass ReadOnlyReportService { constructor(private products: Readable<Product>) { // Can only read - write operations not available }} class InventoryManager { constructor(private products: ReadWriteRepository<Product>) { // Can read and write }} class ProductSearchController { constructor(private products: Searchable<Product>) { // Only search capability }} // Implementation provides all capabilitiesclass ProductRepository implements FullRepository<Product> { findById(id: string) { /* ... */ } findAll(filter?: Filter) { /* ... */ } save(entity: Product) { /* ... */ } delete(id: string) { /* ... */ } search(query: SearchQuery) { /* ... */ } count(filter?: Filter) { /* ... */ }} // DI binds the full implementation; consumers see only their declared typecontainer.bind<FullRepository<Product>>(TYPES.ProductRepo).to(ProductRepository);Interface inheritance (interface B extends A) is appropriate when B is truly a specialized role that includes A's capabilities. Interface intersection (A & B) is appropriate when combining independent capabilities. Don't use inheritance to avoid creating a new interface—if the roles are distinct, use intersection or explicit combined interfaces.
In complex domains, you may have well-segregated role interfaces but want to provide a convenient aggregate for consumers that need multiple roles. The Aggregator Pattern offers a facade without sacrificing ISP.
The Pattern:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
// Step 1: Segregated role interfacesinterface OrderReader { getOrder(id: string): Promise<Order | null>; getOrdersByCustomer(customerId: string): Promise<Order[]>;} interface OrderWriter { createOrder(data: CreateOrderDTO): Promise<Order>; updateOrder(id: string, data: UpdateOrderDTO): Promise<Order>; deleteOrder(id: string): Promise<void>;} interface OrderStateManager { submitOrder(id: string): Promise<void>; cancelOrder(id: string): Promise<void>; fulfillOrder(id: string): Promise<void>;} interface OrderSearcher { searchOrders(criteria: SearchCriteria): Promise<Page<Order>>; countOrders(filter?: OrderFilter): Promise<number>;} // Step 2: Aggregate interface for convenienceinterface OrderService extends OrderReader, OrderWriter, OrderStateManager, OrderSearcher { // No additional methods - just combines roles} // Step 3: Consumers CHOOSE their dependency level// Focused consumers use specific roles:class OrderDetailsController { constructor(private orderReader: OrderReader) { // Minimal dependency }} class WarehouseIntegration { constructor(private stateManager: OrderStateManager) { // Only state transitions }} // Full-service consumers use the aggregate:class OrderAdminDashboard { constructor(private orderService: OrderService) { // Explicitly needs all capabilities }} // Step 4: Implementation provides the aggregateclass OrderServiceImpl implements OrderService { // Implements all methods from all interfaces} // DI configuration can bind both wayscontainer.bind<OrderService>(TYPES.OrderService).to(OrderServiceImpl);container.bind<OrderReader>(TYPES.OrderReader).toService(TYPES.OrderService);container.bind<OrderWriter>(TYPES.OrderWriter).toService(TYPES.OrderService);// Narrow dependencies resolve to the same implementationThe aggregate interface doesn't violate ISP because consumers aren't forced to use it. It's an option for consumers who genuinely need all capabilities. The key is that the segregated interfaces exist and are available. Consumers who only need OrderReader can depend on OrderReader, not the aggregate. The aggregate is convenience, not coercion.
The right granularity depends on context. Here's how to adjust your approach based on system characteristics:
| Context | Lean Toward | Rationale |
|---|---|---|
| Microservices architecture | Finer granularity | Each service already isolated; clear ownership matters |
| Monolithic CRUD application | Coarser granularity | Less change friction; simpler navigation |
| Library/SDK for external consumers | Finer granularity | Consumers can't refactor your code; flexibility crucial |
| Internal team code | Moderate granularity | Can refactor if needed; balance complexity |
| High team turnover | Finer granularity with clear naming | Self-documentation via interface roles |
| Mature, stable domain | Coarser granularity | Changes are rare; simplicity wins |
| Rapidly evolving domain | Finer granularity | Isolation protects against change ripple |
| Strong testing requirements | Finer granularity | Easier mocking, clearer test scope |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
// Example: E-commerce platform decisions // CONTEXT: Payment integration (external dependency, high change risk)// DECISION: Fine granularity to isolate vendor-specific changes interface PaymentAuthorizer { authorize(amount: Money, method: PaymentMethod): Promise<AuthorizationResult>;} interface PaymentCapturer { capture(authorizationId: string): Promise<CaptureResult>;} interface PaymentRefunder { refund(transactionId: string, amount?: Money): Promise<RefundResult>;} // Rationale: Payment vendors change APIs frequently.// Segregated interfaces mean only affected operations need updates. // CONTEXT: User profile (internal, stable domain)// DECISION: Moderate granularity - role-based but not per-method interface UserProfileService { getProfile(userId: string): Promise<UserProfile>; updateProfile(userId: string, data: ProfileUpdateDTO): Promise<void>; uploadAvatar(userId: string, image: Buffer): Promise<string>; getActivityHistory(userId: string): Promise<ActivityEntry[]>;} // Rationale: Profile operations are stable and commonly used together.// Splitting into getProfile/updateProfile/avatar/history would add// complexity without benefit - these are always used by profile consumers. // CONTEXT: Admin analytics (internal, power users)// DECISION: Coarser granularity - admins use many features together interface AdminDashboardService { getUserMetrics(timeRange: TimeRange): Promise<UserMetrics>; getRevenueMetrics(timeRange: TimeRange): Promise<RevenueMetrics>; getSystemHealth(): Promise<SystemHealth>; generateReport(type: ReportType, options: ReportOptions): Promise<Report>; exportData(format: ExportFormat, filter: DataFilter): Promise<Buffer>;} // Rationale: Admin dashboard always shows all metrics together.// Splitting would add DI complexity without benefit - there's only// one consumer (AdminDashboard) that needs all capabilities.What if you've realized your interfaces are at the wrong granularity level—either too coarse or too fine? Refactoring is possible, but requires care.
From Coarse to Fine (Splitting):
FatInterface extends RoleA, RoleB, RoleCFrom Fine to Coarse (Merging):
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
// BEFORE: Over-segregated interfacesinterface ProductGetter { getProduct(id: string): Promise<Product>;}interface ProductLister { listProducts(): Promise<Product[]>;}interface ProductSearcher { searchProducts(query: string): Promise<Product[]>;}interface ProductCreator { createProduct(data: CreateProductDTO): Promise<Product>;}interface ProductUpdater { updateProduct(id: string, data: UpdateProductDTO): Promise<Product>;}interface ProductDeleter { deleteProduct(id: string): Promise<void>;} // Consumer has 6 dependencies for basic CRUD + searchclass ProductService { constructor( private getter: ProductGetter, private lister: ProductLister, private searcher: ProductSearcher, private creator: ProductCreator, private updater: ProductUpdater, private deleter: ProductDeleter, ) {}} // AFTER: Role-based consolidation// Step 1: Create combined role interfacesinterface ProductReader { getProduct(id: string): Promise<Product>; listProducts(): Promise<Product[]>; searchProducts(query: string): Promise<Product[]>;} interface ProductWriter { createProduct(data: CreateProductDTO): Promise<Product>; updateProduct(id: string, data: UpdateProductDTO): Promise<Product>; deleteProduct(id: string): Promise<void>;} // Step 2: Keep granular as aliases if any consumers still need themtype ProductGetter = Pick<ProductReader, 'getProduct'>;type ProductSearcher = Pick<ProductReader, 'searchProducts'>;// ... etc, for backward compatibility // Step 3: Update consumers to role-based dependenciesclass ProductService { constructor( private reader: ProductReader, private writer: ProductWriter, ) { // 2 dependencies instead of 6 }} // Step 4: Simplify DIcontainer.bind<ProductReader>(TYPES.ProductReader).to(ProductRepositoryImpl);container.bind<ProductWriter>(TYPES.ProductWriter).to(ProductRepositoryImpl); // Step 5: Remove aliases after all consumers migrated// (Delete the type aliases and granular interface types)Interface granularity is an art, not a science. But informed by the principles in this page, you can make deliberate, defensible decisions about how to size your interfaces.
Module Complete!
You've now mastered the Interface Segregation Principle in its entirety:
With this knowledge, you can design interfaces that serve their consumers without burdening them, evolve without breaking unrelated code, and maintain without drowning in complexity. ISP is one of the most impactful SOLID principles for day-to-day software design—apply it wisely.
Congratulations! You've completed the ISP in Practice module. You now understand how ISP manifests in production systems, can identify common violations, have a systematic evaluation checklist, and know how to balance interface granularity. You're equipped to apply ISP pragmatically in real-world software design.