Skip to main content
Clean Architecture 70 XP · 7 min

Decoupling Modes: Source, Deployment, and Service

Decoupling can happen at source code, deployment, or service level — good architecture lets you start as a monolith and evolve into services if boundaries are well-defined.

Showing
Ad (728×90)

Why this matters

There are three modes of decoupling, each with different costs and benefits. Source-level decoupling means shared source code in a single deployable — teams can work in separate modules but still step on each other at deploy time. Deployment-level decoupling means separate compiled artifacts (packages, JARs, DLLs) that can be independently deployed — faster to release one component without rebuilding others. Service-level decoupling means separate processes communicating over a network — the strongest isolation, but also the highest cost (latency, network failures, data serialization).

The key insight is that you should not jump straight to service-level decoupling. A monolith with well-drawn boundaries — using abstract ports and adapters — is architecturally ready to be split into services when the business need arises. A poorly drawn distributed system is the worst outcome: you pay the network cost but get none of the isolation benefits.

The problem

Orders and inventory tightly coupled via direct imports and a shared database session — impossible to split without major surgery, impossible to scale independently.

Bad

# orders/service.py
from inventory.manager import InventoryManager  # direct coupling

class OrderService:
    def __init__(self, db):
        self.db        = db
        self.inventory = InventoryManager(db)   # shared DB session!

    def place(self, user_id, item_id, qty):
        if not self.inventory.check_and_decrement(item_id, qty):
            raise ValueError("Out of stock")
        return self.db.insert("orders", {"user_id": user_id, "item_id": item_id})

# inventory/manager.py
class InventoryManager:
    def __init__(self, db): self.db = db
    def check_and_decrement(self, item_id, qty):
        item = self.db.find("inventory", item_id)
        if item["stock"] < qty: return False
        self.db.update("inventory", item_id, {"stock": item["stock"] - qty})
        return True

# Shared DB session: inventory can never move to its own database.
# Direct import: extracting to a microservice requires rewriting both classes.
// orders/OrderService.ts
import { InventoryManager } from "../inventory/InventoryManager"; // direct coupling
import { SharedDatabase } from "../shared/Database";

export class OrderService {
  private inventory: InventoryManager;
  constructor(private db: SharedDatabase) {
    this.inventory = new InventoryManager(db);  // hard-wired, shared DB
  }

  async place(userId: string, itemId: string, qty: number): Promise {
    const ok = await this.inventory.checkAndDecrement(itemId, qty);
    if (!ok) throw new Error("Out of stock");
    return this.db.insert("orders", { userId, itemId, qty });
  }
}
// To split into microservices: rewrite both classes, handle distributed transactions.
// The boundary was never drawn — now there's nothing to promote to a service.

The solution

An abstract port defines the boundary — today a function call (source-level), tomorrow a REST call (service-level). OrderService never changes.

Good

from abc import ABC, abstractmethod

class InventoryPort(ABC):       # defined on the orders side
    @abstractmethod
    def reserve(self, item_id: str, qty: int) -> bool: ...

class OrderService:
    def __init__(self, order_repo, inventory: InventoryPort):
        self._orders    = order_repo
        self._inventory = inventory

    def place(self, user_id: str, item_id: str, qty: int) -> str:
        if not self._inventory.reserve(item_id, qty):
            raise ValueError("Out of stock")
        return self._orders.create(user_id, item_id, qty)

# Today — in-process adapter (source-level decoupling)
class InProcessInventoryAdapter(InventoryPort):
    def __init__(self, svc): self._svc = svc
    def reserve(self, item_id, qty): return self._svc.reserve(item_id, qty)

# Tomorrow — HTTP adapter (service-level decoupling, OrderService unchanged)
class HttpInventoryAdapter(InventoryPort):
    def __init__(self, url): self._url = url
    def reserve(self, item_id, qty):
        import httpx
        r = httpx.post(f"{self._url}/reserve", json={"item_id": item_id, "qty": qty})
        return r.json()["reserved"]
// orders/ports/InventoryPort.ts — interface owned by the orders domain
export interface InventoryPort {
  reserve(itemId: string, qty: number): Promise;
}

// orders/OrderService.ts — depends only on the port
export class OrderService {
  constructor(
    private orders: OrderRepository,
    private inventory: InventoryPort,
  ) {}

  async place(userId: string, itemId: string, qty: number): Promise {
    const ok = await this.inventory.reserve(itemId, qty);
    if (!ok) throw new Error("Out of stock");
    return this.orders.create(userId, itemId, qty);
  }
}

// Today — source-level decoupling (monolith)
export class InProcessInventoryAdapter implements InventoryPort {
  constructor(private svc: InventoryService) {}
  reserve(itemId: string, qty: number) { return this.svc.reserve(itemId, qty); }
}

// Tomorrow — service-level decoupling (microservice, OrderService unchanged)
export class HttpInventoryAdapter implements InventoryPort {
  constructor(private baseUrl: string) {}
  async reserve(itemId: string, qty: number) {
    const r = await fetch(`${this.baseUrl}/reserve`, {
      method: "POST", body: JSON.stringify({ itemId, qty }),
      headers: { "Content-Type": "application/json" },
    });
    return (await r.json()).reserved as boolean;
  }
}

Key takeaway

The best microservices architecture starts as a well-structured monolith. A poorly structured monolith becomes a distributed monolith — you pay the network tax but get none of the isolation benefits. Draw the boundaries first; choose the decoupling mode later.

Done with this lesson?

Mark it complete to earn XP and track your progress.