Hexagonal và clean architecture: đổi payment provider mà không sửa use case đặt hàng Trung cấp
Thời gian đọc ước tính: 9 phút
Vấn đề
Phần tiêu đề “Vấn đề”Service đặt hàng có ba rule: giảm 5% khi tạm tính từ 2.000.000 VND, kiểm tồn kho, và hạn mức công nợ 5.000.000 VND mỗi khách. Cả ba nằm trong OrdersController, xen giữa lời gọi orderRepo.findOne() của TypeORM và axios.post() tới cổng thanh toán.
Đội muốn đổi payment provider và viết unit test cho rule. Cả hai đều vướng: test phải dựng Nest app, database và mock axios; đổi provider phải mở controller.
Tách lõi nghiệp vụ khỏi framework theo cách nào, và lúc nào việc tách đáng cái giá của nó?
Chọn gì? (tóm tắt trước)
Phần tiêu đề “Chọn gì? (tóm tắt trước)”| Tình huống | Chọn | Điều kiện |
|---|---|---|
| CRUD đơn giản, ít rule | Layered (controller → service → repository) là đủ | Rule chủ yếu là validate input |
| Domain nhiều rule, nhiều lối vào (HTTP, queue, cron) hoặc nhiều hệ ngoài | Hexagonal | Có rule cần test nhanh hoặc adapter có thể đổi |
| Nhóm nhỏ, prototype | Giữ đơn giản, tách dần | Bắt đầu từ lõi thuần; tạo port khi xuất hiện adapter thứ hai hoặc test bị chậm |
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”Ý gốc của Cockburn (2005)
Phần tiêu đề “Ý gốc của Cockburn (2005)”Mục tiêu Cockburn đặt ra: ứng dụng được điều khiển như nhau bởi người dùng, chương trình, test tự động hay batch script, và test được tách khỏi database thật.
- Ứng dụng ở giữa, mọi thứ còn lại ở ngoài. Số 6 cạnh không có nghĩa.
- Port là một “cuộc hội thoại có mục đích” giữa ứng dụng và bên ngoài, thể hiện dưới dạng API. Trong code, port là interface do phía ứng dụng định nghĩa theo nhu cầu của nó.
- Adapter chuyển giữa port và một công nghệ cụ thể (HTTP, SQL, SDK).
Cockburn chia adapter theo ai khởi xướng hội thoại:
| Driving (primary) | Driven (secondary) | |
|---|---|---|
| Ai gọi ai | Bên ngoài gọi vào ứng dụng | Ứng dụng gọi ra ngoài |
| Ví dụ | Controller HTTP, consumer queue, cron, test | Repository Postgres, client thanh toán, gửi email |
| Port | Use case mà ứng dụng cung cấp | Interface ứng dụng cần (OrderRepository) |
Bài gốc đã nhắc adapter database giả chạy trong bộ nhớ: chính là fake adapter dùng cho test bên dưới.
Clean Architecture của Robert C. Martin (2012)
Phần tiêu đề “Clean Architecture của Robert C. Martin (2012)”Martin vẽ bốn vòng tròn: Entities, Use Cases, Interface Adapters, Frameworks and Drivers. Rule chi phối là dependency rule: phụ thuộc trong source code chỉ hướng vào trong. Vòng trong không biết tên bất cứ thứ gì ở vòng ngoài.
Đang tải diagram…
Use case cần ghi database ở vòng ngoài thì dùng Dependency Inversion: vòng trong khai báo interface, vòng ngoài implement. Luồng gọi đi ra, import vẫn đi vào. Dữ liệu qua ranh giới là cấu trúc đơn giản, không phải entity ORM.
Hexagonal, onion, clean: giống và khác
Phần tiêu đề “Hexagonal, onion, clean: giống và khác”| Hexagonal (Cockburn, 2005) | Onion (Palermo, 2008) | Clean (Martin, 2012) | |
|---|---|---|---|
| Hình | Trong / ngoài, nhiều port | Vòng đồng tâm | Bốn vòng đồng tâm |
| Nhấn mạnh | Port, adapter driving/driven, đối xứng giữa UI và test | Domain model ở tâm, interface repository nằm trong lõi | Dependency rule, use case là vòng riêng |
| Phân lớp bên trong lõi | Không quy định | Domain model, rồi các lớp service quanh nó | Entities tách khỏi Use Cases |
Điểm chung: phụ thuộc hướng vào lõi, interface khai báo ở trong, hạ tầng ở ngoài. Martin tự ghi Clean Architecture là cách gộp hexagonal, onion và vài kiến trúc khác. Khác biệt chủ yếu là từ vựng và mức quy định lớp bên trong.
Cách hoạt động
Phần tiêu đề “Cách hoạt động”Câu hỏi: một request đặt hàng đi qua những phần nào, và đổi payment provider thì phần nào đổi?
POST /ordersClient gửi đơn o-1 (2 × SKU-A, 1 × SKU-B) tới controller.
| Bước | Thành phần làm việc | Biết về HTTP/SQL? | Thay đổi |
|---|---|---|---|
| 1–2 | OrdersController |
Có (DTO, mã HTTP) | DTO → PlaceOrderCommand |
| 3 | PostgresOrderRepository |
Có (SQL) | Đọc tồn kho, hạn mức |
| 4 | domain/order.ts |
Không | Tính 2.280.000, kiểm kho và hạn mức |
| 5 | HttpPaymentGateway |
Có | amount_minor, card_declined → ChargeResult |
| 6 | PostgresOrderRepository |
Có | Trừ kho, cộng dư nợ, lưu đơn trong một transaction |
| 7 | Module wiring | Có (NestJS) | Chỉ đổi provider của PaymentGateway |
Bước 4 là lõi thuần: hexagonal mở rộng ý đó thành ranh giới cho cả ứng dụng.
Cấu trúc thư mục mẫu
Phần tiêu đề “Cấu trúc thư mục mẫu”src/orders/ domain/ # rule thuần: không import gì ngoài domain application/ # use case + port (abstract class) adapters/ in/http/ # driving: controller, DTO in/queue/ # driving: consumer out/postgres/ # driven: repository out/payment/ # driven: client provider orders.module.ts # composition rootChi tiết vận hành
Phần tiêu đề “Chi tiết vận hành”Trong NestJS 11, interface bị xoá lúc biên dịch nên không làm DI token được; tài liệu Custom providers gợi ý abstract class làm cả contract lẫn token. Use case để là class thường, tạo bằng useFactory, nên application/ không import @nestjs/common:
export abstract class PaymentGateway { abstract charge(input: { orderRef: string; customerId: string; amountVnd: number }): Promise<ChargeResult>;}
providers: [ { provide: OrderRepository, useClass: PostgresOrderRepository }, { provide: PaymentGateway, useClass: HttpPaymentGateway }, { provide: PlaceOrder, useFactory: (o, p) => new PlaceOrder(o, p), inject: [OrderRepository, PaymentGateway] },]- Use case làm
@Injectable()cũng chạy, đổi lại vòng trong import framework. - Cách module
exportsprovider và lỗi thiếu export: xem Module, provider, controller và DI container. - Dependency rule không tự giữ: cần một bước CI kiểm import (dependency-cruiser, ESLint
no-restricted-imports, hoặc script như demo).
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Demo examples/src/hexagonal-architecture/ (Node 24.17, PostgreSQL 17, schema riêng, xoá khi xong) chạy cùng use case và 5 test với hai bộ adapter: in-memory, và PostgresOrderRepository + HttpPaymentGateway gọi provider giả lập (node:http, localhost).
cd examples/src/hexagonal-architecturenpx ts-node run-both.ts && npx ts-node nest-demo.tsnpx ts-node check-deps.ts . && npx ts-node check-deps.ts bad-exampleOutput thật, rút gọn (số ms đổi theo lần chạy)
[in-memory] 5 case ok happy path: giảm 5% khi tạm tính >= 2.000.000 ... ok giỏ rỗng[postgres + http] 5 case: cùng 5 dòng ok
in-memory : 2000 vòng x 5 case = 10.1 ms, 1.0 µs/casepostgres + http: 20 vòng x 5 case = 381.6 ms, 3816.3 µs/case
[PaymentGateway = FakePaymentGateway] 201 {"ok":true,"orderId":"mem-1","totalVnd":2280000,"paymentId":"fake-o-1"} 409 {"code":"OUT_OF_STOCK","sku":"SKU-B","requested":2,"available":0}[PaymentGateway = HttpPaymentGateway] 201 {"ok":true,"orderId":"mem-1","totalVnd":2280000,"paymentId":"ch_1"}
OK .: domain/ và application/ không import framework/adapterVI PHẠM bad-example/domain/order-entity.ts: import '@nestjs/common'VI PHẠM bad-example/domain/order-entity.ts: import 'pg'2 vi phạmBa lần chạy trên máy dev: in-memory khoảng 1 µs/case, Postgres + HTTP 3,5–4,6 ms/case (gồm TRUNCATE và seed mỗi case). Provider thật qua mạng còn chậm hơn. Số chỉ để so thứ bậc.
Phân vai test theo kết quả này (xem Unit, integration, e2e):
- Unit test use case với fake adapter: mọi nhánh rule.
- Integration test từng adapter với Postgres thật hoặc sandbox: SQL, transaction, mapping lỗi.
- Vài e2e qua HTTP để kiểm wiring.
Trade-offs
Phần tiêu đề “Trade-offs”Ưu điểm
-
Rule test bằng fake adapter, không cần database hay mạng.
-
Đổi provider hoặc thêm lối vào (queue, cron) không sửa use case.
-
Ranh giới kiểm được tự động bằng rule import.
< slot="cons"> >
-
Nhiều file hơn: port, adapter, wiring cho mỗi tích hợp.
-
Mapping DTO ↔ command ↔ row ở mỗi ranh giới, dễ sót field.
-
Port chỉ có một implementation là trừu tượng hoá có thể chưa cần (backlog: “Trừu tượng hoá quá sớm”).
-
Transaction xuyên nhiều repository khó giữ trong port gọn.
Nhược điểm
Checklist production
Phần tiêu đề “Checklist production”- Viết ra rule nào cần tách: không có rule đáng test riêng thì dừng ở layered.
- Đặt tên port theo ngôn ngữ use case (
charge,stockOf), không theo SDK. - Use case và domain không import
@nestjs/*, ORM, HTTP client; thêm bước CI kiểm import. - Mỗi port có fake in-memory dùng chung cho unit test.
- Mỗi driven adapter có integration test với hạ tầng thật hoặc sandbox.
- Adapter thanh toán vẫn cần timeout, idempotency key, phân loại lỗi (xem Adapter pattern).
- Wiring nằm ở một module; đổi adapter là đổi provider, không
newtrong use case.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Lỗi hay gặp nhất là để entity ORM chảy vào domain:
Domain dùng kiểu của ORM/framework
Hay gặp
import { Entity, Column } from 'typeorm';@Entity() export class Order { @Column() total!: number; }Nên viết
// domain/order.ts: kiểu thuần; adapters/out/postgres map OrderRow <-> Orderexport interface Order { customerId: string; lines: OrderLine[]; totalVnd: number }Vì sao
Domain import typeorm thì unit test kéo theo metadata ORM và đổi ORM phải sửa rule.
Sai lầm thứ hai nằm ở hình dạng của port:
| Cách hiểu sai | Hệ quả | Cần làm |
|---|---|---|
| Hexagonal = nhiều thư mục | Thư mục đúng tên, import vẫn đi ngược | Kiểm dependency rule bằng tool |
| Mọi repository đều cần port | Interface một implementation, thêm file không thêm giá trị | Tạo port khi có adapter thứ hai hoặc test chậm |
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”- Trong demo, vì sao
application/ports.tsdùng abstract class thay chointerface? - Thêm consumer Kafka đặt hàng: file nào mới, file nào giữ nguyên?
- Use case gọi
PaymentGatewayra ngoài, vậy dependency rule có bị vi phạm không? Giải thích bằng chiềuimport.
Tóm tắt
Phần tiêu đề “Tóm tắt”Tham khảo
Phần tiêu đề “Tham khảo”- Alistair Cockburn — Hexagonal architecture: mục tiêu gốc, port, adapter primary/secondary, adapter database trong bộ nhớ.
- Robert C. Martin — The Clean Architecture: bốn vòng, dependency rule, dữ liệu qua ranh giới.
- Jeffrey Palermo — The Onion Architecture: domain ở tâm, interface repository trong lõi.
- NestJS — Custom providers: token là class, abstract class,
useFactoryvớiinject.