Hexagonal Architecture — Kiến trúc đặt domain làm trung tâm

Phong Hy

Hồi mới đi làm, service nào mình viết cũng bắt đầu từ framework: model khai báo theo kiểu của ORM, handler gắn liền database, controller phình to dần. Vài tháng sau đổi database là đau đầu, đổi framework là muốn khóc. Mãi tới khi đọc về Hexagonal Architecture (hay còn gọi Ports & Adapters) mình mới thấm: vấn đề không phải chọn công nghệ gì, mà là ai đang làm chủ code.

Bản vẽ kiến trúc phần mềm Ảnh: Jakub Zerdzicki — Pexels

Ý tưởng cốt lõi: domain ở giữa, mọi thứ xoay quanh

Hexagonal Architecture do Alistair Cockburn đề xuất, ý tưởng đơn giản bất ngờ: đặt business logic ở trung tâm (hình lục giác), còn web, database, message queue... đều là thứ bên ngoài.

  • Port — interface do domain định nghĩa, thể hiện điều domain cần, không phải điều thư viện cung cấp. Ví dụ: OrderRepository với method Save, FindByID.
  • Adapter — phần cài đặt cụ thể của port: adapter Postgres, adapter HTTP, adapter fake cho test.

Nguyên tắc vàng là dependency rule: mọi phụ thuộc phải hướng vào trong. Domain không import gì của framework, còn adapter thì import domain. Nghe quen không? Đó chính là Dependency Inversion trong SOLID, áp dụng ở quy mô kiến trúc.

Code Go thực chiến

Domain thuần tuý, không dính gì tới database hay HTTP:

// domain/order.go — chỉ import stdlib
package domain

type Order struct {
    ID     string
    Amount int64
    Paid   bool
}

// Port: domain định nghĩa điều nó cần
type OrderRepository interface {
    Save(ctx context.Context, o Order) error
    FindByID(ctx context.Context, id string) (Order, error)
}

// Use case thuần tuý — không biết Postgres hay Gin là gì
type OrderService struct {
    repo OrderRepository
}

func (s *OrderService) Pay(ctx context.Context, id string) error {
    o, err := s.repo.FindByID(ctx, id)
    if err != nil {
        return err
    }
    o.Paid = true
    return s.repo.Save(ctx, o)
}

Adapter Postgres nằm ở vòng ngoài, chỉ lo chuyển đổi dữ liệu:

// adapter/postgres/order_repo.go
type OrderRepo struct { db *sql.DB }

func (r *OrderRepo) Save(ctx context.Context, o domain.Order) error {
    _, err := r.db.ExecContext(ctx,
        `INSERT INTO orders (id, amount, paid) VALUES ($1, $2, $3)
         ON CONFLICT (id) DO UPDATE SET paid = $3`,
        o.ID, o.Amount, o.Paid)
    return err
}

Còn test thì khỏi cần database thật — chỉ cần một adapter fake:

// adapter/fake/order_repo.go — dùng cho test
type Repo struct { orders map[string]domain.Order }

func (f *Repo) Save(ctx context.Context, o domain.Order) error {
    f.orders[o.ID] = o
    return nil
}

Developer đang lên kế hoạch kiến trúc Ảnh: Sharad Bhat — Pexels

Kinh nghiệm thực tế của mình

Lợi ích mình nghiệm ra sau khi áp dụng:

  • Test nhanh và thật: test use case chạy với fake adapter, không cần spin database, không cần mock framework — chạy dưới giây.
  • Đổi công nghệ không đau: chuyển Postgres sang MySQL chỉ cần viết adapter mới, domain không đổi một dòng. Framework (gin, echo, chi...) bị "đẩy ra ngoài cùng" — muốn đổi framework cũng không đụng domain.
  • Domain là nơi duy nhất chứa logic quan trọng: mấy rule nghiệp vụ (giảm giá, trạng thái đơn hàng) nằm gọn một chỗ, dễ review, dễ đúng.

Cạm bẫy — cái này quan trọng hơn:

  • Interface pollution: đừng tạo interface cho mọi struct. Go có câu "accept interfaces, return structs" — chỉ tạo port khi domain thật sự cần trừu tượng hoá (gọi ra ngoài: DB, HTTP, queue). Interface tạo sẵn mà chưa có nhu cầu chỉ làm code rối.
  • Đừng áp cho project CRUD thuần: service chỉ là form đọc-ghi đơn giản thì hexagonal là over-engineering — boilerplate nhiều hơn lợi ích. Quy tắc của mình: bắt đầu từ domain + use cases trước, thêm framework sau; độ phức tạp tới đâu mới kéo kiến trúc theo tới đó.

Nói chung, hexagonal không phải viên đạn bạc, nhưng nó dạy mình một thói quen đáng giá: trước khi viết code, nghĩ xem domain cần gì, chứ không phải framework cho gì.

Còn service của bạn thì sao — domain đang làm chủ, hay framework đang dắt mũi? Chia sẻ với mình ở dưới nha.

📋 Phụ lục thuật ngữ

  • Hexagonal Architecture — kiến trúc đặt business logic ở trung tâm, các thành phần bên ngoài (web, DB, queue) kết nối qua port và adapter
  • Port — interface do domain định nghĩa, mô tả điều domain cần từ thế giới bên ngoài
  • Adapter — cài đặt cụ thể của một port (Postgres adapter, HTTP adapter, fake adapter cho test)
  • Dependency Inversion — nguyên tắc SOLID: module cấp cao không phụ thuộc module cấp thấp, cả hai cùng phụ thuộc abstraction
  • Interface pollution — thói quen tạo interface không cần thiết, làm code rối và khó đọc