Back to blog

Clean Architecture: Uncle Bob's Dependency Rule

software-architectureclean-architecturedesign-patternsbackendtypescriptjava
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


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

ScenarioWithout Dependency RuleWith Dependency Rule
Switch databasesRewrite service layerImplement new repository adapter
Add CLI interfaceDuplicate business logicPlug in new controller
Test business rulesNeed running databasePure unit tests, no I/O
Replace email providerModify service layerSwap email gateway implementation
Upgrade frameworkRisk breaking business logicFramework 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.ts
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 root

The 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:

BoundaryData StructurePurpose
HTTP → ControllerRequest DTOWeb-specific format (camelCase, pagination params)
Controller → Use CaseInput DTOApplication-specific format (validated, typed)
Use Case ↔ EntityDomain objectRich business object with behavior
Use Case → ControllerOutput DTOApplication result (no entity internals exposed)
Controller → HTTPResponse DTOWeb-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 LevelWhat It TestsDependenciesSpeed
Entity testsBusiness rulesNoneVery fast
Use case testsApplication logicMocked interfacesFast
Adapter testsDatabase, HTTP, etc.Real infrastructureSlower
E2E testsFull systemEverything runningSlowest

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:

AspectHexagonal (2005)Onion (2008)Clean (2012)
CreatorAlistair CockburnJeffrey PalermoRobert C. Martin
Core ideaPorts & AdaptersLayers around domainDependency Rule
TerminologyPorts, AdaptersDomain Model, Domain Services, Application ServicesEntities, Use Cases, Interface Adapters
Diagram styleHexagon with portsConcentric circlesConcentric circles
FocusSymmetry between input and outputDomain model at centerExplicit use cases
Boundary crossingPorts (interfaces)Interfaces at layer edgesInput/Output boundaries

They're More Similar Than Different

All three patterns share the same fundamental principles:

  1. Business logic at the center — protected from external details
  2. Dependency inversion — inner layers define interfaces, outer layers implement them
  3. Framework independence — the core doesn't know what framework runs it
  4. Database independence — business logic doesn't know how data is stored
  5. 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

SituationWorth it?Why
Complex domain logic✅ YesBusiness rules deserve isolation and thorough testing
Multiple UIs (web + mobile + CLI)✅ YesUse cases are reused across interfaces
Frequent infrastructure changes✅ YesSwap databases/providers without touching business logic
Long-lived product✅ YesClean boundaries pay off as the system grows
Simple CRUD app❌ NoToo many layers for pass-through operations
Prototype / MVP❌ NoSpeed of development matters more than clean boundaries
One-off script or tool❌ NoWon't live long enough to benefit
Solo developer, small project⚠️ MaybeDepends 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 controller

2. 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 shapes

3. 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 primitive

Pragmatic Shortcuts

  1. 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.

  2. 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.

  3. Don't abstract primitives — Dates, strings, and numbers don't need wrapper interfaces. Abstract databases and external services — things that actually change.

  4. 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.

  5. 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 format

Decision 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 rules

Layer Responsibilities

LayerContainsDepends OnExample
EntitiesEnterprise business rulesNothingOrder, Money, OrderItem
Use CasesApplication business rulesEntitiesCreateOrderUseCase, CancelOrderUseCase
AdaptersInterface conversionUse Cases, EntitiesOrderController, PostgresOrderRepository
FrameworksGlue code, configurationEverythingExpress 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

  1. The Dependency Rule is the foundation — dependencies must always point inward, toward higher-level policies. Inner layers define interfaces, outer layers implement them.

  2. Four concentric layers — Entities (enterprise rules) → Use Cases (application rules) → Interface Adapters (controllers, gateways) → Frameworks & Drivers (web, database, external services).

  3. Entities encapsulate business rules — They're pure domain objects with behavior, not data bags with getters and setters. Keep them free of framework annotations.

  4. Use cases orchestrate, entities decide — Use cases coordinate the flow between entities and external dependencies. Business rules live in entities, not in use cases.

  5. Interface adapters translate — Controllers convert HTTP to use case input. Gateways convert domain entities to database models. Each layer speaks its own language.

  6. 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.

  7. 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:

Related Posts in This Series:

📬 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.