In This Guide
Clean architecture, popularized by Robert C. Martin (Uncle Bob), organizes code so that business rules are independent of frameworks, databases, and UI. The core idea: your order processing logic shouldn't change because you switched from Express to Fastify, or from PostgreSQL to MongoDB. We've applied this to a client project where the original codebase had Sequelize ORM calls scattered across 40 Express route handlers. Changing the database would have meant rewriting the entire app. After restructuring to clean architecture, we swapped PostgreSQL for DynamoDB in 3 days — only the repository implementations changed. Zero business logic touched.
1. Why Clean Architecture Matters
Most codebases couple business logic to infrastructure from day one. Your "create order" logic lives inside an Express route handler, calls Sequelize directly, and throws HTTP status codes. This means:
- You can't test business rules without spinning up Express and a database
- Switching ORMs means rewriting business logic
- Adding a CLI or message consumer means duplicating logic
- New developers must understand Express, Sequelize, AND business rules simultaneously
Clean architecture inverts this. Business rules know nothing about Express or Sequelize. They're pure functions and classes that take inputs and produce outputs. Express is just one way to deliver those inputs — you could swap it for a CLI, a queue consumer, or a gRPC server.
| Coupled Code | Clean Architecture |
|---|---|
| Business logic in route handlers | Business logic in use case classes |
| Direct ORM calls everywhere | Repository interfaces, ORM in implementations |
| Can't test without database | Unit test with in-memory fakes |
| Framework lock-in | Framework is a detail — swappable |
| Change database = rewrite everything | Change database = new repository implementation |
2. The Dependency Rule
The fundamental rule: dependencies point inward. Outer layers can depend on inner layers, never the reverse. Your entities don't know about use cases. Your use cases don't know about controllers. Your controllers don't know about the web framework's internals (ideally).
The Dependency Rule (arrows = "depends on"):
┌─────────────────────────────────────────────┐
│ Frameworks & Drivers │
│ Express, PostgreSQL, Redis, AWS SDK │
│ ┌─────────────────────────────────────┐ │
│ │ Interface Adapters │ │
│ │ Controllers, Repositories, Presenters│ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ Application Layer │ │ │
│ │ │ Use Cases / Interactors │ │ │
│ │ │ ┌───────────────────┐ │ │ │
│ │ │ │ Entities │ │ │ │
│ │ │ │ Business rules │ │ │ │
│ │ │ │ Value objects │ │ │ │
│ │ │ └───────────────────┘ │ │ │
│ │ └─────────────────────────────┘ │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
Dependencies flow INWARD only. Inner layers never import from outer layers.
3. The Four Layers
| Layer | Contains | Knows About | Changes When |
|---|---|---|---|
| Entities | Business rules, domain objects, value objects | Nothing else | Business rules change |
| Use Cases | Application-specific business rules, orchestration | Entities + port interfaces | App workflow changes |
| Interface Adapters | Controllers, presenters, repository implementations | Use cases + entities | API format, DB schema change |
| Frameworks | Express, Sequelize, AWS SDK, React | Everything above | Framework upgrade/swap |
4. Use Cases — Where Business Logic Lives
A use case (also called interactor) represents one thing the user can do: "Place Order," "Cancel Subscription," "Generate Invoice." It orchestrates entities and calls repository interfaces — no HTTP, no SQL, no framework code.
// Use Case: Place Order
// No Express, no Sequelize, no HTTP status codes
interface PlaceOrderInput {
customerId: string;
items: Array<{ productId: string; quantity: number }>;
shippingAddress: Address;
}
interface PlaceOrderOutput {
orderId: string;
total: number;
estimatedDelivery: Date;
}
class PlaceOrderUseCase {
constructor(
private orderRepo: OrderRepository, // interface, not implementation
private productRepo: ProductRepository,
private inventoryService: InventoryService,
private pricingService: PricingService
) {}
async execute(input: PlaceOrderInput): Promise<PlaceOrderOutput> {
// 1. Validate products exist and get prices
const products = await Promise.all(
input.items.map(i => this.productRepo.findById(i.productId))
);
if (products.some(p => p === null)) {
throw new ProductNotFoundError();
}
// 2. Check inventory
for (const item of input.items) {
const available = await this.inventoryService.checkStock(item.productId);
if (available < item.quantity) {
throw new InsufficientStockError(item.productId, available);
}
}
// 3. Calculate pricing (business rule in entity)
const order = Order.create(input.customerId, input.shippingAddress);
for (const item of input.items) {
const product = products.find(p => p.id === item.productId)!;
const price = this.pricingService.getPrice(product, item.quantity);
order.addItem(product, item.quantity, price);
}
// 4. Persist
await this.orderRepo.save(order);
return {
orderId: order.id,
total: order.total().amount,
estimatedDelivery: order.estimatedDelivery()
};
}
}
Notice: this use case has zero imports from Express, Sequelize, or any framework. You could call it from an HTTP controller, a CLI command, a queue consumer, or a test — it doesn't care.
5. Dependency Inversion in Practice
The use case defines what it needs through interfaces (ports). The infrastructure layer provides implementations (adapters). This is the Dependency Inversion Principle — high-level modules don't depend on low-level modules, both depend on abstractions.
// PORT: defined in the domain layer (what we need)
interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
findByCustomer(customerId: string): Promise<Order[]>;
}
// ADAPTER 1: PostgreSQL (production)
class PostgresOrderRepository implements OrderRepository {
constructor(private pool: Pool) {}
async save(order: Order): Promise<void> {
await this.pool.query(
'INSERT INTO orders (id, customer_id, status, total) VALUES ($1,$2,$3,$4)
ON CONFLICT (id) DO UPDATE SET status=$3, total=$4',
[order.id, order.customerId, order.status, order.total().amount]
);
}
// ... other methods
}
// ADAPTER 2: In-memory (testing)
class InMemoryOrderRepository implements OrderRepository {
private orders: Map<string, Order> = new Map();
async save(order: Order): Promise<void> {
this.orders.set(order.id, order);
}
async findById(id: string): Promise<Order | null> {
return this.orders.get(id) || null;
}
// ... other methods
}
// ADAPTER 3: DynamoDB (if you switch)
class DynamoOrderRepository implements OrderRepository {
// Same interface, different persistence
}
6. Testing Without Frameworks
This is where clean architecture pays for itself. Business logic tests run in milliseconds — no database, no HTTP server, no Docker containers.
// Testing the PlaceOrder use case — pure unit test
describe('PlaceOrderUseCase', () => {
let useCase: PlaceOrderUseCase;
let orderRepo: InMemoryOrderRepository;
let productRepo: InMemoryProductRepository;
beforeEach(() => {
orderRepo = new InMemoryOrderRepository();
productRepo = new InMemoryProductRepository();
productRepo.add(Product.create('p1', 'Widget', Money.of(29.99, 'USD')));
useCase = new PlaceOrderUseCase(
orderRepo,
productRepo,
new FakeInventoryService({ p1: 100 }),
new SimplePricingService()
);
});
it('creates an order with correct total', async () => {
const result = await useCase.execute({
customerId: 'cust_1',
items: [{ productId: 'p1', quantity: 3 }],
shippingAddress: Address.create('Mumbai', '400001', 'IN')
});
expect(result.total).toBe(89.97);
expect(result.orderId).toBeDefined();
const saved = await orderRepo.findById(result.orderId);
expect(saved).not.toBeNull();
expect(saved!.items).toHaveLength(1);
});
it('rejects order for out-of-stock items', async () => {
await expect(useCase.execute({
customerId: 'cust_1',
items: [{ productId: 'p1', quantity: 999 }],
shippingAddress: Address.create('Mumbai', '400001', 'IN')
})).rejects.toThrow(InsufficientStockError);
});
});
// These tests run in < 10ms. No database. No HTTP server. No Docker.
| Test Type | What It Tests | Speed | Dependencies |
|---|---|---|---|
| Entity unit tests | Business rules (Order.addItem validation) | < 1ms | None |
| Use case tests | Application workflows | < 10ms | In-memory fakes |
| Repository integration | Database mapping correctness | 100-500ms | Real database (Testcontainers) |
| Controller/API tests | HTTP routing, serialization | 50-200ms | HTTP framework + in-memory fakes |
7. Project Structure That Works
src/
├── domain/ ← Entities + Value Objects (no deps)
│ ├── order/
│ │ ├── Order.ts ← Aggregate root
│ │ ├── OrderItem.ts
│ │ ├── OrderStatus.ts
│ │ └── OrderRepository.ts ← Interface (port)
│ ├── shared/
│ │ ├── Money.ts
│ │ └── Address.ts
│ └── product/
│ ├── Product.ts
│ └── ProductRepository.ts
│
├── application/ ← Use Cases (depends on domain only)
│ ├── PlaceOrder.ts
│ ├── CancelOrder.ts
│ ├── GetOrderDetails.ts
│ └── dto/ ← Input/Output data structures
│ ├── PlaceOrderInput.ts
│ └── PlaceOrderOutput.ts
│
├── infrastructure/ ← Adapters (depends on everything)
│ ├── persistence/
│ │ ├── PostgresOrderRepo.ts ← Implements OrderRepository
│ │ └── PostgresProductRepo.ts
│ ├── http/
│ │ ├── OrderController.ts ← Express routes
│ │ └── middleware/
│ ├── messaging/
│ │ └── KafkaOrderConsumer.ts
│ └── external/
│ └── StripePaymentAdapter.ts
│
├── config/
│ └── container.ts ← Dependency injection wiring
└── main.ts ← Composition root
The key rule: domain/ imports nothing from application/ or infrastructure/. application/ imports from domain/ only. infrastructure/ imports from both. The composition root (main.ts) wires everything together.
8. Pragmatic Clean Architecture
Purist clean architecture can create excessive boilerplate. Here's where we bend the rules in practice:
| Purist Rule | Pragmatic Shortcut | When to Shortcut |
|---|---|---|
| Every interaction needs a use case class | Simple CRUD reads can skip the use case layer | GET endpoints with no business logic |
| Separate input/output DTOs for every use case | Reuse entity types when the shape matches | Small projects, internal APIs |
| Framework code must be in outermost layer | Use ORM decorators on entities for simple apps | Team knows the ORM, unlikely to switch |
| Full dependency injection container | Constructor injection with manual wiring | Under 20 services to wire |
9. Frequently Asked Questions
How is clean architecture different from hexagonal architecture?
They're essentially the same idea with different terminology. Hexagonal architecture (ports and adapters) uses "ports" where clean architecture uses "interfaces" and "adapters" where clean architecture uses "implementations." The core principle is identical: business logic at the center, infrastructure at the edges, dependencies pointing inward. We use the terms interchangeably on our projects.
Is clean architecture too much boilerplate for small projects?
For a true MVP or prototype, yes — it's overhead. For anything that will be maintained for more than 6 months, the investment pays off. The key is applying it selectively: use clean architecture for your core business domain and simpler patterns for supporting features. A 3-person startup doesn't need four layers for a settings page, but they'll appreciate it for their payment processing logic.
Which DI framework should I use with clean architecture?
For TypeScript/Node.js: tsyringe or InversifyJS work well, but manual constructor injection is fine for small projects. For .NET: the built-in DI container is excellent. For Java: Spring's DI is the standard. Don't over-engineer DI — the goal is constructor injection so you can pass different implementations (production vs test), not to build an enterprise service locator. We use manual wiring for projects under 30 dependencies.
How does clean architecture work with serverless (Lambda)?
Perfectly. The Lambda handler is just another adapter — it parses the event, calls the use case, and formats the response. Your domain and application layers stay the same whether the trigger is API Gateway, SQS, or EventBridge. The cold start concern is valid: heavy DI containers add latency. We use lightweight composition (manual wiring) in serverless to keep cold starts under 200ms.
We've refactored messy codebases into clean architecture and built greenfield projects with it from day one. Our approach is pragmatic: use the layers where complexity demands it, simplify where it doesn't. The best architecture is the one your team can maintain.