Why this matters
When a high-level component calls a low-level component, both the flow of control and the source code dependency naturally point the same direction — downward. This is the default, and it's wrong architecturally. It means changing the low-level component forces a recompile of the high-level one. It means you can't test the high-level component without the low-level one present.
The fix is to invert the dependency using an interface. The high-level component defines an interface that describes what it needs. The low-level component implements that interface. At runtime, control still flows from high to low. But in source code, the low-level module now depends on the high-level interface — pointing upward. This is Dependency Inversion at the architectural boundary level. It is how boundaries work in Clean Architecture, ports and adapters, and hexagonal architecture alike.
The problem
PaymentProcessor (high-level) directly imports StripeClient (low-level) — control flow and source dependency both point downward. No inversion. Tightly coupled to Stripe.
Bad
# stripe_client.py (low-level detail)
import stripe
class StripeClient:
def charge(self, card_token: str, amount: float) -> dict:
return stripe.PaymentIntent.create(
amount=int(amount * 100), currency="usd",
payment_method=card_token, confirm=True)
# payment_processor.py (high-level policy)
from stripe_client import StripeClient # HIGH-LEVEL imports LOW-LEVEL — wrong!
class PaymentProcessor:
def __init__(self):
self._stripe = StripeClient()
def process(self, order_id: str, card_token: str, amount: float) -> bool:
result = self._stripe.charge(card_token, amount)
return result["status"] == "succeeded"
# Flow of control: PaymentProcessor → StripeClient (downward)
# Source dependency: PaymentProcessor → StripeClient (same direction — no inversion)
# To test: need a real Stripe account or complex patching.
// stripe-client.ts (low-level)
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_KEY!);
export class StripeClient {
async charge(cardToken: string, amount: number): Promise {
const intent = await stripe.paymentIntents.create({
amount: Math.round(amount * 100), currency: "usd",
payment_method: cardToken, confirm: true,
});
return intent.status === "succeeded";
}
}
// payment-processor.ts (high-level)
import { StripeClient } from "./stripe-client"; // imports the detail — wrong!
export class PaymentProcessor {
private stripe = new StripeClient();
async process(orderId: string, cardToken: string, amount: number): Promise {
return this.stripe.charge(cardToken, amount);
}
}
// Control and dependency both flow downward. No boundary. No inversion.
The solution
PaymentGateway interface lives in the high-level module. StripeGateway (low-level) depends on it. Control flows down at runtime; dependency points up in source code.
Good
from abc import ABC, abstractmethod
# payment_gateway.py — interface defined in the HIGH-level module
class PaymentGateway(ABC):
@abstractmethod
def charge(self, card_token: str, amount: float) -> bool: ...
# payment_processor.py (high-level) — depends on its own abstraction
class PaymentProcessor:
def __init__(self, gateway: PaymentGateway):
self._gateway = gateway
def process(self, order_id: str, card_token: str, amount: float) -> bool:
return self._gateway.charge(card_token, amount)
# stripe_gateway.py (low-level) — depends on the HIGH-level interface
import stripe
from payment_gateway import PaymentGateway # LOW-LEVEL imports HIGH-LEVEL!
class StripeGateway(PaymentGateway):
def charge(self, card_token: str, amount: float) -> bool:
result = stripe.PaymentIntent.create(
amount=int(amount * 100), currency="usd",
payment_method=card_token, confirm=True)
return result["status"] == "succeeded"
# Flow of control: PaymentProcessor → StripeGateway (runtime, downward)
# Source dependency: StripeGateway → PaymentGateway ← PaymentProcessor (inverted!)
# Test PaymentProcessor with a stub — no Stripe needed.
// domain/payment-gateway.ts — interface lives with the HIGH-level policy
export interface PaymentGateway {
charge(cardToken: string, amount: number): Promise;
}
// domain/PaymentProcessor.ts (high-level) — imports only its own interface
import type { PaymentGateway } from "./payment-gateway";
export class PaymentProcessor {
constructor(private gateway: PaymentGateway) {}
async process(orderId: string, cardToken: string, amount: number): Promise {
return this.gateway.charge(cardToken, amount);
}
}
// infra/StripeGateway.ts (low-level) — implements the HIGH-level interface
import Stripe from "stripe";
import type { PaymentGateway } from "../domain/payment-gateway"; // points UPWARD
export class StripeGateway implements PaymentGateway {
private client = new Stripe(process.env.STRIPE_KEY!);
async charge(cardToken: string, amount: number): Promise {
const intent = await this.client.paymentIntents.create({
amount: Math.round(amount * 100), currency: "usd",
payment_method: cardToken, confirm: true,
});
return intent.status === "succeeded";
}
}
// Source dependencies: StripeGateway → PaymentGateway ← PaymentProcessor
// Runtime control: PaymentProcessor → StripeGateway
// The interface makes the dependency flow opposite to the control flow.
Key takeaway
The interface belongs to the HIGH-level component, not the low-level one. The low-level component depends on the high-level component's interface — not the other way around. This is what makes crossing a boundary safe: runtime control can flow in any direction while source dependencies always point toward the stable policy.