Loading content...
In truly well-architected systems, components don't just work together—they're designed to work apart. Each component can be developed by a different team, tested in isolation, deployed independently, and scaled according to its own demands. The secret to this independence isn't clever infrastructure or containerization. It's interface design.
Decoupling is the degree to which components can change independently without affecting each other. Tight coupling means changes ripple across the codebase; loose coupling means changes stay contained. Interfaces are the primary mechanism for achieving loose coupling in object-oriented systems—but only if designed correctly.
Sloppy interface design creates an illusion of decoupling while maintaining tight coupling underneath. Fat interfaces are the most common culprit: they appear to provide abstraction but actually cement components together through unnecessary dependencies. ISP-compliant interfaces, by contrast, create true architectural boundaries.
By the end of this page, you will understand how to use interfaces as decoupling mechanisms, not just polymorphism tools. You'll learn the principles of boundary design, see how segregated interfaces enable team autonomy, and master techniques for creating interfaces that truly separate concerns.
Many developers confuse abstraction with decoupling. They believe that using an interface automatically decouples code. This is a dangerous misconception that leads to false confidence in system architecture.
Abstraction is hiding implementation details behind a contract. You depend on what something does, not how it does it.
Decoupling is minimizing the impact of changes in one component on other components. Changes in one area don't ripple to others.
Abstraction is necessary for decoupling but not sufficient. A fat interface provides abstraction—you don't know how its 30 methods are implemented. But it provides poor decoupling—when any of those 30 methods change, all clients may be affected.
Why the distinction matters:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
// ABSTRACTION WITHOUT DECOUPLING// The interface abstracts the implementation, but clients are still coupled interface INotificationService { sendEmail(to: string, subject: string, body: EmailBody): Promise<void>; sendSMS(to: string, message: string): Promise<void>; sendPush(deviceId: string, payload: PushPayload): Promise<void>; sendSlack(channel: string, message: SlackMessage): Promise<void>; sendWebhook(url: string, payload: WebhookPayload): Promise<void>; scheduleSend(notification: Notification, time: Date): Promise<ScheduleId>; getDeliveryStatus(notificationId: string): Promise<DeliveryStatus>; retryFailedNotifications(): Promise<RetryResult>;} // The OrderService only sends email confirmationsclass OrderService { constructor(private notifications: INotificationService) {} async completeOrder(order: Order): Promise<void> { // Only uses one method await this.notifications.sendEmail( order.customerEmail, 'Order Confirmation', this.buildConfirmationEmail(order) ); }} // Despite abstraction:// - If sendSlack() signature changes, OrderService may need recompilation// - OrderService must import SlackMessage, PushPayload, WebhookPayload types// - Tests must stub 8 methods even though 7 are never called// - Cannot deploy OrderService without NotificationService team coordination // ABSTRACTION WITH DECOUPLING// Each interface provides both abstraction AND isolation interface IEmailSender { sendEmail(to: string, subject: string, body: EmailBody): Promise<void>;} interface ISMSSender { sendSMS(to: string, message: string): Promise<void>;} interface IPushNotifier { sendPush(deviceId: string, payload: PushPayload): Promise<void>;} // OrderService only depends on what it usesclass OrderServiceDecoupled { constructor(private emailSender: IEmailSender) {} // Only this one interface! async completeOrder(order: Order): Promise<void> { await this.emailSender.sendEmail( order.customerEmail, 'Order Confirmation', this.buildConfirmationEmail(order) ); }} // Now truly decoupled:// - SMS, Push, Slack changes don't affect OrderService at all// - OrderService only imports EmailBody type// - Tests only stub one 1-method interface// - OrderService can deploy completely independentlyNot all interfaces are created equal. A decoupling interface has specific characteristics that differentiate it from a simple polymorphism interface. Understanding these characteristics helps you design interfaces that actually achieve their decoupling purpose.
Characteristic 1: Single Coherent Purpose
A decoupling interface serves exactly one purpose from the client's perspective. All its methods work toward that single goal. If you can't describe the interface's purpose in one sentence without using 'and', it likely needs to be split.
Characteristic 2: Minimal Surface Area
Every method and type in an interface is a potential coupling point. Decoupling interfaces minimize surface area by exposing only what's absolutely necessary. If a method could be useful but isn't currently used, it doesn't belong.
Characteristic 3: Stable Contract
Decoupling interfaces change infrequently. They represent stable abstractions—the fundamental capabilities a client needs. Volatile implementation details are hidden behind this stable facade.
Characteristic 4: Client-Centric Design
The interface is designed from the client's perspective, not the provider's. It expresses what the client needs to accomplish, not what the provider happens to offer. This inverts the typical 'here's what I have' approach to 'here's what you need'.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
// ANALYZING INTERFACE QUALITY FOR DECOUPLING // ❌ POOR: Provider-centric, kitchen-sink interfaceinterface IDataPersistence { // Database operations connect(connectionString: string): Promise<Connection>; disconnect(): Promise<void>; query<T>(sql: string, params: unknown[]): Promise<T[]>; execute(sql: string, params: unknown[]): Promise<ExecuteResult>; // Transaction control beginTransaction(): Promise<Transaction>; commit(tx: Transaction): Promise<void>; rollback(tx: Transaction): Promise<void>; // Connection pooling getPoolStats(): PoolStatistics; drainPool(): Promise<void>; // Migration runMigrations(): Promise<MigrationResult>; rollbackMigration(version: number): Promise<void>; // Backup createBackup(): Promise<BackupHandle>; restoreFromBackup(handle: BackupHandle): Promise<void>;} // Problems:// - Purpose: "database stuff" (too vague)// - Surface area: 14 methods, 10+ types// - Stability: Migration APIs change often// - Design: Provider-centric ("here's what our DB can do") // ✅ GOOD: Client-centric, minimal decoupling interfaces // For application code that needs to read/write domain objectsinterface IEntityRepository<T> { findById(id: string): Promise<T | null>; findAll(criteria: QueryCriteria<T>): Promise<T[]>; save(entity: T): Promise<T>; delete(id: string): Promise<boolean>;}// Purpose: "Persist and retrieve domain entities" (one sentence, no 'and'-splitting)// Surface area: 4 methods, 2 types (T and QueryCriteria)// Stability: CRUD is extremely stable// Design: Client-centric ("here's what the domain layer needs") // For transaction boundaries (separate concern)interface ITransactionScope { execute<T>(work: () => Promise<T>): Promise<T>;}// Purpose: "Execute work atomically"// Surface area: 1 method, 0 additional types// Stability: Transaction semantics are stable// Design: Client only cares about atomicity, not how // For operations tooling (completely separate audience)interface IDatabaseAdmin { runMigrations(): Promise<MigrationResult>; createBackup(): Promise<BackupHandle>; restoreFromBackup(handle: BackupHandle): Promise<void>;}// Purpose: "Administrative database operations"// Surface area: 3 methods, clear admin focus// Stability: Ops tooling, less stable, but isolated// Design: Ops team's perspective, not app developers| Criterion | Question to Ask | Red Flag | Green Flag |
|---|---|---|---|
| Purpose | Can I describe this in one sentence? | 'It handles orders and payments and notifications' | 'It sends transactional emails' |
| Surface Area | Do all clients use most methods? | Clients typically use <30% of methods | Clients use 80%+ of methods |
| Stability | How often does this interface change? | Monthly signature changes | Hasn't changed in 6+ months |
| Perspective | Whose vocabulary is used? | Implementation terms (SQL, HTTP) | Domain terms (Order, Payment) |
| Types | Are exposed types minimal? | Exposes internal DTOs, configs | Only domain types or primitives |
Interfaces are most powerful when used to define architectural boundaries—the lines between components, layers, modules, or teams. A well-designed boundary interface enables everything on each side to evolve independently.
What Makes a Boundary:
A boundary exists wherever:
At every boundary, interfaces should:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
// ARCHITECTURAL BOUNDARY EXAMPLE: Order Service → Payment Service // BOUNDARY DEFINITION// The Order Service needs to process payments but shouldn't know how payments work internally.// The Payment Service can switch processors, add fraud detection, change retry logic.// Neither side should be affected by the other's internal changes. // ====== ORDER SERVICE SIDE (Consumer of payment capability) ====== // The Order Service defines what it needs (Dependency Inversion!)interface IPaymentCapability { // Express the capability, not the implementation chargeForOrder(request: OrderPaymentRequest): Promise<PaymentResult>;} // Types are owned by the Order Service domaininterface OrderPaymentRequest { orderId: string; customerId: string; amount: MoneyAmount; idempotencyKey: string; // Ensures retry safety} interface PaymentResult { success: boolean; transactionId: string; failureReason?: PaymentFailureReason;} type PaymentFailureReason = | 'INSUFFICIENT_FUNDS' | 'CARD_DECLINED' | 'PROCESSOR_ERROR' | 'INVALID_REQUEST'; // Order Service only sees domains conceptsclass OrderProcessor { constructor(private payments: IPaymentCapability) {} async processOrder(order: Order): Promise<ProcessResult> { const paymentResult = await this.payments.chargeForOrder({ orderId: order.id, customerId: order.customerId, amount: order.total, idempotencyKey: `order-${order.id}`, }); if (!paymentResult.success) { return { success: false, error: paymentResult.failureReason }; } return { success: true, transactionId: paymentResult.transactionId }; }} // ====== PAYMENT SERVICE SIDE (Provider of payment capability) ====== // Internal Payment Service types (these DO NOT cross the boundary)interface StripeChargeRequest { amount: number; currency: string; customer: string; metadata: Record<string, string>; idempotency_key: string;} interface FraudCheckResult { riskScore: number; recommendation: 'ALLOW' | 'BLOCK' | 'REVIEW'; signals: FraudSignal[];} // Adapter that implements the boundary interfaceclass PaymentCapabilityAdapter implements IPaymentCapability { constructor( private stripe: StripeClient, private fraudService: FraudService, private retryPolicy: RetryPolicy, private auditLog: AuditLogger, ) {} async chargeForOrder(request: OrderPaymentRequest): Promise<PaymentResult> { // All this complexity is HIDDEN from Order Service // 1. Check fraud const fraudCheck = await this.fraudService.assess(request); if (fraudCheck.recommendation === 'BLOCK') { return { success: false, transactionId: '', failureReason: 'CARD_DECLINED' }; } // 2. Translate to Stripe format const stripeRequest: StripeChargeRequest = { amount: request.amount.cents, currency: request.amount.currency, customer: await this.lookupStripeCustomer(request.customerId), metadata: { orderId: request.orderId }, idempotency_key: request.idempotencyKey, }; // 3. Charge with retry const charge = await this.retryPolicy.execute(() => this.stripe.charges.create(stripeRequest) ); // 4. Audit await this.auditLog.logCharge(request, charge); return { success: charge.status === 'succeeded', transactionId: charge.id, failureReason: charge.status !== 'succeeded' ? this.mapStripeFailure(charge.failure_code) : undefined, }; }} // RESULT:// - Order Service knows nothing about Stripe, fraud detection, retries, auditing// - Payment Service can switch to different processor without Order Service changes// - Both sides can deploy independently// - Testing Order Service only requires a simple IPaymentCapability mockApply Dependency Inversion: the consumer (higher-level policy) should own the interface definition, not the provider (lower-level implementation). Order Service defines IPaymentCapability—it says 'this is what I need.' Payment Service adapts to satisfy it. This ensures interfaces are stable from the consumer's perspective.
In organizations with multiple teams, interface design directly impacts team velocity and autonomy. Fat interfaces create coordination overhead—every change requires cross-team synchronization. Segregated interfaces give teams the freedom to move independently.
The Team Interface Contract:
When teams communicate through well-segregated interfaces:
This requires interfaces that:
| Scenario | Fat Interface Impact | Segregated Interface Impact |
|---|---|---|
| Team A adds new feature | All consumers must review change | Only affected consumers review |
| Team B deprecates a method | All consumers must migrate | Only actual users migrate |
| Bug fix in shared code | Coordinated release across teams | Team deploys independently |
| Breaking API change | Big-bang synchronized release | Gradual per-interface migration |
| Refactoring internals | Risk of breaking consumers | Complete freedom to refactor |
| Performance optimization | All consumers retest | Only affected flows retest |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
// SCENARIO: Platform with 3 teams// - Catalog Team: manages product catalog// - Search Team: provides search capabilities // - Storefront Team: builds customer-facing UI // ❌ WITHOUT ISP: Storefront couples to everythinginterface IProductService { // Catalog operations getProduct(id: string): Product; listProducts(filter: ProductFilter): Product[]; // Admin operations (used by Catalog Team internally) createProduct(data: CreateProductRequest): Product; updateProduct(id: string, data: UpdateProductRequest): Product; deleteProduct(id: string): void; // Search operations (implemented by Search Team) searchProducts(query: SearchQuery): SearchResults; suggestProducts(prefix: string): Suggestion[]; // Inventory (cross-cutting concern) getInventory(productId: string): InventoryLevel; reserveInventory(productId: string, quantity: number): Reservation; // Analytics (used by various teams) getProductAnalytics(productId: string): ProductAnalytics; trackProductView(productId: string, userId: string): void;} // Storefront only shows products, but:// - Must wait for Catalog Team's admin API stabilization// - Must update when Search Team changes query format// - Must retest when Analytics tracking changes// - 3 teams must coordinate every release! // ✅ WITH ISP: Each team owns their interface contract // Consumer: Storefront | Provider: Catalog Teaminterface IProductReader { getProduct(id: string): Product; listProducts(filter: ProductFilter): Product[];} // Consumer: Storefront | Provider: Search Teaminterface IProductSearch { searchProducts(query: SearchQuery): SearchResults; suggestProducts(prefix: string): Suggestion[];} // Consumer: Checkout Service | Provider: Catalog Teaminterface IInventoryChecker { getInventory(productId: string): InventoryLevel; reserveInventory(productId: string, quantity: number): Reservation;} // Consumer: Internal Tools | Provider: Catalog Team (admin only)interface IProductAdmin { createProduct(data: CreateProductRequest): Product; updateProduct(id: string, data: UpdateProductRequest): Product; deleteProduct(id: string): void;} // Consumer: Analytics Dashboard | Provider: Catalog Teaminterface IProductAnalytics { getProductAnalytics(productId: string): ProductAnalytics; trackProductView(productId: string, userId: string): void;} // TEAM AUTONOMY RESULTS: // Storefront Team:// - Depends on: IProductReader, IProductSearch// - Can release anytime Catalog and Search contracts are stable// - Not blocked by Admin or Analytics changes // Catalog Team:// - Owns: IProductReader, IInventoryChecker, IProductAdmin, IProductAnalytics// - Can refactor internal product storage freely// - Admin changes don't affect Storefront // Search Team:// - Owns: IProductSearch// - Can experiment with search algorithms// - Changes to suggestions don't affect listing pages // Each team can:// - Maintain their own release schedule// - Refactor internal implementation freely// - Version their interfaces independently// - Test against stable interface contractsConway's Law states that system structure mirrors organizational structure. By designing interfaces along team boundaries, you align your architecture with your organization. Each team's public interface becomes their product—their contract with other teams. This isn't just good design; it's organizational efficiency.
Segregating interfaces doesn't mean losing flexibility. Through composition patterns, you can build sophisticated contracts from simple pieces while maintaining decoupling. These patterns let you satisfy different clients with different combinations of capabilities.
Pattern 1: Interface Inheritance for Capability Extension
Use interface inheritance to create specialized interfaces that extend base capabilities. This maintains the single-responsibility of each interface while allowing composition.
Pattern 2: Facade Aggregation for Convenience
When a specific client legitimately needs multiple capabilities, create a facade interface that aggregates the relevant segregated interfaces. The aggregation is explicit—the dependencies are visible.
Pattern 3: Composite Implementation for Providers
A single implementation class can implement multiple segregated interfaces. Clients get decoupled views; the implementation remains cohesive.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
// PATTERN 1: Interface Inheritance for Capability Extension // Base capability: read-only data accessinterface IDataReader<T> { findById(id: string): T | null; findAll(): T[];} // Extended capability: read + writeinterface IDataWriter<T> extends IDataReader<T> { save(entity: T): T; delete(id: string): boolean;} // Even more capability: full admininterface IDataAdmin<T> extends IDataWriter<T> { purgeAll(): number; bulkImport(entities: T[]): ImportResult;} // Clients request only the capability level they needclass ReportGenerator { constructor(private data: IDataReader<Report>) {} // Read-only is sufficient} class ReportEditor { constructor(private data: IDataWriter<Report>) {} // Needs write access} class ReportAdminTool { constructor(private data: IDataAdmin<Report>) {} // Needs full control} // PATTERN 2: Facade Aggregation for Convenience // Individual capabilities remain segregatedinterface IOrderReader { getOrder(id: string): Order; getOrderHistory(customerId: string): Order[];} interface IOrderWriter { createOrder(data: CreateOrderData): Order; updateOrder(id: string, data: UpdateOrderData): Order;} interface IOrderStatus { getOrderStatus(id: string): OrderStatus; updateOrderStatus(id: string, status: OrderStatus): void;} // Convenience aggregation for a specific use case (Order Management Dashboard)interface IOrderManagementFullAccess extends IOrderReader, IOrderWriter, IOrderStatus {} // The dashboard explicitly declares its broad needsclass OrderManagementDashboard { constructor(private orders: IOrderManagementFullAccess) {} // Clear that this component needs everything} // Other clients can still use minimal interfacesclass OrderTracker { constructor(private orders: IOrderReader) {} // Just reads} // PATTERN 3: Composite Implementation // Single implementation, multiple interfacesclass OrderService implements IOrderReader, IOrderWriter, IOrderStatus { private orders: Map<string, Order> = new Map(); // IOrderReader implementation getOrder(id: string): Order { return this.orders.get(id) ?? null; } getOrderHistory(customerId: string): Order[] { return [...this.orders.values()] .filter(o => o.customerId === customerId); } // IOrderWriter implementation createOrder(data: CreateOrderData): Order { const order = new Order(data); this.orders.set(order.id, order); return order; } updateOrder(id: string, data: UpdateOrderData): Order { const order = this.getOrder(id); Object.assign(order, data); return order; } // IOrderStatus implementation getOrderStatus(id: string): OrderStatus { return this.getOrder(id)?.status ?? 'UNKNOWN'; } updateOrderStatus(id: string, status: OrderStatus): void { const order = this.getOrder(id); if (order) order.status = status; }} // Dependency injection provides the appropriate interfaceconst readerClient = new OrderTracker(orderService as IOrderReader);const writerClient = new OrderEditor(orderService as IOrderWriter);// Both get decoupled views of the same implementationIDataWriter genuinely extends IDataReader capabilities.In brownfield systems, coupling is already established. Fat interfaces are deployed, clients depend on them, and changing anything seems impossible without a big-bang rewrite. But systematic interface extraction can incrementally break coupling without disrupting the system.
The Extraction Strategy:
Identify coupling pain points — Which clients are suffering from unnecessary dependencies? Which changes ripple too widely?
Analyze actual usage — For each client, document exactly which methods they call. This reveals the minimal interface each client actually needs.
Extract minimal interfaces — Create new interfaces containing only the methods each client uses. Names should reflect client perspective.
Adapt existing implementation — Make the existing implementation also implement the new interfaces. This is additive—nothing breaks.
Migrate clients gradually — Update each client to depend on its minimal interface instead of the fat interface. Each migration is an isolated change.
Deprecate the fat interface — Once all clients have migrated, the fat interface can be removed or retained for backward compatibility only.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
// BEFORE: Legacy fat interface with many clients interface IUserManagerLegacy { // Used by ProfilePage getUser(id: string): User; updateProfile(id: string, data: ProfileData): User; // Used by AuthController validateCredentials(email: string, password: string): AuthResult; generateToken(userId: string): Token; revokeToken(token: string): void; // Used by AdminDashboard listAllUsers(): User[]; suspendUser(id: string): void; deleteUser(id: string): void; // Used by ReportingService getUserActivity(userId: string): ActivityRecord[]; getLoginHistory(userId: string): LoginEvent[];} // All 3 clients import this interface// Any change affects all 3 clients // STEP 2: Analyze actual usage per client // ProfilePage uses: getUser, updateProfile (2 methods)// AuthController uses: validateCredentials, generateToken, revokeToken (3 methods)// AdminDashboard uses: listAllUsers, suspendUser, deleteUser (3 methods)// ReportingService uses: getUserActivity, getLoginHistory (2 methods) // Total: 4 distinct usage patterns, 10 methods in fat interface // STEP 3: Extract minimal interfaces for each usage pattern // For ProfilePageinterface IProfileManager { getUser(id: string): User; updateProfile(id: string, data: ProfileData): User;} // For AuthControllerinterface IAuthenticator { validateCredentials(email: string, password: string): AuthResult; generateToken(userId: string): Token; revokeToken(token: string): void;} // For AdminDashboardinterface IUserAdmin { listAllUsers(): User[]; suspendUser(id: string): void; deleteUser(id: string): void;} // For ReportingServiceinterface IUserAnalytics { getUserActivity(userId: string): ActivityRecord[]; getLoginHistory(userId: string): LoginEvent[];} // STEP 4: Adapt existing implementation (additive, nothing breaks) class UserManager implements IUserManagerLegacy, IProfileManager, IAuthenticator, IUserAdmin, IUserAnalytics { // Existing implementation unchanged // Now satisfies all the new interfaces too getUser(id: string): User { /* existing code */ } updateProfile(id: string, data: ProfileData): User { /* existing code */ } validateCredentials(email: string, password: string): AuthResult { /* existing code */ } // ... all other methods unchanged} // STEP 5: Migrate clients one at a time // Before migrationclass ProfilePageBefore { constructor(private users: IUserManagerLegacy) {} // Fat interface} // After migration (minimal change)class ProfilePageAfter { constructor(private profiles: IProfileManager) {} // Minimal interface} // Each migration is an isolated PR:// - ProfilePage: IUserManagerLegacy → IProfileManager// - AuthController: IUserManagerLegacy → IAuthenticator// - AdminDashboard: IUserManagerLegacy → IUserAdmin// - ReportingService: IUserManagerLegacy → IUserAnalytics // STEP 6: After all clients migrated, deprecate legacy /** @deprecated Use IProfileManager, IAuthenticator, IUserAdmin, or IUserAnalytics */interface IUserManagerLegacy extends IProfileManager, IAuthenticator, IUserAdmin, IUserAnalytics {} // Eventually remove when no code references itThis interface extraction approach is the same principle as the Strangler Fig Pattern for system migrations. The new interfaces grow around the old, gradually handling more traffic. Eventually, the old interface is 'strangled' and can be removed. Each step is safe and reversible.
Decoupling is measurable. By tracking the right metrics, you can quantify how independent your components actually are and whether interface segregation is improving the situation.
Metric 1: Afferent Coupling (Ca)
The number of external components that depend on a component. High Ca means the component is heavily relied upon—changes have broad impact. For interfaces, high Ca with low method count indicates good segregation.
Metric 2: Efferent Coupling (Ce)
The number of external components a component depends on. High Ce means the component has many dependencies—it's coupled to much of the system. ISP reduces Ce by eliminating unnecessary interface dependencies.
Metric 3: Instability (I)
Calculated as Ce / (Ca + Ce). Ranges from 0 (maximally stable) to 1 (maximally unstable). Stable components are depended upon but don't depend on others. Interfaces should be stable.
Metric 4: Abstractness (A)
The ratio of abstract types to total types. High abstractness means the component expresses intent without implementation details.
Metric 5: Distance from Main Sequence (D)
An ideally balanced component has A + I ≈ 1. Distance from this ideal indicates either too abstract (no implementations) or too concrete and unstable (implementation churn flows to dependents).
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
// Decoupling Metrics Calculator interface ComponentMetrics { name: string; afferentCoupling: number; // How many depend on this efferentCoupling: number; // How many this depends on instability: number; // Ce / (Ca + Ce) abstractness: number; // Abstract types / Total types distanceFromMainSequence: number; // |A + I - 1|} function analyzeComponent(component: Component): ComponentMetrics { const ca = countDependents(component); // Who depends on me? const ce = countDependencies(component); // What do I depend on? const instability = ca + ce === 0 ? 0 : ce / (ca + ce); const abstractness = countAbstractTypes(component) / countTotalTypes(component); const distance = Math.abs(abstractness + instability - 1); return { name: component.name, afferentCoupling: ca, efferentCoupling: ce, instability, abstractness, distanceFromMainSequence: distance, };} // EXAMPLE ANALYSIS: Before and After ISP // Before: IOrderManagement (fat interface)const beforeMetrics: ComponentMetrics = { name: 'IOrderManagement', afferentCoupling: 47, // 47 clients depend on it efferentCoupling: 23, // Exposes 23 domain types instability: 0.33, // Moderately stable (many dependents) abstractness: 1.0, // Pure interface distanceFromMainSequence: 0.33, // Some distance from ideal}; // After: Segregated interfacesconst afterMetrics: ComponentMetrics[] = [ { name: 'IOrderReader', afferentCoupling: 28, // Many still read orders efferentCoupling: 3, // Only Order, filter types instability: 0.10, // Very stable abstractness: 1.0, distanceFromMainSequence: 0.10, // Excellent }, { name: 'IOrderWriter', afferentCoupling: 8, // Fewer write operations efferentCoupling: 5, // Order + mutation types instability: 0.38, // Moderate stability abstractness: 1.0, distanceFromMainSequence: 0.38, }, { name: 'IOrderAdmin', afferentCoupling: 3, // Only admin tools efferentCoupling: 7, // Admin-specific types instability: 0.70, // Less stable (ok for admin) abstractness: 1.0, distanceFromMainSequence: 0.30, },]; // KEY IMPROVEMENTS:// - Average instability decreased (changes affect fewer clients)// - Efferent coupling per interface drastically reduced// - Admin volatility is isolated from main read path| Metric | Ideal Range | Warning Signs | Action |
|---|---|---|---|
| Afferent Coupling (Ca) | Low-Medium | Ca > 50 for a single interface | Split interface for different client groups |
| Efferent Coupling (Ce) | Low | Ce > 10 types in interface | Hide types behind DTOs or narrow return types |
| Instability (I) | <0.3 for interfaces | I > 0.5 for widely-used interfaces | Stabilize contract, push changes to implementations |
| Abstractness (A) | ~1.0 for interfaces | A < 0.5 (too concrete) | Extract interface from implementation |
| Distance (D) | <0.2 | D > 0.3 | Rebalance toward main sequence |
Interfaces are your primary tool for achieving true architectural decoupling. But only well-designed, ISP-compliant interfaces create real independence. Fat interfaces merely provide abstraction while maintaining tight coupling underneath.
What's Next:
With a strong foundation in interface-based decoupling, we'll explore how ISP applies specifically at module boundaries—the lines between deployable units, packages, and services. Proper module boundaries are where decoupling pays the biggest dividends.
You now understand how to use interfaces as true decoupling mechanisms. Apply these principles to create architectures where components can evolve independently, teams can work autonomously, and changes remain contained.