Hexagonal Architecture trong Go: Đừng để Infrastructure “bóp nghẹt” Business Logic của bạn

Development tutorial - IT technology blog
Development tutorial - IT technology blog

Ác mộng khi Business Logic bị “xâm lấn” bởi Infrastructure

Hãy tưởng tượng bạn đang bảo trì một dự án Go đã chạy ổn định 2 năm. Bỗng một ngày, sếp yêu cầu đổi từ MySQL sang MongoDB để tối ưu chi phí, hoặc chuyển từ SendGrid sang AWS SES. Bạn mở code và nhận ra các câu lệnh SQL hay struct của GORM nằm chằng chịt ngay trong hàm xử lý đơn hàng.

Cái giá phải trả rất đắt. Bạn không thể viết Unit Test cho logic tính giảm giá mà không cần bật Database thật. Chỉ cần sửa một cột nhỏ ở tầng lưu trữ, logic nghiệp vụ bỗng dưng lăn đùng ra chết. Đây chính là biểu hiện của “Big Ball of Mud” – một đống bùn lầy mà ai cũng ngại đụng vào.

Tại sao chúng ta thường mắc sai lầm này?

Sai lầm kinh điển là chọn Framework (Gin, Echo) hay ORM (GORM) ngay khi vừa khởi tạo dự án. Chúng ta vô tình để các thư viện bên ngoài định hình cấu trúc dữ liệu cốt lõi.

Khi Business Logic biết quá nhiều về chi tiết kỹ thuật (như bảng này có bao nhiêu cột, API trả về JSON gì), nó trở nên cực kỳ mong manh. Một hệ thống tốt cần logic cốt lõi ổn định. Việc hôm nay dùng PostgreSQL hay ngày mai dùng Redis không được phép làm thay đổi cách chúng ta tính toán doanh thu.

Giải pháp: Kiến trúc Hexagonal (Ports and Adapters)

Kiến trúc Hexagonal do Alistair Cockburn đề xuất nhằm cô lập Core Logic khỏi các tác động bên ngoài. Ý tưởng rất đơn giản: Đặt Business Logic vào trung tâm và bao bọc nó bằng các Interface (Ports). Các thành phần bên ngoài (Adapters) sẽ kết nối vào các Port này.

Cụ thể hơn, bộ khung này gồm ba phần chính:

  • Core (Domain/Service): Nơi chứa logic nghiệp vụ thuần túy. Nó không chứa bất kỳ dependency nào từ bên ngoài.
  • Ports (Interfaces): Các “hợp đồng” mà Core cung cấp hoặc yêu cầu.
  • Adapters: Phần triển khai thực tế. Ví dụ: MySQL Adapter, REST API Adapter hoặc gRPC Adapter.

Sự khác biệt: Layered vs Hexagonal Architecture

Kiến trúc 3 tầng truyền thống thường có dạng: UI -> Business -> Data Access. Vấn đề là tầng Business thường phụ thuộc trực tiếp vào Data Access.

Hexagonal Architecture đảo ngược hoàn toàn sự phụ thuộc này (Dependency Inversion). Cả UI và Database đều phải xoay quanh Core thông qua các Port. Nhờ đó, Core trở thành một “ốc đảo” độc lập. Bạn có thể chạy Unit Test cho toàn bộ logic nghiệp vụ trong vài mili giây mà không cần bất kỳ hạ tầng nào.

Hướng dẫn triển khai thực tế trong Go

Hãy bắt tay vào xây dựng module quản lý User. Đây là cấu trúc thư mục chuẩn để tách biệt hoàn toàn các lớp:

/internal
  /core
    /domain      # Chứa các struct nghiệp vụ (User, Order...)
    /ports       # Định nghĩa các Interface
    /services    # Logic nghiệp vụ thực tế
  /adapters
    /repository  # Triển khai DB (Gorm, SQLX...)
    /handler     # Triển khai HTTP/gRPC (Gin, Echo...)

1. Thiết lập “Hợp đồng” (Domain và Ports)

Đầu tiên, chúng ta định nghĩa đối tượng User và cách các bên giao tiếp với nhau.

// internal/core/domain/user.go
package domain

type User struct {
    ID    int64
    Email string
    Name  string
}

// internal/core/ports/ports.go
package ports

import "project/internal/core/domain"

// Driven Port: Core yêu cầu để lưu trữ dữ liệu
type UserRepository interface {
    Save(user *domain.User) error
    GetByID(id int64) (*domain.User, error)
}

// Driving Port: Bên ngoài dùng để gọi vào Core
type UserService interface {
    CreateUser(email, name string) error
    GetUser(id int64) (*domain.User, error)
}

2. Viết Core Logic (Trái tim của ứng dụng)

Service này chỉ làm việc với Interface. Nó hoàn toàn mù tịt về việc dữ liệu được lưu vào MySQL hay file text.

// internal/core/services/usersrv/service.go
package usersrv

import (
    "project/internal/core/domain"
    "project/internal/core/ports"
)

type service struct {
    repo ports.UserRepository
}

func New(repo ports.UserRepository) ports.UserService {
    return &service{repo: repo}
}

func (s *service) CreateUser(email, name string) error {
    // Tại đây bạn có thể thêm logic kiểm tra email, hash password
    user := &domain.User{Email: email, Name: name}
    return s.repo.Save(user)
}

3. Triển khai Adapter (Chi tiết hạ tầng)

Bây giờ mới là lúc viết code thực tế cho MySQL. Nếu sau này cần đổi sang MongoDB, bạn chỉ cần tạo một file mongodb.go trong folder adapters mà không cần sửa một dòng code nào trong folder core.

// internal/adapters/repository/mysql.go
package repository

import (
    "project/internal/core/domain"
    "gorm.io/gorm"
)

type mysqlRepo struct {
    db *gorm.DB
}

func NewMySQL(db *gorm.DB) *mysqlRepo {
    return &mysqlRepo{db: db}
}

func (r *mysqlRepo) Save(user *domain.User) error {
    return r.db.Create(user).Error
}

Mẹo nhỏ: Khi làm việc với Hexagonal, bạn sẽ phải map dữ liệu qua lại giữa Domain struct và Database struct rất nhiều. Để kiểm tra nhanh các cấu trúc JSON phức tạp, mình thường dùng toolcraft.app/vi/tools/developer/json-formatter. Công cụ này giúp format và soi lỗi JSON cực nhanh, tiện hơn việc cài extension nặng nề vào VS Code.

4. Lắp ghép linh kiện tại Main

Hàm main.go đóng vai trò như một người thợ máy, kết nối các bộ phận lại với nhau thông qua Dependency Injection.

func main() {
    db := initDB() // Khởi tạo kết nối thật

    userRepo := repository.NewMySQL(db)       // Adapter
    userService := usersrv.New(userRepo)      // Core (Inject Adapter vào Port)
    handler := http.NewHandler(userService)   // Transport Adapter

    handler.Run()
}

Đánh giá công tâm: Có nên áp dụng ngay?

Lợi ích rõ rệt

  • Test cực sướng: Bạn có thể Mock hoàn toàn Repository để test logic CreateUser chỉ trong 1-2 giây.
  • Thay đổi không sợ hãi: Việc nâng cấp GORM hay đổi thư viện log không còn là cơn ác mộng.
  • Code sạch và chuyên sâu: Logic nghiệp vụ không bị pha tạp bởi các annotation của Database hay Framework.

Hạn chế cần cân nhắc

  • Viết nhiều code hơn (Boilerplate): Bạn phải định nghĩa nhiều Interface và thực hiện mapping dữ liệu.
  • Over-engineering cho dự án nhỏ: Với một dự án CRUD đơn giản chỉ làm trong 1 tuần, kiến trúc này có thể làm chậm tốc độ của bạn.

Lời kết

Kinh nghiệm của mình là: Hãy dùng Hexagonal cho các dự án dự kiến chạy trên 6 tháng hoặc có logic phức tạp. Nó giúp bạn ngủ ngon hơn mỗi khi hệ thống cần mở rộng.

Nếu bạn đang bắt đầu dự án Go mới, hãy dành 30 phút thiết kế các Port trước khi code. Sự tách biệt này sẽ mang lại lợi ích khổng lồ khi dự án phình to và các yêu cầu thay đổi công nghệ ập đến.

Share: