Mở đầu — vì sao bài này quan trọng
Hãy tưởng tượng bạn xây một hệ thống đặt hàng cho một sàn thương mại điện tử. Ban đầu mọi thứ chạy trên MySQL, gọi payment qua cổng VNPAY, gửi thông báo qua email. Sáu tháng sau, sếp muốn thêm MoMo, đổi email sang gửi qua Telegram, và team data muốn đọc dữ liệu đơn hàng từ một message queue thay vì query trực tiếp database. Nếu logic nghiệp vụ "tính tổng đơn, áp mã giảm giá, kiểm tra tồn kho" của bạn nằm chèn lẫn trong cùng một hàm với mysqli_query() và curl gọi VNPAY, thì mỗi lần đổi một mảnh I/O bạn phải mổ xẻ đúng cái phần quan trọng nhất — logic tiền bạc — và rất dễ làm hỏng.
Đây chính là vấn đề mà Hexagonal Architecture (Kiến trúc Lục giác), còn gọi là Ports & Adapters, ra đời để giải quyết. Alistair Cockburn đề xuất mô hình này năm 2005 với một ý tưởng cực kỳ giản dị nhưng mạnh mẽ: tách phần logic nghiệp vụ (business logic) ra khỏi mọi thứ liên quan đến đầu vào/đầu ra (I/O) — database, API bên ngoài, giao diện, hàng đợi tin nhắn.
Trong khuôn khổ khóa học về ra quyết định kiến trúc, bài này không dạy bạn viết code hay ho hơn, mà dạy bạn một khung tổ chức phụ thuộc (dependency organization) giúp quyết định kiến trúc của bạn không bị "khóa cứng" vào một công nghệ cụ thể. Đây là nền tảng trực tiếp cho hai bài kế tiếp — Onion và Clean Architecture (Bài 15) — vốn là những biến thể của cùng một tư tưởng. Hiểu Hexagonal tốt, bạn sẽ hiểu tại sao rất nhiều hệ thống hiện đại "dễ test, dễ thay thế hạ tầng" đến vậy.
Khái niệm cốt lõi
Vấn đề gốc: sự lệ thuộc hướng vào trong hay ra ngoài?
Trong kiến trúc phân tầng (layered) truyền thống, luồng phụ thuộc thường là: UI → Business → Database. Nghĩa là tầng nghiệp vụ phụ thuộc vào tầng database. Khi database là thứ bạn phụ thuộc, mọi thay đổi từ dưới đáy sẽ dội ngược lên trên.
Hexagonal đảo ngược tư duy này bằng một nguyên tắc: logic nghiệp vụ không được phép biết gì về công nghệ bên ngoài. Database, framework web, cổng thanh toán... tất cả đều là "chi tiết" (detail), và chi tiết phải phụ thuộc vào logic, chứ không phải ngược lại.
Ba lớp của Hexagonal
Mô hình có ba vòng, hình dung như một lục giác:
1. Core / Domain (lõi nghiệp vụ) — Đây là trái tim. Nó chứa các quy tắc nghiệp vụ thuần túy: một đơn hàng được tính thế nào, một mã giảm giá hợp lệ ra sao, khi nào tồn kho không đủ. Lớp này không import bất kỳ thư viện database, HTTP, hay SDK nào. Nếu bạn mở một file trong core và thấy import mysql hay import stripe, thì bạn đã vi phạm nguyên tắc. Core chỉ nói ngôn ngữ nghiệp vụ.
2. Ports (cổng) — Ports là các interface (giao diện, hợp đồng) mà core định nghĩa để giao tiếp với thế giới. Có hai loại:
- Driving ports (cổng chủ động / inbound): mô tả những gì hệ thống cho phép người ngoài làm với nó. Ví dụ:
PlaceOrderUseCase— "đặt một đơn hàng". Ai đó gọi vào hệ thống qua cổng này. - Driven ports (cổng bị động / outbound): mô tả những gì hệ thống cần từ thế giới bên ngoài. Ví dụ:
OrderRepository— "lưu và đọc đơn hàng", hoặcPaymentGateway— "thu tiền". Core định nghĩa interface này nhưng không biết ai sẽ hiện thực nó.
3. Adapters (bộ chuyển đổi) — Adapters là phần hiện thực cụ thể của các port, nằm ngoài cùng:
- Driving adapters (inbound): một REST controller, một CLI command, một consumer của Kafka — chúng nhận yêu cầu từ bên ngoài và gọi vào driving port.
- Driven adapters (outbound): một class
MySQLOrderRepositoryhiện thựcOrderRepository, mộtVNPAYPaymentGatewayhiện thựcPaymentGateway. Chúng dịch từ ngôn ngữ nghiệp vụ sang lời gọi kỹ thuật cụ thể.
Nguyên tắc đảo ngược phụ thuộc
Sức mạnh của Hexagonal đến từ Dependency Inversion. Core định nghĩa interface PaymentGateway; adapter VNPAYPaymentGateway hiện thực nó. Mũi tên phụ thuộc chỉ từ ngoài vào trong — adapter biết về core, nhưng core hoàn toàn mù về adapter. Muốn đổi từ VNPAY sang MoMo? Bạn viết một adapter mới MoMoPaymentGateway, cắm vào chỗ cũ. Core không đổi một dòng.
Một cách nhớ: "tất cả các mũi tên phụ thuộc đều chĩa vào tâm lục giác." Nếu bạn thấy một mũi tên đi từ core ra ngoài, kiến trúc đã sai.
Vì sao gọi là "lục giác"?
Cockburn chọn hình lục giác không vì con số 6 có ý nghĩa gì đặc biệt, mà để phá vỡ tư duy "trên–dưới" của mô hình phân tầng. Một hình lục giác gợi ý rằng core có nhiều cạnh, mỗi cạnh là một port, và bạn có thể cắm bao nhiêu adapter tùy thích quanh nó — không có "trên" hay "dưới", chỉ có "trong" và "ngoài".
Tình huống thực tế
Ví dụ 1 — Tiki và bài toán đa cổng thanh toán
Giả sử một đội kỹ thuật tại một sàn TMĐT lớn ở Việt Nam (lấy bối cảnh kiểu Tiki) xây module thanh toán. Ban đầu, code xử lý đơn hàng gọi thẳng SDK của một cổng thanh toán duy nhất. Trong hai năm, họ phải tích hợp thêm VNPAY, MoMo, ZaloPay, thẻ quốc tế qua một cổng nước ngoài, và trả góp qua đối tác tài chính — tổng cộng 5 phương thức, mỗi cái một SDK riêng, một cách xử lý webhook callback riêng.
Nếu logic "xác nhận đơn khi thanh toán thành công, hoàn kho nếu thất bại" bị viết lẫn với từng SDK, họ sẽ có 5 bản sao gần giống nhau của cùng logic đó — địa ngục bảo trì.
Diễn giải: Đội áp dụng Hexagonal. Core định nghĩa một driven port duy nhất:
interface PaymentGateway {
charge(order: Order, amount: Money): PaymentResult
verifyCallback(payload): PaymentStatus
}
Mỗi cổng là một adapter riêng: VNPAYAdapter, MoMoAdapter, ZaloPayAdapter... đều hiện thực cùng interface. Logic nghiệp vụ "khi charge trả về SUCCESS thì confirm đơn" được viết một lần duy nhất trong core.
Bài học: Khi bạn dự đoán một loại phụ thuộc bên ngoài sẽ nhân bản (nhiều nhà cung cấp cùng làm một việc), Hexagonal biến chi phí thêm nhà cung cấp thứ N từ "sửa toàn hệ thống" xuống "viết một adapter mới, khoảng 200–300 dòng code". Đó là khoản đầu tư kiến trúc có ROI rõ ràng.
Ví dụ 2 — Một startup fintech và khả năng test không cần hạ tầng thật
Một startup fintech ở TP.HCM (khoảng 15 kỹ sư) làm ví điện tử. Đau đầu lớn nhất của họ: mỗi lần chạy test tự động phải kết nối tới sandbox của ngân hàng đối tác, mà sandbox đó hay sập, chạy chậm, và giới hạn số lần gọi mỗi ngày. Bộ test end-to-end mất 40 phút và fail ngẫu nhiên 20% số lần vì lỗi mạng — không phải lỗi code.
Diễn giải: Vì họ đã dùng Hexagonal, port BankTransferGateway là một interface. Trong test, họ cắm vào một fake adapter — InMemoryBankGateway — trả về kết quả định trước, chạy trong bộ nhớ. Toàn bộ logic nghiệp vụ (kiểm tra số dư, tính phí, hạn mức giao dịch/ngày) được test đầy đủ mà không đụng tới sandbox ngân hàng. Bộ test lõi giảm từ 40 phút xuống dưới 90 giây, và tỉ lệ fail ngẫu nhiên về gần 0. Họ chỉ chạy test tích hợp thật với sandbox một lần mỗi đêm.
Bài học: Lợi ích lớn nhất của Hexagonal trong thực tế thường không phải là "dễ đổi database" (điều hiếm khi xảy ra), mà là khả năng test logic nghiệp vụ một cách cô lập, nhanh, ổn định. Khi hạ tầng bên ngoài đứng sau một port, bạn thay nó bằng đồ giả bất cứ lúc nào.
Ví dụ 3 — Khi Hexagonal là "quá tay" cho một CRUD nội bộ
Một công ty logistics vừa và nhỏ cần một tool nội bộ để nhân viên nhập và tra cứu vận đơn — thuần CRUD, không có logic nghiệp vụ phức tạp, dùng nội bộ cho 30 người, gần như chắc chắn sẽ luôn dùng PostgreSQL.
Một kỹ sư mới, hào hứng với Hexagonal, dựng đầy đủ port và adapter cho mọi thao tác. Kết quả: để thêm một trường "ghi chú" vào vận đơn, anh phải sửa 6 file ở 3 lớp khác nhau. Cả team phàn nàn tốc độ phát triển chậm gấp đôi so với việc dùng thẳng một ORM.
Diễn giải: Ở đây, "core" gần như trống rỗng — không có quy tắc nghiệp vụ nào đáng để bảo vệ. Các port chỉ là lớp chuyển tiếp vô nghĩa (passthrough), không mang lại giá trị cách ly nào cả.
Bài học: Hexagonal có chi phí (indirection, số lượng file, thời gian đọc hiểu). Nó đáng giá khi core có logic nghiệp vụ thật sự đáng bảo vệ và/hoặc bạn thật sự cần thay thế hạ tầng hay test cô lập. Với CRUD thuần, nó là over-engineering. Ra quyết định kiến trúc luôn là bài toán đánh đổi, không phải áp dụng mẫu một cách máy móc.
Hướng dẫn từng bước
Giả sử bạn xây một use case "Đặt hàng". Đây là cách áp dụng Hexagonal có hệ thống:
Bước 1 — Xác định core domain. Viết ra bằng ngôn ngữ nghiệp vụ, không nhắc công nghệ: "Đặt hàng cần kiểm tra tồn kho, tính tổng tiền sau giảm giá, thu tiền, và ghi nhận đơn." Đây là những gì phải đúng bất kể database hay cổng thanh toán nào.
Bước 2 — Định nghĩa driving port (inbound). Tạo interface use case:
interface PlaceOrderUseCase {
execute(command: PlaceOrderCommand): OrderResult
}
Đây là "cánh cửa" duy nhất để bên ngoài yêu cầu hệ thống đặt hàng.Bước 3 — Định nghĩa driven ports (outbound). Liệt kê những gì core cần từ thế giới: InventoryRepository, OrderRepository, PaymentGateway, NotificationSender. Định nghĩa chúng thành interface, đặt ngay cạnh core, bằng thuật ngữ nghiệp vụ (charge, reserveStock) chứ không phải thuật ngữ kỹ thuật (insertRow, httpPost).
Bước 4 — Hiện thực logic trong core. Viết class PlaceOrderService implements PlaceOrderUseCase. Nó chỉ gọi tới các interface port, không bao giờ tới class cụ thể. Đây là nơi toàn bộ quy tắc nghiệp vụ sống.
Bước 5 — Viết driven adapters. PostgresOrderRepository implements OrderRepository, MoMoPaymentGateway implements PaymentGateway... Mỗi adapter dịch từ interface nghiệp vụ sang lời gọi kỹ thuật thật.
Bước 6 — Viết driving adapters. Một OrderController (REST) hoặc một Kafka consumer nhận request bên ngoài, dựng PlaceOrderCommand, và gọi PlaceOrderUseCase.
Bước 7 — Ráp nối ở "biên" (composition root). Ở điểm khởi động ứng dụng (main / DI container), bạn "cắm" adapter cụ thể vào port: nói với hệ thống rằng PaymentGateway sẽ là MoMoPaymentGateway. Đây là nơi duy nhất công nghệ cụ thể gặp core.
Lỗi thường gặp & mẹo
Lỗi 1 — Rò rỉ hạ tầng vào core. Dấu hiệu kinh điển: một entity trong core mang annotation @Entity, @Table của ORM, hay một object nghiệp vụ có trường kiểu ResultSet. Khi đó core đã bị buộc vào database. Mẹo: dùng object nghiệp vụ thuần trong core, và để adapter chịu trách nhiệm map sang model của ORM.
Lỗi 2 — Port định nghĩa bằng ngôn ngữ kỹ thuật. Nếu port của bạn tên saveToMySQL() thay vì save(), bạn đã để công nghệ rò vào hợp đồng. Port phải phản ánh ý định nghiệp vụ, không phải cách hiện thực.
Lỗi 3 — Port "hạt quá mịn", ánh xạ 1–1 với query. Nếu mỗi câu SQL thành một port riêng, bạn chỉ đang bọc một lớp vô nghĩa quanh database. Port nên phản ánh nhu cầu nghiệp vụ ("tìm đơn chưa thanh toán quá 24h"), gom các thao tác có ý nghĩa.
Lỗi 4 — Áp dụng cho mọi thứ. Như ví dụ 3, đừng dùng Hexagonal cho CRUD thuần không logic. Mẹo ra quyết định: hỏi "core này có quy tắc nghiệp vụ nào đáng bảo vệ khỏi thay đổi hạ tầng không? Tôi có thật sự cần test nó cô lập không?" Nếu cả hai đều "không", hãy dùng kiến trúc đơn giản hơn.
Mẹo 5 — Bắt đầu từ port outbound quan trọng nhất. Không cần Hexagon hóa toàn hệ thống ngay. Hãy đặt port cho những phụ thuộc dễ thay đổi hoặc khó test nhất (payment, external API) trước, và để những phần ổn định dùng cách đơn giản.
Mẹo 6 — Một adapter, một trách nhiệm. Đừng để một adapter vừa gọi database vừa gửi email. Mỗi port một adapter, giữ ranh giới rõ ràng.
Bài tập thực hành
- Nhận diện port. Lấy một tính năng bạn đang làm (ví dụ: "gửi OTP đăng nhập"). Viết ra: đâu là driving port, đâu là các driven port. Đặt tên chúng bằng ngôn ngữ nghiệp vụ.
- Vẽ lục giác. Vẽ core ở giữa, các port quanh nó, và với mỗi driven port liệt kê ít nhất 2 adapter khả dĩ (ví dụ với
OtpSender:TwilioSmsAdapter,SpeedSMSAdapter,FakeOtpAdaptercho test). Kiểm tra: mọi mũi tên phụ thuộc có chĩa vào tâm không?
- Refactor tư duy. Tìm một hàm trong code hiện tại của bạn có trộn lẫn logic nghiệp vụ với I/O (query DB, gọi API). Viết lại (trên giấy cũng được) tách nó thành: core logic + một driven port + một adapter. Ghi lại xem tách xong bạn test được logic đó mà không cần DB thật hay không.
- Ra quyết định. Cho 3 tình huống: (a) một blog cá nhân, (b) hệ thống thanh toán ngân hàng, (c) một tool xuất báo cáo nội bộ đọc từ 1 database cố định. Với mỗi cái, quyết định có nên dùng Hexagonal không và giải thích bằng tiêu chí "logic đáng bảo vệ" và "nhu cầu test/thay hạ tầng".
Tóm tắt
- Hexagonal Architecture (Ports & Adapters), do Alistair Cockburn đề xuất năm 2005, tách logic nghiệp vụ ra khỏi mọi mối quan tâm về I/O (database, API, UI, message queue).
- Ba lớp: Core/Domain (quy tắc nghiệp vụ thuần, không biết công nghệ), Ports (interface core định nghĩa — driving/inbound và driven/outbound), Adapters (hiện thực cụ thể của port, nằm ngoài cùng).
- Nguyên tắc vàng: mọi phụ thuộc chĩa vào tâm. Core mù về adapter; adapter biết về core. Nhờ dependency inversion, đổi công nghệ chỉ cần viết adapter mới.
- Lợi ích thực tế lớn nhất thường là khả năng test cô lập, nhanh, ổn định — thay adapter thật bằng đồ giả — chứ không chỉ là "dễ đổi database".
- Nó có chi phí (indirection, nhiều file). Chỉ dùng khi core có logic đáng bảo vệ hoặc bạn thật sự cần thay hạ tầng / test cô lập. Với CRUD thuần, đó là over-engineering.
- Đây là nền tảng để hiểu Onion và Clean Architecture ở bài kế tiếp — chúng là những biến thể cùng một tư tưởng "phụ thuộc hướng vào trong".