Bỏ qua để đến nội dung
Knowledge Hub

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

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ó?

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

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.

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 (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â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?

Request đi qua driving adapter, use case, driven port; thay adapter Payment không đụng PlaceOrder
Request đi qua driving adapter, use case, driven port; thay adapter Payment không đụng PlaceOrderBước 1/7: Client gửi đơn o-1 (2 × SKU-A, 1 × SKU-B) tới controller.Driving adapterApplication: use case+ portDriven adapterHTTP clientPOST /ordersOrdersControllerDTO → commandPlaceOrderuse caseOrderRepositoryportPaymentGatewayportPostgresOrderRepopgHttpPaymentGatewayfetchFakePaymentGatewayin-memoryPOST /orders
HTTP client → OrdersControllerPOST /orders
Bước 1/7

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

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 root

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 exports provider 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).

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

Terminal window
cd examples/src/hexagonal-architecture
npx ts-node run-both.ts && npx ts-node nest-demo.ts
npx ts-node check-deps.ts . && npx ts-node check-deps.ts bad-example
Output 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/case
postgres + 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/adapter
VI 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ạm

Ba 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):

  1. Unit test use case với fake adapter: mọi nhánh rule.
  2. Integration test từng adapter với Postgres thật hoặc sandbox: SQL, transaction, mapping lỗi.
  3. Vài e2e qua HTTP để kiểm wiring.

Ư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

  1. Viết ra rule nào cần tách: không có rule đáng test riêng thì dừng ở layered.
  2. Đặt tên port theo ngôn ngữ use case (charge, stockOf), không theo SDK.
  3. Use case và domain không import @nestjs/*, ORM, HTTP client; thêm bước CI kiểm import.
  4. Mỗi port có fake in-memory dùng chung cho unit test.
  5. Mỗi driven adapter có integration test với hạ tầng thật hoặc sandbox.
  6. Adapter thanh toán vẫn cần timeout, idempotency key, phân loại lỗi (xem Adapter pattern).
  7. Wiring nằm ở một module; đổi adapter là đổi provider, không new trong use case.

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

domain/order.ts
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 <-> Order
export 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
  1. Trong demo, vì sao application/ports.ts dùng abstract class thay cho interface?
  2. Thêm consumer Kafka đặt hàng: file nào mới, file nào giữ nguyên?
  3. Use case gọi PaymentGateway ra ngoài, vậy dependency rule có bị vi phạm không? Giải thích bằng chiều import.