Skip to main content

Sign in to CleanKata

Track your progress, earn XP, and unlock every lesson.

By signing in you agree to our Terms of Use and Privacy Policy.

Clean Architecture80 XP8 min

Anatomy of a Boundary: Crossing Boundaries

When crossing an architectural boundary, source code dependencies must point opposite to the flow of control — polymorphism enforces this regardless of who calls whom.

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.

🔧 Some exercises may still have errors. If something seems wrong, use the Feedback button (bottom-right of the page) to report it — it helps us fix it fast.

Hint: 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.

✗ Your version

Anatomy of a Boundary: Crossing Boundaries — CleanKata — CleanKata