Loading content...
A butterfly flaps its wings in Tokyo, and three weeks later there's a hurricane in Miami. Chaos theory's famous metaphor describes how small perturbations in complex systems can cascade into massive, unpredictable effects.
Fat interfaces create an analogous phenomenon in software: a developer adds an optional parameter to one method, and suddenly 47 files need updating, 3 teams must coordinate, 2 deployment pipelines break, and the quarterly release is at risk.
This is the change propagation problem of fat interfaces—and it's one of the most practically damaging consequences of interface obesity.
By the end of this page, you will understand how changes to fat interfaces propagate through systems, why seemingly minor modifications cascade into major disruptions, and how to analyze and predict the blast radius of interface changes. You will see concrete examples of how segregated interfaces contain change impact.
What Is Change Propagation?
Change propagation refers to the phenomenon where a modification to one part of a system requires modifications to other parts. It's a fundamental property of software: components that depend on each other must be kept in sync.
The Propagation Chain:
Change Origin (Interface Method)
│
▼
Direct Dependents (classes that implement or use the interface)
│
▼
Transitive Dependents (code that depends on direct dependents)
│
▼
Build Artifacts (compiled modules, packages)
│
▼
Deployment Units (services, applications)
│
▼
Operational Impact (runtime behavior, monitoring, alerts)
Each level amplifies the change. A one-line interface modification can affect hundreds of files by the time it reaches deployment.
Change Propagation in Well-Designed Systems:
In systems with properly segregated interfaces, change propagation is contained:
Change Propagation with Fat Interfaces:
In systems with fat interfaces, change propagation explodes:
The difference isn't linear—it's often exponential.
Fat interfaces are coupling multipliers. They tie together components that have no logical relationship, forcing them to change together. A payment method signature change triggers redeployment of a completely unrelated reporting service—not because reporting uses payments, but because both depend on the same fat interface.
Let's trace a real change through a fat interface to see propagation in action.
The Scenario:
The payment team needs to add multi-currency support. The existing method:
processPayment(orderId: string, amount: number): Promise<PaymentResult>
Must become:
processPayment(orderId: string, amount: number, currency: Currency): Promise<PaymentResult>
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
// STEP 1: The change originates in IOrderService (fat interface with 30 methods)interface IOrderService { // ... 20 read/write methods // CHANGING THIS METHOD processPayment(orderId: string, amount: number, currency: Currency): Promise<PaymentResult>; // ... 9 more methods} // STEP 2: All implementations must update// Even implementations that never actually process payments! // Implementation 1: Full service - needs the changeclass OrderServiceImpl implements IOrderService { processPayment(orderId: string, amount: number, currency: Currency) { // Actually uses the new currency parameter return this.paymentGateway.charge(amount, currency); }} // Implementation 2: Read-only reporting - doesn't need the change, but must updateclass ReadOnlyOrderProxy implements IOrderService { processPayment(orderId: string, amount: number, currency: Currency) { // Added currency throw new Error("Read-only proxy cannot process payments"); // Still throws - but signature must match interface }} // Implementation 3: Test mock - doesn't need the change, but must updateclass MockOrderService implements IOrderService { processPayment(orderId: string, amount: number, currency: Currency) { // Added currency return Promise.resolve({ success: true, mockData: true }); // Ignores currency entirely }} // STEP 3: All mock factories must updateconst createMockOrderService = (): jest.Mocked<IOrderService> => ({ // ... 20 other mock methods processPayment: jest.fn((orderId, amount, currency) => // Added currency Promise.resolve({ success: true })), // ... 9 more mock methods}); // STEP 4: All tests that mock the interface must updatedescribe('ReportingService', () => { let mockOrderService: jest.Mocked<IOrderService>; beforeEach(() => { mockOrderService = { // Even though ReportingService never calls processPayment, // we must update the mock signature processPayment: jest.fn(), // Type error until we add currency param // ... all other methods }; });}); // STEP 5: Integration tests must updatedescribe('Order Integration Tests', () => { it('processes order end-to-end', async () => { // Even tests for order creation must use the new signature // if they construct mock implementations });});The Propagation Tally:
For this single method change:
| Category | Files Affected | Time Required |
|---|---|---|
| Interface definition | 1 | 5 min |
| Full implementation | 3 | 2 hours |
| Stub implementations | 7 | 1 hour |
| Mock factories | 12 | 1 hour |
| Test files | 45 | 3 hours |
| Build configurations | 4 | 30 min |
| Total | 72 files | 7.5 hours |
For a segregated interface (e.g., IPaymentProcessor with 3 methods):
| Category | Files Affected | Time Required |
|---|---|---|
| Interface definition | 1 | 5 min |
| Implementations | 3 | 2 hours |
| Mock factories | 3 | 20 min |
| Test files | 8 | 1 hour |
| Total | 15 files | 3.5 hours |
The fat interface caused 5x more files to change and doubled the work time.
The time estimates above assume you know all the affected files. In practice, you discover affected files as the compiler complains. Each discovery interrupts your flow, requires context switching, and adds frustration. The psychological cost compounds the time cost.
Different types of interface changes propagate differently. Understanding this helps predict the blast radius of proposed modifications.
| Change Type | Propagation Scope | Breaking? | Mitigation Difficulty |
|---|---|---|---|
| Add new method | All implementations | Yes | Medium - implementations must add method |
| Remove method | All callers + implementations | Yes | High - must find and remove all usages |
| Change return type | All callers + implementations | Yes | High - callers expect old type |
| Add required parameter | All callers + implementations | Yes | Very High - every call site updates |
| Add optional parameter | All implementations | Partial | Medium - implementations update, callers optional |
| Change exception types | All callers (if checked) | Yes | Medium - catch blocks must update |
| Rename method | All callers + implementations | Yes | Medium - mechanical but widespread |
| Change parameter types | All callers + implementations | Yes | High - call sites may need logic changes |
The Fat Interface Amplifier:
For each change type, the fat interface multiplies impact:
The Cascade Effect:
With fat interfaces, changes often cascade into more changes:
1. Add currency parameter to processPayment
│
├── 2. Update Currency type to be more comprehensive
│ │
│ └── 3. Currency now requires exchange rate source
│ │
│ └── 4. ExchangeRateService interface touched
│
└── 5. Mock factories need Currency generation
│
└── 6. Test utility library updated
│
└── 7. All tests importing utility recompile
What started as one method change has become a system-wide refactoring session.
Before making an interface change, run a dependency analysis: 'Find all files that import this interface.' The count indicates your blast radius. For fat interfaces, this count is always larger than necessary. For segregated interfaces, the count matches actual usage.
Change propagation doesn't stop at source files—it extends to builds and deployments.
Build Impact:
Modern build systems track dependencies to enable incremental compilation. When a file changes, only files that depend on it recompile. But fat interfaces create dense dependency graphs:
┌─────────────────┐
│ IOrderService │ ← Fat interface: 30 methods
└────────┬────────┘
│
╔════╧════╗ ← 50 files depend on this interface
║ ??? ║
╚════╤════╝
│
┌────────┴────────┐
│ All 50 files │ ← All recompile when interface changes
│ recompile │
└─────────────────┘
Versus segregated interfaces:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ IOrderReader │ │IPaymentProc │ │IOrderNotify │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
10 files 8 files 7 files
Changing IPaymentProcessor affects only 8 files, not 50.
Deployment Impact:
In microservices architectures, fat interfaces have particularly severe deployment consequences:
| Architecture Style | Fat Interface Impact | Segregated Interface Impact |
|---|---|---|
| Monolith | Full recompile + redeploy | Full recompile + redeploy |
| Modular Monolith | Affected modules recompile | Only related module recompiles |
| Microservices (shared lib) | All services update library | Only relevant services update |
| Microservices (per-service) | Interface duplicated everywhere | Clean service boundaries |
The Shared Library Problem:
A common pattern is packaging interfaces in shared libraries:
shared-interfaces.jar (or npm package)
├── IOrderService (30 methods)
├── IUserService (20 methods)
└── IInventoryService (15 methods)
Services depending on shared-interfaces:
├── order-service
├── payment-service
├── shipping-service
├── reporting-service
├── analytics-service
└── admin-service
When IOrderService changes, shared-interfaces publishes a new version. All 6 services must update to the new version—even if only order-service actually uses the changed method.
With segregated interfaces:
order-reading.jar → used by: reporting-service, analytics-service
payment.jar → used by: payment-service, order-service
shipping.jar → used by: shipping-service, order-service
Changing payment.jar affects only 2 services.
When a fat interface change requires deploying 6 services simultaneously, you need deployment coordination—rollback plans, database migration timing, cross-service version compatibility. A segregated interface change affecting 2 services is simpler by an order of magnitude.
Fat interfaces create severe version compatibility challenges that constrain how systems can evolve.
The Versioning Dilemma:
When an interface changes, consumers must update to the new version. But with fat interfaces, determining compatibility is difficult:
IOrderService v1.0.0 → v1.1.0
What changed?
- Added: new analytics method
- Changed: payment method signature
For ReportingService (uses only read methods):
- Doesn't need analytics → but must accept it
- Doesn't use payment → but signature changed
- Is v1.1.0 compatible? Technically yes, but it must rebuild.
For PaymentService (uses payment methods):
- Needs the new signature
- Must update
For AnalyticsService (wants new analytics):
- Wants the new method
- Must update
Result: All services update, even those unaffected by the changes.
123456789101112131415161718192021222324252627282930313233343536373839404142
// Semantic Versioning with Fat Interfaces /** * IOrderService version history: * * v1.0.0 - Initial release (25 methods) * v1.1.0 - Added getOrderMetrics() - analytics feature * v1.2.0 - Changed processPayment() signature - currency support * v1.3.0 - Added archiveOrder() - archival feature * v1.4.0 - Deprecated getOrderHistory(), added getOrderAuditLog() * v2.0.0 - Removed getOrderHistory() - breaking change * * Semantic versioning says: * - MINOR bumps (1.1, 1.2, 1.3) should be backward compatible * - MAJOR bumps (2.0) indicate breaking changes * * But wait: adding a method IS a breaking change for implementations! * * v1.1.0 adds getOrderMetrics(): * - Consumers: Compatible (don't have to call new method) * - Implementations: Breaking! (must add new method) * * So is v1.1.0 a minor or major version? * - For consumers: minor * - For implementers: major * * Fat interface versioning is inherently confused. */ // With fat interfaces, you often see:// - Premature major version bumps (v47.0.0 for a 3-year-old library)// - Version conflicts between services// - Dependency hell trying to align versions // With segregated interfaces:// IOrderReader: changes rarely, stable at v1.x// IPaymentProcessor: v3.x due to payment industry changes// IOrderArchive: new interface, at v1.0// // Each interface evolves at its own pace.// Services consume specific interfaces at specific versions.// No forced upgrades for unrelated changes.The Diamond Dependency Problem:
In complex dependency graphs, fat interfaces cause version conflicts:
┌──────────────────┐
│ order-service │
└────────┬─────────┘
needs IOrderService v2.0
│
┌────────┴─────────┐
│ │
▼ ▼
┌───────────┐ ┌───────────┐
│ lib-a │ │ lib-b │
│ uses v1.0 │ │ uses v2.0 │
└─────┬─────┘ └─────┬─────┘
│ │
└─────────┬────────┘
▼
┌───────────┐
│ shared- │
│ interfaces│
│ v??? CONFLICT │
└───────────┘
Both lib-a and lib-b are in the dependency tree, but they need different versions. Classic diamond dependency.
With segregated interfaces, this rarely happens—each library depends on only the interfaces it needs, and those interfaces evolve independently.
Small, focused interfaces tend to be more stable. When an interface has one job (reading orders), it only changes when that job changes. Fat interfaces change whenever any of their many jobs change. Interface stability is a direct result of proper segregation.
Experienced engineers develop metrics and techniques to predict change propagation before it happens.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
/** * Example Change Impact Analysis * * Interface: IOrderService (fat interface) * Proposed Change: Add async updateOrderStatus(orderId, status, reason) */ // METRIC 1: Fan-Out Countconst fanOutAnalysis = { interface: 'IOrderService', totalImports: 156, // 156 files import this interface categories: { implementations: 12, directClients: 45, testFiles: 67, mockFactories: 18, typeDefinitions: 14, }}; // METRIC 2: Change Impact Setconst changeImpactSet = { definitelyAffected: [ // Files that MUST change 'interfaces/IOrderService.ts', // Interface definition 'services/OrderServiceImpl.ts', // Primary implementation 'services/OrderServiceV2Impl.ts', // Alternative implementation 'adapters/ExternalOrderAdapter.ts', // External adapter // ... 9 more implementations 'testing/MockOrderService.ts', // Main mock 'testing/OrderServiceTestDouble.ts', // Test double // ... 16 more mocks ], likelyAffected: [ // Files that probably need review 'testing/__mocks__/orderService.ts', 'e2e/helpers/mockServices.ts', // ... integration test helpers ], possiblyAffected: [ // Files that might break at runtime 'plugins/OrderStatusPlugin.ts', // Might try to call the method 'scripts/migrateOrders.ts', // Utility scripts ], stats: { definite: 32, likely: 18, possible: 8, totalBlastRadius: 58, }}; // COMPARISON: Segregated IOrderStatusManager interfaceconst segregatedChangeImpactSet = { definitelyAffected: [ 'interfaces/IOrderStatusManager.ts', 'services/OrderStatusManagerImpl.ts', 'testing/MockOrderStatusManager.ts', ], likelyAffected: [], possiblyAffected: [], stats: { definite: 3, likely: 0, possible: 0, totalBlastRadius: 3, }}; // The segregated approach has 95% smaller blast radiusUsing Metrics for Decision Making:
| Blast Radius | Action |
|---|---|
| 1-10 files | Normal development; proceed with change |
| 11-30 files | Consider if change is necessary; plan carefully |
| 31-75 files | Strong signal interface is too fat; consider refactoring first |
| 75+ files | Interface urgently needs segregation; change will be very costly |
Pre-Change Analysis:
Before making any interface change:
If updating all affected files takes less than 5 minutes of mechanical work, the change is manageable. If it takes hours, the interface is too coupled. Use this rule to trigger segregation conversations.
Given an existing fat interface, there are strategies to contain change propagation while working toward full segregation.
IOrderServiceExtensions inherits from IOrderService and adds new methods. Only new implementations implement the extension.IOrderServiceV1, IOrderServiceV2). Clients upgrade at their own pace. Eventually deprecate old versions.12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
// STRATEGY 1: Extension Interfaces// Instead of modifying the fat interface: // Original interface - don't touchinterface IOrderService { getOrder(id: string): Promise<Order>; saveOrder(order: Order): Promise<void>; // ... 28 other methods} // New capability added via extensioninterface IOrderServiceWithAnalytics extends IOrderService { getOrderMetrics(dateRange: DateRange): Promise<Metrics>;} // New implementations can implement the extensionclass OrderServiceWithAnalytics implements IOrderServiceWithAnalytics { // Implements all IOrderService methods + getOrderMetrics} // Clients that need analytics depend on the extensionclass AnalyticsClient { constructor(private service: IOrderServiceWithAnalytics) {}} // Existing clients continue to use IOrderService - unaffected // STRATEGY 2: Facade Layer// Create focused facades for specific client needs // Fat interface in the implementation layerinterface IOrderService { /* 30 methods */ } // Focused facade for reportingclass OrderReportingFacade { constructor(private orderService: IOrderService) {} async getOrdersForReport(customerId: string): Promise<Order[]> { return this.orderService.getOrdersByCustomer(customerId); } async getOrderStatistics(range: DateRange): Promise<Statistics> { return this.orderService.getOrderMetrics(range); } // Only exposes 2-3 methods, not 30} // Clients depend on the facade, not the fat interfaceclass ReportGenerator { constructor(private facade: OrderReportingFacade) {} // Isolated from IOrderService changes that don't affect reporting}These strategies buy time, but they don't solve the underlying problem. Extension interfaces accumulate. Facades multiply. Version numbers inflate. Eventually, the fat interface must be properly segregated. Use containment to stabilize, then plan for real refactoring.
Change propagation is perhaps the most practically damaging consequence of fat interfaces—the mechanism that turns routine modifications into cross-team, multi-day efforts.
Module Complete:
You have now completed the module on Fat Interfaces Problem. You understand:
Together, these four dimensions paint a complete picture of why interface obesity is a serious design flaw, not merely an aesthetic concern. The next module will explore role-based interface design—the solution to fat interface problems.
You now understand the full scope of the fat interfaces problem: from initial bloat to unused dependencies, from implementation burden to change propagation. You can identify fat interfaces, predict their impact, and articulate why they must be addressed. The next module shows you how to fix them through role-based interface design.