Why this matters
Robert Martin's most confrontational claim: the database is a detail. Business rules don't care whether data is stored in MySQL, MongoDB, a flat file, or in memory. The Repository pattern abstracts this — the business logic calls repo.save(order) and the concrete implementation decides how. Swap MySQL for PostgreSQL by rewriting one class.
The web is a detail. HTTP is one delivery mechanism. The same Use Cases that serve a REST API could also serve a CLI, a message queue consumer, or a batch job. If your Use Case imports the web framework, it cannot be triggered from a message queue without rewriting it. A Use Case that accepts a plain DTO as input doesn't care who called it.
Frameworks are tools — don't marry them. The sign of a framework marriage: replacing the web framework requires rewriting business logic. A well-architected system lets you swap the framework by rewriting only a thin adapter layer. The business logic is untouched because it never knew the framework existed. You should be able to describe your entire business logic without mentioning a single framework, database, or protocol.
The problem
Business logic written in Django/Express — the framework is so deeply embedded that replacing it means rewriting the entire application, not just the adapter layer.
Bad
# views.py — business logic written IN Django
from django.http import JsonResponse
from django.views import View
from .models import Product # Django ORM model used as domain entity
class CheckoutView(View):
def post(self, request):
cart_items = request.session.get("cart", []) # Django session
total = 0
for item in cart_items:
product = Product.objects.get(pk=item["id"]) # Django ORM
if product.stock < item["qty"]:
return JsonResponse({"error": f"{product.name} out of stock"}, status=422)
total += product.price * item["qty"]
if total > 100:
total *= 0.9 # business rule tangled in view
return JsonResponse({"total": str(total)})
# Move to FastAPI: rewrite views, models, forms, session handling.
# Business rules must be found and extracted from Django code.
# The framework has colonized every layer.
// Business logic written IN Express
import express, { Request, Response } from "express";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const app = express();
app.post("/checkout", async (req: Request, res: Response) => {
const { cartItems } = req.body;
let total = 0;
for (const item of cartItems) {
const product = await prisma.product.findUnique({ where: { id: item.id } });
if (!product || product.stock < item.qty) {
return res.status(422).json({ error: \`\${product?.name} out of stock\` });
}
total += product.price * item.qty;
}
if (total > 100) total *= 0.9; // business rule in handler
res.json({ total: total.toFixed(2) });
});
// Switch to NestJS: rewrite the entire handler. Business rules are buried inside.
The solution
Domain entities and use cases are pure Python/TypeScript with zero framework imports. A thin adapter layer translates between HTTP and the Use Case. Switching the framework means rewriting only the adapter.
Good
# Domain and use case — pure Python, no Django
from dataclasses import dataclass
from decimal import Decimal
@dataclass
class CartItem:
name: str
price: Decimal
qty: int
stock: int
def subtotal(self) -> Decimal: return self.price * self.qty
class CheckoutUseCase:
THRESHOLD = Decimal("100.00")
DISCOUNT = Decimal("0.90")
def execute(self, items: list[CartItem]) -> dict:
for item in items:
if item.stock < item.qty:
raise ValueError(f"{item.name} out of stock")
total = sum(item.subtotal() for item in items)
if total > self.THRESHOLD:
total *= self.DISCOUNT
return {"total": total}
# Django adapter — the ONLY file that knows about Django
class CheckoutView(View):
def post(self, request):
repo = DjangoCartRepository()
items = repo.get_cart_items(request.session.get("cart", []))
try:
result = CheckoutUseCase().execute(items)
return JsonResponse(result)
except ValueError as e:
return JsonResponse({"error": str(e)}, status=422)
# Switching to FastAPI: rewrite only CheckoutView — CheckoutUseCase is unchanged.
// Domain and use case — pure TypeScript, no Express, no Prisma
export interface CartItem { name: string; price: number; qty: number; stock: number; }
export class CheckoutUseCase {
private readonly THRESHOLD = 100;
private readonly DISCOUNT = 0.9;
execute(items: CartItem[]): { total: number } {
for (const item of items) {
if (item.stock < item.qty) throw new Error(\`\${item.name} out of stock\`);
}
let total = items.reduce((sum, item) => sum + item.price * item.qty, 0);
if (total > this.THRESHOLD) total *= this.DISCOUNT;
return { total };
}
}
// Express adapter — the ONLY file that knows about Express and Prisma
import express from "express";
import { PrismaCartRepository } from "./PrismaCartRepository";
import { CheckoutUseCase } from "./CheckoutUseCase";
const app = express();
const useCase = new CheckoutUseCase();
app.post("/checkout", async (req, res) => {
try {
const items = await new PrismaCartRepository().getCartItems(req.body.cartItems);
const result = useCase.execute(items);
res.json({ total: result.total.toFixed(2) });
} catch (e: any) {
res.status(422).json({ error: e.message });
}
});
// Switch to NestJS: rewrite only the adapter. CheckoutUseCase is completely untouched.
Key takeaway
You should be able to describe your entire business logic to a non-technical person without mentioning a single framework, database, or protocol. The database, web layer, and frameworks are delivery mechanisms — valuable tools, but details. Keep them at the edges. Business rules in the center, delivery mechanisms at the boundary.