Hexagonal Architecture (Ports & Adapters): Cách cô lập Business Logic khỏi công nghệ bên ngoài
7/19/2026 · 11p đọc
title: "Hexagonal Architecture (Ports & Adapters): Cách cô lập Business Logic khỏi công nghệ bên ngoài"
series: "Software Architecture Masterclass"
part: "Phần 2 — Cấu trúc & Patterns"
order: 16
audience: "Solutions Architect & Technical Lead"
reading_time: "13 phút"
tags: ["hexagonal-architecture", "ports-and-adapters", "clean-architecture", "testability", "dependency-inversion", "domain-driven-design"]
Hexagonal Architecture (Ports & Adapters): Cách cô lập Business Logic khỏi công nghệ bên ngoài
Một team viết OrderService.createOrder() gọi thẳng this.mysqlConnection.query(...), publish message qua this.rabbitChannel.publish(...), và gọi this.stripeClient.charge(...) ngay trong cùng một hàm. Code chạy tốt, ship đúng deadline. Hai năm sau, công ty quyết định migrate từ MySQL sang một giải pháp NewSQL để scale, và đổi payment provider vì Stripe tính phí cao hơn đối thủ ở thị trường mới. Việc tưởng chừng chỉ là "đổi driver" biến thành một dự án viết lại toàn bộ domain logic, vì business rule (tính discount, validate tồn kho, áp dụng chính sách giá) bị trộn lẫn với SQL query cụ thể và SDK của Stripe.
Đây không phải là lỗi của lập trình viên thiếu kinh nghiệm — đây là hậu quả tất yếu của việc không phân định ranh giới giữa "cái gì hệ thống làm" (business logic) và "hệ thống làm điều đó bằng công nghệ nào" (infrastructure). Khi hai thứ này dính chặt vào nhau, mọi thay đổi công nghệ đều kéo theo rủi ro phá vỡ logic nghiệp vụ, và mọi unit test đều biến thành integration test cần một database thật đang chạy.
Bài này nói về Hexagonal Architecture — hay còn gọi là Ports & Adapters, do Alistair Cockburn đề xuất năm 2005 — pattern giải quyết chính xác vấn đề đó bằng cách vẽ một ranh giới tường minh và bắt buộc thực thi giữa lõi nghiệp vụ và thế giới bên ngoài.
The Problem
Vấn đề kiến trúc cụ thể ở đây có ba biểu hiện, thường xuất hiện đồng thời:
Một, business logic gắn chặt (coupled) vào một công nghệ hạ tầng cụ thể. Service class import trực tiếp TypeORM repository, driver MySQL, SDK của một message broker, hoặc HTTP client của một API bên thứ ba. Logic tính giá, logic phê duyệt, logic workflow — những thứ đại diện cho tri thức nghiệp vụ có giá trị lâu dài — bị chôn lẫn với chi tiết triển khai có vòng đời ngắn hơn nhiều.
Hai, testing đòi hỏi hạ tầng thật. Muốn test logic "đơn hàng có được áp dụng mã giảm giá X hay không", developer phải khởi động MySQL container, seed dữ liệu, chờ connection pool sẵn sàng — chỉ để verify một điều kiện if/else thuần túy. Test suite chậm, giòn (flaky), và không ai muốn chạy trước mỗi lần commit.
Ba, đổi công nghệ đồng nghĩa viết lại logic nghiệp vụ. Vì business rule import trực tiếp API của framework/DB/SDK, không có ranh giới nào để "cắt" khi cần thay driver. Migrate từ REST sang gRPC, đổi message queue, chuyển vector DB — mỗi lần đều là một cuộc phẫu thuật xâm lấn vào phần code lẽ ra không nên bị động tới.
Gốc rễ của cả ba là thiếu một nguyên tắc dependency direction rõ ràng: ai được phép phụ thuộc vào ai.
The Theory
Hexagonal Architecture định nghĩa hai vùng: lõi (core/domain) chứa business logic thuần túy, và các adapter bao quanh lõi để kết nối nó với thế giới bên ngoài — database, HTTP, message queue, hệ thống bên thứ ba.
Ranh giới giữa hai vùng này được định nghĩa bằng port — một interface (trong TypeScript là interface, trong nhiều ngôn ngữ khác là abstract class/protocol) do chính lõi nghiệp vụ định nghĩa, theo đúng ngôn ngữ và nhu cầu của nghiệp vụ, không theo ngôn ngữ của công nghệ hạ tầng.
Có hai loại port:
- Driving port (primary/inbound): cách thế giới bên ngoài gọi vào lõi — ví dụ
OrderUseCase.createOrder(cmd). Controller HTTP, message consumer, CLI command đều là adapter gọi vào port này. - Driven port (secondary/outbound): cách lõi gọi ra ngoài để lấy dữ liệu hoặc side-effect — ví dụ
OrderRepository.save(order),PaymentGateway.charge(amount),EventPublisher.publish(event). Lõi định nghĩa interface này, còn implementation cụ thể (TypeORM repository, Stripe adapter, RabbitMQ publisher) nằm ở lớp ngoài.
Nguyên tắc cốt lõi là Dependency Inversion: lõi không phụ thuộc vào adapter; ngược lại, adapter phụ thuộc vào port do lõi định nghĩa, rồi implement nó. Mũi tên phụ thuộc luôn hướng vào trong, không bao giờ hướng ra ngoài. Ở compile-time, domain/ module không có bất kỳ import nào trỏ tới infrastructure/.
Điều này khác một cách tinh vi so với layered architecture truyền thống (presentation → business → data access), nơi tầng business vẫn thường phụ thuộc trực tiếp vào tầng data access bên dưới nó. Trong hexagonal, tầng data access (adapter) phụ thuộc ngược lại vào business (qua port), không phải chiều ngược lại.
Hệ quả trực tiếp: muốn test lõi nghiệp vụ, ta không cần database thật — chỉ cần một in-memory implementation hoặc mock của port. Muốn đổi công nghệ, ta chỉ viết một adapter mới implement đúng port cũ, lõi không hề hay biết hay bị động tới.
Một điểm hay bị hiểu nhầm: hexagonal không nói "không dùng framework". Nó nói "framework không được xuất hiện trong chữ ký hàm và kiểu dữ liệu của lõi". NestJS, TypeORM, hay bất kỳ công nghệ nào vẫn được dùng thoải mái — nhưng chỉ ở tầng adapter, nơi chúng được cấu hình rồi wire vào port thông qua Dependency Injection container. Cái tên "hexagon" (lục giác) trong hình vẽ gốc của Cockburn không mang ý nghĩa kỹ thuật đặc biệt — sáu cạnh chỉ đơn giản là đủ chỗ để vẽ nhiều port khác nhau (HTTP vào, message queue vào, database ra, payment gateway ra, cache ra...) mà không cần hình chữ nhật hai chiều gò bó như layered diagram truyền thống. Điều quan trọng không phải là số cạnh, mà là nguyên tắc: mọi cạnh của lục giác đều là một port, và lõi bên trong không biết gì về những gì nằm bên ngoài từng cạnh đó.
Một cách khác để nhìn nhận: hexagonal architecture là một dạng cụ thể hóa của Dependency Inversion Principle (chữ D trong SOLID) ở cấp độ kiến trúc toàn hệ thống, thay vì chỉ ở cấp độ một class riêng lẻ. Thay vì "high-level module không nên phụ thuộc vào low-level module, cả hai nên phụ thuộc vào abstraction" áp dụng cho hai class, ta áp dụng nó cho toàn bộ ranh giới giữa domain và infrastructure.
The Trade-off
Đây là phần phải nói thẳng: Hexagonal Architecture không miễn phí, và nó không phải lựa chọn đúng cho mọi tình huống.
Được gì:
- Testability tăng vọt. Unit test cho business logic chạy bằng in-memory fake/mock implementation của port, không cần Docker container, không cần network I/O. Test suite chạy trong mili-giây thay vì giây, chạy được hàng nghìn lần trong CI mà không tốn chi phí hạ tầng test.
- Khả năng thay thế công nghệ (technology substitutability) thực sự tồn tại. Migrate MySQL sang một DB khác, đổi RabbitMQ sang một broker khác, đổi Stripe sang một payment provider khác — mỗi việc chỉ là viết một adapter mới implement đúng port cũ. Lõi nghiệp vụ không đổi một dòng.
- Business logic trở thành tài sản có thể đọc độc lập. Một kiến trúc sư hoặc domain expert đọc lõi mà không bị nhiễu bởi chi tiết
connection.query()hay retry logic của HTTP client. Đây là lợi ích thường bị đánh giá thấp: code trở thành tài liệu sống của nghiệp vụ. - Nhiều adapter cho cùng một port song song. Ví dụ vừa expose REST API vừa nhận lệnh qua message queue, cả hai đều gọi vào cùng một use case — không nhân bản logic.
Mất gì — và phải nói rõ, không né:
- Số lượng file và interface tăng đáng kể. Một thao tác đơn giản như "lưu đơn hàng" giờ cần: một interface
OrderRepositoryPort, một implementationTypeOrmOrderRepositoryAdapter, một DI binding nối hai thứ này. Với một CRUD đơn giản không có business logic đáng kể, đây là bureaucracy thuần túy — ba file cho việc mà trước đây một dòng ORM call đã xong. - Gián tiếp (indirection) làm chậm việc đọc code lần đầu. Developer mới join phải nhảy qua ít nhất hai file (port + adapter) để hiểu một luồng dữ liệu chạy tới đâu. Debugger phải step qua thêm một lớp interface. Với domain đơn giản, chi phí nhận thức (cognitive overhead) này lớn hơn lợi ích.
- Rủi ro over-abstraction khi định nghĩa port sai. Nếu port được thiết kế mô phỏng quá sát API của một công nghệ cụ thể (ví dụ port có method
findByIdWithLock()chỉ có ý nghĩa với một DB engine cụ thể), lớp trừu tượng trở thành giả — vẫn phải sửa lõi khi đổi công nghệ, chỉ là sửa gián tiếp qua port thay vì trực tiếp. - Mapping overhead giữa domain model và persistence model. Lõi làm việc với domain entity thuần (plain object, không decorator ORM); adapter phải map qua lại giữa domain entity và ORM entity/DTO. Đây là thêm một tầng chuyển đổi cần viết và maintain, và là nguồn bug nếu mapping không đầy đủ (thiếu field, sai kiểu dữ liệu).
Nói cách khác: hexagonal đánh đổi tốc độ viết code ban đầu và sự đơn giản trực quan để lấy khả năng test và khả năng thích ứng công nghệ dài hạn. Nếu domain logic của bạn thực sự đơn giản và ổn định (ít thay đổi, ít rule phức tạp), cái giá phải trả không đáng — bạn đang mua bảo hiểm cho một rủi ro không cao.
The Architecture Diagram
flowchart TB
subgraph Hex["HEXAGON — Ranh giới ứng dụng"]
direction TB
subgraph Core["Lõi nghiệp vụ (Domain + Application)"]
UC["Use Case: CreateOrder<br/>Business Rules: pricing, validation, discount"]
end
PIn["Driving Port<br/>(interface) OrderUseCase"]
POut1["Driven Port<br/>(interface) OrderRepositoryPort"]
POut2["Driven Port<br/>(interface) PaymentGatewayPort"]
POut3["Driven Port<br/>(interface) EventPublisherPort"]
PIn --> UC
UC --> POut1
UC --> POut2
UC --> POut3
end
subgraph Driving["Driving Adapters (đi vào)"]
HTTP["REST Controller<br/>(NestJS)"]
MQ["Message Consumer<br/>(RabbitMQ)"]
CLI["CLI Command"]
end
subgraph Driven["Driven Adapters (đi ra)"]
DB["TypeOrmOrderRepository<br/>(MySQL)"]
PAY["StripePaymentAdapter"]
EVT["RabbitMqPublisherAdapter"]
end
HTTP -->|"gọi vào"| PIn
MQ -->|"gọi vào"| PIn
CLI -->|"gọi vào"| PIn
DB -.->|"implement"| POut1
PAY -.->|"implement"| POut2
EVT -.->|"implement"| POut3
style Core fill:#2d3748,stroke:#63b3ed,stroke-width:3px,color:#fff
style Hex fill:#1a202c,stroke:#63b3ed,stroke-width:2px
Điểm mấu chốt trong sơ đồ: mũi tên nét liền (driving adapter → port) đi vào lõi, mũi tên nét đứt (driven adapter → port) là quan hệ "implement" — chiều phụ thuộc thực tế của driven adapter là hướng vào trong (nó phụ thuộc vào interface do lõi định nghĩa), dù luồng dữ liệu runtime chạy ra ngoài. Đây chính là dependency inversion: code hạ tầng phụ thuộc vào domain, không phải ngược lại.
Implementation Guardrails
Bốn quy tắc cứng, không thương lượng, khi triển khai pattern này trong một codebase NestJS/TypeORM thực tế:
Tầng domain/application KHÔNG được import bất kỳ package framework hay infrastructure nào. Không
import { Entity, Column } from 'typeorm', khôngimport { Injectable } from '@nestjs/common'trong domain entity thuần, khôngimport Redis from 'ioredis'. Nếu domain layer cần decorator DI, dùng interface + constructor injection tại tầng application, còn domain entity nên là plain class/object không phụ thuộc gì. Vi phạm quy tắc này ngay lập tức xóa bỏ toàn bộ lợi ích của pattern — dùng ESLint boundary rule (ví dụeslint-plugin-boundarieshoặc dependency-cruiser) để enforce tự động trong CI, không dựa vào code review bằng mắt.Port được định nghĩa bằng ngôn ngữ nghiệp vụ, không phải ngôn ngữ công nghệ.
OrderRepositoryPort.findPendingOrdersOlderThan(days: number)là đúng;OrderRepositoryPort.rawQuery(sql: string)là sai — nó rò rỉ chi tiết SQL vào interface mà lõi phải phụ thuộc. Nếu tên method hoặc tham số của port chỉ có ý nghĩa với một công nghệ cụ thể (ví dụ tham khảoWHEREclause,TTLcủa Redis), port đó thất bại trong việc trừu tượng hóa.Mọi adapter implement port phải có ít nhất một fake/in-memory version dùng cho test. Không chỉ mock bằng
jest.fn()rời rạc ở từng test case — viết mộtInMemoryOrderRepository implements OrderRepositoryPortdùng chung cho toàn bộ test suite của use case đó. Đây là cách buộc team thực sự thiết kế port đủ nhỏ và đủ rõ ràng để implement lại được; nếu fake khó viết, port đang thiết kế sai.DI container là nơi DUY NHẤT biết cả port lẫn adapter cụ thể. Trong NestJS, dùng token injection (
@Inject('ORDER_REPOSITORY_PORT')) và bind trong module ({ provide: 'ORDER_REPOSITORY_PORT', useClass: TypeOrmOrderRepositoryAdapter }). Không import trực tiếp adapter class vào use case class dưới bất kỳ hình thức nào, kể cả "tạm thời cho nhanh" — thứ tạm thời này luôn trở thành vĩnh viễn.
Architect's Verdict
Đầu tư lớp trừu tượng Ports & Adapters cho phần domain logic lõi có tuổi thọ dài, có nhiều business rule phức tạp, và có khả năng đổi công nghệ hạ tầng trong vòng đời sản phẩm — đó là khoản đầu tư trả lãi kép qua nhiều năm. Nhưng áp dụng nó cho một CRUD module đơn giản, một prototype dùng thử rồi bỏ, hoặc một service mà bạn biết chắc sẽ không bao giờ đổi database, là thừa thãi thuần túy — bạn đang trả phí bảo hiểm cho rủi ro không tồn tại. Quy tắc kinh nghiệm: áp dụng hexagonal có chọn lọc, chỉ cho bounded context nào chứa tri thức nghiệp vụ cốt lõi (core domain, theo ngôn ngữ DDD), để lại các module hạ tầng phụ trợ (logging, reporting đơn giản) chạy layered architecture thông thường.
🔗 Bài viết liên quan
Bài trước: Service-Oriented Architecture (SOA) thời đại mới · Bài tiếp theo: Clean Architecture