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.