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.