Loading learning content...
In our previous exploration of interfaces as abstraction mechanisms, we established that interfaces create boundaries between components. We saw how both high-level and low-level modules can depend on interfaces rather than on each other.
But we glossed over a question that turns out to be the most important question in dependency inversion: Who defines and owns the interface?
This question might seem like a minor detail—an implementation afterthought. It is anything but. The answer to this question determines whether your dependency inversion actually works, or whether you've merely added an unnecessary layer of indirection while preserving all the coupling problems you were trying to solve.
Get interface ownership wrong, and you'll have all the complexity of abstraction with none of the benefits. Get it right, and you unlock the full power of the Dependency Inversion Principle.
By the end of this page, you will understand why interface ownership matters, how wrong ownership undermines DIP, and the correct principles for placing interfaces. You'll learn the crucial distinction between consumer-owned and provider-owned abstractions, and how this distinction affects coupling, testing, and system evolution.
When we introduce an interface between two components, we create three entities:
The implementation clearly belongs logically with its provider—a StripePaymentProcessor implementation belongs in the Stripe integration module. But where does the interface belong?
There are two possible answers:
Option A: Interface lives with the implementation (provider-owned)
[High-Level Module] → [Low-Level Module: Interface + Implementation]
Option B: Interface lives with the consumer (consumer-owned)
[High-Level Module: Interface] ← [Low-Level Module: Implementation]
The choice between these options has profound implications that we need to explore in depth.
The Naive Approach That Feels Right But Is Wrong:
Developers often intuitively place interfaces with their implementations. This feels natural because:
But this intuition leads to a critical failure mode. Let's trace through why.
Many developers believe they've applied DIP when they've created an interface that the high-level module uses. But if that interface is defined and owned by the low-level module, the dependency hasn't been inverted—it's been hidden behind a thin abstraction layer that provides false comfort.
Let's examine what happens when interfaces are owned by the provider (the low-level module that implements them).
Scenario: Email Service
You're building an order processing system that needs to send confirmation emails. You decide to depend on an interface rather than a concrete email service. The email team provides this:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
// ❌ PROBLEMATIC: Interface owned by email module (low-level) // ===========================================// email-service module (low-level provider)// ===========================================// File: email-service/EmailService.tsexport interface EmailService { sendEmail( to: string[], cc: string[], bcc: string[], subject: string, htmlBody: string, plainTextBody: string, attachments: EmailAttachment[], headers: EmailHeaders, priority: EmailPriority, trackingPixel: boolean, openTracking: boolean, clickTracking: boolean, ): Promise<SendResult>; getEmailStatus(messageId: string): Promise<EmailStatus>; getDeliveryReport(messageId: string): Promise<DeliveryReport>; getBounces(since: Date): Promise<BounceReport[]>; getComplaints(since: Date): Promise<ComplaintReport[]>;} // File: email-service/GmailEmailService.tsexport class GmailEmailService implements EmailService { // Gmail-specific implementation} // ===========================================// order-processing module (high-level consumer) // ===========================================// File: order-processing/OrderProcessor.tsimport { EmailService, SendResult } from 'email-service'; // ← Dependency! class OrderProcessor { constructor(private emailService: EmailService) {} async processOrder(order: Order): Promise<void> { // ... order processing logic ... // Send confirmation - forced to use email-centric API await this.emailService.sendEmail( [order.customer.email], [], // cc - don't need [], // bcc - don't need 'Order Confirmation', this.buildHtmlEmail(order), this.buildTextEmail(order), [], // attachments - don't need {}, // headers - don't need 'normal', // priority - don't care false, // tracking - don't care false, // open tracking - don't care false, // click tracking - don't care ); }}Why This Is Broken:
At first glance, this looks like dependency inversion—we're depending on an interface, not a concrete class. But examine the reality:
1. The import statement reveals the truth:
import { EmailService } from 'email-service'
The high-level module (order-processing) has a compile-time dependency on the low-level module (email-service). If email-service changes, order-processing must be recompiled. If email-service is unavailable, order-processing cannot be built.
2. The interface is designed for the provider, not the consumer:
Look at all those parameters the OrderProcessor doesn't need (cc, bcc, attachments, tracking options). The interface exposes email-specific concepts (tracking pixels, bounces, complaints) that have nothing to do with order confirmation.
3. The consumer cannot use a non-email alternative:
What if you want to send order confirmations via SMS or push notification? The interface assumes email as the transport. You'd need to create a fake "email" wrapper around SMS—a clear design smell.
4. Changes to email-service cascade to consumers:
When the email team adds a new parameter (say, scheduling options), all consumers must update their calls, even if they don't use the feature.
When interfaces are owned by providers, you haven't inverted any dependencies—you've merely added a level of indirection. The high-level module still depends on the low-level module (to get the interface definition), and is still designed around the low-level module's concepts. This is arguably worse than no interface at all, because it provides an illusion of decoupling.
Now let's see how consumer-owned abstractions properly implement the Dependency Inversion Principle.
Scenario: The Same Order Processing System
Instead of using the email service's interface, the order processing module defines its own interface representing what it needs:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788
// ✅ CORRECT: Interface owned by order-processing module (high-level) // ===========================================// order-processing module (high-level consumer)// ===========================================// File: order-processing/ports/OrderNotifier.ts// This interface is OWNED by order-processing, not email-service export interface OrderNotifier { /** * Sends an order confirmation to the customer. * The implementation decides HOW (email, SMS, push, etc.) */ sendOrderConfirmation(order: Order): Promise<NotificationResult>; /** * Notifies the customer that their order has shipped. */ sendShippingNotification(order: Order, tracking: TrackingInfo): Promise<NotificationResult>; /** * Notifies the customer of order cancellation. */ sendCancellationNotification(order: Order, reason: string): Promise<NotificationResult>;} // File: order-processing/OrderProcessor.ts// NO import from email-service! Interface is local. class OrderProcessor { constructor(private orderNotifier: OrderNotifier) {} async processOrder(order: Order): Promise<void> { // ... order processing logic ... // Clean, high-level call focused on WHAT, not HOW await this.orderNotifier.sendOrderConfirmation(order); }} // ===========================================// email-adapter module (low-level, implements the interface)// ===========================================// File: email-adapter/EmailOrderNotifier.tsimport { OrderNotifier, Order, TrackingInfo, NotificationResult } from 'order-processing';import { GmailClient } from 'gmail-sdk'; export class EmailOrderNotifier implements OrderNotifier { constructor(private gmailClient: GmailClient) {} async sendOrderConfirmation(order: Order): Promise<NotificationResult> { // This adapter handles all the email-specific details const html = this.buildConfirmationHtml(order); const text = this.buildConfirmationText(order); const result = await this.gmailClient.send({ to: order.customer.email, subject: `Order Confirmation #${order.id}`, html, text, // All email-specific options handled here, // invisible to the order-processing module }); return { success: result.sent, messageId: result.id }; } // ... other notification methods} // ===========================================// sms-adapter module (alternative implementation)// ===========================================// File: sms-adapter/SmsOrderNotifier.tsimport { OrderNotifier } from 'order-processing';import { TwilioClient } from 'twilio-sdk'; export class SmsOrderNotifier implements OrderNotifier { constructor(private twilioClient: TwilioClient) {} async sendOrderConfirmation(order: Order): Promise<NotificationResult> { const message = `Order #${order.id} confirmed! Total: ${order.total}`; await this.twilioClient.sendSms(order.customer.phone, message); return { success: true }; } // ... other notification methods}Why This Works:
1. The dependency direction is truly inverted:
[order-processing: OrderProcessor + OrderNotifier interface]
↑
[email-adapter: EmailOrderNotifier]
The low-level module (email-adapter) now depends on the high-level module (order-processing) to get the interface. The arrow has flipped!
2. The interface is designed for the consumer:
The OrderNotifier interface speaks the language of orders—sendOrderConfirmation, sendShippingNotification. It doesn't mention email, SMTP, HTML, or tracking pixels. The consumer gets exactly what it needs.
3. Implementations are freely substitutable:
Email, SMS, push notification, Slack—any transport that can implement OrderNotifier works. The consumer doesn't know or care.
4. Changes to implementations don't affect consumers:
The email service can add scheduling, analytics, or any other feature. The adapter handles them; OrderProcessor is unaffected.
5. The consumer is independently testable:
12345678910111213141516171819202122232425
// Testing is trivial because the interface is focused and consumer-owned class MockOrderNotifier implements OrderNotifier { public confirmationsSent: Order[] = []; async sendOrderConfirmation(order: Order): Promise<NotificationResult> { this.confirmationsSent.push(order); return { success: true }; } // ... other methods} describe('OrderProcessor', () => { it('sends confirmation after successful order', async () => { const mockNotifier = new MockOrderNotifier(); const processor = new OrderProcessor(mockNotifier); await processor.processOrder(testOrder); expect(mockNotifier.confirmationsSent).toContainEqual(testOrder); });}); // No email-specific mocking needed. The test is focused on order logic.Interfaces should be owned by the module that uses them, not the module that implements them. This ensures the interface serves the consumer's needs and that the dependency arrow truly points from low-level to high-level.
The concept of interface ownership is formalized in Hexagonal Architecture (also known as Ports and Adapters), introduced by Alistair Cockburn. This architecture provides a clear model for understanding where interfaces belong.
The Hexagonal Model:
Imagine your application as a hexagon. At the center is your domain logic—the business rules and policies that define your application's purpose. This is the high-level module.
Around the edge of the hexagon are ports—interfaces that define how the outside world interacts with your application. There are two types:
Driving Ports (Primary) — Used by external actors to interact with the application (e.g., OrderService interface called by HTTP handlers)
Driven Ports (Secondary) — Used by the application to interact with external systems (e.g., OrderRepository, PaymentGateway, NotificationSender)
Outside the hexagon are adapters—implementations that connect ports to actual infrastructure (databases, APIs, message queues, etc.).
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
// Hexagonal Architecture in practice // ===========================================// CORE DOMAIN (center of the hexagon)// =========================================== // Driven Ports - owned by the domain, implemented by infrastructure// These are the interfaces we've been discussing interface OrderRepository { // Driven Port save(order: Order): Promise<Order>; findById(id: OrderId): Promise<Order | null>;} interface PaymentGateway { // Driven Port charge(amount: Money, method: PaymentMethod): Promise<ChargeResult>;} interface InventoryService { // Driven Port reserve(items: OrderItem[]): Promise<Reservation>; release(reservationId: string): Promise<void>;} // Domain Service - uses ports, contains business logicclass OrderProcessor { constructor( private orderRepo: OrderRepository, // Uses driven port private paymentGateway: PaymentGateway, private inventoryService: InventoryService, ) {} async createOrder(request: CreateOrderRequest): Promise<Order> { // Pure business logic - no infrastructure details const reservation = await this.inventoryService.reserve(request.items); const payment = await this.paymentGateway.charge(request.total, request.paymentMethod); const order = Order.create(request, reservation.id, payment.chargeId); return this.orderRepo.save(order); }} // Driving Port - how external actors interact with the domaininterface OrderApplicationService { // Driving Port createOrder(command: CreateOrderCommand): Promise<OrderResult>; cancelOrder(command: CancelOrderCommand): Promise<CancellationResult>;} // ===========================================// ADAPTERS (outside the hexagon)// =========================================== // Driven Adapters - implement driven portsclass PostgresOrderRepository implements OrderRepository { // SQL-specific implementation} class StripePaymentGateway implements PaymentGateway { // Stripe-specific implementation} class HttpInventoryService implements InventoryService { // HTTP client to inventory microservice} // Driving Adapters - use driving portsclass HttpOrderController { constructor(private orderService: OrderApplicationService) {} // HTTP-specific handling, delegates to domain async handleCreateOrder(req: HttpRequest): Promise<HttpResponse> { const command = this.parseRequest(req); const result = await this.orderService.createOrder(command); return this.buildResponse(result); }}The Key Insight:
In Hexagonal Architecture, all ports (interfaces) are owned by the domain. The domain doesn't know about PostgreSQL, Stripe, or HTTP—it knows about OrderRepository, PaymentGateway, and OrderApplicationService. These are defined in domain terms.
Adapters live outside the domain and depend on the domain's ports. This means:
The Dependency Rule:
In Hexagonal Architecture, dependencies always point inward—from adapters toward the domain. Adapters depend on ports; ports are owned by the domain. This is the Dependency Inversion Principle expressed architecturally.
Hexagonal Architecture makes interface ownership explicit through the concept of ports. Ports are always defined by the application core (high-level), not by the infrastructure (low-level). This reinforces that abstractions should be owned by their consumers.
Understanding interface ownership is one thing; implementing it correctly in your codebase structure is another. Let's examine how to organize packages and modules to reinforce correct ownership.
The Wrong Structure (Provider-Owned):
1234567891011121314
# ❌ PROBLEMATIC: Interface lives with implementation project/├── email-service/│ ├── EmailService.ts # Interface defined here│ ├── GmailEmailService.ts # Implementation│ └── index.ts # Exports both│├── order-processing/│ ├── OrderProcessor.ts # Imports from email-service│ └── index.ts│└── package.json # order-processing depends on email-service (wrong direction)The Correct Structure (Consumer-Owned):
12345678910111213141516171819202122232425
# ✅ CORRECT: Interface lives with consumer project/├── order-processing/│ ├── domain/│ │ ├── Order.ts # Domain entities│ │ └── OrderProcessor.ts # Uses port│ ││ ├── ports/│ │ ├── OrderNotifier.ts # Interface - OWNED by order-processing│ │ └── OrderRepository.ts # Interface - OWNED by order-processing│ ││ └── index.ts│├── email-adapter/│ ├── EmailOrderNotifier.ts # Implements OrderNotifier from order-processing│ └── index.ts # Imports from order-processing (correct direction)│├── postgres-adapter/│ ├── PostgresOrderRepository.ts # Implements OrderRepository│ └── index.ts # Imports from order-processing│└── package.json # email-adapter and postgres-adapter depend on order-processing # order-processing has NO dependencies on adaptersCommon Folder Organization Patterns:
ports/ directory in domain moduleadapters/ directory for implementations1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
# Comprehensive project structure with correct ownership project/├── src/│ ├── domain/ # Core business logic│ │ ├── entities/│ │ │ ├── Order.ts│ │ │ ├── Customer.ts│ │ │ └── Product.ts│ │ ││ │ ├── services/│ │ │ ├── OrderProcessor.ts│ │ │ └── PricingCalculator.ts│ │ ││ │ └── ports/ # Consumer-owned interfaces│ │ ├── repositories/│ │ │ ├── OrderRepository.ts│ │ │ └── ProductRepository.ts│ │ ││ │ ├── gateways/│ │ │ ├── PaymentGateway.ts│ │ │ └── ShippingGateway.ts│ │ ││ │ └── notifiers/│ │ └── OrderNotifier.ts│ ││ ├── adapters/ # Infrastructure implementations│ │ ├── persistence/│ │ │ ├── postgres/│ │ │ │ ├── PostgresOrderRepository.ts│ │ │ │ └── PostgresProductRepository.ts│ │ │ └── redis/│ │ │ └── RedisCacheRepository.ts│ │ ││ │ ├── payment/│ │ │ ├── StripePaymentGateway.ts│ │ │ └── PayPalPaymentGateway.ts│ │ ││ │ └── notification/│ │ ├── EmailOrderNotifier.ts│ │ └── SmsOrderNotifier.ts│ ││ └── api/ # Entry points (driving adapters)│ ├── http/│ │ └── OrderController.ts│ └── graphql/│ └── OrderResolver.ts│└── tests/ ├── unit/ │ └── domain/ # Unit tests use mock implementations │ └── OrderProcessor.test.ts │ └── integration/ └── adapters/ # Integration tests use real implementations └── PostgresOrderRepository.test.tsUse tools like dependency-cruiser (Node.js), deptrac (PHP), or architectural fitness functions to enforce that adapters depend on domain and never the reverse. Automated validation prevents accidental introduction of wrong-direction dependencies.
A natural question arises: What happens when multiple high-level modules need the same abstraction? Do we duplicate interfaces?
The Shared Kernel Pattern:
When multiple domain modules need the same abstraction, you have several options:
Option 1: Duplicate Focused Interfaces
If different consumers have different needs, they should define their own interfaces even if there's overlap. This maintains consumer-centric design.
12345678910111213141516
// Order module needs specific notification capability// File: order-processing/ports/OrderNotifier.tsinterface OrderNotifier { notifyOrderConfirmation(order: Order): Promise<void>; notifyOrderShipped(order: Order, tracking: TrackingInfo): Promise<void>;} // Marketing module has different needs// File: marketing/ports/CampaignNotifier.tsinterface CampaignNotifier { sendPromotion(campaign: Campaign, customers: Customer[]): Promise<BatchResult>; sendNewsletter(newsletter: Newsletter, subscribers: Subscriber[]): Promise<BatchResult>;} // Both might be implemented by the same EmailService underneath,// but each consumer has its own tailored interfaceOption 2: Extract to Shared Kernel
When multiple modules genuinely need the exact same abstraction (same methods, same semantics), extract the interface to a shared module that both depend on. This shared module should be:
1234567891011121314151617181920212223242526272829
// Shared abstractions for genuinely common concepts// File: shared-kernel/ports/AuditLogger.ts /** * Shared interface for audit logging. * Used by multiple modules that need consistent audit trails. * Changes to this interface require coordination across all consumers. */export interface AuditLogger { logAction( actor: ActorId, action: AuditAction, resource: ResourceId, details: AuditDetails, ): Promise<void>; getAuditTrail( resource: ResourceId, filter: AuditFilter, ): Promise<AuditEntry[]>;} // Multiple modules depend on shared-kernel// order-processing/// └── AuditService using AuditLogger// inventory/// └── StockAuditor using AuditLogger // payment/// └── TransactionAuditor using AuditLoggerGuidelines for Shared Abstractions:
Over-sharing creates coupling between unrelated modules. If changing an interface requires coordinating multiple teams, you've replaced implementation coupling with interface coupling. When in doubt, duplicate. It's easier to consolidate later than to untangle premature shared abstractions.
In many real-world scenarios, you'll work with third-party libraries or services that have their own interfaces. You can't modify these external interfaces, so how do you achieve consumer-owned abstractions?
The answer is the Adapter Pattern—a wrapper that translates between your interface and the external one.
The Problem:
You're using a payment service SDK that exposes StripeClient. Your domain shouldn't depend on Stripe's interface directly (that would be provider-owned).
The Solution:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
// ===========================================// DOMAIN - defines what it needs// =========================================== // Your domain's interface - consumer-ownedinterface PaymentGateway { charge(amount: Money, method: PaymentMethodDetails): Promise<ChargeResult>; refund(chargeId: ChargeId, amount?: Money): Promise<RefundResult>; getCharge(chargeId: ChargeId): Promise<ChargeInfo>;} // Domain entities - in your termsinterface ChargeResult { success: boolean; chargeId: ChargeId; authorizationCode?: string; error?: PaymentError;} // ===========================================// ADAPTER - translates between interfaces// =========================================== import Stripe from 'stripe'; // Third-party SDK class StripePaymentGatewayAdapter implements PaymentGateway { constructor(private stripeClient: Stripe) {} async charge(amount: Money, method: PaymentMethodDetails): Promise<ChargeResult> { try { // Translate from domain terms to Stripe terms const stripeCharge = await this.stripeClient.charges.create({ amount: this.toStripeAmount(amount), currency: amount.currency.toLowerCase(), source: this.toStripeSource(method), description: method.description, }); // Translate from Stripe response to domain terms return { success: stripeCharge.status === 'succeeded', chargeId: new ChargeId(stripeCharge.id), authorizationCode: stripeCharge.authorization_code, }; } catch (error) { return this.handleStripeError(error); } } async refund(chargeId: ChargeId, amount?: Money): Promise<RefundResult> { // Similar translation logic } async getCharge(chargeId: ChargeId): Promise<ChargeInfo> { // Similar translation logic } // Private translation methods private toStripeAmount(money: Money): number { // Convert Money to Stripe's cents-based integer return Math.round(money.amount * 100); } private toStripeSource(method: PaymentMethodDetails): Stripe.SourceCreateParams { // Convert domain payment method to Stripe source } private handleStripeError(error: unknown): ChargeResult { // Translate Stripe errors to domain errors }} // ===========================================// USAGE// =========================================== // Domain service uses domain interfaceclass PaymentService { constructor(private paymentGateway: PaymentGateway) {} async processPayment(order: Order): Promise<PaymentOutcome> { // Speaks in domain terms only const result = await this.paymentGateway.charge( order.total, order.paymentMethod, ); if (!result.success) { throw new PaymentFailedException(result.error); } return PaymentOutcome.successful(result.chargeId); }} // Composition root wires everything togetherconst stripeClient = new Stripe(config.stripeKey);const paymentGateway = new StripePaymentGatewayAdapter(stripeClient);const paymentService = new PaymentService(paymentGateway);The Adapter's Responsibilities:
Benefits of the Adapter Approach:
In Domain-Driven Design, this adapter layer is called the Anti-Corruption Layer (ACL). It 'protects' your domain from being 'corrupted' by external concepts. The ACL ensures that external systems don't dictate your domain's terminology, structure, or behavior.
Interface ownership is not a detail—it is the mechanism that determines whether dependency inversion actually works. Let's consolidate the key insights:
The Simple Test:
To check if you've done interface ownership correctly, ask:
"If we delete the implementation module entirely, can the consumer module still compile?"
If yes, you have correct ownership—the consumer owns its interface and has no dependency on the implementation. If no, the interface is still owned by the provider, and you haven't truly inverted the dependency.
What's Next:
Now that we understand how to define and own interfaces correctly, we'll explore where they should appear in a system. The next page examines Abstractions at Boundaries—the principle that interfaces should mark the seams between architectural layers and components, creating explicit contracts at every point where components interact.
You now understand why interface ownership matters and how to implement it correctly. You've learned that consumer-owned abstractions are the key to true dependency inversion, while provider-owned abstractions merely add indirection without decoupling. Next, we'll explore where these abstractions should appear in your system architecture.