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
- Quen thuộc với Layered (N-Tier) Architecture
- Nắm vững SOLID Principles, đặc biệt Dependency Inversion
- Hiểu về Dependency Injection
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
| Scenario | Không có Dependency Rule | Có Dependency Rule |
|---|---|---|
| Switch database | Rewrite service layer | Implement adapter mới |
| Thêm CLI interface | Duplicate business logic | Plug in controller mới |
| Test business rules | Cần database đang chạy | Pure unit test, không I/O |
| Thay email provider | Sửa service layer | Swap email gateway implementation |
| Upgrade framework | Risk hỏng business logic | Framework 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.tsCá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 rootBà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:
| Boundary | Data Structure | Purpose |
|---|---|---|
| HTTP → Controller | Request DTO | Web-specific format (camelCase, pagination params) |
| Controller → Use Case | Input DTO | Application-specific format (đã validate, typed) |
| Use Case ↔ Entity | Domain object | Rich business object có behavior |
| Use Case → Controller | Output DTO | Application result (không expose entity internals) |
| Controller → HTTP | Response DTO | Web-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 Level | Test cái gì | Dependencies | Tốc độ |
|---|---|---|---|
| Entity tests | Business rules | Không có | Rất nhanh |
| Use case tests | Application logic | Mocked interfaces | Nhanh |
| Adapter tests | Database, HTTP, v.v. | Real infrastructure | Chậm hơn |
| E2E tests | Toàn bộ system | Mọi thứ đang chạy | Chậ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ạnh | Hexagonal (2005) | Onion (2008) | Clean (2012) |
|---|---|---|---|
| Creator | Alistair Cockburn | Jeffrey Palermo | Robert C. Martin |
| Core idea | Ports & Adapters | Layers quanh domain | Dependency Rule |
| Terminology | Ports, Adapters | Domain Model, Domain Services, Application Services | Entities, Use Cases, Interface Adapters |
| Diagram style | Hexagon với ports | Vòng tròn đồng tâm | Vòng tròn đồng tâm |
| Focus | Đối xứng giữa input và output | Domain model ở trung tâm | Explicit use cases |
| Boundary crossing | Ports (interfaces) | Interfaces ở rìa layer | Input/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:
- Business logic ở trung tâm — được bảo vệ khỏi external details
- Dependency inversion — inner layers define interfaces, outer layers implement
- Framework independence — core không biết framework nào chạy nó
- Database independence — business logic không biết data lưu trữ thế nào
- 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ông | Quá nhiều layer cho pass-through operations |
| Prototype / MVP | ❌ Không | Tốc độ phát triển quan trọng hơn clean boundaries |
| Script hoặc tool dùng một lần | ❌ Không | Không sống đủ lâu để hưởng lợi |
| Solo developer, project nhỏ | ⚠️ Tùy | Phụ 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ừ controller2. 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 nhau3. 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 primitiveShortcuts Thực Dụng
-
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.
-
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.
-
Đừ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.
-
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.
-
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 formatFlowchart 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 rulesLayer Responsibilities
| Layer | Chứa gì | Depend vào | Ví dụ |
|---|---|---|---|
| Entities | Enterprise business rules | Không gì cả | Order, Money, OrderItem |
| Use Cases | Application business rules | Entities | CreateOrderUseCase, CancelOrderUseCase |
| Adapters | Interface conversion | Use Cases, Entities | OrderController, PostgresOrderRepository |
| Frameworks | Glue code, configuration | Mọ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
-
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.
-
Bốn layer đồng tâm — Entities (enterprise rules) → Use Cases (application rules) → Interface Adapters (controllers, gateways) → Frameworks & Drivers (web, database, external services).
-
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.
-
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.
-
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.
-
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.
-
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:
- The Clean Architecture — Bài blog gốc của Uncle Bob
- Hexagonal Architecture — Bài viết gốc của Alistair Cockburn
Bài viết liên quan trong Series:
- Software Architecture Patterns Roadmap — Tổng quan tất cả patterns
- Layered (N-Tier) Architecture — Pattern mà Clean Architecture tiến hóa từ
- SOLID Principles — Nền tảng của dependency inversion
- Dependency Injection & IoC — Cách wire các Clean Architecture layers
📬 Đă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.