Một dịch vụ checkout thường khởi đầu rất gọn: một route, một model của ORM và một lời gọi đến SDK thanh toán. Sau nửa năm, việc đổi nhà cung cấp thanh toán lại buộc đội ngũ sửa cả luật nghiệp vụ; muốn chạy test phải bật cơ sở dữ liệu; còn một lần nâng cấp framework tưởng như vô hại có thể làm thay đổi nửa repository. Vấn đề không nằm ở chuyện chọn sai framework. Vấn đề là framework đã vô tình trở thành kiến trúc.
Kiến trúc lục giác, hay ports and adapters, đặt lại trọng tâm. Những quyết định của ứng dụng nằm ở trung tâm. Các interface mô tả rõ ứng dụng cung cấp điều gì và cần điều gì. HTTP, SQL, hàng đợi, đồng hồ hệ thống và SDK của nhà cung cấp đều đứng ngoài các interface đó. Mục tiêu không phải vẽ được một hình lục giác đẹp hay tuân theo một cây thư mục cứng nhắc, mà là giữ cho nghiệp vụ tồn tại độc lập khi hạ tầng thay đổi.
Bài viết này triển khai nguyên tắc ấy qua use case đặt hàng, từ thiết kế port đến adapter, kiểm thử, xử lý lỗi và những đánh đổi thường gặp trong dự án thật.
Giá trị nằm ở đường biên
Sơ đồ phân lớp quen thuộc thường đặt controller ở trên service, service ở trên repository, rồi cơ sở dữ liệu ở đáy. Cách này không sai, nhưng hình ảnh các tầng xếp chồng dễ khiến dependency cùng trỏ xuống dưới. Service import repository của ORM, trả về response object của framework và gọi trực tiếp đồng hồ hệ thống. Thư mục có vẻ tách biệt, nhưng phần lõi vẫn hiểu quá nhiều về thế giới bên ngoài.
Kiến trúc lục giác thay câu hỏi “tầng nào gọi tầng tiếp theo?” bằng “phía nào sở hữu contract này?” Ứng dụng sở hữu các interface diễn đạt nhu cầu của chính nó. Hạ tầng triển khai những interface đó. Dependency vì thế hướng vào trong, về phía policy nghiệp vụ.
Hướng mũi tên quan trọng hơn hình dạng. Use case biết PaymentPort, không biết Stripe. Adapter PostgreSQL import contract OrderRepository do application định nghĩa, còn application không hề import adapter. Khi chạy, composition root nối các implementation cụ thể lại. Khi biên dịch, policy vẫn độc lập.
Note
“Bên trong” và “bên ngoài” mô tả hướng dependency, không mô tả vị trí trên mạng. Một domain module ở process khác vẫn có thể thuộc phần lõi về mặt khái niệm, trong khi ORM chạy cùng process vẫn là hạ tầng.
Domain model giữ những quy tắc luôn đúng bất kể dữ liệu đến qua kênh nào: đơn hàng không được rỗng, tổng tiền không được âm và chỉ thanh toán thành công mới cho phép xác nhận. Application layer điều phối một kịch bản: lấy dữ liệu, gọi hành vi domain, yêu cầu thanh toán và lưu kết quả. Adapter chuyển đổi giữa các khái niệm ấy với công nghệ cụ thể.
Phân biệt inbound port và outbound port
Port là contract nhìn từ phía ứng dụng. Hướng của nó cũng được đặt tên từ góc nhìn ứng dụng, không phải theo chiều của gói tin mạng.
Inbound port mô tả các năng lực ứng dụng cung cấp. Đó là API của use case như PlaceOrder, CancelOrder hoặc GetAccountBalance. HTTP controller, lệnh CLI, scheduled job hay message consumer đều có thể gọi cùng một port. Kiểu dữ liệu đầu vào nên nói ngôn ngữ của ứng dụng thay vì phơi bày Request của Express hay record của Kafka.
Outbound port mô tả năng lực ứng dụng cần từ bên ngoài. Repository, payment gateway, message publisher, bộ sinh ID và clock là các ví dụ phổ biến. Use case gọi chúng; hạ tầng cung cấp implementation.
| Khía cạnh | Inbound port | Outbound port | Ví dụ adapter |
|---|---|---|---|
| Mục đích | Công khai năng lực của ứng dụng | Yêu cầu một năng lực bên ngoài | Chuyển đổi giữa công nghệ và port |
| Bên gọi | Driving adapter | Application use case | Được nối tại runtime |
| Bên sở hữu | Application | Application | Infrastructure |
| Hình dạng thường gặp | Interface command/query | Interface repository hoặc gateway | HTTP controller, SQL repository, SDK wrapper |
| Nguồn thay đổi | Luồng nghiệp vụ | Nhu cầu nghiệp vụ | Framework và API nhà cung cấp |
Đặt tên port theo ý định giúp giữ lại ngữ nghĩa. PaymentPort.capture() có giá trị hơn một abstraction chung chung kiểu ExternalService.execute(). Tương tự, repository chỉ nên cung cấp thao tác use case thực sự cần, chẳng hạn save(order), thay vì sao chép toàn bộ primitive truy vấn của ORM.
Port cũng không bắt buộc phải là interface của TypeScript. Function type hoặc abstract class đều dùng được. Điều quan trọng là contract thuộc sở hữu application và không làm rò rỉ type của thế giới bên ngoài. Nếu PaymentPort trả về Stripe.PaymentIntent, Stripe đã vượt qua đường biên dù code có interface.
Một lát cắt TypeScript hoàn chỉnh
Xét use case kiểm tra đơn hàng, thu tiền và lưu đơn đã xác nhận. Ví dụ dưới đây đủ lớn để thể hiện trách nhiệm của từng phần, nhưng cố ý bỏ qua chi tiết HTTP và cơ sở dữ liệu không liên quan.
// application/place-order.ts
export type PlaceOrderCommand = {
orderId: string;
customerId: string;
currency: 'USD' | 'EUR';
items: ReadonlyArray<{
productId: string;
quantity: number;
unitPriceInCents: number;
}>;
};
export type PlaceOrderResult =
| { status: 'placed'; orderId: string; paymentId: string }
| { status: 'declined'; reason: string };
export interface PlaceOrder {
execute(command: PlaceOrderCommand): Promise<PlaceOrderResult>;
}
export interface PaymentPort {
capture(request: {
orderId: string;
customerId: string;
amountInCents: number;
currency: 'USD' | 'EUR';
}): Promise<
| { status: 'captured'; paymentId: string }
| { status: 'declined'; reason: string }
>;
}
export interface OrderRepository {
exists(orderId: string): Promise<boolean>;
save(order: Order): Promise<void>;
}
export interface Clock {
now(): Date;
}
export class Order {
private constructor(
readonly id: string,
readonly customerId: string,
readonly totalInCents: number,
readonly paymentId: string,
readonly placedAt: Date,
) {}
static totalFor(command: PlaceOrderCommand): number {
if (command.items.length === 0) {
throw new Error('An order must contain at least one item');
}
return command.items.reduce((total, item) => {
if (item.quantity <= 0 || item.unitPriceInCents < 0) {
throw new Error('Invalid order item');
}
return total + item.quantity * item.unitPriceInCents;
}, 0);
}
static place(
command: PlaceOrderCommand,
paymentId: string,
placedAt: Date,
): Order {
const totalInCents = Order.totalFor(command);
return new Order(
command.orderId,
command.customerId,
totalInCents,
paymentId,
placedAt,
);
}
}
export class PlaceOrderService implements PlaceOrder {
constructor(
private readonly payments: PaymentPort,
private readonly orders: OrderRepository,
private readonly clock: Clock,
) {}
async execute(command: PlaceOrderCommand): Promise<PlaceOrderResult> {
if (await this.orders.exists(command.orderId)) {
throw new Error(`Order ${command.orderId} already exists`);
}
const amountInCents = Order.totalFor(command);
const payment = await this.payments.capture({
orderId: command.orderId,
customerId: command.customerId,
amountInCents,
currency: command.currency,
});
if (payment.status === 'declined') {
return payment;
}
const order = Order.place(command, payment.paymentId, this.clock.now());
await this.orders.save(order);
return {
status: 'placed',
orderId: order.id,
paymentId: payment.paymentId,
};
}
}
PlaceOrder là inbound port. PaymentPort, OrderRepository và Clock là outbound port. PlaceOrderService điều phối transaction, còn Order bảo vệ invariant của domain. Không thành phần nào import HTTP framework, ORM hay package thanh toán.
Adapter đảm nhận việc dịch dữ liệu tại biên:
// infrastructure/stripe-payment-adapter.ts
import type { PaymentPort } from '../application/place-order.js';
type StripeClient = {
paymentIntents: {
create(input: {
amount: number;
currency: string;
metadata: Record<string, string>;
}): Promise<{ id: string; status: string }>;
};
};
export class StripePaymentAdapter implements PaymentPort {
constructor(private readonly stripe: StripeClient) {}
async capture(request: Parameters<PaymentPort['capture']>[0]) {
const intent = await this.stripe.paymentIntents.create({
amount: request.amountInCents,
currency: request.currency.toLowerCase(),
metadata: {
orderId: request.orderId,
customerId: request.customerId,
},
});
return intent.status === 'succeeded'
? ({ status: 'captured', paymentId: intent.id } as const)
: ({ status: 'declined', reason: intent.status } as const);
}
}
Adapter hấp thụ cách đặt tên và status model của Stripe. Nếu nhà cung cấp bổ sung trạng thái mới, chính mapping này quyết định cách application nhìn thấy chúng. Composition root khởi tạo Stripe client, repository, clock và service; đây là nơi duy nhất được phép biết tất cả concrete type.
Kiểm thử hành vi mà không xem nhẹ hạ tầng
Nhờ port, use case có thể chạy nhanh với adapter in-memory. Test cung cấp clock cố định, payment stub và order repository dùng Map hoặc mảng. Sau đó nó kiểm tra kết quả nghiệp vụ thay vì phụ thuộc vào HTTP status hay bản ghi SQL.
const saved: Order[] = [];
const service = new PlaceOrderService(
{
capture: async () => ({
status: 'captured',
paymentId: 'pay-123',
}),
},
{
exists: async () => false,
save: async (order) => {
saved.push(order);
},
},
{ now: () => new Date('2025-06-18T10:00:00Z') },
);
const result = await service.execute({
orderId: 'order-42',
customerId: 'customer-7',
currency: 'USD',
items: [{ productId: 'book', quantity: 2, unitPriceInCents: 2500 }],
});
expect(result).toEqual({
status: 'placed',
orderId: 'order-42',
paymentId: 'pay-123',
});
expect(saved).toHaveLength(1);
Nhóm test này rất hữu ích, nhưng không chứng minh adapter PostgreSQL serialize ngày giờ đúng hay Stripe SDK chấp nhận request. Mỗi adapter vẫn cần contract test hoặc integration test với đường biên thật. Một chiến lược cân bằng gồm ba lớp:
- Domain test kiểm tra invariant mà không cần port.
- Use-case test thay outbound port bằng fake hoặc stub tập trung.
- Adapter test xác minh SQL, HTTP, serialization, retry và hành vi của vendor.
Nên dùng chung contract test cho mọi implementation của một port. Nếu mọi OrderRepository đều phải bảo toàn đơn hàng sau vòng save-load, hãy chạy cùng một test suite cho adapter in-memory và PostgreSQL. Cách này bắt được lỗi phổ biến: fake rất tiện cho test nhưng hành xử khác production.
Tip
Hãy xem test double là adapter nhỏ, không phải giấy phép để mock mọi lời gọi hàm. Kiểm tra hành vi và trạng thái quan sát được; một kịch bản interaction quá chi li chỉ sao chép implementation hiện tại.
Giữ framework ở biên và thiết kế ngữ nghĩa lỗi
Độc lập với framework không có nghĩa là cấm dùng framework. Express vẫn phù hợp để parse HTTP, Nest có thể quản lý composition, Prisma thực thi truy vấn và thư viện queue xử lý acknowledgment. Quy tắc là khái niệm của framework phải dừng ở adapter.
Một inbound HTTP adapter kiểm tra cú pháp, xác thực thông tin ở tầng transport, chuyển request thành PlaceOrderCommand, gọi use case rồi map kết quả thành HTTP response. Thanh toán bị từ chối nên trả 422 hay 409 là policy của HTTP. Application chỉ nên trả outcome giàu ý nghĩa như declined, không nên ném HttpException.
Outbound adapter dịch theo chiều ngược lại. Nó map value của application thành bản ghi SQL hoặc request của vendor, rồi chuyển lỗi bên ngoài về một tập failure có chủ đích và đủ nhỏ. Thiết kế tập lỗi này rất quan trọng. Timeout có thể retry, thẻ bị từ chối là kết quả nghiệp vụ, còn sai API credential là lỗi vận hành. Gom cả ba vào Error khiến bên gọi không thể phản ứng đúng.
Transaction boundary cũng cần được nhìn thẳng. Trong ví dụ trên, thanh toán có thể thành công trước khi save() thất bại. Không một cách chia thư mục nào khiến hai hệ thống đó trở thành atomic. Use case cần policy rõ ràng: retry idempotent với cùng order ID, lưu intent trước khi capture, thực hiện compensation, hoặc phát sự kiện qua outbox. Kiến trúc lục giác làm quyết định này lộ ra vì boundary đã rõ; nó không tự động giải quyết distributed transaction.
Cancellation, timeout và observability cũng vậy. Truyền cancellation qua port khi use case có thể xử lý nó. Đặt metrics và tracing quanh adapter hoặc use case, nhưng đừng bắt domain entity import telemetry SDK. Context kỹ thuật nên được ghi ở biên, còn business event nên dùng ngôn ngữ domain.
Những cách làm hỏng kiến trúc và lúc không nên dùng
Sai lầm phổ biến nhất là làm kiến trúc bằng tên thư mục. Đội ngũ tạo domain, application, infrastructure, sau đó vẫn gắn decorator ORM lên domain entity và import database client trong service. Tên đã đổi, hướng dependency thì chưa.
Sai lầm tiếp theo là tạo interface cho mọi class. Port nên đánh dấu boundary có ý nghĩa về ownership hoặc khả năng thay đổi, không phải trang trí internal helper. Một OrderTotalCalculator thuần túy và deterministic thường không cần port. Một use case đơn giản cũng không nhất thiết cần cả IPlaceOrderService lẫn class implementation một method nếu function type đã đủ diễn đạt inbound contract.
Port bị rò rỉ cũng gây chi phí lớn. Trả về ORM entity, framework exception, SDK object hay JSON chung chung sẽ ghép chặt hai phía dù đã phủ lên bằng interface. Repository quá rộng như Repository<T> còn kéo các thao tác persistence không cần thiết vào use case.
Mô hình này có chi phí thật: nhiều type hơn, phải mapping rõ ràng, cần composition root và cần thời gian học. Khi debug, kỹ sư đi qua nhiều boundary có tên. Một thay đổi nhỏ đôi khi chạm command type, port, adapter và test. Chi phí đó đáng trả khi luật nghiệp vụ có giá trị và hạ tầng thay đổi độc lập. Nó trở nên lãng phí khi ứng dụng hầu như chỉ là lớp bọc của hạ tầng.
Không cần dựng đầy đủ kiến trúc lục giác cho script migration dùng một lần, màn hình CRUD quản trị nhỏ, proof of concept chỉ nhằm kiểm tra SDK, hoặc service gần như không có domain policy. Hãy bắt đầu bằng module và pure function. Tách port khi external dependency làm test chậm, khi hai delivery mechanism cần dùng chung một use case, hoặc khi type của vendor bắt đầu len vào quyết định nghiệp vụ.
Cách áp dụng thực dụng nhất là đi từng phần. Chọn một workflow đang gây đau, viết inbound API cho use case đó, xác định hai hoặc ba năng lực outbound, rồi đẩy việc chuyển đổi vào adapter. Giữ composition tường minh. Đo thành công bằng mức độ dễ thay đổi và độ rõ của test, không phải số lượng interface hay sự đối xứng của cây thư mục.
Điều cần ghi nhớ
Kiến trúc lục giác trước hết là kỷ luật về dependency: policy nghiệp vụ sở hữu contract, còn công nghệ phụ thuộc vào contract đó. Inbound port đưa use case đến controller, job và consumer. Outbound port diễn đạt nhu cầu của use case đối với persistence, thanh toán, thời gian, messaging và các năng lực bên ngoài. Adapter làm nhiệm vụ chuyển đổi ở cả hai phía.
Lợi ích thực tế không phải lời hứa viển vông rằng một ngày nào đó ta sẽ đổi mọi framework. Đó là khả năng kiểm thử quyết định mà không bật hạ tầng, thay vendor mà không viết lại policy và thảo luận failure tại một boundary rõ ràng. Để giữ được lợi ích ấy, type của port phải hướng về domain, adapter thật phải được kiểm thử và vấn đề nhất quán xuyên hệ thống phải được xử lý trực diện.
Hãy dùng mô hình khi hành vi nghiệp vụ độc lập đủ quan trọng để xứng đáng với phần mapping và composition bổ sung. Hãy bỏ qua hoặc trì hoãn khi chương trình đơn giản, tạm thời, hoặc bị chi phối hoàn toàn bởi một công cụ bên ngoài. Một kiến trúc lục giác tốt không phải kiến trúc có nhiều cạnh nhất, mà là kiến trúc nơi dependency đi đúng hướng và mỗi boundary đều có lý do tồn tại.