Loading learning content...
Imagine you're hired as a restaurant's pastry chef. You're excellent at desserts—cakes, pastries, soufflés. But your employment contract states you must also be able to perform heart surgery, file corporate tax returns, and pilot commercial aircraft.
"I'm a pastry chef," you protest. "I can't do those things."
"Doesn't matter. The contract says you must. Either perform all duties or don't take the job."
This absurd scenario is precisely what fat interfaces impose on implementing classes. A class designed for a specific, focused purpose is forced to implement methods that have nothing to do with its responsibilities—methods it cannot meaningfully provide.
This is the implementation burden of fat interfaces.
By the end of this page, you will understand the specific challenges that implementers face with fat interfaces, the anti-patterns that emerge when forced to implement irrelevant methods, and the long-term maintainability costs of interface bloat from the implementer's perspective.
When a class implements an interface, it enters a contract: "I will provide an implementation for every method this interface declares." This is the fundamental promise of interface implementation.
The Formal Contract:
interface I {
method1(): Result1;
method2(): Result2;
...
methodN(): ResultN;
}
class C implements I {
// Must provide implementations for ALL of:
// method1, method2, ..., methodN
}
For a well-designed interface, this is a reasonable requirement. Each method represents a coherent capability that the implementing class should provide.
The Fat Interface Breakdown:
For a fat interface, this contract becomes unreasonable. The implementing class faces methods that:
The Implementer's Choice:
Faced with methods that cannot be meaningfully implemented, the developer has limited options:
| Option | Implementation | Problems |
|---|---|---|
| Throw NotImplementedException | throw new Error("Not implemented") | Runtime failures, contract violation |
| Return empty/null | return null or return [] | Silent failures, incorrect behavior |
| Throw NotSupportedException | throw new Error("Operation not supported") | Runtime failures, confusing API |
| Implement incorrectly | Stub that does something "close enough" | Subtle bugs, unpredictable behavior |
| Refuse implementation | Don't implement the interface | Lose polymorphism benefits |
None of these options are good. Each represents a different flavor of failure.
When a class implements an interface, clients expect all methods to work. A fat interface forces implementers to break this promise. The interface says 'I can do X, Y, and Z,' but the implementation can only do X. This is a contract lie—the interface overpromises, the implementation underdelivers.
When developers are forced to implement interface methods that don't fit their class, predictable anti-patterns emerge. Let's examine each in detail.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
// Fat interface demanding too many capabilitiesinterface IDataStore { // Reading - every data store should do this read(key: string): Promise<Data | null>; list(prefix: string): Promise<Data[]>; // Writing - some data stores are read-only! write(key: string, data: Data): Promise<void>; delete(key: string): Promise<void>; // Transactions - not all stores support this! beginTransaction(): Promise<Transaction>; commitTransaction(tx: Transaction): Promise<void>; rollbackTransaction(tx: Transaction): Promise<void>; // Full-text search - definitely not universal! search(query: string): Promise<SearchResults>; indexForSearch(key: string): Promise<void>;} // ========================================// ANTI-PATTERN 1: NotImplementedException Graveyard// ========================================class ReadOnlyS3Adapter implements IDataStore { async read(key: string) { /* works */ return { key, value: 'data' }; } async list(prefix: string) { /* works */ return []; } // DANGER ZONE: Runtime bombs waiting to explode async write() { throw new Error("NotImplementedException: S3 adapter is read-only"); } async delete() { throw new Error("NotImplementedException: S3 adapter is read-only"); } async beginTransaction() { throw new Error("NotImplementedException: S3 doesn't support transactions"); } async commitTransaction() { throw new Error("NotImplementedException: S3 doesn't support transactions"); } async rollbackTransaction() { throw new Error("NotImplementedException: S3 doesn't support transactions"); } async search() { throw new Error("NotImplementedException: S3 doesn't support search"); } async indexForSearch() { throw new Error("NotImplementedException: S3 doesn't support search"); } // 7 out of 10 methods are bombs. This class is 70% lies.} // ========================================// ANTI-PATTERN 2: Silent Null Return// ========================================class LegacyDatabaseAdapter implements IDataStore { async read(key: string) { /* works */ return { key, value: 'data' }; } async list(prefix: string) { /* works */ return []; } async write(key: string, data: Data) { /* works */ } async delete(key: string) { /* works */ } // Transactions not supported - but we pretend they are async beginTransaction() { return null as unknown as Transaction; // Silent lie } async commitTransaction(tx: Transaction) { // Does nothing. Caller thinks transaction committed. // Actually, data was written without transaction safety. } async rollbackTransaction(tx: Transaction) { // Cannot actually rollback. Data is already persisted. // Caller will have corrupted state and no error. } async search() { return { results: [], total: 0 }; } // Empty, not "not found" async indexForSearch() { } // Pretends to index. Nothing happens.}These anti-patterns don't fail at compile time—they fail at runtime, often in production, often when you least expect it. A client that worked fine with one implementation suddenly explodes when given another implementation of the same interface. The fat interface has hidden the incompatibility until it's too late.
The implementation burden of fat interfaces directly leads to violations of the Liskov Substitution Principle (LSP). Let's trace this connection.
LSP Refresher:
The Liskov Substitution Principle states:
If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of the program.
In interface terms: any class implementing an interface should be substitutable for any other implementation without changing program correctness.
How Fat Interfaces Break LSP:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
// fat interfaceinterface IOrderRepository { getOrder(id: string): Promise<Order | null>; saveOrder(order: Order): Promise<void>; deleteOrder(id: string): Promise<void>; getAllOrders(): Promise<Order[]>; // Archive operations - not all repositories support this archiveOrder(id: string): Promise<void>; getArchivedOrders(): Promise<Order[]>; restoreFromArchive(id: string): Promise<void>;} // Implementation 1: Full database with archivingclass PostgresOrderRepository implements IOrderRepository { async getOrder(id: string) { /* works */ return null; } async saveOrder(order: Order) { /* works */ } async deleteOrder(id: string) { /* works */ } async getAllOrders() { /* works */ return []; } async archiveOrder(id: string) { /* moves to archive table */ } async getArchivedOrders() { /* queries archive table */ return []; } async restoreFromArchive(id: string) { /* moves back */ }} // Implementation 2: In-memory cache with no archivingclass InMemoryOrderCache implements IOrderRepository { async getOrder(id: string) { /* works */ return null; } async saveOrder(order: Order) { /* works */ } async deleteOrder(id: string) { /* works */ } async getAllOrders() { /* works */ return []; } // These cannot work - there's no archive in memory async archiveOrder(id: string) { throw new Error("In-memory cache does not support archiving"); } async getArchivedOrders() { throw new Error("In-memory cache does not support archiving"); } async restoreFromArchive(id: string) { throw new Error("In-memory cache does not support archiving"); }} // Client code written against the interfaceclass OrderArchiveService { constructor(private repo: IOrderRepository) {} async archiveOldOrders(olderThan: Date) { const orders = await this.repo.getAllOrders(); for (const order of orders.filter(o => o.date < olderThan)) { await this.repo.archiveOrder(order.id); // BOOM with InMemoryOrderCache } }} // LSP VIOLATION:// OrderArchiveService works with PostgresOrderRepository// OrderArchiveService fails with InMemoryOrderCache// These implementations are NOT substitutable—the program's behavior changes// from "archives orders" to "throws exceptions"The LSP Contract Chain:
Fat Interface (IOrderRepository with 7 methods)
│
├── PostgresOrderRepository (implements all 7 correctly)
│ └── ✓ Satisfies full contract
│
└── InMemoryOrderCache (can only implement 4 correctly)
└── ✗ Breaks contract for 3 methods
└── ✗ Not substitutable for PostgresOrderRepository
└── ✗ Violates LSP
The Root Cause:
The fat interface demanded capabilities that not all implementations can provide. The interface is the problem—it defined a contract broader than the common capability set of its implementations.
The Segregated Solution:
IOrderRepository (4 methods: get, save, delete, getAll)
│
├── PostgresOrderRepository ✓
└── InMemoryOrderCache ✓
IOrderArchive (3 methods: archive, getArchived, restore)
│
└── PostgresOrderRepository ✓
└── InMemoryOrderCache: doesn't implement this interface
Now InMemoryOrderCache doesn't claim to support archiving. Clients that need archiving depend on IOrderArchive and get PostgresOrderRepository. Clients that just need basic operations get either implementation. LSP is preserved.
Interface Segregation and Liskov Substitution are deeply connected. When interfaces are properly segregated, implementations can accurately claim to fulfill the contracts they implement. Fat interfaces force implementations to claim capabilities they don't have, violating LSP.
The implementation burden can be measured and tracked. Here are metrics that reveal the severity of the problem.
Metric 1: Implementation Completeness Ratio
Completeness = (Meaningfully Implemented Methods) / (Total Interface Methods)
| Implementation | Total Methods | Meaningful | Stub/Throw | Completeness |
|---|---|---|---|---|
| PostgresRepo | 10 | 10 | 0 | 100% |
| InMemoryCache | 10 | 6 | 4 | 60% |
| ReadOnlyProxy | 10 | 3 | 7 | 30% |
| MockForTests | 10 | 2 | 8 | 20% |
Any implementation below 100% completeness indicates a fat interface problem.
Metric 2: Line Count of Stub Methods
Stub Waste = Lines of code in methods that throw or return null
In a codebase with many fat interface implementations, stub methods can constitute significant wasted code:
| Codebase Section | Total Lines | Stub Method Lines | Waste % |
|---|---|---|---|
| Data Access Layer | 15,000 | 2,400 | 16% |
| Service Layer | 25,000 | 1,500 | 6% |
| Integration Adapters | 8,000 | 3,200 | 40% |
Integration adapters are often the worst offenders—external systems rarely support all capabilities that internal fat interfaces demand.
Metric 3: UnsupportedOperationException Frequency
Track how often UnsupportedOperationException, NotImplementedException, or equivalent errors occur:
In production logs over 30 days:
- UnsupportedOperationException: 847 occurrences
- "Not implemented" errors: 234 occurrences
- Null pointer from stub return: 156 occurrences
Total interface contract failures: 1,237
Distinct failure sites: 42 methods across 12 interfaces
These are not bugs in the traditional sense—they're the expected consequence of fat interfaces forcing impossible implementations.
Static analysis tools can identify methods that only throw exceptions or return default values. Adding these checks to your CI pipeline creates visibility into implementation burden across the codebase.
Fat interfaces dramatically increase testing complexity, both for the implementations themselves and for any code that depends on them.
Implementation Testing:
For a class implementing a fat interface, the test surface is massive:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
// Testing an implementation of a 10-method fat interface describe('InMemoryOrderCache', () => { // The 4 methods that actually work - meaningful tests describe('getOrder', () => { it('returns order when exists', async () => { /* real test */ }); it('returns null when not exists', async () => { /* real test */ }); it('handles concurrent access', async () => { /* real test */ }); }); describe('saveOrder', () => { it('saves new order', async () => { /* real test */ }); it('updates existing order', async () => { /* real test */ }); it('validates order before saving', async () => { /* real test */ }); }); describe('deleteOrder', () => { it('removes order from cache', async () => { /* real test */ }); it('is idempotent for missing orders', async () => { /* real test */ }); }); describe('getAllOrders', () => { it('returns all cached orders', async () => { /* real test */ }); it('returns empty array when cache empty', async () => { /* real test */ }); }); // The 6 methods that DON'T work - what do we even test? describe('archiveOrder', () => { it('throws UnsupportedOperationException', async () => { // This test just verifies we throw an exception. // It's testing failure, not functionality. await expect(cache.archiveOrder('123')) .rejects.toThrow('not supported'); }); }); describe('getArchivedOrders', () => { it('throws UnsupportedOperationException', async () => { await expect(cache.getArchivedOrders()) .rejects.toThrow('not supported'); }); }); describe('restoreFromArchive', () => { it('throws UnsupportedOperationException', async () => { await expect(cache.restoreFromArchive('123')) .rejects.toThrow('not supported'); }); }); // ... 3 more "test that it throws" tests}); // ANALYSIS:// - 10 meaningful tests for the 4 working methods// - 6 "verify it throws" tests for the 6 non-working methods// - 37.5% of our test effort validates that failures happen correctly// - These tests add maintenance burden without validating real functionalityMock Creation Burden:
For clients that depend on the fat interface, creating mocks becomes tedious:
12345678910111213141516171819202122232425262728293031323334353637
// Testing a class that uses IOrderRepository// We only need getOrder(), but must mock all 10 methods describe('OrderDisplayService', () => { let mockRepo: jest.Mocked<IOrderRepository>; beforeEach(() => { // We have to define ALL 10 methods, even though we only use 1 mockRepo = { getOrder: jest.fn(), // ← Actually used saveOrder: jest.fn(), // Unused mock deleteOrder: jest.fn(), // Unused mock getAllOrders: jest.fn(), // Unused mock archiveOrder: jest.fn(), // Unused mock getArchivedOrders: jest.fn(), // Unused mock restoreFromArchive: jest.fn(), // Unused mock searchOrders: jest.fn(), // Unused mock countOrders: jest.fn(), // Unused mock getOrderHistory: jest.fn(), // Unused mock }; }); it('displays order when found', async () => { mockRepo.getOrder.mockResolvedValue({ id: '123', total: 100 }); const service = new OrderDisplayService(mockRepo); const display = await service.displayOrder('123'); expect(display).toContain('Order #123'); }); // The 9 unused mock methods are noise: // - They make the test setup harder to read // - They suggest the service might use those methods (it doesn't) // - They must be updated if the interface changes // - They create maintenance burden for zero value});With properly segregated interfaces, tests become focused and meaningful. Mock setup shrinks to only the methods actually being tested. Test maintenance drops dramatically. This is one of the most immediate, measurable benefits of applying ISP.
When implementations cannot cleanly implement a fat interface, a common workaround emerges: adapters. Teams create adapter classes that wrap implementations and fill in the gaps.
While adapters are a legitimate pattern, their proliferation around fat interfaces is a symptom of poor interface design.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687
// The fat interface everyone must implementinterface IOrderService { // Core operations getOrder(id: string): Promise<Order>; saveOrder(order: Order): Promise<void>; // Audit operations getAuditLog(orderId: string): Promise<AuditEntry[]>; // Analytics operations getOrderMetrics(dateRange: DateRange): Promise<Metrics>; // Notification operations sendOrderNotification(orderId: string, type: NotificationType): Promise<void>;} // External order system - has its own API, doesn't match our interfaceclass ExternalOrderSystem { fetchOrder(id: string): ExternalOrder { /* ... */ } storeOrder(order: ExternalOrder): void { /* ... */ } // No audit, no analytics, no notifications} // Adapter 1: Make ExternalOrderSystem fit IOrderServiceclass ExternalOrderAdapter implements IOrderService { constructor(private external: ExternalOrderSystem) {} async getOrder(id: string) { return this.convertToInternal(this.external.fetchOrder(id)); } async saveOrder(order: Order) { this.external.storeOrder(this.convertToExternal(order)); } // These don't exist in the external system async getAuditLog() { throw new Error("External system doesn't support audit logs"); } async getOrderMetrics() { throw new Error("External system doesn't support analytics"); } async sendOrderNotification() { throw new Error("External system doesn't support notifications"); } private convertToInternal(ext: ExternalOrder): Order { /* ... */ } private convertToExternal(order: Order): ExternalOrder { /* ... */ }} // Now we have the same problem: the adapter is 60% stubs.// So someone creates... // Adapter 2: Wrap the adapter to add missing featuresclass EnhancedExternalOrderAdapter implements IOrderService { constructor( private external: ExternalOrderAdapter, private auditService: AuditService, private analyticsService: AnalyticsService, private notificationService: NotificationService, ) {} async getOrder(id: string) { return this.external.getOrder(id); } async saveOrder(order: Order) { await this.external.saveOrder(order); await this.auditService.log(order.id, 'saved'); } async getAuditLog(orderId: string) { return this.auditService.getLog(orderId); } async getOrderMetrics(dateRange: DateRange) { return this.analyticsService.getMetrics(dateRange); } async sendOrderNotification(orderId: string, type: NotificationType) { return this.notificationService.send(orderId, type); }} // Now we have:// - ExternalOrderSystem (original)// - ExternalOrderAdapter (wraps external, has stubs)// - EnhancedExternalOrderAdapter (wraps adapter, adds services)// - AuditService, AnalyticsService, NotificationService (separate concerns)//// The fat interface forced 3 layers of wrapping to work around its demands.The Adapter Multiplication:
For each external system or constrained implementation:
1 Fat Interface × N Limited Implementations = N Adapter Chains
If each adapter chain averages 2-3 layers:
Total Adapter Classes = N × 2.5
For 10 external systems with varying capabilities:
~25 adapter classes just to work around one fat interface
The Maintenance Nightmare:
When the fat interface changes:
A single method addition ripples through 25-50 files.
The proliferation of adapters around a fat interface is a symptom, not a cure. Each adapter represents cognitive overhead, maintenance burden, and architectural complexity that wouldn't exist with properly segregated interfaces. If you find yourself writing many adapters for one interface, the interface is the problem.
Implementation burden extends beyond code to affect team dynamics and organizational efficiency.
Cross-Team Dependencies:
Fat interfaces often span multiple team boundaries. One team owns parts of the interface, another team owns other parts:
IOrderService
│
┌────────────────┼─────────────────┐
│ │ │
Order Team Payment Team Shipping Team
(owns getOrder, (owns process- (owns calculate-
saveOrder, Payment, Shipping,
getAllOrders) refundPayment) createShipment)
When a class must implement IOrderService, it must coordinate with all three teams—even if it only needs one team's methods.
Decision-Making Paralysis:
When adding a new method to a fat interface, multiple teams must agree:
Resolution can take weeks, blocking development.
Onboarding Friction:
New team members face the full interface even when working on a narrow area:
| Impact Area | Fat Interface Cost | Segregated Interface Benefit |
|---|---|---|
| Team coordination | All teams involved in every change | Only affected team involved |
| Code review scope | Changes touch many unrelated files | Changes isolated to relevant components |
| Onboarding time | Must understand full interface (weeks) | Learn one focused interface at a time (days) |
| Deployment risk | All implementations deploy together | Independent deployment of components |
| Feature velocity | Blocked by cross-team dependencies | Teams work independently |
Systems tend to mirror organizational structure. Fat interfaces often emerge when multiple teams fall under a single architectural umbrella, creating monolithic contracts that resist team autonomy. Segregating interfaces enables teams to own distinct contracts, aligning architecture with organization.
Implementation burden is what happens when fat interfaces meet real-world implementations—classes forced to pretend capabilities they don't have, leading to anti-patterns, LSP violations, and organizational friction.
What's Next:
Now that we understand both the client-side problems (unused method dependencies) and the implementer-side problems (implementation burden), the next page examines change propagation issues—how fat interfaces amplify the impact of changes throughout a codebase.
You now understand the implementation burden imposed by fat interfaces—the impossible choices faced by implementers, the anti-patterns that result, and the cascading effects on testing, architecture, and organization. Next, we'll explore how fat interfaces turn routine changes into system-wide disruptions.