Loading learning content...
Software systems must simultaneously embrace two contradictory forces: change and stability.
Systems must change because requirements evolve, technology advances, and business needs shift. Yet systems must also be stable—we can't rebuild everything from scratch with each new requirement. We need foundations we can rely upon, contracts that hold, and behaviors that remain predictable.
How do we reconcile these opposing forces? The answer lies in carefully separating what should be stable from what can change freely. This separation is formalized in the Stable Abstractions Principle (SAP): Abstractions should be stable; implementations should be volatile.
This principle is the keystone of the Dependency Inversion Principle. It explains why we depend on abstractions and how to design them so that depending on them is safe.
By the end of this page, you will understand how stability and abstraction are related, how to measure and design for stability, and why the most abstract components should be the most stable. You'll learn practical techniques for creating stable interfaces and managing inevitable change when it does occur.
Before we can discuss stable abstractions, we need a precise definition of stability in software engineering.
Stability ≠ Unchangeable:
Stability does not mean the component never changes. It means the component is hard to change—not because it's frozen, but because many other components depend on it. Changing a stable component has widespread consequences, so we change it carefully and infrequently.
Stability ≠ Quality:
A stable component isn't necessarily better than a volatile one. Both have their roles. Stable components provide the foundation; volatile components implement the specifics. The goal is to put the right components in each category.
Robert Martin's Stability Definition:
In his work on package design principles, Robert C. Martin provided a quantitative definition:
Stability = the difficulty of changing a component.
Difficulty increases when:
A component with many dependents and few dependencies is highly stable—changing it affects many others, but external changes don't affect it.
12345678910111213141516171819202122232425262728293031
// Measuring Stability: The Instability Metric // For a package/module/component P:// Ca = Afferent Couplings (incoming: number of classes outside P that depend on classes inside P)// Ce = Efferent Couplings (outgoing: number of classes inside P that depend on classes outside P)// I = Instability = Ce / (Ca + Ce)// Range: 0 (maximally stable) to 1 (maximally unstable) // Example 1: Domain Core// ─────────────────────// Many modules depend on our domain entities// Domain entities depend on little else // OrderEntity depends on: Money, OrderId, OrderStatus (all internal)// External modules depend on OrderEntity: OrderRepository, OrderService, // OrderController, ShippingService, BillingService// Ca = 5, Ce = 0, I = 0 / (5 + 0) = 0.0 → Maximally Stable // Example 2: Stripe Adapter// ─────────────────────────// Nothing depends on our Stripe adapter (could swap it)// Stripe adapter depends on: PaymentGateway interface, Stripe SDK, domain types // No external module depends on StripeAdapter// StripeAdapter depends on: PaymentGateway, Order, Money, Stripe SDK// Ca = 0, Ce = 4, I = 4 / (0 + 4) = 1.0 → Maximally Unstable // This is exactly right:// - Stable components (domain) are depended upon, don't depend outward// - Unstable components (adapters) depend on stable ones, nothing depends on them// - Changing adapters is safe; changing domain requires careful considerationStability emerges from dependency structure, not from labels or intentions. A component becomes stable when many things depend on it—regardless of whether you planned it that way. This is why dependency management is critical: uncontrolled dependencies make the wrong things stable.
Robert Martin introduced two diagnostic zones for understanding component design based on stability and abstractness:
Abstractness (A):
The ratio of abstract types to total types in a component.
The I-A Graph:
1.0 ┌─────────────────────────────────────────┐
│ Zone of Uselessness (1,1) │
│ \ │
A │ \ │
b │ \ Main Sequence │
s │ \ (the ideal line) │
t 0.5├───────\────────────────────────────────┤
r │ \ │
a │ \ │
c │ \ │
t │ \ │
│ \ │
0.0 │ (0,0) \ Zone of Pain │
└─────────────────────────────────────────┘
0.0 0.5 1.0
Instability (I)
The Zone of Pain (Bottom Left: Low A, Low I):
Components that are:
This is painful because concrete components are rigid—they specify how things work. When such components are also highly stable (many things depend on them), changes are both necessary (concrete details need updating) and difficult (many dependents break).
Examples of Zone of Pain residents:
The Zone of Uselessness (Top Right: High A, High I):
Components that are:
Abstractions only have value when things depend on them. An interface that nothing uses is dead code—a useless abstraction that adds complexity without benefit.
Examples of Zone of Uselessness:
The companion principle to SAP is the Stable Dependencies Principle (SDP): 'Depend in the direction of stability.' Combine SAP and SDP, and you get: 'Depend on stable abstractions.' This is Dependency Inversion stated in terms of package design.
Since stable components should be abstract, and since interfaces will be depended upon by many things, interface design requires special care. A poorly designed interface that becomes stable is a permanent liability.
Principles for Stable Interface Design:
1. Design for the Consumer:
Stable interfaces should reflect what consumers need, not what implementations provide. Consumer needs tend to be more stable than implementation details.
2. Minimize the Surface Area:
Every method in an interface is a commitment. Stable interfaces should be minimal—include only what consumers actually need, nothing speculative.
3. Use Primitive and Abstract Types:
Interface parameters and return types should be primitives, value objects, or other abstractions—not concrete types that might change.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
// ❌ UNSTABLE: Leaks implementation details and concrete types interface UserRepository { // Exposes ORM-specific concepts findWithEagerLoading(id: string, relations: string[]): Promise<User>; // Exposes SQL concepts findByRawQuery(sql: string, params: any[]): Promise<User[]>; // Exposes internal database structure findByJoinedTables( userFilter: UserTableFilter, profileFilter: ProfileTableFilter, orderBy: ColumnReference ): Promise<UserWithProfile[]>; // Uses concrete types saveWithTransaction(user: User, txn: PostgresTransaction): Promise<void>;} // ✅ STABLE: Abstract, consumer-focused, technology-agnostic interface UserRepository { // Operations defined in domain terms save(user: User): Promise<void>; findById(id: UserId): Promise<User | null>; findByEmail(email: EmailAddress): Promise<User | null>; findActive(): Promise<User[]>; // Uses domain types, not database types exists(id: UserId): Promise<boolean>; delete(id: UserId): Promise<void>;} // ❌ UNSTABLE: Too many methods, implementation-driven interface NotificationService { sendEmail(to: string, subject: string, body: string, html: string): Promise<void>; sendEmailWithAttachments(to: string, subject: string, body: string, attachments: File[]): Promise<void>; sendSms(phone: string, message: string): Promise<void>; sendPush(deviceToken: string, title: string, body: string, data: object): Promise<void>; sendSlack(channel: string, message: string, blocks: SlackBlock[]): Promise<void>; scheduleEmail(to: string, subject: string, body: string, sendAt: Date): Promise<void>; // ... 20 more methods for each delivery channel variation} // ✅ STABLE: Minimal, polymorphic, consumer-focused interface NotificationSender { send(notification: Notification, recipient: Recipient): Promise<DeliveryResult>;} // Variations handled by types, not method proliferationtype Notification = | OrderConfirmationNotification | ShippingUpdateNotification | PasswordResetNotification; type Recipient = | EmailRecipient | SmsRecipient | PushRecipient;4. Prefer Immutable Data Structures:
Data that crosses stable interfaces should be immutable. Mutable data creates hidden contracts about who can modify it and when.
5. Define Clear Semantics:
Stable interfaces need clear documentation of their contract—not just method signatures, but preconditions, postconditions, error conditions, and behavioral expectations.
6. Anticipate Extension, Not Specifics:
Design interfaces so new behaviors can be added without changing the interface. Use patterns like:
123456789101112131415161718192021222324252627282930313233343536373839404142
// ❌ HARD TO EXTEND: Adding features means adding parameters interface PaymentProcessor { processPayment( amount: Money, cardNumber: string, expiry: string, cvv: string, billingAddress: Address, // Oops, need 3D Secure now: threeDSecureData?: ThreeDSecureData, // Oops, need fraud check: fraudCheckOptions?: FraudCheckOptions, // Oops, need custom metadata: metadata?: Record<string, string>, // ... interface bloat continues ): Promise<PaymentResult>;} // ✅ EXTENSIBLE: New features don't change the interface interface PaymentProcessor { processPayment(request: PaymentRequest): Promise<PaymentResult>;} // Request object can evolve without interface changeinterface PaymentRequest { amount: Money; method: PaymentMethod; // Add new optional properties without breaking consumers threeDSecure?: ThreeDSecureData; fraudCheck?: FraudCheckOptions; metadata?: Metadata;} // Even better: use a builder for complex requestsconst request = PaymentRequest.builder() .amount(Money.of(100, 'USD')) .card(card) .withThreeDSecure(threeDSecureData) .withFraudCheck(FraudCheckLevel.High) .build();Stable interfaces naturally support the Open-Closed Principle. Open for extension: new implementations can be added. Closed for modification: the interface itself doesn't change. This connection isn't coincidental—both principles work together to create flexible, stable architectures.
Despite our best efforts, interfaces sometimes need to change. Managing these changes without breaking dependent code is a critical skill.
Types of Interface Changes:
| Change Type | Example | Breaking? | Strategy |
|---|---|---|---|
| Add method | addNewOperation() | No* | Default implementations or new interface |
| Add optional parameter | find(id, options?) | No | Default value preserves behavior |
| Widen return type | User → User | null | Usually no | Consumers handle broader type |
| Add required parameter | find(id, tenant) | Yes | New method or version |
| Remove method | deleteOperation() | Yes | Deprecate first, new interface |
| Narrow return type | User | null → User | Yes | New method or version |
| Change parameter type | string → UserId | Yes | Deprecate + new method |
| Change semantics | Same signature, different behavior | Yes (subtle) | New method with clear name |
Strategy 1: Interface Versioning
Create new interface versions for breaking changes:
12345678910111213141516171819202122232425262728293031
// Original interfaceinterface PaymentGateway { charge(amount: Money, card: CardDetails): Promise<ChargeResult>;} // Breaking change needed: add 3D Secure requirement// Don't modify! Create a new version: interface PaymentGatewayV2 extends PaymentGateway { chargeWithVerification( amount: Money, card: CardDetails, verification: VerificationResult ): Promise<ChargeResult>;} // Or, if completely redesigned:interface PaymentGatewayV2 { processPayment(request: PaymentRequest): Promise<PaymentResult>; // Backward compatibility helper static fromV1(v1: PaymentGateway): PaymentGatewayV2 { return new PaymentGatewayV2Adapter(v1); }} // Migration path:// 1. Introduce V2 interface// 2. Migrate consumers gradually// 3. Deprecate V1// 4. Remove V1 after migration completeStrategy 2: Extension Interfaces
Add new capabilities through additional interfaces rather than modifying existing ones:
1234567891011121314151617181920212223242526272829303132
// Original interface - stable, unchangedinterface OrderRepository { save(order: Order): Promise<void>; findById(id: OrderId): Promise<Order | null>; findByCustomer(customerId: CustomerId): Promise<Order[]>;} // New capability needed: batch operations// Don't add to OrderRepository! Create extension interface: interface BatchOrderRepository extends OrderRepository { saveBatch(orders: Order[]): Promise<void>; findByIds(ids: OrderId[]): Promise<Map<OrderId, Order>>;} // Consumers that need batch ops depend on BatchOrderRepository// Consumers that don't need batch ops continue with OrderRepository// No changes to existing code // Implementation provides both:class PostgresOrderRepository implements BatchOrderRepository { // Implements all methods} // Service chooses what it needs:class OrderMigrationService { constructor(private repo: BatchOrderRepository) {} // Needs batch} class OrderDetailsService { constructor(private repo: OrderRepository) {} // Only needs basic}Strategy 3: The Adapter Pattern for Compatibility
When you must support both old and new interfaces:
123456789101112131415161718192021222324252627282930313233
// Old interface that consumers depend oninterface LegacyPaymentService { pay(amount: number, cardToken: string): Promise<boolean>;} // New interface with richer modelinterface PaymentService { processPayment(request: PaymentRequest): Promise<PaymentResult>;} // Adapter allows gradual migrationclass LegacyPaymentServiceAdapter implements LegacyPaymentService { constructor(private newService: PaymentService) {} async pay(amount: number, cardToken: string): Promise<boolean> { const request = PaymentRequest.create({ amount: Money.fromCents(amount, 'USD'), method: PaymentMethod.fromToken(cardToken), }); const result = await this.newService.processPayment(request); // Translate rich result to simple boolean return result.status === PaymentStatus.Success; }} // Old consumers continue working:const legacyService: LegacyPaymentService = new LegacyPaymentServiceAdapter(newPaymentService);const success = await legacyService.pay(1000, 'tok_123'); // New consumers use rich interface:const result = await paymentService.processPayment(request);When interfaces are published (especially in libraries or APIs), follow semantic versioning: MAJOR for breaking changes, MINOR for backward-compatible additions, PATCH for fixes. This communicates to consumers the risk level of upgrading.
Let's see how stable abstractions work in a realistic system design.
Case Study: E-Commerce Platform
We're building an e-commerce platform with these requirements:
The Stability Structure:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
// ============================================// STABLE CORE: Domain entities and interfaces// I ≈ 0.1, A ≈ 0.8 (stable and abstract)// ============================================ // domain/entities/Order.ts - Stable, many dependentsclass Order { private constructor( readonly id: OrderId, readonly customerId: CustomerId, readonly items: readonly OrderItem[], private _status: OrderStatus, readonly shippingAddress: Address, ) {} static create(customerId: CustomerId, items: OrderItem[], address: Address): Order { // Business rules here } get total(): Money { return this.items.reduce((sum, item) => sum.add(item.subtotal), Money.zero()); } submit(): void { if (this.items.length === 0) { throw new EmptyOrderError(); } this._status = OrderStatus.Submitted; } // Rich domain model with business logic} // domain/ports/PaymentGateway.ts - Stable abstractioninterface PaymentGateway { authorize(amount: Money, method: PaymentMethod): Promise<Authorization>; capture(authorization: Authorization): Promise<PaymentResult>; refund(paymentId: PaymentId, amount?: Money): Promise<RefundResult>;} // domain/ports/ShippingService.ts - Stable abstractioninterface ShippingService { calculateRates(items: OrderItem[], destination: Address): Promise<ShippingRate[]>; createShipment(order: Order, rate: ShippingRate): Promise<Shipment>; trackShipment(shipmentId: ShipmentId): Promise<TrackingInfo>;} // domain/ports/NotificationSender.ts - Stable abstractioninterface NotificationSender { send(notification: Notification): Promise<SendResult>;} // domain/services/OrderProcessor.ts - Stable serviceclass OrderProcessor { constructor( private paymentGateway: PaymentGateway, private shippingService: ShippingService, private notificationSender: NotificationSender, private orderRepository: OrderRepository, ) {} async processOrder(order: Order, payment: PaymentMethod): Promise<ProcessedOrder> { // Orchestrates the order fulfillment // Uses stable interfaces, unaware of concrete providers }} // ============================================// UNSTABLE EDGE: Adapters and integrations// I ≈ 0.9, A ≈ 0.1 (unstable and concrete)// ============================================ // adapters/payment/StripeGateway.ts - Unstable, can changeclass StripePaymentGateway implements PaymentGateway { constructor(private stripe: Stripe) {} async authorize(amount: Money, method: PaymentMethod): Promise<Authorization> { // Stripe-specific implementation const paymentIntent = await this.stripe.paymentIntents.create({ amount: amount.toCents(), currency: amount.currency, payment_method: method.stripeId, capture_method: 'manual', }); return new Authorization(paymentIntent.id, paymentIntent.client_secret); } // Can change Stripe SDK versions, API patterns, error handling // without affecting domain} // adapters/payment/PayPalGateway.ts - Another unstable adapterclass PayPalPaymentGateway implements PaymentGateway { constructor(private paypal: PayPalClient) {} async authorize(amount: Money, method: PaymentMethod): Promise<Authorization> { // PayPal-specific implementation }} // adapters/shipping/FedExService.ts - Unstableclass FedExShippingService implements ShippingService { constructor(private fedex: FedExAPI) {} async calculateRates(items: OrderItem[], dest: Address): Promise<ShippingRate[]> { // FedEx-specific rate calculation }} // adapters/notification/SendGridNotifier.ts - Unstableclass SendGridNotificationSender implements NotificationSender { constructor(private sendGrid: SendGridClient) {} async send(notification: Notification): Promise<SendResult> { // SendGrid-specific email sending }}The Dependencies in This Design:
Stable (low I, high A) Unstable (high I, low A)
┌─────────────────────────┐ ┌─────────────────────────┐
│ Domain Core │ │ Adapters │
│ ───────────────── │ │ ──────── │
│ Order │ ◄────── │ StripeGateway │
│ PaymentGateway │ │ PayPalGateway │
│ ShippingService │ ◄────── │ FedExService │
│ NotificationSender │ │ SendGridNotifier │
│ OrderProcessor │ │ │
└─────────────────────────┘ └─────────────────────────┘
│
│ All dependencies point
│ toward stable core
▼
Benefits of This Structure:
The Composition Root is where stable and unstable components meet—where concrete adapters are wired to abstract interfaces. This is typically in your application startup code or dependency injection configuration. It's the only place that knows about all the concrete types.
Understanding anti-patterns helps recognize violations of the stable abstractions principle in your own code.
Anti-Pattern 1: The Concrete Hub
A concrete class that many other components depend on, placing it in the Zone of Pain.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
// ❌ ANTI-PATTERN: Concrete class with many dependents class DatabaseConnection { private pool: pg.Pool; constructor(config: DbConfig) { this.pool = new pg.Pool(config); } query(sql: string, params: any[]): Promise<QueryResult> { return this.pool.query(sql, params); } transaction<T>(work: (client: PoolClient) => Promise<T>): Promise<T> { // Transaction handling }} // Many classes depend on this concrete typeclass OrderRepository { constructor(private db: DatabaseConnection) {} // Concrete dependency} class CustomerRepository { constructor(private db: DatabaseConnection) {} // Concrete dependency} class ProductRepository { constructor(private db: DatabaseConnection) {} // Concrete dependency} // DatabaseConnection is now highly stable (many dependents)// But it's concrete (PostgreSQL-specific)// Result: Changing database is extremely painful // ✅ FIX: Abstract the database connection interface DatabaseClient { query<T>(sql: string, params?: unknown[]): Promise<T[]>; execute(sql: string, params?: unknown[]): Promise<void>; transaction<T>(work: (client: TransactionClient) => Promise<T>): Promise<T>;} class PostgresClient implements DatabaseClient { // PostgreSQL-specific implementation} class MySqlClient implements DatabaseClient { // MySQL-specific implementation} // Now repositories depend on the abstractionAnti-Pattern 2: The Utility Class Trap
Utility classes that accumulate dependencies become accidentally stable concrete types.
1234567891011121314151617181920212223242526272829303132333435363738394041
// ❌ ANTI-PATTERN: Growing utility class // Starts innocentclass StringUtils { static capitalize(s: string): string { /* ... */ } static truncate(s: string, len: number): string { /* ... */ }} // Grows over time, accumulates dependencies everywhereclass StringUtils { static capitalize(s: string): string { /* ... */ } static truncate(s: string, len: number): string { /* ... */ } static toSlug(s: string): string { /* ... */ } static parseTemplate(template: string, vars: object): string { /* ... */ } static sanitizeHtml(s: string): string { /* ... */ } static extractUrls(s: string): string[] { /* ... */ } // 50 more methods...} // Half the codebase now depends on StringUtils// It's concrete, stable, and IN THE ZONE OF PAIN// Any change risks breaking unknown callers // ✅ FIX: Separate by concerns and abstract where needed // Pure functions for simple transforms - okay as staticconst stringTransforms = { capitalize: (s: string) => /* ... */, truncate: (s: string, len: number) => /* ... */,}; // Things with complex logic or external concerns become servicesinterface HtmlSanitizer { sanitize(html: string): string;} interface TemplateRenderer { render(template: string, variables: Record<string, unknown>): string;} // Now dependencies are targeted and replaceableAnti-Pattern 3: The Speculative Abstraction
Interfaces created "for flexibility" that have only one implementation and no dependents.
1234567891011121314151617181920212223242526272829303132333435
// ❌ ANTI-PATTERN: Abstraction nobody uses interface IUserValidator { validate(user: User): ValidationResult;} class UserValidator implements IUserValidator { validate(user: User): ValidationResult { // Only implementation, ever }} // The interface adds nothing:// - Only one implementation exists// - No other implementations are planned// - Interface just mirrors the class// - In the Zone of Uselessness // ✅ FIX: Don't abstract prematurely class UserValidator { validate(user: User): ValidationResult { // Just use the class directly }} // Later, IF you need multiple validation strategies:interface ValidationStrategy { validate(user: User): ValidationResult;} class StrictUserValidator implements ValidationStrategy { /* ... */ }class LenientUserValidator implements ValidationStrategy { /* ... */ } // Now the abstraction serves a purposeApply YAGNI (You Aren't Gonna Need It) to abstractions too. Create interfaces when you have a concrete need: multiple implementations, testing requirements, or clear boundary definition. Don't create them 'just in case.' Unnecessary abstractions are worse than missing ones—they obscure the code without providing benefit.
How do you know if your system has proper stability structure? Measurement and monitoring help identify problems before they become painful.
Metrics to Track:
1. Instability (I) per Package:
For each package/module, calculate:
I = Ce / (Ca + Ce)
Ca = incoming dependencies (other packages that depend on this one)
Ce = outgoing dependencies (packages this one depends on)
Expected values:
2. Abstractness (A) per Package:
A = number of abstract types / total types
Expected values:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
// Example stability analysis output interface PackageMetrics { name: string; abstractness: number; // A: 0.0 to 1.0 instability: number; // I: 0.0 to 1.0 distanceFromMain: number; // D = |A + I - 1| dependsOn: string[]; dependedOnBy: string[];} const analysisResult: PackageMetrics[] = [ { name: 'domain/entities', abstractness: 0.1, // Mostly concrete domain objects instability: 0.08, // Few outgoing deps, many incoming distanceFromMain: 0.02, // ✓ Near main sequence (expected for low-A stable) dependsOn: ['domain/value-objects'], dependedOnBy: ['domain/services', 'adapters/postgres', 'api/http'], }, { name: 'domain/ports', abstractness: 1.0, // All interfaces instability: 0.0, // No outgoing deps, many incoming distanceFromMain: 0.0, // ✓ Perfect on main sequence dependsOn: [], dependedOnBy: ['domain/services', 'adapters/stripe', 'adapters/sendgrid'], }, { name: 'adapters/stripe', abstractness: 0.0, // All concrete instability: 1.0, // Many outgoing, no incoming distanceFromMain: 0.0, // ✓ Perfect on main sequence dependsOn: ['domain/ports', 'stripe-sdk'], dependedOnBy: [], }, { name: 'shared/utilities', abstractness: 0.0, // All concrete instability: 0.2, // Many incoming, few outgoing distanceFromMain: 0.8, // ⚠️ Zone of Pain! dependsOn: ['logging'], dependedOnBy: ['domain/services', 'adapters/stripe', 'api/http', '...'], },]; // Flag the problematic packagefor (const pkg of analysisResult) { if (pkg.distanceFromMain > 0.5) { console.warn(`⚠️ ${pkg.name} is ${pkg.abstractness < 0.5 ? 'in Zone of Pain' : 'in Zone of Uselessness'}`); }}Tools for Stability Analysis:
Continuous Monitoring:
Integrate stability checks into your CI/CD pipeline:
Create automated 'fitness functions' that verify architectural rules. Example: 'No package with I < 0.3 should have A < 0.5' (stable packages must be abstract). These tests catch violations early, before wrong abstractions become load-bearing.
The Stable Abstractions Principle completes our understanding of why and how to depend on abstractions. By ensuring that stable components are abstract and volatile components are concrete, we create systems that can evolve gracefully. Let's consolidate the key insights:
The Complete Picture:
With this page, we've completed our exploration of Depending on Abstractions:
Together, these concepts form the foundation of the Dependency Inversion Principle. When you depend on stable, consumer-owned abstractions at appropriate boundaries, you create systems that are testable, maintainable, and able to evolve with changing requirements.
Moving Forward:
In the next module, we'll explore Dependency Injection Fundamentals—the practical mechanism for providing implementations to components that depend on abstractions. Understanding DI transforms these principles from theory into daily practice.
You've mastered the art of depending on abstractions—from understanding what they are, to owning them correctly, to placing them at boundaries, to designing them for stability. These skills are foundational to building large-scale, maintainable software systems. Next, we'll explore how dependency injection makes these abstractions practical.