Clean Architecture: Uncle Bob's Dependency Rule

Every architecture pattern you've seen so far — layered, microservices, event-driven — answers the question: how do I organize my system? Clean Architecture answers a different question: how do I protect my business logic from everything else?
Introduced by Robert C. Martin ("Uncle Bob") in his 2012 blog post and later expanded in his 2017 book Clean Architecture: A Craftsman's Guide to Software Structure and Design, Clean Architecture distills decades of architectural thinking into one powerful rule: dependencies must point inward.
This single rule — the Dependency Rule — is what separates codebases that age gracefully from codebases that become unmaintainable. It's why some projects can swap databases, replace web frameworks, or switch from REST to GraphQL without rewriting business logic, while others require months of refactoring for the simplest infrastructure change.
Series: Software Architecture Patterns Roadmap
Previous: Hexagonal Architecture (Ports & Adapters)
Next: Domain-Driven Design (DDD)
What You'll Learn
✅ Understand the Dependency Rule and why it matters
✅ Know each concentric layer: Entities, Use Cases, Interface Adapters, Frameworks
✅ Implement Clean Architecture in TypeScript and Java (Spring Boot)
✅ Compare Clean Architecture with Hexagonal and Onion architectures
✅ Recognize when Clean Architecture is overkill and when shortcuts are acceptable
✅ Avoid common mistakes: over-engineering, anemic use cases, leaky abstractions
Prerequisites
- Familiar with Layered (N-Tier) Architecture
- Comfortable with SOLID Principles, especially Dependency Inversion
- Understanding of Dependency Injection
The Problem: Why Layered Architecture Isn't Enough
In Layered Architecture, we learned to separate concerns into layers: presentation, business logic, and data access. This is a huge improvement over spaghetti code. But layered architecture has a fundamental flaw:
Dependencies point downward — toward the database.
This means your business logic depends on your database. Need to change from PostgreSQL to MongoDB? Your service layer breaks. Want to test business rules without a database? You can't — the service depends on concrete repository implementations.
Here's what this looks like in practice:
// Layered Architecture — Business layer depends on data layer
class OrderService {
// ❌ Direct dependency on concrete implementation
private repository = new PostgresOrderRepository();
async createOrder(items: CartItem[]): Promise<Order> {
// Business logic mixed with infrastructure concerns
const order = new Order(items);
if (order.total() > 10000) {
// ❌ Business rule depends on how we send emails
const emailService = new SmtpEmailService();
await emailService.sendHighValueAlert(order);
}
// ❌ Tied to PostgreSQL
return this.repository.save(order);
}
}The business rule ("high-value orders trigger an alert") is trapped inside infrastructure concerns (SMTP, PostgreSQL). You can't test it without a database. You can't reuse it in a CLI tool. You can't change the email provider without modifying business logic.
Clean Architecture fixes this by inverting the dependency direction.
The Dependency Rule
Source code dependencies must point inward, toward higher-level policies.
This is the entire philosophy of Clean Architecture in one sentence. Inner layers define interfaces. Outer layers implement them. Nothing in an inner layer knows anything about an outer layer — not a class name, not a function, not even a data format.
The key insight: your business logic doesn't depend on your web framework. Your web framework depends on your business logic. If Express.js or Spring Boot disappears tomorrow, your business rules survive untouched.
Why This Matters
| Scenario | Without Dependency Rule | With Dependency Rule |
|---|---|---|
| Switch databases | Rewrite service layer | Implement new repository adapter |
| Add CLI interface | Duplicate business logic | Plug in new controller |
| Test business rules | Need running database | Pure unit tests, no I/O |
| Replace email provider | Modify service layer | Swap email gateway implementation |
| Upgrade framework | Risk breaking business logic | Framework is outer layer, isolated |
The Four Concentric Layers
Clean Architecture organizes code into four concentric circles, from innermost to outermost:
Let's explore each layer with a concrete example: an e-commerce order system.
Layer 1: Entities (Enterprise Business Rules)
Entities represent the core business objects and the rules that govern them. These are the most stable, most reusable parts of your system. An Entity could be used across multiple applications in the same enterprise.
Key characteristics:
- Pure business logic — no frameworks, no I/O, no dependencies
- Encapsulate enterprise-wide business rules
- The least likely to change when something external changes
- Can be plain classes, objects, or even functions with data structures
// TypeScript — Entity layer
// Zero dependencies. Pure business logic.
class Money {
constructor(
readonly amount: number,
readonly currency: string = "USD"
) {
if (amount < 0) throw new Error("Amount cannot be negative");
}
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error(`Cannot add ${this.currency} and ${other.currency}`);
}
return new Money(this.amount + other.amount, this.currency);
}
multiply(factor: number): Money {
return new Money(Math.round(this.amount * factor * 100) / 100, this.currency);
}
}
class OrderItem {
constructor(
readonly productId: string,
readonly productName: string,
readonly quantity: number,
readonly unitPrice: Money
) {
if (quantity <= 0) throw new Error("Quantity must be positive");
}
subtotal(): Money {
return this.unitPrice.multiply(this.quantity);
}
}
class Order {
private _items: OrderItem[] = [];
private _status: OrderStatus = "draft";
constructor(
readonly id: string,
readonly customerId: string,
readonly createdAt: Date = new Date()
) {}
// ✅ Business rule: order must have items
addItem(item: OrderItem): void {
if (this._status !== "draft") {
throw new Error("Cannot modify a confirmed order");
}
this._items.push(item);
}
// ✅ Business rule: calculate total
total(): Money {
return this._items.reduce(
(sum, item) => sum.add(item.subtotal()),
new Money(0)
);
}
// ✅ Business rule: high-value threshold
isHighValue(): boolean {
return this.total().amount > 10000;
}
// ✅ Business rule: can only confirm with items
confirm(): void {
if (this._items.length === 0) {
throw new Error("Cannot confirm an empty order");
}
this._status = "confirmed";
}
get items(): readonly OrderItem[] {
return this._items;
}
get status(): OrderStatus {
return this._status;
}
}
type OrderStatus = "draft" | "confirmed" | "shipped" | "delivered" | "cancelled";// Java — Entity layer
public class Money {
private final BigDecimal amount;
private final String currency;
public Money(BigDecimal amount, String currency) {
if (amount.compareTo(BigDecimal.ZERO) < 0)
throw new IllegalArgumentException("Amount cannot be negative");
this.amount = amount;
this.currency = currency;
}
public Money add(Money other) {
if (!this.currency.equals(other.currency))
throw new IllegalArgumentException("Currency mismatch");
return new Money(this.amount.add(other.amount), this.currency);
}
public Money multiply(int factor) {
return new Money(this.amount.multiply(BigDecimal.valueOf(factor)), this.currency);
}
// getters...
}
public class Order {
private final String id;
private final String customerId;
private final List<OrderItem> items = new ArrayList<>();
private OrderStatus status = OrderStatus.DRAFT;
public Order(String id, String customerId) {
this.id = id;
this.customerId = customerId;
}
// ✅ Business rule: only draft orders can be modified
public void addItem(OrderItem item) {
if (status != OrderStatus.DRAFT)
throw new IllegalStateException("Cannot modify a confirmed order");
items.add(item);
}
// ✅ Business rule: total calculation
public Money total() {
return items.stream()
.map(OrderItem::subtotal)
.reduce(new Money(BigDecimal.ZERO, "USD"), Money::add);
}
// ✅ Business rule: high-value threshold
public boolean isHighValue() {
return total().getAmount().compareTo(new BigDecimal("10000")) > 0;
}
// ✅ Business rule: confirm requires items
public void confirm() {
if (items.isEmpty())
throw new IllegalStateException("Cannot confirm an empty order");
this.status = OrderStatus.CONFIRMED;
}
}Notice: No @Entity, no @Column, no database annotations. Entities are pure domain objects. They don't know they'll be stored in PostgreSQL or MongoDB.
Layer 2: Use Cases (Application Business Rules)
Use cases contain application-specific business rules. They orchestrate the flow of data between entities and direct them to perform their enterprise-wide business rules. A use case is a single action that the application performs.
Key characteristics:
- Orchestrate entities to achieve a specific application goal
- Define interfaces (ports) for external dependencies
- Each use case represents one user action or system operation
- Depend only on entities — never on frameworks or infrastructure
// TypeScript — Use Case layer
// ✅ Interfaces defined by the use case layer
// Outer layers must implement these
interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
nextId(): string;
}
interface NotificationService {
notifyHighValueOrder(order: Order): Promise<void>;
}
interface InventoryChecker {
checkAvailability(productId: string, quantity: number): Promise<boolean>;
}
// ✅ Input boundary — what the use case receives
interface CreateOrderInput {
customerId: string;
items: Array<{
productId: string;
productName: string;
quantity: number;
unitPrice: number;
}>;
}
// ✅ Output boundary — what the use case returns
interface CreateOrderOutput {
orderId: string;
total: number;
status: string;
isHighValue: boolean;
}
// ✅ The use case — pure application logic
class CreateOrderUseCase {
constructor(
private orderRepo: OrderRepository,
private inventory: InventoryChecker,
private notifier: NotificationService
) {}
async execute(input: CreateOrderInput): Promise<CreateOrderOutput> {
// 1. Create entity
const order = new Order(
this.orderRepo.nextId(),
input.customerId
);
// 2. Check inventory and add items
for (const item of input.items) {
const available = await this.inventory.checkAvailability(
item.productId,
item.quantity
);
if (!available) {
throw new Error(`Product ${item.productId} is out of stock`);
}
order.addItem(new OrderItem(
item.productId,
item.productName,
item.quantity,
new Money(item.unitPrice)
));
}
// 3. Confirm order (entity enforces its own rules)
order.confirm();
// 4. Persist
await this.orderRepo.save(order);
// 5. Side effects
if (order.isHighValue()) {
await this.notifier.notifyHighValueOrder(order);
}
// 6. Return output DTO (not the entity itself)
return {
orderId: order.id,
total: order.total().amount,
status: order.status,
isHighValue: order.isHighValue(),
};
}
}// Java — Use Case layer
// ✅ Input DTO
public record CreateOrderInput(
String customerId,
List<OrderItemInput> items
) {}
public record OrderItemInput(
String productId,
String productName,
int quantity,
BigDecimal unitPrice
) {}
// ✅ Output DTO
public record CreateOrderOutput(
String orderId,
BigDecimal total,
String status,
boolean isHighValue
) {}
// ✅ Port interfaces — defined by use case, implemented by adapters
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(String id);
String nextId();
}
public interface NotificationService {
void notifyHighValueOrder(Order order);
}
public interface InventoryChecker {
boolean checkAvailability(String productId, int quantity);
}
// ✅ The use case
public class CreateOrderUseCase {
private final OrderRepository orderRepo;
private final InventoryChecker inventory;
private final NotificationService notifier;
public CreateOrderUseCase(
OrderRepository orderRepo,
InventoryChecker inventory,
NotificationService notifier) {
this.orderRepo = orderRepo;
this.inventory = inventory;
this.notifier = notifier;
}
public CreateOrderOutput execute(CreateOrderInput input) {
Order order = new Order(orderRepo.nextId(), input.customerId());
for (OrderItemInput item : input.items()) {
if (!inventory.checkAvailability(item.productId(), item.quantity()))
throw new RuntimeException("Product " + item.productId() + " out of stock");
order.addItem(new OrderItem(
item.productId(), item.productName(),
item.quantity(), new Money(item.unitPrice(), "USD")
));
}
order.confirm();
orderRepo.save(order);
if (order.isHighValue()) {
notifier.notifyHighValueOrder(order);
}
return new CreateOrderOutput(
order.getId(),
order.total().getAmount(),
order.getStatus().name(),
order.isHighValue()
);
}
}The critical pattern: The use case defines interfaces (OrderRepository, NotificationService, InventoryChecker) but never implements them. This is the Dependency Inversion Principle applied at the architectural level.
Layer 3: Interface Adapters (Controllers, Gateways, Presenters)
Interface adapters convert data between the format most convenient for use cases/entities and the format most convenient for external services (web, database, etc.).
Key characteristics:
- Controllers receive HTTP requests and convert them to use case input
- Gateways implement repository interfaces defined by use cases
- Presenters format use case output for the view
- This is where MVC lives — controllers and presenters are adapters
// TypeScript — Interface Adapter: REST Controller
import express from "express";
class OrderController {
constructor(private createOrder: CreateOrderUseCase) {}
// ✅ Converts HTTP request → Use Case input
async handleCreateOrder(req: express.Request, res: express.Response) {
try {
const input: CreateOrderInput = {
customerId: req.body.customerId,
items: req.body.items.map((item: any) => ({
productId: item.productId,
productName: item.productName,
quantity: Number(item.quantity),
unitPrice: Number(item.unitPrice),
})),
};
// ✅ Delegates to use case — no business logic here
const output = await this.createOrder.execute(input);
// ✅ Converts Use Case output → HTTP response
res.status(201).json({
id: output.orderId,
total: output.total,
status: output.status,
highValue: output.isHighValue,
});
} catch (error: any) {
if (error.message.includes("out of stock")) {
res.status(409).json({ error: error.message });
} else {
res.status(500).json({ error: "Internal server error" });
}
}
}
}// TypeScript — Interface Adapter: Database Gateway
import { Pool } from "pg";
class PostgresOrderRepository implements OrderRepository {
constructor(private pool: Pool) {}
async save(order: Order): Promise<void> {
const client = await this.pool.connect();
try {
await client.query("BEGIN");
await client.query(
`INSERT INTO orders (id, customer_id, status, created_at)
VALUES ($1, $2, $3, $4)`,
[order.id, order.customerId, order.status, order.createdAt]
);
for (const item of order.items) {
await client.query(
`INSERT INTO order_items (order_id, product_id, product_name, quantity, unit_price)
VALUES ($1, $2, $3, $4, $5)`,
[order.id, item.productId, item.productName, item.quantity, item.unitPrice.amount]
);
}
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
async findById(id: string): Promise<Order | null> {
const result = await this.pool.query(
"SELECT * FROM orders WHERE id = $1", [id]
);
if (result.rows.length === 0) return null;
// Map database rows back to domain entities
const row = result.rows[0];
const order = new Order(row.id, row.customer_id, row.created_at);
const itemsResult = await this.pool.query(
"SELECT * FROM order_items WHERE order_id = $1", [id]
);
for (const itemRow of itemsResult.rows) {
order.addItem(new OrderItem(
itemRow.product_id,
itemRow.product_name,
itemRow.quantity,
new Money(itemRow.unit_price)
));
}
return order;
}
nextId(): string {
return crypto.randomUUID();
}
}// Java (Spring Boot) — Interface Adapter: REST Controller
@RestController
@RequestMapping("/api/orders")
public class OrderRestController {
private final CreateOrderUseCase createOrder;
public OrderRestController(CreateOrderUseCase createOrder) {
this.createOrder = createOrder;
}
@PostMapping
public ResponseEntity<OrderResponse> create(@RequestBody CreateOrderRequest request) {
// ✅ Convert HTTP request → Use Case input
CreateOrderInput input = new CreateOrderInput(
request.customerId(),
request.items().stream()
.map(i -> new OrderItemInput(
i.productId(), i.productName(),
i.quantity(), i.unitPrice()))
.toList()
);
// ✅ Delegate to use case
CreateOrderOutput output = createOrder.execute(input);
// ✅ Convert output → HTTP response
return ResponseEntity.status(HttpStatus.CREATED)
.body(new OrderResponse(
output.orderId(), output.total(),
output.status(), output.isHighValue()));
}
}// Java (Spring Boot) — Interface Adapter: JPA Gateway
@Repository
public class JpaOrderRepository implements OrderRepository {
private final JpaOrderEntityRepository jpaRepo;
public JpaOrderRepository(JpaOrderEntityRepository jpaRepo) {
this.jpaRepo = jpaRepo;
}
@Override
public void save(Order order) {
// ✅ Convert domain entity → JPA entity (database model)
OrderJpaEntity entity = OrderJpaEntity.fromDomain(order);
jpaRepo.save(entity);
}
@Override
public Optional<Order> findById(String id) {
return jpaRepo.findById(id)
.map(OrderJpaEntity::toDomain); // ✅ Convert JPA entity → domain entity
}
@Override
public String nextId() {
return UUID.randomUUID().toString();
}
}Important distinction: In Clean Architecture, the JPA @Entity is NOT the same as a domain Entity. The JPA entity (OrderJpaEntity) is a database model in the adapter layer. The domain Order is a business object in the entity layer. The gateway maps between them.
Layer 4: Frameworks & Drivers
The outermost layer contains frameworks, tools, and drivers — the details that your business logic should never know about.
Key characteristics:
- Web frameworks (Express, Spring MVC, NestJS)
- Database drivers (pg, TypeORM, JPA/Hibernate)
- External service SDKs (AWS SDK, Stripe SDK)
- UI frameworks (React, Angular)
- This layer is "glue code" — wiring everything together
// TypeScript — Framework layer: Application composition
import express from "express";
import { Pool } from "pg";
// Composition Root — wire dependencies together
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// Create adapters (outer layer implements inner layer interfaces)
const orderRepo = new PostgresOrderRepository(pool);
const inventory = new HttpInventoryChecker(process.env.INVENTORY_API_URL!);
const notifier = new EmailNotificationService(process.env.SMTP_HOST!);
// Create use cases (inject adapters via constructor)
const createOrderUseCase = new CreateOrderUseCase(orderRepo, inventory, notifier);
// Create controllers (inject use cases)
const orderController = new OrderController(createOrderUseCase);
// Wire routes
const app = express();
app.use(express.json());
app.post("/api/orders", (req, res) => orderController.handleCreateOrder(req, res));
app.listen(3000);// Java (Spring Boot) — Framework layer: Spring configuration
@Configuration
public class OrderConfig {
@Bean
public CreateOrderUseCase createOrderUseCase(
OrderRepository orderRepo,
InventoryChecker inventory,
NotificationService notifier) {
return new CreateOrderUseCase(orderRepo, inventory, notifier);
}
// Spring auto-discovers @Repository and @Component adapters
// and injects them into the use case via constructor injection
}The composition root is the only place where all layers know about each other. It wires outer implementations to inner interfaces.
Project Structure
How does this translate to actual directories? Here are two common approaches:
Approach 1: Layer-Based Structure
src/
├── domain/ # Layer 1: Entities
│ ├── Order.ts
│ ├── OrderItem.ts
│ ├── Money.ts
│ └── OrderStatus.ts
│
├── application/ # Layer 2: Use Cases
│ ├── CreateOrderUseCase.ts
│ ├── GetOrderUseCase.ts
│ ├── CancelOrderUseCase.ts
│ └── ports/ # Interfaces for outer layers
│ ├── OrderRepository.ts
│ ├── NotificationService.ts
│ └── InventoryChecker.ts
│
├── adapters/ # Layer 3: Interface Adapters
│ ├── controllers/
│ │ └── OrderController.ts
│ ├── gateways/
│ │ ├── PostgresOrderRepository.ts
│ │ └── HttpInventoryChecker.ts
│ └── presenters/
│ └── OrderPresenter.ts
│
└── infrastructure/ # Layer 4: Frameworks & Drivers
├── web/
│ └── express-app.ts
├── database/
│ └── pg-connection.ts
└── config/
└── environment.tsApproach 2: Feature-Based Structure (Recommended for larger projects)
src/
├── order/
│ ├── domain/
│ │ ├── Order.ts
│ │ ├── OrderItem.ts
│ │ └── Money.ts
│ ├── application/
│ │ ├── CreateOrderUseCase.ts
│ │ ├── GetOrderUseCase.ts
│ │ └── ports/
│ │ ├── OrderRepository.ts
│ │ └── NotificationService.ts
│ └── adapters/
│ ├── OrderController.ts
│ └── PostgresOrderRepository.ts
│
├── inventory/
│ ├── domain/
│ ├── application/
│ └── adapters/
│
├── notification/
│ ├── domain/
│ ├── application/
│ └── adapters/
│
└── infrastructure/ # Shared framework config
├── web/
└── database/Java (Spring Boot) Structure
com.example.shop/
├── order/
│ ├── domain/
│ │ ├── Order.java
│ │ ├── OrderItem.java
│ │ └── Money.java
│ ├── application/
│ │ ├── CreateOrderUseCase.java
│ │ ├── CreateOrderInput.java
│ │ ├── CreateOrderOutput.java
│ │ └── port/
│ │ ├── OrderRepository.java # Interface
│ │ └── NotificationService.java # Interface
│ └── adapter/
│ ├── in/
│ │ └── OrderRestController.java # REST controller
│ └── out/
│ ├── JpaOrderRepository.java # Implements OrderRepository
│ ├── OrderJpaEntity.java # JPA entity (NOT domain entity)
│ └── EmailNotificationService.java
│
├── inventory/
│ ├── domain/
│ ├── application/
│ └── adapter/
│
└── config/
└── OrderConfig.java # Composition rootThe Data Crossing Boundaries Problem
One of the trickiest aspects of Clean Architecture is handling data as it crosses layer boundaries. The Dependency Rule says inner layers can't know about outer layer data formats. So how does data flow from the database to the UI?
The Rule: Use Simple Data Structures at Boundaries
Each boundary has its own data structure:
| Boundary | Data Structure | Purpose |
|---|---|---|
| HTTP → Controller | Request DTO | Web-specific format (camelCase, pagination params) |
| Controller → Use Case | Input DTO | Application-specific format (validated, typed) |
| Use Case ↔ Entity | Domain object | Rich business object with behavior |
| Use Case → Controller | Output DTO | Application result (no entity internals exposed) |
| Controller → HTTP | Response DTO | Web-specific format (JSON, status codes) |
Why so many DTOs? Because each layer has different concerns:
// ❌ Bad: Returning entity directly to HTTP layer
app.post("/api/orders", async (req, res) => {
const order = await createOrderUseCase.execute(req.body);
res.json(order); // Exposes internal entity structure!
});
// ✅ Good: Each layer has its own data format
app.post("/api/orders", async (req, res) => {
// HTTP request → Use Case input
const input: CreateOrderInput = {
customerId: req.body.customer_id,
items: req.body.items,
};
// Use Case returns output DTO
const output = await createOrderUseCase.execute(input);
// Output DTO → HTTP response format
res.status(201).json({
id: output.orderId,
total: `$${output.total.toFixed(2)}`,
status: output.status,
});
});Testing in Clean Architecture
One of the biggest benefits of Clean Architecture is testability. Because dependencies point inward and interfaces separate layers, you can test each layer independently.
Testing Entities (No Mocks Needed)
// Entity tests — pure logic, no dependencies
describe("Order", () => {
it("calculates total correctly", () => {
const order = new Order("1", "customer-1");
order.addItem(new OrderItem("p1", "Widget", 2, new Money(25.00)));
order.addItem(new OrderItem("p2", "Gadget", 1, new Money(50.00)));
expect(order.total().amount).toBe(100.00);
});
it("identifies high-value orders", () => {
const order = new Order("1", "customer-1");
order.addItem(new OrderItem("p1", "Luxury Item", 1, new Money(15000)));
expect(order.isHighValue()).toBe(true);
});
it("prevents confirming empty order", () => {
const order = new Order("1", "customer-1");
expect(() => order.confirm()).toThrow("Cannot confirm an empty order");
});
});Testing Use Cases (Mock Interfaces)
// Use case tests — mock the ports
describe("CreateOrderUseCase", () => {
let useCase: CreateOrderUseCase;
let mockRepo: jest.Mocked<OrderRepository>;
let mockInventory: jest.Mocked<InventoryChecker>;
let mockNotifier: jest.Mocked<NotificationService>;
beforeEach(() => {
mockRepo = {
save: jest.fn(),
findById: jest.fn(),
nextId: jest.fn().mockReturnValue("order-123"),
};
mockInventory = {
checkAvailability: jest.fn().mockResolvedValue(true),
};
mockNotifier = {
notifyHighValueOrder: jest.fn(),
};
useCase = new CreateOrderUseCase(mockRepo, mockInventory, mockNotifier);
});
it("creates order and saves to repository", async () => {
const input: CreateOrderInput = {
customerId: "customer-1",
items: [{
productId: "p1",
productName: "Widget",
quantity: 2,
unitPrice: 25.00,
}],
};
const output = await useCase.execute(input);
expect(output.orderId).toBe("order-123");
expect(output.total).toBe(50.00);
expect(mockRepo.save).toHaveBeenCalledTimes(1);
});
it("notifies on high-value orders", async () => {
const input: CreateOrderInput = {
customerId: "customer-1",
items: [{
productId: "p1",
productName: "Luxury",
quantity: 1,
unitPrice: 15000,
}],
};
await useCase.execute(input);
expect(mockNotifier.notifyHighValueOrder).toHaveBeenCalledTimes(1);
});
it("rejects order when product is out of stock", async () => {
mockInventory.checkAvailability.mockResolvedValue(false);
const input: CreateOrderInput = {
customerId: "customer-1",
items: [{
productId: "p1",
productName: "Widget",
quantity: 1,
unitPrice: 25.00,
}],
};
await expect(useCase.execute(input)).rejects.toThrow("out of stock");
});
});Testing Adapters (Integration Tests)
// Adapter tests — real infrastructure, real database
describe("PostgresOrderRepository", () => {
let repo: PostgresOrderRepository;
let pool: Pool;
beforeAll(async () => {
pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });
repo = new PostgresOrderRepository(pool);
});
afterAll(async () => {
await pool.end();
});
it("persists and retrieves order", async () => {
const order = new Order("test-1", "customer-1");
order.addItem(new OrderItem("p1", "Widget", 2, new Money(25.00)));
order.confirm();
await repo.save(order);
const retrieved = await repo.findById("test-1");
expect(retrieved).not.toBeNull();
expect(retrieved!.total().amount).toBe(50.00);
});
});Test Pyramid in Clean Architecture
| Test Level | What It Tests | Dependencies | Speed |
|---|---|---|---|
| Entity tests | Business rules | None | Very fast |
| Use case tests | Application logic | Mocked interfaces | Fast |
| Adapter tests | Database, HTTP, etc. | Real infrastructure | Slower |
| E2E tests | Full system | Everything running | Slowest |
The power of Clean Architecture: Most tests (entity + use case) are fast, isolated, and don't need infrastructure. Only adapter tests require databases and external services.
Clean Architecture vs Hexagonal vs Onion
Clean Architecture didn't emerge in a vacuum. It builds on two earlier patterns with similar goals:
| Aspect | Hexagonal (2005) | Onion (2008) | Clean (2012) |
|---|---|---|---|
| Creator | Alistair Cockburn | Jeffrey Palermo | Robert C. Martin |
| Core idea | Ports & Adapters | Layers around domain | Dependency Rule |
| Terminology | Ports, Adapters | Domain Model, Domain Services, Application Services | Entities, Use Cases, Interface Adapters |
| Diagram style | Hexagon with ports | Concentric circles | Concentric circles |
| Focus | Symmetry between input and output | Domain model at center | Explicit use cases |
| Boundary crossing | Ports (interfaces) | Interfaces at layer edges | Input/Output boundaries |
They're More Similar Than Different
All three patterns share the same fundamental principles:
- Business logic at the center — protected from external details
- Dependency inversion — inner layers define interfaces, outer layers implement them
- Framework independence — the core doesn't know what framework runs it
- Database independence — business logic doesn't know how data is stored
- Testability — core logic testable without infrastructure
When to Choose Each Label
- Hexagonal: When you want to emphasize the symmetry between driving (input) and driven (output) ports
- Onion: When you want to emphasize domain-centric design and domain services
- Clean: When you want explicit use cases and clear input/output boundaries
In practice, most teams use a hybrid approach — taking the best ideas from all three. The label matters less than the principles.
Pragmatic Clean Architecture
Clean Architecture provides clear principles, but applying them dogmatically leads to over-engineering. Here's how to be pragmatic.
When Clean Architecture Is Worth the Overhead
| Situation | Worth it? | Why |
|---|---|---|
| Complex domain logic | ✅ Yes | Business rules deserve isolation and thorough testing |
| Multiple UIs (web + mobile + CLI) | ✅ Yes | Use cases are reused across interfaces |
| Frequent infrastructure changes | ✅ Yes | Swap databases/providers without touching business logic |
| Long-lived product | ✅ Yes | Clean boundaries pay off as the system grows |
| Simple CRUD app | ❌ No | Too many layers for pass-through operations |
| Prototype / MVP | ❌ No | Speed of development matters more than clean boundaries |
| One-off script or tool | ❌ No | Won't live long enough to benefit |
| Solo developer, small project | ⚠️ Maybe | Depends on expected growth |
Common Over-Engineering Mistakes
1. Use cases that just delegate:
// ❌ Over-engineered: use case adds no value
class GetUserByIdUseCase {
constructor(private repo: UserRepository) {}
async execute(id: string): Promise<User | null> {
return this.repo.findById(id); // Just delegating!
}
}
// ✅ For simple CRUD, it's OK to skip the use case layer
// Call the repository directly from the controller2. Too many DTOs for simple data:
// ❌ Four separate classes for what's essentially the same data
class UserHttpRequest { name: string; email: string; }
class CreateUserInput { name: string; email: string; }
class UserEntity { name: string; email: string; }
class CreateUserOutput { name: string; email: string; }
// ✅ For simple CRUD, fewer DTOs are fine
// Only add DTOs when layers genuinely need different shapes3. Abstracting what doesn't change:
// ❌ Over-abstracted: you'll never swap your date library
interface DateProvider {
now(): Date;
format(date: Date, pattern: string): string;
}
// ✅ Just use Date directly in entities — it's a language primitivePragmatic Shortcuts
-
Skip use cases for simple CRUD — If a use case just delegates to a repository, it's ceremony. Let the controller call the repository directly.
-
Reduce DTOs when shapes are identical — If the input DTO and entity have the same fields, use the entity as input. Add DTOs when shapes diverge.
-
Don't abstract primitives — Dates, strings, and numbers don't need wrapper interfaces. Abstract databases and external services — things that actually change.
-
Start monolithic, extract layers as needed — Begin with a well-structured monolith. Add use case interfaces when you discover a need for testability or reuse.
-
Follow the "rule of three" — The first time you write code, just write it. The second time, notice the duplication. The third time, abstract it.
Common Mistakes and Anti-Patterns
1. Entity Anemia
// ❌ Anemic entity — just a data bag
class Order {
id: string;
customerId: string;
items: OrderItem[];
status: string;
total: number;
}
// Business logic lives in use case (wrong!)
class CreateOrderUseCase {
execute(input: CreateOrderInput) {
const order = new Order();
order.items = input.items;
// ❌ Business rule outside entity
order.total = input.items.reduce((sum, i) => sum + i.price * i.qty, 0);
// ❌ Validation outside entity
if (order.items.length === 0) throw new Error("No items");
order.status = "confirmed";
}
}// ✅ Rich entity — encapsulates business rules
class Order {
private _items: OrderItem[] = [];
private _status: OrderStatus = "draft";
// Business rules belong here
addItem(item: OrderItem): void { /* validation + add */ }
total(): Money { /* calculation */ }
confirm(): void { /* state transition with guards */ }
}
// Use case orchestrates, doesn't contain business rules
class CreateOrderUseCase {
execute(input: CreateOrderInput) {
const order = new Order(this.orderRepo.nextId(), input.customerId);
for (const item of input.items) {
order.addItem(/* ... */); // Entity enforces its own rules
}
order.confirm(); // Entity manages its own state
this.orderRepo.save(order);
}
}2. Framework Leaking Into Inner Layers
// ❌ Entity knows about Express
import { Request } from "express";
class OrderService {
createOrder(req: Request) {
// Entity layer depends on web framework!
}
}
// ❌ Entity knows about TypeORM
import { Entity, Column, PrimaryColumn } from "typeorm";
@Entity()
class Order {
@PrimaryColumn()
id: string;
@Column()
status: string;
}// ✅ Entity is framework-free
class Order {
constructor(readonly id: string, readonly customerId: string) {}
// Pure business logic only
}
// ✅ TypeORM entity lives in adapter layer (separate class)
@Entity("orders")
class OrderOrmEntity {
@PrimaryColumn()
id: string;
@Column()
status: string;
static fromDomain(order: Order): OrderOrmEntity { /* map */ }
toDomain(): Order { /* map */ }
}3. Use Cases Knowing About HTTP
// ❌ Use case returns HTTP-specific data
class CreateOrderUseCase {
async execute(input: CreateOrderInput): Promise<{
statusCode: number; // ❌ HTTP concept
body: string; // ❌ HTTP concept
headers: Record<string, string>; // ❌ HTTP concept
}> {
// ...
}
}
// ✅ Use case returns domain-specific output
class CreateOrderUseCase {
async execute(input: CreateOrderInput): Promise<CreateOrderOutput> {
return {
orderId: order.id,
total: order.total().amount,
status: order.status,
isHighValue: order.isHighValue(),
};
}
}
// Controller decides HTTP status codes and response formatDecision Flowchart
Use this flowchart to decide if Clean Architecture is right for your project:
Quick Reference
Dependency Rule Summary
✅ Inner layers define interfaces (ports)
✅ Outer layers implement interfaces (adapters)
✅ Dependencies always point inward
✅ Data crosses boundaries as simple DTOs
❌ Entities never import framework classes
❌ Use cases never return HTTP responses
❌ Controllers never contain business logic
❌ Repositories never define business rulesLayer Responsibilities
| Layer | Contains | Depends On | Example |
|---|---|---|---|
| Entities | Enterprise business rules | Nothing | Order, Money, OrderItem |
| Use Cases | Application business rules | Entities | CreateOrderUseCase, CancelOrderUseCase |
| Adapters | Interface conversion | Use Cases, Entities | OrderController, PostgresOrderRepository |
| Frameworks | Glue code, configuration | Everything | Express app setup, Spring Boot config |
Clean Architecture Checklist
When reviewing a Clean Architecture codebase, check:
✅ Can you test business rules without a database?
✅ Can you test use cases by mocking repositories?
✅ Do entities have zero imports from frameworks?
✅ Do use cases define their own input/output DTOs?
✅ Are database models separate from domain entities?
✅ Is there a clear composition root that wires everything?
✅ Could you swap the web framework without touching use cases?
Key Takeaways
-
The Dependency Rule is the foundation — dependencies must always point inward, toward higher-level policies. Inner layers define interfaces, outer layers implement them.
-
Four concentric layers — Entities (enterprise rules) → Use Cases (application rules) → Interface Adapters (controllers, gateways) → Frameworks & Drivers (web, database, external services).
-
Entities encapsulate business rules — They're pure domain objects with behavior, not data bags with getters and setters. Keep them free of framework annotations.
-
Use cases orchestrate, entities decide — Use cases coordinate the flow between entities and external dependencies. Business rules live in entities, not in use cases.
-
Interface adapters translate — Controllers convert HTTP to use case input. Gateways convert domain entities to database models. Each layer speaks its own language.
-
Clean, Hexagonal, and Onion are cousins — Same principles (dependency inversion, domain at center, framework independence), different terminology. Choose the label that resonates with your team.
-
Be pragmatic — Skip use cases for simple CRUD. Reduce DTOs when shapes are identical. Don't abstract things that never change. Clean Architecture is a guideline, not a religion.
Further Reading
Books:
- Clean Architecture: A Craftsman's Guide to Software Structure and Design — Robert C. Martin (the definitive reference)
- Get Your Hands Dirty on Clean Architecture — Tom Hombergs (practical Spring Boot implementation)
Articles:
- The Clean Architecture — Uncle Bob's original blog post
- Hexagonal Architecture — Alistair Cockburn's original article
Related Posts in This Series:
- Software Architecture Patterns Roadmap — Overview of all patterns
- Layered (N-Tier) Architecture — The pattern Clean Architecture evolves from
- SOLID Principles — The foundation of dependency inversion
- Dependency Injection & IoC — How to wire Clean Architecture layers
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.