Back to blog

Clean Architecture: Dependency Rule của Uncle Bob

software-architectureclean-architecturedesign-patternsbackendtypescriptjava
Clean Architecture: Dependency Rule của Uncle Bob

Mọi architecture pattern bạn đã gặp — layered, microservices, event-driven — đều trả lời câu hỏi: tổ chức hệ thống như thế nào? Clean Architecture trả lời một câu hỏi khác: làm sao bảo vệ business logic khỏi mọi thứ bên ngoài?

Được Robert C. Martin ("Uncle Bob") giới thiệu trong bài blog năm 2012 và mở rộng trong cuốn sách Clean Architecture: A Craftsman's Guide to Software Structure and Design năm 2017, Clean Architecture chắt lọc hàng thập kỷ tư duy architecture thành một rule duy nhất: dependencies phải chỉ vào trong.

Chỉ một rule duy nhất — Dependency Rule — là thứ phân biệt giữa codebase già đi một cách thanh lịch và codebase trở thành mớ bòng bong không maintain nổi. Đó là lý do tại sao một số project có thể swap database, thay web framework, hoặc chuyển từ REST sang GraphQL mà không cần rewrite business logic, trong khi project khác tốn hàng tháng refactor cho thay đổi infrastructure đơn giản nhất.

Series: Software Architecture Patterns Roadmap
Previous: Hexagonal Architecture (Ports & Adapters)
Next: Domain-Driven Design (DDD)

Bạn Sẽ Học Được Gì

✅ Hiểu Dependency Rule và tại sao nó quan trọng
✅ Biết từng layer đồng tâm: Entities, Use Cases, Interface Adapters, Frameworks
✅ Implement Clean Architecture bằng TypeScript và Java (Spring Boot)
✅ So sánh Clean Architecture với Hexagonal và Onion Architecture
✅ Nhận biết khi nào Clean Architecture là over-engineering và khi nào shortcut được chấp nhận
✅ Tránh những sai lầm phổ biến: over-engineering, anemic use cases, leaky abstractions

Prerequisites


Vấn Đề: Tại Sao Layered Architecture Chưa Đủ

Trong Layered Architecture, chúng ta đã học cách tách concerns thành các layer: presentation, business logic, và data access. Đây là bước tiến lớn so với spaghetti code. Nhưng layered architecture có một lỗ hổng cơ bản:

Dependencies chỉ xuống — hướng về database.

Nghĩa là business logic phụ thuộc vào database. Cần đổi từ PostgreSQL sang MongoDB? Service layer vỡ toàn bộ. Muốn test business rule không cần database? Không được — service phụ thuộc vào concrete repository implementation.

Đây là thực tế trông như thế nào:

// 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 trộn lẫn với infrastructure concerns
    const order = new Order(items);
 
    if (order.total() > 10000) {
      // ❌ Business rule phụ thuộc vào cách gửi email
      const emailService = new SmtpEmailService();
      await emailService.sendHighValueAlert(order);
    }
 
    // ❌ Gắn chặt với PostgreSQL
    return this.repository.save(order);
  }
}

Business rule ("đơn hàng giá trị cao trigger alert") bị mắc kẹt trong infrastructure concerns (SMTP, PostgreSQL). Không test được mà không cần database. Không reuse được trong CLI tool. Không đổi email provider được mà không sửa business logic.

Clean Architecture fix vấn đề này bằng cách đảo ngược dependency direction.


Dependency Rule

Source code dependencies phải chỉ vào trong, hướng về higher-level policies.

Đây là toàn bộ triết lý của Clean Architecture trong một câu. Inner layers define interfaces. Outer layers implement chúng. Không gì trong inner layer biết về outer layer — không tên class, không function, thậm chí không cả data format.

Insight quan trọng: business logic không phụ thuộc vào web framework. Web framework phụ thuộc vào business logic. Nếu Express.js hoặc Spring Boot biến mất ngày mai, business rules vẫn sống sót nguyên vẹn.

Tại Sao Điều Này Quan Trọng

ScenarioKhông có Dependency RuleCó Dependency Rule
Switch databaseRewrite service layerImplement adapter mới
Thêm CLI interfaceDuplicate business logicPlug in controller mới
Test business rulesCần database đang chạyPure unit test, không I/O
Thay email providerSửa service layerSwap email gateway implementation
Upgrade frameworkRisk hỏng business logicFramework là outer layer, isolated

Bốn Layer Đồng Tâm

Clean Architecture tổ chức code thành bốn vòng tròn đồng tâm, từ trong ra ngoài:

Hãy khám phá từng layer với ví dụ cụ thể: hệ thống đơn hàng e-commerce.


Layer 1: Entities (Enterprise Business Rules)

Entities đại diện cho core business objects và rules chi phối chúng. Đây là phần ổn định nhất, tái sử dụng được nhiều nhất trong system. Một Entity có thể dùng chung cho nhiều application trong cùng enterprise.

Đặc điểm chính:

  • Pure business logic — không framework, không I/O, không dependency
  • Encapsulate enterprise-wide business rules
  • Ít thay đổi nhất khi thứ gì đó bên ngoài thay đổi
  • Có thể là class thuần, object, hoặc thậm chí function với 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: đơn hàng phải có items
  addItem(item: OrderItem): void {
    if (this._status !== "draft") {
      throw new Error("Cannot modify a confirmed order");
    }
    this._items.push(item);
  }
 
  // ✅ Business rule: tính tổng
  total(): Money {
    return this._items.reduce(
      (sum, item) => sum.add(item.subtotal()),
      new Money(0)
    );
  }
 
  // ✅ Business rule: ngưỡng giá trị cao
  isHighValue(): boolean {
    return this.total().amount > 10000;
  }
 
  // ✅ Business rule: chỉ confirm được khi có 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: chỉ draft order mới sửa được
    public void addItem(OrderItem item) {
        if (status != OrderStatus.DRAFT)
            throw new IllegalStateException("Cannot modify a confirmed order");
        items.add(item);
    }
 
    // ✅ Business rule: tính tổng
    public Money total() {
        return items.stream()
            .map(OrderItem::subtotal)
            .reduce(new Money(BigDecimal.ZERO, "USD"), Money::add);
    }
 
    // ✅ Business rule: ngưỡng giá trị cao
    public boolean isHighValue() {
        return total().getAmount().compareTo(new BigDecimal("10000")) > 0;
    }
 
    // ✅ Business rule: confirm cần có items
    public void confirm() {
        if (items.isEmpty())
            throw new IllegalStateException("Cannot confirm an empty order");
        this.status = OrderStatus.CONFIRMED;
    }
}

Chú ý: Không @Entity, không @Column, không database annotation nào. Entities là pure domain objects. Chúng không biết mình sẽ được lưu vào PostgreSQL hay MongoDB.


Layer 2: Use Cases (Application Business Rules)

Use cases chứa application-specific business rules. Chúng điều phối luồng data giữa entities và hướng dẫn entities thực hiện enterprise-wide business rules. Một use case là một hành động duy nhất mà application thực hiện.

Đặc điểm chính:

  • Điều phối entities để đạt được mục tiêu application cụ thể
  • Define interfaces (ports) cho external dependencies
  • Mỗi use case đại diện cho một user action hoặc system operation
  • Chỉ depend vào entities — không bao giờ vào frameworks hoặc infrastructure
// TypeScript — Use Case layer
 
// ✅ Interfaces do use case layer define
// Outer layers phải implement chúng
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 — use case nhận gì
interface CreateOrderInput {
  customerId: string;
  items: Array<{
    productId: string;
    productName: string;
    quantity: number;
    unitPrice: number;
  }>;
}
 
// ✅ Output boundary — use case trả về gì
interface CreateOrderOutput {
  orderId: string;
  total: number;
  status: string;
  isHighValue: boolean;
}
 
// ✅ Use case — pure application logic
class CreateOrderUseCase {
  constructor(
    private orderRepo: OrderRepository,
    private inventory: InventoryChecker,
    private notifier: NotificationService
  ) {}
 
  async execute(input: CreateOrderInput): Promise<CreateOrderOutput> {
    // 1. Tạo entity
    const order = new Order(
      this.orderRepo.nextId(),
      input.customerId
    );
 
    // 2. Kiểm tra inventory và thêm 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 tự enforce rules)
    order.confirm();
 
    // 4. Persist
    await this.orderRepo.save(order);
 
    // 5. Side effects
    if (order.isHighValue()) {
      await this.notifier.notifyHighValueOrder(order);
    }
 
    // 6. Trả output DTO (không trả entity trực tiếp)
    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 — do use case define, adapters implement
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);
}
 
// ✅ 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()
        );
    }
}

Pattern quan trọng: Use case define interfaces (OrderRepository, NotificationService, InventoryChecker) nhưng không bao giờ implement chúng. Đây chính là Dependency Inversion Principle áp dụng ở architecture level.


Layer 3: Interface Adapters (Controllers, Gateways, Presenters)

Interface adapters convert data giữa format tiện nhất cho use cases/entities và format tiện nhất cho external services (web, database, v.v.).

Đặc điểm chính:

  • Controllers nhận HTTP requests và convert thành use case input
  • Gateways implement repository interfaces do use cases define
  • Presenters format use case output cho view
  • Đây là nơi MVC sống — controllers và presenters là adapters
// TypeScript — Interface Adapter: REST Controller
 
import express from "express";
 
class OrderController {
  constructor(private createOrder: CreateOrderUseCase) {}
 
  // ✅ Convert 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),
        })),
      };
 
      // ✅ Delegate cho use case — không business logic ở đây
      const output = await this.createOrder.execute(input);
 
      // ✅ Convert 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 ngược về 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 cho 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();
    }
}

Phân biệt quan trọng: Trong Clean Architecture, JPA @Entity KHÔNG phải là domain Entity. JPA entity (OrderJpaEntity) là database model ở adapter layer. Domain Order là business object ở entity layer. Gateway map giữa chúng.


Layer 4: Frameworks & Drivers

Layer ngoài cùng chứa frameworks, tools, và drivers — những chi tiết mà business logic không bao giờ nên biết.

Đặc điểm chính:

  • Web frameworks (Express, Spring MVC, NestJS)
  • Database drivers (pg, TypeORM, JPA/Hibernate)
  • External service SDKs (AWS SDK, Stripe SDK)
  • UI frameworks (React, Angular)
  • Layer này là "glue code" — nối mọi thứ lại với nhau
// TypeScript — Framework layer: Application composition
 
import express from "express";
import { Pool } from "pg";
 
// Composition Root — wire dependencies lại với nhau
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
 
// Tạo adapters (outer layer implement 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!);
 
// Tạo use cases (inject adapters qua constructor)
const createOrderUseCase = new CreateOrderUseCase(orderRepo, inventory, notifier);
 
// Tạo 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 tự phát hiện @Repository và @Component adapters
    // và inject chúng vào use case qua constructor injection
}

Composition root là nơi duy nhất tất cả layers biết về nhau. Nó wire outer implementations vào inner interfaces.


Project Structure

Kiến trúc này dịch ra thành directories thực tế như thế nào? Đây là hai cách tiếp cận phổ biến:

Cách 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 cho 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

Cách 2: Feature-Based Structure (Khuyến nghị cho project lớn)

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 (KHÔNG PHẢI domain entity)
│           └── EmailNotificationService.java
│
├── inventory/
│   ├── domain/
│   ├── application/
│   └── adapter/
│
└── config/
    └── OrderConfig.java                     # Composition root

Bài Toán Data Vượt Qua Ranh Giới

Một trong những khía cạnh phức tạp nhất của Clean Architecture là xử lý data khi nó vượt qua layer boundaries. Dependency Rule nói inner layers không được biết outer layer data formats. Vậy data chảy từ database lên UI như thế nào?

Nguyên Tắc: Dùng Simple Data Structures Ở Ranh Giới

Mỗi boundary có data structure riêng:

BoundaryData StructurePurpose
HTTP → ControllerRequest DTOWeb-specific format (camelCase, pagination params)
Controller → Use CaseInput DTOApplication-specific format (đã validate, typed)
Use Case ↔ EntityDomain objectRich business object có behavior
Use Case → ControllerOutput DTOApplication result (không expose entity internals)
Controller → HTTPResponse DTOWeb-specific format (JSON, status codes)

Tại sao nhiều DTO vậy? Vì mỗi layer có concerns khác nhau:

// ❌ Bad: Trả entity trực tiếp cho HTTP layer
app.post("/api/orders", async (req, res) => {
  const order = await createOrderUseCase.execute(req.body);
  res.json(order); // Expose internal entity structure!
});
 
// ✅ Good: Mỗi layer có data format riêng
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 trả 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 Trong Clean Architecture

Một trong những lợi ích lớn nhất của Clean Architecture là testability. Vì dependencies chỉ vào trong và interfaces tách biệt các layers, bạn có thể test từng layer độc lập.

Testing Entities (Không Cần Mocks)

// Entity tests — pure logic, không dependency
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 các 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 — infrastructure thật, database thật
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 Trong Clean Architecture

Test LevelTest cái gìDependenciesTốc độ
Entity testsBusiness rulesKhông cóRất nhanh
Use case testsApplication logicMocked interfacesNhanh
Adapter testsDatabase, HTTP, v.v.Real infrastructureChậm hơn
E2E testsToàn bộ systemMọi thứ đang chạyChậm nhất

Sức mạnh của Clean Architecture: Phần lớn tests (entity + use case) nhanh, isolated, và không cần infrastructure. Chỉ adapter tests mới cần databases và external services.


Clean Architecture vs Hexagonal vs Onion

Clean Architecture không xuất hiện từ hư không. Nó xây dựng trên hai pattern trước đó với mục tiêu tương tự:

Khía cạnhHexagonal (2005)Onion (2008)Clean (2012)
CreatorAlistair CockburnJeffrey PalermoRobert C. Martin
Core ideaPorts & AdaptersLayers quanh domainDependency Rule
TerminologyPorts, AdaptersDomain Model, Domain Services, Application ServicesEntities, Use Cases, Interface Adapters
Diagram styleHexagon với portsVòng tròn đồng tâmVòng tròn đồng tâm
FocusĐối xứng giữa input và outputDomain model ở trung tâmExplicit use cases
Boundary crossingPorts (interfaces)Interfaces ở rìa layerInput/Output boundaries

Giống Nhau Nhiều Hơn Khác Nhau

Cả ba pattern chia sẻ cùng các principle cơ bản:

  1. Business logic ở trung tâm — được bảo vệ khỏi external details
  2. Dependency inversion — inner layers define interfaces, outer layers implement
  3. Framework independence — core không biết framework nào chạy nó
  4. Database independence — business logic không biết data lưu trữ thế nào
  5. Testability — core logic testable không cần infrastructure

Khi Nào Chọn Label Nào

  • Hexagonal: Khi bạn muốn nhấn mạnh sự đối xứng giữa driving (input) và driven (output) ports
  • Onion: Khi bạn muốn nhấn mạnh domain-centric design và domain services
  • Clean: Khi bạn muốn explicit use cases và clear input/output boundaries

Trong thực tế, hầu hết team dùng hybrid approach — lấy ý tưởng tốt nhất từ cả ba. Label ít quan trọng hơn principles.


Pragmatic Clean Architecture

Clean Architecture cung cấp principles rõ ràng, nhưng áp dụng dogmatic dẫn đến over-engineering. Đây là cách thực dụng.

Khi Nào Clean Architecture Đáng Overhead

Tình huốngĐáng không?Tại sao
Complex domain logic✅ CóBusiness rules xứng đáng được isolate và test kỹ
Multiple UIs (web + mobile + CLI)✅ CóUse cases được reuse qua các interfaces
Thường xuyên thay đổi infrastructure✅ CóSwap databases/providers không chạm business logic
Product sống lâu✅ CóClean boundaries trả lại giá trị khi system lớn lên
Simple CRUD app❌ KhôngQuá nhiều layer cho pass-through operations
Prototype / MVP❌ KhôngTốc độ phát triển quan trọng hơn clean boundaries
Script hoặc tool dùng một lần❌ KhôngKhông sống đủ lâu để hưởng lợi
Solo developer, project nhỏ⚠️ TùyPhụ thuộc vào expected growth

Sai Lầm Over-Engineering Phổ Biến

1. Use cases chỉ delegate:

// ❌ Over-engineered: use case không thêm giá trị gì
class GetUserByIdUseCase {
  constructor(private repo: UserRepository) {}
 
  async execute(id: string): Promise<User | null> {
    return this.repo.findById(id); // Chỉ delegate thôi!
  }
}
 
// ✅ Với simple CRUD, OK để skip use case layer
// Gọi repository trực tiếp từ controller

2. Quá nhiều DTOs cho data đơn giản:

// ❌ Bốn class riêng biệt cho thực chất là cùng một 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; }
 
// ✅ Với simple CRUD, ít DTO hơn là được
// Chỉ thêm DTOs khi layers thực sự cần shapes khác nhau

3. Abstract thứ không thay đổi:

// ❌ Over-abstracted: bạn sẽ không bao giờ swap date library
interface DateProvider {
  now(): Date;
  format(date: Date, pattern: string): string;
}
 
// ✅ Cứ dùng Date trực tiếp trong entities — nó là language primitive

Shortcuts Thực Dụng

  1. Skip use cases cho simple CRUD — Nếu use case chỉ delegate cho repository, đó là ceremony. Cho controller gọi repository trực tiếp.

  2. Giảm DTOs khi shapes giống hệt — Nếu input DTO và entity có cùng fields, dùng entity làm input. Thêm DTOs khi shapes khác nhau.

  3. Đừng abstract primitives — Dates, strings, và numbers không cần wrapper interfaces. Abstract databases và external services — những thứ thực sự thay đổi.

  4. Bắt đầu monolithic, extract layers khi cần — Bắt đầu với monolith có cấu trúc tốt. Thêm use case interfaces khi bạn thấy cần testability hoặc reuse.

  5. Theo "rule of three" — Lần đầu viết code, cứ viết. Lần thứ hai, nhận ra duplication. Lần thứ ba, abstract nó.


Sai Lầm Phổ Biến và Anti-Patterns

1. Entity Anemia

// ❌ Anemic entity — chỉ là túi data
class Order {
  id: string;
  customerId: string;
  items: OrderItem[];
  status: string;
  total: number;
}
 
// Business logic sống trong use case (sai!)
class CreateOrderUseCase {
  execute(input: CreateOrderInput) {
    const order = new Order();
    order.items = input.items;
    // ❌ Business rule ngoài entity
    order.total = input.items.reduce((sum, i) => sum + i.price * i.qty, 0);
    // ❌ Validation ngoài entity
    if (order.items.length === 0) throw new Error("No items");
    order.status = "confirmed";
  }
}
// ✅ Rich entity — encapsulate business rules
class Order {
  private _items: OrderItem[] = [];
  private _status: OrderStatus = "draft";
 
  // Business rules thuộc về đây
  addItem(item: OrderItem): void { /* validation + add */ }
  total(): Money { /* calculation */ }
  confirm(): void { /* state transition with guards */ }
}
 
// Use case điều phối, không chứa 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 tự enforce rules
    }
    order.confirm(); // Entity tự quản lý state
    this.orderRepo.save(order);
  }
}

2. Framework Rò Rỉ Vào Inner Layers

// ❌ Entity biết về Express
import { Request } from "express";
 
class OrderService {
  createOrder(req: Request) {
    // Entity layer phụ thuộc vào web framework!
  }
}
 
// ❌ Entity biết về TypeORM
import { Entity, Column, PrimaryColumn } from "typeorm";
 
@Entity()
class Order {
  @PrimaryColumn()
  id: string;
 
  @Column()
  status: string;
}
// ✅ Entity không biết framework
class Order {
  constructor(readonly id: string, readonly customerId: string) {}
  // Chỉ pure business logic
}
 
// ✅ TypeORM entity sống ở adapter layer (class riêng)
@Entity("orders")
class OrderOrmEntity {
  @PrimaryColumn()
  id: string;
 
  @Column()
  status: string;
 
  static fromDomain(order: Order): OrderOrmEntity { /* map */ }
  toDomain(): Order { /* map */ }
}

3. Use Cases Biết Về HTTP

// ❌ Use case trả 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 trả 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 quyết định HTTP status codes và response format

Flowchart Quyết Định

Dùng flowchart này để quyết định Clean Architecture có phù hợp cho project của bạn không:


Quick Reference

Tóm Tắt Dependency Rule

✅ Inner layers define interfaces (ports)
✅ Outer layers implement interfaces (adapters)
✅ Dependencies luôn chỉ vào trong
✅ Data vượt boundaries dưới dạng simple DTOs
 
❌ Entities không bao giờ import framework classes
❌ Use cases không bao giờ trả HTTP responses
❌ Controllers không bao giờ chứa business logic
❌ Repositories không bao giờ define business rules

Layer Responsibilities

LayerChứa gìDepend vàoVí dụ
EntitiesEnterprise business rulesKhông gì cảOrder, Money, OrderItem
Use CasesApplication business rulesEntitiesCreateOrderUseCase, CancelOrderUseCase
AdaptersInterface conversionUse Cases, EntitiesOrderController, PostgresOrderRepository
FrameworksGlue code, configurationMọi thứExpress app setup, Spring Boot config

Clean Architecture Checklist

Khi review một Clean Architecture codebase, kiểm tra:

✅ Bạn có thể test business rules mà không cần database?
✅ Bạn có thể test use cases bằng cách mock repositories?
✅ Entities có zero imports từ frameworks?
✅ Use cases có define input/output DTOs riêng?
✅ Database models có tách biệt khỏi domain entities?
✅ Có composition root rõ ràng wire mọi thứ?
✅ Bạn có thể swap web framework mà không chạm use cases?


Key Takeaways

  1. Dependency Rule là nền tảng — dependencies phải luôn chỉ vào trong, hướng về higher-level policies. Inner layers define interfaces, outer layers implement chúng.

  2. Bốn layer đồng tâm — Entities (enterprise rules) → Use Cases (application rules) → Interface Adapters (controllers, gateways) → Frameworks & Drivers (web, database, external services).

  3. Entities encapsulate business rules — Chúng là pure domain objects có behavior, không phải túi data với getters và setters. Giữ chúng sạch khỏi framework annotations.

  4. Use cases điều phối, entities quyết định — Use cases coordinate luồng giữa entities và external dependencies. Business rules sống trong entities, không trong use cases.

  5. Interface adapters dịch — Controllers convert HTTP thành use case input. Gateways convert domain entities thành database models. Mỗi layer nói ngôn ngữ riêng.

  6. Clean, Hexagonal, và Onion là anh em họ — Cùng principles (dependency inversion, domain ở trung tâm, framework independence), terminology khác nhau. Chọn label phù hợp với team bạn.

  7. Thực dụng — Skip use cases cho simple CRUD. Giảm DTOs khi shapes giống nhau. Đừng abstract thứ không thay đổi. Clean Architecture là guideline, không phải tôn giáo.


Further Reading

Books:

  • Clean Architecture: A Craftsman's Guide to Software Structure and Design — Robert C. Martin (tài liệu tham khảo chính thức)
  • Get Your Hands Dirty on Clean Architecture — Tom Hombergs (triển khai thực tế với Spring Boot)

Articles:

Bài viết liên quan trong Series:

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.