Phần lớn phần mềm thất bại không phải vì không thể xây dựng. Nó thất bại vì sau một hai năm, mỗi thay đổi lại tốn thời gian hơn lần trước, những bản sửa nhỏ làm hỏng tính năng không liên quan, và không ai dám động vào phần lõi. Clean architecture và các nguyên tắc SOLID là tập hợp ý tưởng để ngăn chặn chính điều đó, bằng cách tách biệt những phần của hệ thống thay đổi vì những lý do khác nhau.
Vì sao codebase xuống cấp
Sự xuống cấp gần như luôn đến từ sự phụ thuộc chặt chẽ (coupling): các quy tắc nghiệp vụ biết về HTTP request, dòng dữ liệu trong cơ sở dữ liệu và component giao diện. Khi logic tính giá nằm trong một controller vừa phân tích JSON vừa viết SQL, bạn không thể kiểm thử việc tính giá nếu không có web server và cơ sở dữ liệu. Bạn không thể đổi cơ sở dữ liệu mà không đụng đến logic tính giá, và hoàn toàn không thể tái sử dụng nó trong một job chạy nền.
Kiến trúc tốt là vạch ra các ranh giới sao cho phần code giá trị nhất, tức các quy tắc nghiệp vụ, phụ thuộc vào càng ít thứ càng tốt.
Quy tắc phụ thuộc
Clean architecture, theo mô tả của Robert C. Martin, sắp xếp code thành các tầng đồng tâm:
- Entity ở trung tâm: các đối tượng và quy tắc nghiệp vụ cốt lõi, chẳng hạn một đơn hàng và cách tính tổng tiền của nó.
- Use case bao quanh: các quy trình riêng của ứng dụng, như đặt hàng hoặc hoàn tiền.
- Interface adapter: controller, presenter và repository chuyển đổi dữ liệu giữa use case và thế giới bên ngoài.
- Framework và driver ở ngoài cùng: web framework, cơ sở dữ liệu, giao diện, dịch vụ bên thứ ba.
Một quy tắc duy nhất làm nên tất cả: sự phụ thuộc trong mã nguồn chỉ hướng vào trong. Các tầng bên trong không biết gì về các tầng bên ngoài. Một use case không bao giờ import client cơ sở dữ liệu. Thay vào đó, nó khai báo một interface mô tả những gì nó cần, gọi là port, và một tầng bên ngoài cung cấp phần cài đặt, gọi là adapter. Đó là lý do phong cách này còn được gọi là ports and adapters, hay kiến trúc lục giác (hexagonal architecture).
Một cấu trúc thư mục thể hiện rõ các tầng có thể trông như sau:
src/
domain/ entities and value objects; no framework imports
order.ts
application/ use cases and the ports they need
ports.ts
place-order.ts
infrastructure/ adapters: database, email, payment provider
postgres-order-repository.ts
email-notifier.ts
interface/ HTTP handlers, CLI commands, UI
http/orders-controller.ts
main.ts composition root: wires adapters into use casesMột ví dụ hoàn chỉnh bằng TypeScript
Bắt đầu với entity. Nó chứa các quy tắc nghiệp vụ thuần túy và không import gì từ framework:
// domain/order.ts
export type OrderLine = { sku: string; quantity: number; unitPriceCents: number }
export class Order {
private constructor(
readonly id: string,
readonly customerId: string,
readonly lines: readonly OrderLine[]
) {}
static create(id: string, customerId: string, lines: OrderLine[]): Order {
if (lines.length === 0) throw new Error('An order needs at least one line')
if (lines.some((line) => line.quantity <= 0)) throw new Error('Quantities must be positive')
return new Order(id, customerId, lines)
}
get totalCents(): number {
return this.lines.reduce((sum, line) => sum + line.quantity * line.unitPriceCents, 0)
}
}Use case mô tả quy trình và khai báo các port mà nó phụ thuộc. Lưu ý rằng các interface thuộc về tầng ứng dụng, không thuộc về code cơ sở dữ liệu:
// application/ports.ts
import type { Order } from '../domain/order'
export interface OrderRepository {
save(order: Order): Promise<void>
}
export interface Notifier {
orderPlaced(order: Order): Promise<void>
}
// application/place-order.ts
import { Order, type OrderLine } from '../domain/order'
import type { Notifier, OrderRepository } from './ports'
export class PlaceOrder {
constructor(
private readonly orders: OrderRepository,
private readonly notifier: Notifier
) {}
async execute(input: { customerId: string; lines: OrderLine[] }) {
const order = Order.create(crypto.randomUUID(), input.customerId, input.lines)
await this.orders.save(order)
await this.notifier.orderPlaced(order)
return { orderId: order.id, totalCents: order.totalCents }
}
}Một adapter ở tầng ngoài cài đặt port bằng công nghệ thực, và một composition root kết nối mọi thứ lại khi khởi động:
// infrastructure/postgres-order-repository.ts
import type { Pool } from 'pg'
import type { OrderRepository } from '../application/ports'
import type { Order } from '../domain/order'
export class PostgresOrderRepository implements OrderRepository {
constructor(private readonly db: Pool) {}
async save(order: Order) {
await this.db.query('INSERT INTO orders (id, customer_id, total_cents) VALUES ($1, $2, $3)', [order.id, order.customerId, order.totalCents])
}
}
// main.ts
const placeOrder = new PlaceOrder(new PostgresOrderRepository(pool), new EmailNotifier(mailer))Lợi ích thể hiện rõ khi kiểm thử. Use case có thể được kiểm thử trong vài mili giây với các bản giả lập đơn giản trong bộ nhớ, không cần cơ sở dữ liệu, không cần mail server và không cần thư viện mock:
import { expect, test } from 'vitest'
import { PlaceOrder } from './place-order'
import type { Order } from '../domain/order'
test('places an order and notifies the customer', async () => {
const saved: Order[] = []
const notified: Order[] = []
const placeOrder = new PlaceOrder({ save: async (order) => void saved.push(order) }, { orderPlaced: async (order) => void notified.push(order) })
const result = await placeOrder.execute({
customerId: 'customer-1',
lines: [{ sku: 'TS-1042', quantity: 2, unitPriceCents: 1500 }],
})
expect(result.totalCents).toBe(3000)
expect(saved).toHaveLength(1)
expect(notified).toHaveLength(1)
})Các nguyên tắc SOLID, từng nguyên tắc một
SOLID là năm nguyên tắc thiết kế hướng đối tượng phục vụ cùng một mục tiêu ở cấp độ từng class và module.
S: Nguyên tắc trách nhiệm duy nhất
Một module chỉ nên có một lý do để thay đổi, nghĩa là nó chỉ phục vụ một nhóm người hoặc một mối quan tâm. Một class hóa đơn vừa tính thuế, vừa xuất PDF, vừa gửi email sẽ thay đổi mỗi khi bộ phận tài chính, thiết kế hay IT đổi ý. Hãy tách nó thành bộ tính toán, bộ xuất bản và bộ gửi.
O: Nguyên tắc đóng/mở
Code nên mở để mở rộng nhưng đóng để sửa đổi: bạn thêm hành vi bằng cách thêm code mới chứ không sửa code đã được kiểm thử. Một kỹ thuật phổ biến là mẫu strategy:
interface DiscountRule {
appliesTo(order: Order): boolean
amountCents(order: Order): number
}
const rules: DiscountRule[] = [new LoyaltyDiscount(), new BulkOrderDiscount()]
export const discountCents = (order: Order) => rules.filter((rule) => rule.appliesTo(order)).reduce((sum, rule) => sum + rule.amountCents(order), 0)
// A new promotion is a new class in the list, not another branch in a growing if/elseL: Nguyên tắc thay thế Liskov
Mọi phần cài đặt của một interface phải dùng được ở bất cứ đâu interface đó được mong đợi mà không gây bất ngờ cho bên gọi. Một ReadOnlyOrderRepository cài đặt OrderRepository nhưng ném lỗi khi gọi save() là vi phạm nguyên tắc này. Bên gọi dựa vào hợp đồng sẽ lỗi khi chạy. Nếu một thứ không thể tuân thủ toàn bộ hợp đồng, nó cần một interface khác, nhỏ hơn.
I: Nguyên tắc phân tách interface
Client không nên phụ thuộc vào những phương thức nó không dùng. Hãy ưu tiên nhiều interface nhỏ, tập trung, như OrderReader và OrderWriter, thay vì một interface lớn với hai mươi phương thức. Interface nhỏ dễ cài đặt hơn, dễ giả lập trong test hơn và dễ thay đổi hơn.
D: Nguyên tắc đảo ngược phụ thuộc
Chính sách cấp cao nên phụ thuộc vào trừu tượng, không phụ thuộc vào chi tiết cụ thể. Use case PlaceOrder ở trên phụ thuộc vào interface OrderRepository chứ không phải PostgreSQL, vì vậy cơ sở dữ liệu có thể thay đổi mà logic nghiệp vụ không hề hay biết. Nguyên tắc này là động cơ của quy tắc phụ thuộc.
Những mẫu thiết kế đáng biết
- Repository: ẩn cách lưu trữ entity sau một interface giống như một tập hợp.
- Strategy: hoán đổi các thuật toán thay thế được, như quy tắc tính giá, vận chuyển hay giảm giá, phía sau một interface.
- Adapter: bọc một API bên thứ ba trong một interface do ứng dụng của bạn định nghĩa.
- Factory: tập trung việc tạo và kiểm tra đối tượng phức tạp, như
Order.createđang làm. - Observer và domain event: cho phép các phần khác của hệ thống phản ứng với "đã đặt hàng" mà đơn hàng không cần biết ai đang lắng nghe.
- Decorator: thêm cache, log hoặc cơ chế thử lại quanh một phần cài đặt có sẵn mà không thay đổi nó.
Khi nào không nên thiết kế quá mức
Clean architecture là một công cụ, không phải tôn giáo. Một ứng dụng CRUD nhỏ ít logic nghiệp vụ không cần bốn tầng và hàng chục interface. Những thủ tục đó sẽ tốn nhiều hơn những gì chúng tiết kiệm. Hãy áp dụng các ý tưởng tương xứng với độ phức tạp và vòng đời dự kiến của code:
- Luôn giữ quy tắc nghiệp vụ bên ngoài controller và component giao diện, kể cả trong dự án nhỏ. Việc này gần như không tốn gì.
- Đưa vào port và adapter ở những nơi có lý do thực sự: dịch vụ bên ngoài có thể bị thay thế, logic cần test nhanh, hoặc nhiều điểm vào như API, job và CLI.
- Đừng tạo một interface chỉ có đúng một phần cài đặt và không có bản giả lập cho test. Hãy thêm nó khi nhu cầu thứ hai xuất hiện (you aren’t gonna need it, hay YAGNI).
- Refactor dần về phía kiến trúc này, từng module một, dựa trên những chỗ việc thay đổi thực sự gây khó khăn.
Những điểm chính
- Khả năng bảo trì đến từ việc quản lý sự phụ thuộc: giữ quy tắc nghiệp vụ độc lập với framework và hạ tầng.
- Sự phụ thuộc hướng vào trong. Use case khai báo port và các tầng ngoài cung cấp adapter.
- SOLID áp dụng cùng cách tư duy cho class và module: một trách nhiệm, mở rộng thay vì sửa đổi, hợp đồng trung thực, interface nhỏ và phụ thuộc vào trừu tượng.
- Điều chỉnh mức độ kiến trúc tương xứng với độ phức tạp của bài toán.
Kết luận
Rốt cuộc, clean architecture là để bảo vệ phần code giá trị nhất của bạn, những quy tắc giúp doanh nghiệp vận hành, khỏi sự biến động của framework, cơ sở dữ liệu và cách thức phân phối. Giữ sự phụ thuộc hướng vào trong, giữ module tập trung và kiểm thử logic nghiệp vụ mà không cần hạ tầng. Khi đó codebase của bạn sẽ vẫn dễ thay đổi ở năm thứ năm như ở năm đầu tiên.
Đọc thêm
- #Architecture
- #Clean Architecture
- #Design Patterns
- #SOLID
- #TypeScript




