Skip to main content
Clean Architecture 70 XP · 7 min

Use Case Decoupling

Use cases are vertical slices that cut through all layers — decoupling them lets each evolve independently without one use case's changes colliding with another's.

Showing
Ad (728×90)

Why this matters

Horizontal layers (UI, use cases, domain, database) separate concerns by rate of change. But use cases provide a second dimension: vertical slices that separate concerns by intent. Adding an order is completely different from deleting one. Approving an order involves different rules, different actors, and different risk than shipping one. Each is a discrete business scenario with its own path from input to output.

When all these scenarios live as methods on a single controller class, adding a field to AddOrder risks breaking DeleteOrder tests. Two teams working on different use cases keep producing merge conflicts in the same file. Decoupled use cases — each a class in its own file — means each team owns its vertical strip independently. Changes are isolated. Tests are focused. Teams don't collide.

The problem

A monolithic OrderController with five use cases as methods — all scenarios coupled together, changing one risks breaking all others.

Bad

class OrderController:
    def __init__(self, db, mailer, payments):
        self.db, self.mailer, self.payments = db, mailer, payments

    def add(self, user_id, item_id, qty):
        # 40 lines: stock check, total, payment, notification...
        order = {"user_id": user_id, "item_id": item_id, "qty": qty}
        self.db.insert("orders", order)
        self.payments.charge(user_id, self._total(item_id, qty))
        self.mailer.send(user_id, "Order confirmed")

    def delete(self, order_id):
        order = self.db.find("orders", order_id)
        if order["status"] != "pending":
            raise ValueError("Cannot delete")
        self.db.delete("orders", order_id)

    def approve(self, order_id, manager_id): ...
    def cancel(self, order_id, reason): ...
    def ship(self, order_id, tracking): ...

# Team A edits add(). Team B edits approve(). Merge conflict guaranteed.
# A bug in approve() fails tests for delete() and add().
export class OrderController {
  constructor(
    private db: Database,
    private mailer: Mailer,
    private payments: PaymentGateway,
  ) {}

  async add(userId: string, itemId: string, qty: number) {
    const total = await this.calculateTotal(itemId, qty);
    const id    = await this.db.insert("orders", { userId, itemId, qty, total });
    await this.payments.charge(userId, total);
    await this.mailer.send(userId, `Order ${id} confirmed`);
    return id;
  }
  async delete(orderId: string) { /* ... */ }
  async approve(orderId: string, managerId: string) { /* ... */ }
  async cancel(orderId: string, reason: string) { /* ... */ }
  async ship(orderId: string, tracking: string) { /* ... */ }
}
// 5 use cases. 1 file. Entire class must be understood to change anything.

The solution

Each use case is its own class — independently testable, independently owned, independently changeable.

Good

# application/add_order.py
class AddOrderUseCase:
    def __init__(self, order_repo, stock_service, payment_gateway, notifier):
        self._orders   = order_repo
        self._stock    = stock_service
        self._payments = payment_gateway
        self._notifier = notifier

    def execute(self, user_id: str, item_id: str, qty: int) -> str:
        self._stock.reserve(item_id, qty)
        total    = self._stock.price(item_id) * qty
        order_id = self._orders.create(user_id, item_id, qty, total)
        self._payments.charge(user_id, total)
        self._notifier.send(user_id, order_id)
        return order_id

# application/delete_order.py
class DeleteOrderUseCase:
    def __init__(self, order_repo):
        self._orders = order_repo

    def execute(self, order_id: str) -> None:
        order = self._orders.get(order_id)
        if order.status != "pending":
            raise ValueError("Only pending orders can be deleted")
        self._orders.delete(order_id)

# application/approve_order.py — independent of the above, testable alone.
// application/AddOrderUseCase.ts
export class AddOrderUseCase {
  constructor(
    private orders:   OrderRepository,
    private stock:    StockService,
    private payments: PaymentGateway,
    private notifier: Notifier,
  ) {}

  async execute(userId: string, itemId: string, qty: number): Promise {
    await this.stock.reserve(itemId, qty);
    const price   = await this.stock.getPrice(itemId);
    const total   = price * qty;
    const orderId = await this.orders.create(userId, itemId, qty, total);
    await this.payments.charge(userId, total);
    await this.notifier.send(userId, orderId);
    return orderId;
  }
}

// application/DeleteOrderUseCase.ts
export class DeleteOrderUseCase {
  constructor(private orders: OrderRepository) {}

  async execute(orderId: string): Promise {
    const order = await this.orders.get(orderId);
    if (order.status !== "pending") throw new Error("Only pending orders can be deleted");
    await this.orders.delete(orderId);
  }
}
// Team A works on AddOrderUseCase. Team B works on DeleteOrderUseCase.
// No merge conflicts. No shared mutable state. Each test file is minimal.

Key takeaway

A use case should be a noun, not a method on a god object. Each use case owns its own path from input to output — it can be changed, tested, owned, and deployed without touching any other use case.

Done with this lesson?

Mark it complete to earn XP and track your progress.