Loading content...
Interface design mistakes don't announce themselves. They creep into codebases through well-intentioned decisions: "Let's add this method to the existing interface—it's related," or "We might need this capability later, so let's include it now." Each individual decision seems reasonable. Yet the cumulative effect creates fat, unwieldy interfaces that couple unrelated components and make systems brittle.
This page catalogs the most common ISP mistakes, showing you how to recognize the warning signs, understand the damage they cause, and develop the instinct to avoid them. By studying these anti-patterns, you'll build the pattern recognition needed to enforce ISP proactively, not reactively.
By the end of this page, you will recognize the most common ISP violations in code reviews, understand the hidden costs each violation imposes, and develop mental triggers that warn you when an interface is becoming too fat. You'll transform from reacting to ISP violations into preventing them.
The most common ISP violation is the "Kitchen Sink" interface—an interface that accumulates methods whenever someone needs new functionality. Like an actual kitchen junk drawer, it becomes a dumping ground for loosely related operations that happen to work with the same data type.
How It Forms:
UserService with getUser() and createUser()updateUserPreferences() — related enough, addedsendPasswordResetEmail() — involves users, addedcalculateUserEngagementScore() — it's about users, addedexportUserDataForGDPR() — definitely user-related, added12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// Anti-pattern: Kitchen Sink Interfaceinterface UserService { // Core CRUD getUser(id: string): Promise<User>; createUser(data: CreateUserDTO): Promise<User>; updateUser(id: string, data: UpdateUserDTO): Promise<User>; deleteUser(id: string): Promise<void>; // Authentication (different concern) authenticateUser(email: string, password: string): Promise<AuthToken>; refreshToken(token: string): Promise<AuthToken>; revokeAllTokens(userId: string): Promise<void>; // Password management (could be separate) changePassword(userId: string, oldPw: string, newPw: string): Promise<void>; sendPasswordResetEmail(email: string): Promise<void>; resetPasswordWithToken(token: string, newPw: string): Promise<void>; // Profile/preferences (different concern) updateUserPreferences(userId: string, prefs: Preferences): Promise<void>; getUserPreferences(userId: string): Promise<Preferences>; updateProfilePicture(userId: string, image: File): Promise<string>; // Analytics (different concern, different stakeholder) calculateEngagementScore(userId: string): Promise<number>; getUserActivityLog(userId: string, days: number): Promise<Activity[]>; trackUserEvent(userId: string, event: AnalyticsEvent): Promise<void>; // Compliance (different concern, different stakeholder) exportUserDataForGDPR(userId: string): Promise<GDPRExport>; anonymizeUser(userId: string): Promise<void>; getDataRetentionStatus(userId: string): Promise<RetentionStatus>; // Social features (different concern) followUser(followerId: string, followeeId: string): Promise<void>; unfollowUser(followerId: string, followeeId: string): Promise<void>; getFollowers(userId: string): Promise<User[]>; getFollowing(userId: string): Promise<User[]>; blockUser(blockerId: string, blockedId: string): Promise<void>;} // Problems:// 1. ProfileController depends on all 25+ methods// 2. Changes to analytics methods trigger recompilation of auth code// 3. Mocking for tests requires stubbing methods you don't test// 4. No clear ownership - auth team? product team? compliance team?When an interface exceeds 5-7 methods, treat it as a warning sign. Ask: "Do all clients use all methods?" If the answer is no, the interface likely contains multiple roles that should be segregated. There's no magic number, but cognitive load increases with interface size. A 25-method interface is almost certainly violating ISP.
The Fix:
Segment by cohesive role, not by data type:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// Role-based segregationinterface UserRepository { getUser(id: string): Promise<User>; createUser(data: CreateUserDTO): Promise<User>; updateUser(id: string, data: UpdateUserDTO): Promise<User>; deleteUser(id: string): Promise<void>;} interface AuthenticationService { authenticate(email: string, password: string): Promise<AuthToken>; refreshToken(token: string): Promise<AuthToken>; revokeAllTokens(userId: string): Promise<void>;} interface PasswordService { changePassword(userId: string, oldPw: string, newPw: string): Promise<void>; sendResetEmail(email: string): Promise<void>; resetWithToken(token: string, newPw: string): Promise<void>;} interface UserPreferencesService { getPreferences(userId: string): Promise<Preferences>; updatePreferences(userId: string, prefs: Preferences): Promise<void>;} interface UserAnalyticsService { calculateEngagementScore(userId: string): Promise<number>; getActivityLog(userId: string, days: number): Promise<Activity[]>; trackEvent(userId: string, event: AnalyticsEvent): Promise<void>;} interface GDPRComplianceService { exportUserData(userId: string): Promise<GDPRExport>; anonymizeUser(userId: string): Promise<void>; getRetentionStatus(userId: string): Promise<RetentionStatus>;} interface SocialGraphService { follow(followerId: string, followeeId: string): Promise<void>; unfollow(followerId: string, followeeId: string): Promise<void>; getFollowers(userId: string): Promise<User[]>; getFollowing(userId: string): Promise<User[]>; block(blockerId: string, blockedId: string): Promise<void>;} // Now each component depends only on what it usesDevelopers often add interface methods "just in case" we need them later. This anticipatory design seems prudent—after all, adding to an interface later breaks all implementations. But this reasoning is flawed because it confuses interface stability with interface size.
The False Economy:
Yes, adding a method to an interface later requires updating implementations. But:
1234567891011121314151617181920212223242526272829303132333435363738
// Anti-pattern: Over-anticipated interfaceinterface MessageQueue { // Core functionality send(message: Message): Promise<void>; receive(): Promise<Message | null>; // "We might need these later" sendBatch(messages: Message[]): Promise<void>; receiveBatch(count: number): Promise<Message[]>; // "Enterprise features we might add" createTopic(name: string): Promise<Topic>; deleteTopic(name: string): Promise<void>; subscribeTopic(topic: string, handler: MessageHandler): Promise<Subscription>; unsubscribeTopic(subscription: Subscription): Promise<void>; // "Analytics might want these" getQueueDepth(): Promise<number>; getOldestMessageAge(): Promise<number>; getMessagesByTimeRange(start: Date, end: Date): Promise<Message[]>; // "Dead letter queue for future reliability" moveToDeadLetter(messageId: string): Promise<void>; getDeadLetterMessages(): Promise<Message[]>; replayDeadLetterMessage(messageId: string): Promise<void>; // "Priority queues might be useful" sendWithPriority(message: Message, priority: number): Promise<void>; receivePriority(): Promise<Message | null>;} // Reality after 2 years:// - Batch methods: never used// - Topics: implemented differently due to actual requirements// - Analytics methods: requirements changed, these are wrong// - Dead letter: used, but API evolved differently// - Priority: never adopted// Result: 17 methods, 3 used regularly, 8 implemented incorrectlyFuture-proofing interfaces is appropriate when: (1) the need is certain based on committed roadmap items, (2) adding later would break stable contracts (published APIs), or (3) the capability is part of a known pattern (e.g., including close() on resources even if not immediately used). Default to YAGNI; anticipate only when cost of later addition is concretely higher than cost of carrying unused methods.
The "Header Interface" pattern comes from C/C++ development where header files declare all functions for a module. When applied to object-oriented interfaces, it creates a single interface that serves as a catalog of everything a class can do—violating ISP by bundling all capabilities together.
Symptoms:
-Service, -Manager, -Handler, or -Facade with no role specificity12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
// Anti-pattern: Header Interfacepublic interface OrderManager { // Creation Order createOrder(Cart cart, Customer customer); DraftOrder createDraftOrder(Cart cart); QuoteOrder createQuote(Cart cart, ExpirationPolicy policy); // Retrieval Order getOrder(String orderId); List<Order> getOrdersByCustomer(String customerId); List<Order> getOrdersByDateRange(LocalDate start, LocalDate end); Page<Order> searchOrders(OrderSearchCriteria criteria); // Status management void submitOrder(String orderId); void confirmOrder(String orderId); void cancelOrder(String orderId, CancellationReason reason); void fulfillOrder(String orderId, FulfillmentDetails details); void markAsShipped(String orderId, ShipmentInfo shipment); void markAsDelivered(String orderId, DeliveryConfirmation confirmation); // Financial void applyDiscount(String orderId, Discount discount); void removeDiscount(String orderId, String discountCode); void processRefund(String orderId, RefundRequest request); Invoice generateInvoice(String orderId); void recordPayment(String orderId, PaymentRecord payment); // Inventory interaction void reserveInventory(String orderId); void releaseInventory(String orderId); StockAvailability checkStockForOrder(String orderId); // Notifications void sendOrderConfirmation(String orderId); void sendShipmentNotification(String orderId); void sendDeliveryNotification(String orderId); // Analytics OrderMetrics calculateOrderMetrics(String orderId); CustomerOrderSummary getCustomerOrderSummary(String customerId);} // Result: 25+ methods, at least 6 distinct responsibilities// Checkout uses creation + submission// Warehouse uses fulfillment + inventory// Finance uses financial methods + invoicing// Customer Service might use everything but clusters around status + refunds// Each module depends on all 25 methods but uses ~5The Recompilation Cascade:
Even in languages with good incremental compilation, header interfaces create unnecessary dependency chains:
FinanceModule depends on OrderManagerreserveGiftWrapInventory() to handle gift wrappingOrderManager interface changesFinanceModule must be recompiled even though it never uses gift wrappingFinanceModule might need recompilation cascading furtherHeader interfaces feel simpler because there's "just one interface" to remember. But this apparent simplicity is a trap. It's easier to remember "OrderCommand, OrderQuery, InventoryReservation, OrderNotification" than to remember which 5 of 25 methods you actually need from "OrderManager". Conceptual clarity beats method count consolidation.
A "Leaky Abstraction" interface exposes implementation details through its method signatures, forcing consumers to understand and depend on internals they shouldn't need to know about. This violates ISP because it includes methods that only make sense for specific implementations.
Example: Database-Specific Methods in Repository Interfaces:
12345678910111213141516171819202122232425262728293031
// Anti-pattern: Implementation-aware interfaceinterface ProductRepository { // Generic operations - good findById(id: string): Promise<Product | null>; findAll(): Promise<Product[]>; save(product: Product): Promise<Product>; delete(id: string): Promise<void>; // Leaky: SQL-specific operations findByCustomQuery(sql: string, params: any[]): Promise<Product[]>; executeRawUpdate(sql: string): Promise<number>; // Leaky: ORM-specific operations findWithRelations(id: string, include: string[]): Promise<Product | null>; detachFromSession(product: Product): void; // Leaky: Transaction handling beginTransaction(): Promise<Transaction>; commitTransaction(tx: Transaction): Promise<void>; rollbackTransaction(tx: Transaction): Promise<void>; // Leaky: Connection pool management getConnectionPool(): ConnectionPool; releaseConnection(conn: Connection): void;} // Problems:// 1. In-memory implementation can't implement transaction methods sensibly// 2. NoSQL implementation can't support SQL query methods// 3. All consumers exposed to connection pool details// 4. Switching databases requires interface changesWhy This Violates ISP:
Not all implementations can fulfill the contract: An in-memory repository can't meaningfully implement beginTransaction() or findByCustomQuery(sql)
Consumers depend on concepts they don't need: Business logic pulling products doesn't need to know about connection pools
Implementation switching becomes impossible: The interface is welded to SQL databases; changing to Redis or Elasticsearch requires interface redesign
The Fix:
Abstract away implementation details; provide separate interfaces for special capabilities:
12345678910111213141516171819202122232425262728293031
// Clean abstraction - implementation-agnosticinterface ProductRepository { findById(id: string): Promise<Product | null>; findAll(filter?: ProductFilter): Promise<Product[]>; save(product: Product): Promise<Product>; delete(id: string): Promise<void>;} // Optional capability interfaces for when neededinterface TransactionalRepository<T> { runInTransaction<R>(callback: (repo: T) => Promise<R>): Promise<R>;} // Specific implementations expose specific interfacesinterface SQLProductRepository extends ProductRepository, TransactionalRepository<ProductRepository> { // SQL-specific only when truly needed findByCustomQuery(query: string, params: unknown[]): Promise<Product[]>;} // Business logic uses abstract interfaceclass ProductService { constructor(private productRepo: ProductRepository) {} // Works with any implementation} // Code that genuinely needs SQL uses specific typeclass ProductMigrationTool { constructor(private sqlRepo: SQLProductRepository) {} // Explicitly declares SQL dependency}When interfaces are too broad, implementations are forced to provide stub or no-op implementations for methods they can't meaningfully fulfill. This is the Forced Adapter anti-pattern—adapters that throw UnsupportedOperationException or return nonsensical values because the interface demanded more than the implementation can deliver.
Classic Example: Java's List Interface and Immutable Collections:
123456789101112131415161718192021222324252627282930313233343536373839404142
// The problem: List includes mutating methodspublic interface List<E> extends Collection<E> { boolean add(E e); E set(int index, E element); E remove(int index); void clear(); // ... other methods} // Immutable list must implement mutation methodspublic class ImmutableListAdapter<E> implements List<E> { private final E[] elements; @Override public E get(int index) { return elements[index]; // Works } @Override public boolean add(E e) { // Forced adapter: can't add to immutable list throw new UnsupportedOperationException("Cannot modify immutable list"); } @Override public E set(int index, E element) { throw new UnsupportedOperationException("Cannot modify immutable list"); } @Override public E remove(int index) { throw new UnsupportedOperationException("Cannot modify immutable list"); } @Override public void clear() { throw new UnsupportedOperationException("Cannot modify immutable list"); } // Problem: Compile-time type safety, but runtime explosions // Anyone with a List<E> variable can call add() - compiles, but crashes}Javadoc saying "may throw UnsupportedOperationException" doesn't make it safe—it means the interface is too broad. Type systems exist to catch errors at compile time. If correct behavior requires reading documentation before every method call, the interface has failed its primary purpose of defining a reliable contract.
The ISP Solution:
Separate read and write capabilities into distinct interfaces:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
// ISP-compliant approach: Separate read from write // Read-only interfaceinterface ReadableList<T> { get(index: number): T; size(): number; isEmpty(): boolean; contains(item: T): boolean; indexOf(item: T): number; [Symbol.iterator](): Iterator<T>;} // Write interface extends readinterface MutableList<T> extends ReadableList<T> { add(item: T): void; set(index: number, item: T): void; remove(index: number): T; clear(): void;} // Now type system enforces correct usageclass ImmutableList<T> implements ReadableList<T> { // Only implements read methods - no forced adapters needed get(index: number): T { /* ... */ } size(): number { /* ... */ } // ... only methods it can actually support} class ArrayList<T> implements MutableList<T> { // Implements all methods because it supports all operations} // Function that only reads gets ReadableListfunction findMaxValue(list: ReadableList<number>): number { let max = list.get(0); for (const value of list) { if (value > max) max = value; } return max; // Cannot call list.add() - compile error!} // Function that mutates gets MutableListfunction addDefaults(list: MutableList<number>): void { list.add(0); list.add(100);} // ImmutableList works with findMaxValue: ✓ compile success// ImmutableList with addDefaults: ✗ compile error (correct!)Adding "convenience methods" to interfaces seems helpful—why make every consumer write the same boilerplate? But these methods often serve only some clients while burdening all implementations. The convenience becomes a tax on the entire ecosystem.
The Pattern:
find(criteria), save(entity)findById(id) (common case)findByEmail(email), findByUsername(username)findActiveUsers(), findInactiveUsers()findUsersCreatedAfter(date), findUsersWithRole(role)Soon the interface has 20 "convenience" methods built on top of find(criteria).
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
// Anti-pattern: Convenience method proliferationinterface UserRepository { // Core find(criteria: QueryCriteria): Promise<User[]>; save(user: User): Promise<User>; delete(id: string): Promise<void>; // "Convenience" methods - each requires implementation findById(id: string): Promise<User | null>; findByEmail(email: string): Promise<User | null>; findByUsername(username: string): Promise<User | null>; findByIds(ids: string[]): Promise<User[]>; findByEmailDomain(domain: string): Promise<User[]>; findActive(): Promise<User[]>; findInactive(): Promise<User[]>; findByRole(role: Role): Promise<User[]>; findByRoles(roles: Role[]): Promise<User[]>; findByDateRange(start: Date, end: Date): Promise<User[]>; findCreatedAfter(date: Date): Promise<User[]>; findCreatedBefore(date: Date): Promise<User[]>; findWithRecentActivity(days: number): Promise<User[]>; findWithoutRecentActivity(days: number): Promise<User[]>; countByRole(role: Role): Promise<number>; countActive(): Promise<number>; existsByEmail(email: string): Promise<boolean>; existsByUsername(username: string): Promise<boolean>;} // Every implementation must provide 20 methods, most are trivial:class PostgresUserRepository implements UserRepository { findByEmail(email: string) { return this.find({ email }).then(users => users[0] ?? null); } findActive() { return this.find({ status: 'active' }); } // ... 15 more nearly-identical implementations} class InMemoryUserRepository implements UserRepository { findByEmail(email: string) { return Promise.resolve(this.users.find(u => u.email === email) ?? null); } findActive() { return Promise.resolve(this.users.filter(u => u.status === 'active')); } // ... same boilerplate again}Better Approach: Composable Query Building
Keep the interface minimal; provide composition utilities separately:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
// Minimal interfaceinterface UserRepository { find(criteria: QueryCriteria): Promise<User[]>; findOne(criteria: QueryCriteria): Promise<User | null>; save(user: User): Promise<User>; delete(id: string): Promise<void>; count(criteria: QueryCriteria): Promise<number>; exists(criteria: QueryCriteria): Promise<boolean>;} // Composable query builders (not interface methods)class UserQueries { static byId(id: string): QueryCriteria { return { id }; } static byEmail(email: string): QueryCriteria { return { email }; } static active(): QueryCriteria { return { status: 'active' }; } static withRole(role: Role): QueryCriteria { return { role }; } static createdAfter(date: Date): QueryCriteria { return { createdAt: { $gte: date } }; } static combine(...criteria: QueryCriteria[]): QueryCriteria { return Object.assign({}, ...criteria); }} // Usage is just as convenientconst user = await userRepo.findOne(UserQueries.byEmail('test@example.com'));const activeAdmins = await userRepo.find( UserQueries.combine( UserQueries.active(), UserQueries.withRole('admin') )); // Benefits:// 1. UserRepository has only 5 methods to implement// 2. Query builders are reusable, testable utilities// 3. New queries don't require interface changes// 4. Type-safe composition without method proliferationEvent handlers, lifecycle callbacks, and observer patterns often bundle multiple callbacks into a single interface, forcing implementations to handle events they don't care about.
Common in: Android listeners, UI frameworks, plugin systems
1234567891011121314151617181920212223242526272829303132333435363738
// Anti-pattern: All-in-one listener interfacepublic interface DocumentLifecycleListener { void onDocumentCreated(Document doc); void onDocumentOpened(Document doc); void onDocumentModified(Document doc, Change change); void onDocumentSaved(Document doc); void onDocumentPrinted(Document doc); void onDocumentExported(Document doc, ExportFormat format); void onDocumentArchived(Document doc); void onDocumentRestored(Document doc); void onDocumentDeleted(Document doc); void onDocumentVersioned(Document doc, Version version); void onDocumentShared(Document doc, ShareSettings settings); void onDocumentLocked(Document doc, User locker); void onDocumentUnlocked(Document doc);} // Typical implementation: Most methods emptypublic class AutoSaveListener implements DocumentLifecycleListener { @Override public void onDocumentModified(Document doc, Change change) { scheduleAutoSave(doc); // The only method we care about } // 12 empty methods we're forced to implement @Override public void onDocumentCreated(Document doc) { } @Override public void onDocumentOpened(Document doc) { } @Override public void onDocumentSaved(Document doc) { } @Override public void onDocumentPrinted(Document doc) { } @Override public void onDocumentExported(Document doc, ExportFormat f) { } @Override public void onDocumentArchived(Document doc) { } @Override public void onDocumentRestored(Document doc) { } @Override public void onDocumentDeleted(Document doc) { } @Override public void onDocumentVersioned(Document doc, Version v) { } @Override public void onDocumentShared(Document doc, ShareSettings s) { } @Override public void onDocumentLocked(Document doc, User locker) { } @Override public void onDocumentUnlocked(Document doc) { }}Frameworks often provide "Adapter" classes with empty default implementations (e.g., DocumentLifecycleAdapter). This hides but doesn't fix the ISP violation. Listeners still conceptually depend on all events, the interface is still too broad, and the adapter class becomes another maintenance point. The real solution is interface segregation, not a class that papers over a bad design.
ISP Fix: Segregated Event Interfaces
123456789101112131415161718192021222324252627282930313233343536
// ISP-compliant: One interface per event (or cohesive group)@FunctionalInterfacepublic interface OnDocumentCreated { void handle(Document doc);} @FunctionalInterface public interface OnDocumentModified { void handle(Document doc, Change change);} @FunctionalInterfacepublic interface OnDocumentSaved { void handle(Document doc);} // Grouped when cohesivepublic interface DocumentSecurityListener { void onDocumentLocked(Document doc, User locker); void onDocumentUnlocked(Document doc);} // Event bus or subject accepts specific listenerspublic class DocumentEvents { public void onCreated(OnDocumentCreated listener) { /* ... */ } public void onModified(OnDocumentModified listener) { /* ... */ } public void onSaved(OnDocumentSaved listener) { /* ... */ } public void onSecurityChange(DocumentSecurityListener listener) { /* ... */ }} // AutoSave only registers for what it needsdocumentEvents.onModified( (doc, change) -> scheduleAutoSave(doc)); // Lambda-compatible! No empty methods!After examining these anti-patterns, let's consolidate the warning signs that indicate ISP violations. Use this as a checklist during code reviews and design sessions:
UnsupportedOperationException*Adapter classes to avoid implementing unused methods*Manager, *Service, *Handler without role specificity| Question | ISP-Compliant Answer | ISP-Violation Indicator |
|---|---|---|
| Do all implementations use all methods? | Yes, or inherently different capabilities | Some implementations stub or throw |
| Do all clients use all methods? | Yes, or explicitly import narrower interface | Clients ignore most methods |
| Would splitting this interface hurt anything? | Would require awkward composition | Cleaner with separate interfaces |
| Is the interface name specific? | UserReader, OrderCreator | UserManager, OrderService |
| How many methods to mock for a single test? | 2-5 related methods | 10+ methods, most irrelevant |
We've cataloged the most common and damaging ISP violations. Let's consolidate the key lessons:
What's Next:
Now that you can recognize ISP violations, the next page provides a systematic checklist for evaluating and designing ISP-compliant interfaces. You'll have a step-by-step process to follow when creating new interfaces or refactoring existing ones.
You've learned to identify the most common ISP mistakes and understand why they're harmful. Armed with this knowledge, you can catch violations during design reviews before they calcify into technical debt. Next, we'll formalize this into a systematic ISP compliance checklist.