Kiến trúc Hexagonal (Alistair Cockburn, còn gọi ports and adapters) là họ hàng gần của Clean Architecture ở bài trước, nhưng có một cách nhìn tinh tế hơn về ranh giới. Thay vì các vòng tròn đồng tâm, nó vẽ lõi nghiệp vụ như một hình lục giác với các cổng (port) ở mọi cạnh. Điểm mạnh của mô hình này là làm rõ hai loại port đối xứng: cái mà lõi cung cấp cho thế giới bên ngoài, và cái mà lõi cần từ thế giới bên ngoài. Bài này dựng ví dụ chạy được và đo thật cách đổi adapter mà không chạm lõi — điểm bán hàng cốt lõi của Hexagonal.
Hai loại port: driving và driven
Điểm khác biệt cốt lõi của Hexagonal so với "layered" thông thường là phân biệt hai chiều của port. Cả hai đều là interface định nghĩa trong lõi:
package core
// DRIVEN PORT (outbound): lõi CẦN cái này, adapter cung cấp
type GuiThongBao interface { Gui(toi, nd string) error }
// DRIVING PORT (inbound): lõi CUNG CẤP cái này cho adapter ngoài gọi
type DatHang interface { Dat(khach, mon string) error }
Driving port (còn gọi primary, inbound): là API mà lõi cung cấp — một adapter bên ngoài (HTTP handler, CLI, gRPC) gọi vào lõi qua đây. Driven port (còn gọi secondary, outbound): là thứ lõi cần — lõi gọi ra qua đây, và một adapter (CSDL, email, hàng đợi) cung cấp hiện thực. Lõi hiện thực driving port và dùng driven port:
type dichVu struct{ tb GuiThongBao } // giữ driven port
func New(tb GuiThongBao) DatHang { return &dichVu{tb} }
func (s *dichVu) Dat(khach, mon string) error {
// ... logic nghiệp vụ ...
return s.tb.Gui(khach, "Đơn "+mon+" đã nhận") // gọi RA qua driven port
}

Hình 1: Lõi định nghĩa cả hai port — driving (DatHang, lõi cung cấp) và driven (GuiThongBao, lõi cần). Nhiều adapter cùng thỏa một driven port; main là composition root cắm adapter cụ thể vào lõi.
Adapters cắm vào, và đổi được tự do
Adapter là hiện thực cụ thể của một port. Ở đây hai adapter driven cùng thỏa GuiThongBao — một gửi email, một gửi SMS. Nhờ duck typing của Go, chúng thỏa interface chỉ bằng việc có đúng phương thức Gui, không cần import package core:
type EmailTB struct{}
func (EmailTB) Gui(toi, nd string) error { /* gửi email */ }
type SmsTB struct{}
func (SmsTB) Gui(toi, nd string) error { /* gửi SMS */ }
main là composition root — nơi duy nhất cắm adapter cụ thể vào lõi. Đo thật (Go 1.23), đổi adapter chỉ sửa main:
svc := core.New(adapters.EmailTB{}) // cắm adapter EMAIL
svc.Dat("An", "Cà phê")
svc = core.New(adapters.SmsTB{}) // ĐỔI sang SMS
svc.Dat("Bình", "Trà sữa")
Kết quả chạy thật: [email->An] Đơn Cà phê đã nhận rồi [sms->Bình] Đơn Trà sữa đã nhận. Lõi core.Dat không đổi một dòng khi ta chuyển từ email sang SMS — nó chỉ biết interface GuiThongBao, không biết adapter cụ thể nào đứng sau.
Đo thật: lõi cô lập hoàn toàn
Sức mạnh của Hexagonal nằm ở sự cô lập của lõi. Đo thật đồ thị import bằng go list:

Hình 2: go list cho thấy core và adapters đều không import package nội bộ nào; chỉ main import cả hai. Lõi là hình lục giác ở giữa với ports hai phía; adapter cắm từ ngoài. Test lõi bằng spyTB (mock driven port) chạy ok 0.001s.
core không import package nội bộ nào — nó cô lập hoàn toàn với thế giới ngoài. Thú vị hơn, adapters cũng không import core: nhờ duck typing, adapter thỏa port một cách cấu trúc mà không cần phụ thuộc rõ ràng. Chỉ main (composition root) import cả hai để nối dây. Đây là lục giác Cockburn vẽ: lõi ở giữa, mọi thứ khác là adapter cắm vào ports từ bên ngoài, và không adapter nào "thấm" vào lõi.
Lợi ích kiểm thử là trực tiếp: thay driven port bằng một spy (mock ghi lại lời gọi) để test lõi mà không cần email/SMS thật. Đo thật, go test ./core với spyTB chạy ok 0.001s, kiểm được lõi gọi đúng port ra với đúng tham số.
Đánh đổi cần cân nhắc
Hexagonal và Clean Architecture rất gần — đừng tranh cãi tên gọi. Cả hai đều dựa trên dependency inversion: lõi định nghĩa interface, hạ tầng hiện thực. Điểm nhấn của Hexagonal là tính đối xứng driving/driven làm rõ hai chiều vào/ra; Clean Architecture nhấn vào các vòng đồng tâm. Trong Go, cả hai ra cùng một cấu trúc package. Chọn ngôn từ nào đội bạn quen — quan trọng là hướng phụ thuộc, không phải nhãn.
Duck typing của Go làm adapter nhẹ nhưng cần kỷ luật. Vì adapter không import interface nó thỏa, việc adapter "khớp" port là ngầm định — đổi chữ ký Gui trong core sẽ làm adapter lặng lẽ không còn thỏa interface, và lỗi chỉ lộ ở main lúc nối dây. Một mẹo là thêm khẳng định biên dịch var _ core.GuiThongBao = EmailTB{} trong package adapter để bắt lệch sớm — nhưng nó buộc adapter import core, đánh đổi tính tách rời lấy an toàn.
Đừng dựng lục giác cho việc nhỏ. Như Clean Architecture, Hexagonal trả cổ tức khi bạn thật sự có nhiều adapter (nhiều nguồn vào: HTTP + gRPC + CLI; nhiều đích ra: Postgres + cache + message queue). Nếu chỉ có một đường vào và một CSDL, số interface và tầng gián tiếp là chi phí không đáng. Bắt đầu phẳng, tách port ra khi thấy nhu cầu adapter thứ hai thật sự xuất hiện.
Ba ý mang về
- Hexagonal phân biệt hai loại port đối xứng, cả hai định nghĩa trong lõi: driving port (inbound, lõi cung cấp cho adapter ngoài gọi vào) và driven port (outbound, lõi cần, adapter cung cấp) — làm rõ hai chiều vào/ra của lõi.
- Đổi adapter không chạm lõi: đo thật, chuyển gửi thông báo từ
EmailTBsangSmsTBchỉ sửamain(composition root), còncore.Datkhông đổi một dòng vì nó chỉ biết interfaceGuiThongBao. - Lõi cô lập và test được không cần I/O thật: đo thật
go listcho thấycorekhông import package nội bộ nào (nhờ duck typing adapter cũng không import core), và test lõi bằng spy mock chạyok 0.001s— nhưng chỉ đáng dựng khi thật sự có nhiều adapter.
Phần sau ta đi vào chi tiết kỹ thuật của việc nối dây các adapter vào lõi: Phần sau so sánh dependency injection thủ công với công cụ wire của Google — khi nào tự nối dây là đủ, khi nào sinh mã DI đáng dùng.