blogbyAndrew

Hexagonal Architecture (Ports & Adapters)

June 13, 2026

Tổ ong hình lục giác xếp khít nhau — ẩn dụ cho kiến trúc hexagonal với nhiều cổng (ports) bao quanh một lõi

Photo by Ante Hamersmit on Unsplash

Mở đầu

Hãy thử trả lời nhanh một câu hỏi: nếu sếp yêu cầu bạn đổi từ PostgreSQL sang MongoDB, bạn phải sửa bao nhiêu file? Nếu thay REST API bằng gRPC thì sao? Còn nếu muốn viết test cho phần nghiệp vụ mà không cần khởi động database thật?

Trong nhiều codebase, câu trả lời cho cả ba câu hỏi đều là "sửa khắp nơi". Logic nghiệp vụ (business logic) bị trộn lẫn với mã gọi database, mã parse HTTP request, mã gọi API bên thứ ba. Đổi một thứ ở rìa, cả lõi rung lắc theo.

Hexagonal Architecture (hay Ports & Adapters) là một câu trả lời cho vấn đề đó. Ý tưởng cốt lõi rất đơn giản: đặt logic nghiệp vụ vào trung tâm, và để mọi thứ thuộc về "thế giới bên ngoài" — database, framework web, message queue, email service — chỉ là các thành phần cắm vào qua những điểm nối được định nghĩa rõ ràng. Cắm vào, rút ra, thay thế — mà không động đến lõi.

Pattern này được Alistair Cockburn đề xuất từ giữa những năm 1990, và năm 2005 ông đổi tên chính thức thành Ports and Adapters. Mục tiêu mà chính Cockburn phát biểu là:

"Allow an application to equally be driven by users, programs, automated test or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases."

(Cho phép ứng dụng được điều khiển một cách bình đẳng bởi người dùng, chương trình khác, test tự động hay batch script — và được phát triển, kiểm thử một cách độc lập với các thiết bị, database mà nó sẽ chạy cùng sau này.)

Trong bài này, chúng ta sẽ đi qua:

Nếu bạn đã đọc bài Clean Architecture in Go trên blog này, bạn sẽ thấy Hexagonal là một người anh em rất gần — cùng triết lý "đặt nghiệp vụ ở trung tâm, đẩy chi tiết kỹ thuật ra rìa". Chúng ta sẽ làm rõ điểm giống và khác ở cuối bài.

Vấn đề với kiến trúc phân lớp truyền thống

Hầu hết chúng ta đều khởi đầu với một kiến trúc phân lớp (layered/n-tier) quen thuộc: tầng trình bày (UI) ở trên, tầng nghiệp vụ ở giữa, tầng dữ liệu ở dưới. Mỗi tầng gọi xuống tầng ngay bên dưới nó.

text
┌─────────────────────────────────────┐
│         Presentation Layer          │
│         (controllers, HTTP)         │
├─────────────────────────────────────┤
│           Business Layer            │
│             (services)              │
├─────────────────────────────────────┤
│             Data Layer              │
│              (DB, ORM)              │
└─────────────────────────────────────┘

Mô hình này trông gọn gàng, nhưng nó giấu một vấn đề sâu xa: mọi thứ phụ thuộc, trực tiếp hoặc gián tiếp, vào tầng dưới cùng — thường là database. Khi business layer import thẳng package database, hệ quả kéo theo là:

Martin Fowler gọi gốc rễ vấn đề là sự bất đối xứng mà mô hình phân lớp không thể hiện được: có những thành phần điều khiển ứng dụng (người dùng bấm nút), và có những thành phần được ứng dụng điều khiển (database mà ứng dụng ghi vào). Cả hai đều nằm "bên ngoài" lõi nghiệp vụ, nhưng mối quan hệ của chúng với lõi hoàn toàn trái chiều nhau. Vẽ chúng thành "trên" và "dưới" che mất sự khác biệt này.

Business logic (logic nghiệp vụ): Phần mã thể hiện quy tắc và quyết định của bài toán — ví dụ "đơn hàng phải có số tiền dương", "phải thu tiền thành công trước khi xác nhận đơn". Đây là phần có giá trị lâu dài nhất và ít thay đổi nhất; framework, database, giao thức mạng chỉ là phương tiện phục vụ nó.

Câu hỏi đặt ra: làm sao để lõi nghiệp vụ không biết gì về database hay framework, mà vẫn dùng được chúng? Câu trả lời của Hexagonal là đảo ngược chiều phụ thuộc — và đó là nội dung phần tiếp theo.

Các khái niệm cốt lõi

Hexagonal Architecture xoay quanh ba nhóm khái niệm: lõi ứng dụng ở trung tâm, ports là các điểm nối, và adapters là phần cắm vào các điểm nối đó. Hãy định nghĩa từng cái.

Hexagonal Architecture (Ports & Adapters): Một pattern kiến trúc đặt logic nghiệp vụ vào một lõi trung tâm, và mọi tương tác với thế giới bên ngoài (UI, database, API, message queue...) đều đi qua các port trừu tượng. Các adapter cụ thể cắm vào port để hiện thực hoá tương tác đó. Lõi không hề biết adapter nào đang được dùng.

Application Core / Domain (lõi ứng dụng): Trái tim của hệ thống — chứa các thực thể nghiệp vụ (entities) và use case. Đây là phần không import bất kỳ framework, driver database, hay thư viện HTTP nào. Nó chỉ phụ thuộc vào các port mà chính nó định nghĩa.

Port (cổng): Một giao diện trừu tượng mô tả "một cuộc hội thoại có chủ đích" giữa lõi và bên ngoài — Cockburn gọi đó là "a set of interactions with the outside world that share the same intent" (một tập hợp các tương tác cùng chung một ý đồ). Trong code, port thường là một interface. Quan trọng: port mô tả cái gì cần làm, không nói làm bằng công nghệ nào.

Adapter (bộ chuyển đổi): Phần hiện thực cụ thể, "lớp keo dán giữa thành phần và thế giới bên ngoài". Adapter dịch tín hiệu của thế giới bên ngoài (một HTTP request, một dòng trong bảng SQL) sang dạng mà lõi hiểu, và ngược lại. Một port có thể có nhiều adapter — ví dụ cùng một port lưu trữ có thể có adapter Postgres, adapter in-memory, adapter mock cho test.

Hai phía: driving và driven

Đây là điểm tinh tế nhất và cũng hay bị hiểu sai nhất. Không phải port nào cũng giống port nào. Có hai loại, nằm ở hai phía đối xứng của lõi:

Primary / Driving port & adapter (phía trái — "điều khiển"): Là phía khởi xướng tương tác — thế giới bên ngoài gọi vào lõi. Driving adapter (HTTP handler, CLI, bộ test) nhận yêu cầu từ ngoài rồi gọi primary port (chính là các use case mà lõi cung cấp). Ở đây, adapter phụ thuộc vào lõi.

Secondary / Driven port & adapter (phía phải — "được điều khiển"): Là phía bị lõi gọi tới — lõi cần một dịch vụ từ bên ngoài. Secondary port (ví dụ interface OrderRepository) do chính lõi định nghĩa; driven adapter (Postgres, Stripe, SMTP) hiện thực interface đó. Ở đây, adapter hiện thực một interface của lõi.

Sự bất đối xứng này chính là điều mà kiến trúc phân lớp "trên–dưới" che giấu. Vẽ ra, nó trông như sau:

text
        DRIVING SIDE                                  DRIVEN SIDE
        (primary / left)                              (secondary / right)

   +-------------+                                +-------------+
   | HTTP        |                                | Postgres    |
   | handler     |                                | repository  |
   +-------------+      +------------------+      +-------------+
                        |                  |
   +-------------+      |  Application     |      +-------------+
   | CLI         | ===> |  Core            | ===> | Payment     |
   | command     |      |  domain + use    |      | gateway     |
   +-------------+      |  cases           |      +-------------+
                        |                  |
   +-------------+      +------------------+      +-------------+
   | Test        |                                | In-memory   |
   | harness     |                                | repository  |
   +-------------+                                +-------------+

   driving adapters CALL the core         core CALLS secondary ports;
   through primary ports                  driven adapters IMPLEMENT them

Mũi tên ở cả hai phía đều chỉ theo chiều "trái sang phải" theo luồng điều khiển (control flow): request đi vào từ trái, lõi xử lý, rồi gọi ra database/dịch vụ ở phải. Nhưng đây mới là mấu chốt — chiều phụ thuộc (dependency) thì luôn chỉ vào trong.

Dependency Rule: phụ thuộc luôn chỉ vào lõi

Nhìn vào sơ đồ trên, dễ tưởng lõi "phụ thuộc" vào database ở bên phải vì nó gọi sang đó. Thực ra không. Lõi gọi qua một secondary port — một interface do chính lõi định nghĩa. Adapter Postgres mới là kẻ phải đi hiện thực interface đó. Đây chính là Dependency Inversion (đảo ngược phụ thuộc) trong hành động:

text
   Primary adapter             Secondary adapter
   (HTTP / CLI / test)         (Postgres / Stripe / SMTP)
            |                            |
            | depends on                 | depends on
            v                            v
   +-----------------------------------------------+
   |               Application Core                |
   |       domain logic  +  port interfaces        |
   +-----------------------------------------------+

   Both adapters depend on the core's port interfaces.
   The core depends on NOTHING outside itself.

Mọi mũi tên phụ thuộc đều hướng vào trong: lõi không phụ thuộc ra ngoài thứ gì — nó chỉ định nghĩa và sử dụng các port của chính mình. Vì lõi chỉ phụ thuộc vào các interface trừu tượng, ta có thể tráo bất kỳ adapter nào mà lõi không hề hay biết: đổi Postgres sang MongoDB, đổi Stripe sang một cổng thanh toán khác, hay thay database thật bằng một bản in-memory lúc chạy test.

Tại sao là hình lục giác?

Một hiểu lầm phổ biến: con số "sáu" không có ý nghĩa đặc biệt. Cockburn chọn hình lục giác đơn giản vì nó cho ta đủ chỗ để vẽ nhiều cổng quanh lõi — khác với hình chữ nhật chỉ gợi ý "trên/dưới/trái/phải". Lục giác không ám chỉ "phải có đúng sáu port". Nó chỉ là một cách nhắc rằng ứng dụng có nhiều mặt tiếp xúc với bên ngoài, và mọi mặt đều bình đẳng.

Áp dụng bằng Go: dịch vụ đặt hàng

Lý thuyết đủ rồi — hãy xây một ví dụ chạy được. Go là ngôn ngữ rất hợp với Hexagonal: interface ngầm định (implicit) của Go biến port thành thứ tự nhiên, còn việc tiêm phụ thuộc (dependency injection) chỉ là truyền tham số vào constructor.

Ta sẽ xây một dịch vụ đặt hàng nhỏ: nhận yêu cầu tạo đơn, thu tiền qua một cổng thanh toán, rồi lưu đơn. Nó có một primary port (use case) và hai secondary port (lưu trữ + thanh toán) — vừa đủ để thấy cả hai phía của hình lục giác.

Cấu trúc thư mục

text
ecommerce/
├── cmd/
│   └── api/
│       └── main.go              # composition root: chọn adapter, ráp mọi thứ
├── internal/
│   ├── core/                    # LÕI — không import gì từ bên ngoài
│   │   ├── domain/
│   │   │   └── order.go         # entity + quy tắc nghiệp vụ
│   │   ├── port/
│   │   │   ├── order_service.go     # primary port (driving)
│   │   │   ├── order_repository.go  # secondary port (driven)
│   │   │   └── payment.go           # secondary port (driven)
│   │   └── service/
│   │       └── order_service.go     # use case: hiện thực primary port
│   └── adapter/
│       ├── handler/
│       │   └── http.go          # primary adapter (driving)
│       ├── repository/
│       │   ├── memory.go        # secondary adapter (driven)
│       │   └── postgres.go      # secondary adapter (driven)
│       └── payment/
│           └── stripe.go        # secondary adapter (driven)
└── go.mod

Quy ước quan trọng: package dưới core/ không bao giờ import package dưới adapter/. Chiều phụ thuộc chỉ một hướng — từ ngoài vào trong.

Lõi: domain

domain chứa thực thể và quy tắc nghiệp vụ thuần tuý. Không database/sql, không net/http.

go
// internal/core/domain/order.go
package domain
 
import "errors"
 
type Status string
 
const (
    StatusPending Status = "PENDING"
    StatusPaid    Status = "PAID"
)
 
type Order struct {
    ID       string
    Customer string
    Amount   int64 // số tiền tính bằng cent, tránh sai số dấu phẩy động
    Status   Status
}
 
var ErrInvalidAmount = errors.New("order amount must be positive")
 
// NewOrder là "factory" thực thi quy tắc nghiệp vụ: một đơn hợp lệ
// luôn có số tiền dương và khởi đầu ở trạng thái PENDING.
func NewOrder(id, customer string, amount int64) (*Order, error) {
    if amount <= 0 {
        return nil, ErrInvalidAmount
    }
    return &Order{
        ID:       id,
        Customer: customer,
        Amount:   amount,
        Status:   StatusPending,
    }, nil
}
 
// MarkPaid là một chuyển trạng thái nghiệp vụ.
func (o *Order) MarkPaid() {
    o.Status = StatusPaid
}

Lõi: các port

Tất cả interface đều khai báo trong lõi. Đây là điểm khiến Dependency Rule thành hiện thực.

Primary port — use case mà lõi cung cấp ra ngoài:

go
// internal/core/port/order_service.go
package port
 
import "ecommerce/internal/core/domain"
 
// OrderService là PRIMARY (driving) port.
// Driving adapter (HTTP, CLI, test) phụ thuộc vào interface này.
type OrderService interface {
    PlaceOrder(customer string, amount int64) (*domain.Order, error)
    GetOrder(id string) (*domain.Order, error)
}

Secondary port — thứ lõi cần từ bên ngoài. Chú ý: chúng được lõi định nghĩa, adapter mới đi hiện thực.

go
// internal/core/port/order_repository.go
package port
 
import "ecommerce/internal/core/domain"
 
// OrderRepository là SECONDARY (driven) port cho việc lưu trữ.
// Lõi GỌI nó; adapter (Postgres, in-memory...) HIỆN THỰC nó.
type OrderRepository interface {
    Save(order *domain.Order) error
    FindByID(id string) (*domain.Order, error)
}
go
// internal/core/port/payment.go
package port
 
// PaymentGateway là SECONDARY (driven) port cho việc thu tiền.
type PaymentGateway interface {
    Charge(customer string, amount int64) error
}

Lõi: use case

Đây là nơi nghiệp vụ sống. Để ý: nó hiện thực OrderService (primary port) và chỉ phụ thuộc vào các interface secondary port — chưa từng nhắc đến Postgres hay Stripe.

go
// internal/core/service/order_service.go
package service
 
import (
    "github.com/google/uuid"
 
    "ecommerce/internal/core/domain"
    "ecommerce/internal/core/port"
)
 
type orderService struct {
    repo    port.OrderRepository
    payment port.PaymentGateway
}
 
// NewOrderService nhận các driven port qua tham số — đây chính là
// dependency injection. Nó trả về kiểu interface port.OrderService.
func NewOrderService(repo port.OrderRepository, payment port.PaymentGateway) port.OrderService {
    return &orderService{repo: repo, payment: payment}
}
 
func (s *orderService) PlaceOrder(customer string, amount int64) (*domain.Order, error) {
    order, err := domain.NewOrder(uuid.NewString(), customer, amount)
    if err != nil {
        return nil, err
    }
 
    // Quy tắc nghiệp vụ: phải thu tiền THÀNH CÔNG trước khi xác nhận đơn.
    if err := s.payment.Charge(customer, amount); err != nil {
        return nil, err
    }
    order.MarkPaid()
 
    if err := s.repo.Save(order); err != nil {
        return nil, err
    }
    return order, nil
}
 
func (s *orderService) GetOrder(id string) (*domain.Order, error) {
    return s.repo.FindByID(id)
}

Driving adapter: HTTP handler

Adapter này nằm ở phía trái hình lục giác. Nó dịch HTTP request thành lời gọi vào primary port — và nó phụ thuộc vào port.OrderService, không phụ thuộc vào struct orderService cụ thể.

go
// internal/adapter/handler/http.go
package handler
 
import (
    "encoding/json"
    "net/http"
 
    "ecommerce/internal/core/port"
)
 
type OrderHandler struct {
    svc port.OrderService // phụ thuộc vào PORT, không phải implementation
}
 
func NewOrderHandler(svc port.OrderService) *OrderHandler {
    return &OrderHandler{svc: svc}
}
 
type placeOrderRequest struct {
    Customer string `json:"customer"`
    Amount   int64  `json:"amount"`
}
 
func (h *OrderHandler) PlaceOrder(w http.ResponseWriter, r *http.Request) {
    var req placeOrderRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid request body", http.StatusBadRequest)
        return
    }
 
    order, err := h.svc.PlaceOrder(req.Customer, req.Amount)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
 
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    _ = json.NewEncoder(w).Encode(order)
}

Driven adapters: lưu trữ và thanh toán

Phía phải hình lục giác. Bắt đầu bằng adapter in-memory — không cần database thật, lý tưởng cho dev và test:

go
// internal/adapter/repository/memory.go
package repository
 
import (
    "errors"
    "sync"
 
    "ecommerce/internal/core/domain"
    "ecommerce/internal/core/port"
)
 
type InMemoryOrderRepository struct {
    mu     sync.RWMutex
    orders map[string]*domain.Order
}
 
func NewInMemoryOrderRepository() *InMemoryOrderRepository {
    return &InMemoryOrderRepository{orders: make(map[string]*domain.Order)}
}
 
func (r *InMemoryOrderRepository) Save(order *domain.Order) error {
    r.mu.Lock()
    defer r.mu.Unlock()
    r.orders[order.ID] = order
    return nil
}
 
func (r *InMemoryOrderRepository) FindByID(id string) (*domain.Order, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    order, ok := r.orders[id]
    if !ok {
        return nil, errors.New("order not found")
    }
    return order, nil
}
 
// Kiểm tra lúc biên dịch: adapter này thoả mãn secondary port.
// Nếu thiếu method, code sẽ KHÔNG biên dịch được.
var _ port.OrderRepository = (*InMemoryOrderRepository)(nil)

Adapter Postgres hiện thực cùng một port, nhưng nói chuyện với database thật:

go
// internal/adapter/repository/postgres.go
package repository
 
import (
    "database/sql"
 
    "ecommerce/internal/core/domain"
    "ecommerce/internal/core/port"
)
 
type PostgresOrderRepository struct {
    db *sql.DB
}
 
func NewPostgresOrderRepository(db *sql.DB) *PostgresOrderRepository {
    return &PostgresOrderRepository{db: db}
}
 
func (r *PostgresOrderRepository) Save(order *domain.Order) error {
    _, err := r.db.Exec(
        `INSERT INTO orders (id, customer, amount, status)
         VALUES ($1, $2, $3, $4)
         ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status`,
        order.ID, order.Customer, order.Amount, order.Status,
    )
    return err
}
 
func (r *PostgresOrderRepository) FindByID(id string) (*domain.Order, error) {
    order := &domain.Order{}
    err := r.db.QueryRow(
        `SELECT id, customer, amount, status FROM orders WHERE id = $1`, id,
    ).Scan(&order.ID, &order.Customer, &order.Amount, &order.Status)
    if err != nil {
        return nil, err
    }
    return order, nil
}
 
var _ port.OrderRepository = (*PostgresOrderRepository)(nil)

Và adapter thanh toán (ở đây giả lập gọi Stripe):

go
// internal/adapter/payment/stripe.go
package payment
 
import "ecommerce/internal/core/port"
 
type StripeGateway struct {
    apiKey string
}
 
func NewStripeGateway(apiKey string) *StripeGateway {
    return &StripeGateway{apiKey: apiKey}
}
 
func (g *StripeGateway) Charge(customer string, amount int64) error {
    // Thực tế: gọi SDK của Stripe ở đây.
    // Lõi không quan tâm — nó chỉ thấy interface PaymentGateway.
    return nil
}
 
var _ port.PaymentGateway = (*StripeGateway)(nil)

Composition root: ráp mọi thứ trong main

Chỉ có một nơi duy nhất biết về cả port lẫn adapter cụ thể: hàm main. Đây gọi là composition root — nơi ta quyết định adapter nào cắm vào port nào.

Composition Root: Điểm duy nhất (thường là main) nơi toàn bộ đồ thị phụ thuộc được lắp ráp. Mọi nơi khác chỉ làm việc với interface; chỉ riêng composition root mới chạm vào các kiểu cụ thể. Muốn đổi adapter? Sửa đúng một chỗ này.

go
// cmd/api/main.go
package main
 
import (
    "log"
    "net/http"
 
    "ecommerce/internal/adapter/handler"
    "ecommerce/internal/adapter/payment"
    "ecommerce/internal/adapter/repository"
    "ecommerce/internal/core/service"
)
 
func main() {
    // 1. Chọn các DRIVEN adapter để cắm vào secondary port.
    //    Muốn chạy production? Đổi dòng dưới sang Postgres:
    //    repo := repository.NewPostgresOrderRepository(db)
    repo := repository.NewInMemoryOrderRepository()
    gateway := payment.NewStripeGateway("sk_test_xxx")
 
    // 2. Tạo lõi. Lõi chỉ thấy interface — nó KHÔNG biết
    //    repo là in-memory hay Postgres, gateway là Stripe hay gì khác.
    orderSvc := service.NewOrderService(repo, gateway)
 
    // 3. Cắm DRIVING adapter (HTTP) vào primary port.
    orderHandler := handler.NewOrderHandler(orderSvc)
 
    // 4. Định tuyến (yêu cầu Go 1.22+ cho cú pháp "POST /orders").
    mux := http.NewServeMux()
    mux.HandleFunc("POST /orders", orderHandler.PlaceOrder)
 
    log.Println("listening on :8080")
    log.Fatal(http.ListenAndServe(":8080", mux))
}

Toàn bộ "phép màu" nằm ở đây: dòng repo := repository.NewInMemoryOrderRepository()thứ duy nhất phải đổi khi chuyển từ in-memory sang Postgres. Lõi, handler, và mọi test đều không hề hay biết. Đó chính là lời hứa của Hexagonal Architecture, hiện ra dưới dạng code.

Test trở nên dễ đến bất ngờ

Còn nhớ mục tiêu gốc của Cockburn chứ — "developed and tested in isolation from its eventual run-time devices and databases"? Đây là chỗ Hexagonal toả sáng. Vì lõi chỉ phụ thuộc vào port, ta test nó bằng cách cắm vào những adapter "giả" (test double) thay cho database và cổng thanh toán thật.

Cái InMemoryOrderRepository ở phần trước chính là một adapter test sẵn sàng dùng. Ta chỉ cần thêm một cổng thanh toán giả:

go
// internal/core/service/order_service_test.go
package service_test
 
import (
    "testing"
 
    "ecommerce/internal/adapter/repository"
    "ecommerce/internal/core/service"
)
 
// fakeGateway là test double cho secondary port PaymentGateway.
type fakeGateway struct {
    charged int64
}
 
func (f *fakeGateway) Charge(_ string, amount int64) error {
    f.charged += amount
    return nil
}
 
func TestPlaceOrder_ChargesThenPersists(t *testing.T) {
    // Cắm các adapter in-memory vào port — không cần DB, không cần mạng.
    repo := repository.NewInMemoryOrderRepository()
    gateway := &fakeGateway{}
    svc := service.NewOrderService(repo, gateway)
 
    order, err := svc.PlaceOrder("alice@example.com", 4200)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
 
    if order.Status != "PAID" {
        t.Errorf("want status PAID, got %s", order.Status)
    }
    if gateway.charged != 4200 {
        t.Errorf("want 4200 charged, got %d", gateway.charged)
    }
 
    // Đơn đã thực sự được lưu chưa?
    got, err := repo.FindByID(order.ID)
    if err != nil {
        t.Fatalf("order was not persisted: %v", err)
    }
    if got.ID != order.ID {
        t.Errorf("persisted order mismatch")
    }
}

Test này chạy trong vài mili-giây, không cần Docker, không cần Postgres, không cần khoá API Stripe. Nó kiểm tra đúng quy tắc nghiệp vụ (thu tiền trước, rồi đánh dấu PAID, rồi lưu) mà không bị nhiễu bởi hạ tầng. Khi cần kiểm thử adapter Postgres thật, ta viết integration test riêng cho adapter đó — chẳng hạn với Testcontainers — tách bạch khỏi test nghiệp vụ.

Đây cũng là lý do Netflix mô tả Hexagonal giúp họ "ready for changes": trong bài blog kỹ thuật cùng tên, họ chia hệ thống thành ba lớp Domain / Ports / Adapters, nhờ đó có thể hoãn quyết định chọn database và tráo nguồn dữ liệu mà không đụng vào logic nghiệp vụ.

So sánh với Layered và Clean Architecture

Hexagonal không tồn tại trong chân không. Nó là một mắt xích trong họ các kiến trúc "đặt nghiệp vụ ở trung tâm".

Tiêu chíLayered (n-tier)Hexagonal (Ports & Adapters)Clean Architecture
Hình ảnh ẩn dụCác tầng xếp chồngLõi + nhiều cổng quanh nóCác vòng tròn đồng tâm
Chiều phụ thuộcTầng trên phụ thuộc tầng dướiMọi adapter phụ thuộc vào lõiVòng ngoài phụ thuộc vòng trong
Vị trí nghiệp vụTầng giữaLõi trung tâmVòng trong cùng (entities + use cases)
Điểm nhấn riêngĐơn giản, quen thuộcĐối xứng driving/driven, port có thể tráoPhân tầng chi tiết (entity / use case / adapter)
Số "loại" thành phầnThường 3 tầngDomain, port, adapter4 vòng
Quan hệ với DBDB ở đáy, dễ dẫn dắt thiết kếDB chỉ là một driven adapterDB ở vòng ngoài cùng

Điểm mấu chốt: Hexagonal và Clean về cơ bản nói cùng một điều — đảo ngược phụ thuộc để bảo vệ lõi nghiệp vụ. Clean Architecture của Uncle Bob có thể xem như một cách trình bày Hexagonal với nhiều lớp chi tiết hơn (tách rõ entity và use case). Nếu bạn đã đọc Clean Architecture in Go, bạn sẽ nhận ra cấu trúc thư mục ở đó (domain, usecase, delivery, infrastructure) ánh xạ gần như một-một sang domain, service, driving adapter, và driven adapter ở đây.

Đánh đổi: khi nào KHÔNG nên dùng

Hexagonal không phải "best practice" áp dụng cho mọi thứ. Như cộng đồng vẫn nhắc: nó giải quyết một vấn đề trong một bối cảnh, nên đi kèm đánh đổi.

Quy tắc ngón tay cái: Hexagonal đáng giá khi ứng dụng có logic nghiệp vụ thực sựsống đủ lâu để hạ tầng quanh nó thay đổi (đổi DB, thêm kênh giao tiếp gRPC/CLI, cần test nghiệp vụ độc lập). Với CRUD mỏng hay prototype ngắn hạn, một kiến trúc phân lớp đơn giản thường là lựa chọn tỉnh táo hơn.

Kết luận

Hexagonal Architecture không phải một framework, cũng chẳng phải một bộ thư mục bắt buộc. Nó là một cách suy nghĩ: tách điều ứng dụng làm (nghiệp vụ) khỏi điều ứng dụng dùng (công nghệ).

Những điểm cần nhớ:

Bước tiếp theo gợi ý: lấy ví dụ Go ở trên, thử thay InMemoryOrderRepository bằng một adapter Postgres thật và viết integration test riêng cho nó — bạn sẽ tận mắt thấy lõi và các test nghiệp vụ không đổi một dòng nào. Sau đó, thử thêm một driving adapter thứ hai (ví dụ một lệnh CLI) gọi vào cùng OrderService — và cảm nhận tính đối xứng của hình lục giác.

Nếu bạn muốn đào sâu hơn vào cùng triết lý "đặt nghiệp vụ ở trung tâm" nhưng với cách phân tầng chi tiết hơn, hãy đọc tiếp Clean Architecture in Go — người anh em rất gần của Hexagonal.

References