Why this matters
Different parts of a system change for entirely different reasons. The UI changes when the design team has new ideas or a client requests a new view format. The application business rules (use-case logic) change when new workflows are required. The domain business rules (entity logic) change when fundamental business policies change — the rarest kind. The database changes when a new technology is adopted or the schema is optimized. These are four distinct axes of change.
Horizontal layers enforce the Single Responsibility Principle at the architectural scale: each layer has exactly one reason to change. Dependencies point inward — the UI layer knows about the application layer, which knows about the domain layer, which knows nothing about anyone. A UI change can never break a domain rule. A schema migration can never require touching business logic.
The problem
A single views.py mixing all four layers — HTTP parsing, use-case orchestration, discount logic, persistence, and email — changing the UI requires touching the database and the domain rule.
Bad
from flask import request, jsonify
from db import session
from models import Order, User, Item
import smtplib
def place_order():
data = request.get_json()
user_id, item_id, qty = data["user_id"], data["item_id"], data["qty"]
item = session.query(Item).get(item_id)
if item.stock < qty:
return jsonify({"error": "Out of stock"}), 400
user = session.query(User).get(user_id)
price = item.price * qty
if user.is_premium:
price *= 0.9 # domain rule buried inside HTTP handler
order = Order(user_id=user_id, item_id=item_id, qty=qty, total=price)
session.add(order)
session.commit()
smtp = smtplib.SMTP("localhost")
smtp.sendmail("shop@co.com", user.email, f"Total: {price}")
return jsonify({"order_id": order.id, "total": price})
import { Request, Response } from "express";
import { AppDataSource } from "./data-source";
import { sendEmail } from "./mailer";
export async function placeOrder(req: Request, res: Response) {
const { userId, itemId, qty } = req.body;
const item = await AppDataSource.getRepository(ItemEntity)
.findOne({ where: { id: itemId } });
if (!item || item.stock < qty) { res.status(400).json({ error: "Out of stock" }); return; }
const user = await AppDataSource.getRepository(UserEntity)
.findOne({ where: { id: userId } });
let total = item.price * qty;
if (user?.isPremium) total *= 0.9; // domain rule here
const order = AppDataSource.getRepository(OrderEntity)
.create({ userId, itemId, qty, total });
await AppDataSource.getRepository(OrderEntity).save(order);
await sendEmail(user!.email, `Order total: ${total}`);
res.json({ orderId: order.id, total });
}
The solution
Four layers — each ignorant of the one above it. The UI calls the use case, the use case calls the domain entity, the entity knows nothing about anyone.
Good
# domain/order.py — innermost, most stable (no imports from above)
from decimal import Decimal
class Order:
def __init__(self, item_price: Decimal, qty: int, is_premium: bool):
subtotal = item_price * qty
self.total = subtotal * (Decimal("0.9") if is_premium else Decimal("1"))
# application/place_order.py — use-case orchestration
class PlaceOrderUseCase:
def __init__(self, item_repo, order_repo, notifier):
self._items, self._orders, self._notifier = item_repo, order_repo, notifier
def execute(self, user_id, item_id, qty, is_premium) -> dict:
item = self._items.get(item_id)
if item.stock < qty:
raise ValueError("Out of stock")
order = Order(item.price, qty, is_premium)
saved = self._orders.save(order, user_id)
self._notifier.send(user_id, saved.total)
return {"order_id": saved.id, "total": str(saved.total)}
# api/routes.py — HTTP boundary (outermost, changes for UI reasons only)
# @app.post("/orders")
# def place_order():
# data = request.get_json()
# result = PlaceOrderUseCase(sql_items, sql_orders, email_notifier).execute(**data)
# return jsonify(result)
// domain/Order.ts — innermost (no imports from any layer above)
export class Order {
readonly total: number;
constructor(itemPrice: number, qty: number, isPremium: boolean) {
const subtotal = itemPrice * qty;
this.total = isPremium ? subtotal * 0.9 : subtotal;
}
}
// application/PlaceOrderUseCase.ts — orchestrates domain + repositories
export class PlaceOrderUseCase {
constructor(
private items: ItemRepository,
private orders: OrderRepository,
private notifier: Notifier,
) {}
async execute(userId: string, itemId: string, qty: number, isPremium: boolean) {
const item = await this.items.get(itemId);
if (!item || item.stock < qty) throw new Error("Out of stock");
const order = new Order(item.price, qty, isPremium);
const saved = await this.orders.save(order, userId);
await this.notifier.send(userId, saved.total);
return { orderId: saved.id, total: saved.total };
}
}
// api/orderRoutes.ts — UI layer (knows about HTTP, knows about use case)
// router.post("/orders", async (req, res) => {
// const result = await new PlaceOrderUseCase(pgItems, pgOrders, emailNotifier)
// .execute(req.body.userId, req.body.itemId, req.body.qty, req.user.isPremium);
// res.json(result);
// });
Key takeaway
When a UI change requires touching the database layer, your layers are not actually layers — they are a big ball of mud. True layers change independently: a new API response format never touches a discount calculation, and a schema migration never touches an HTTP route.