Clean Architecture trong Go: Xây dựng ứng dụng Go dễ bảo trì và mở rộng

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Codebase Mà Không Ai Muốn Đụng Vào

Mọi lập trình viên Go rồi cũng sẽ gặp phải bức tường này. Bạn vào một dự án, mở repository lên và thấy một file main.go dài tới 800 dòng. Query database nằm lẫn lộn với business logic. HTTP handler gọi thẳng SQL. Viết test gần như bất khả thi vì mọi thứ đều bị coupled chặt vào nhau.

Tôi đã trải qua điều đó. Một Go service production khởi đầu là “tool nội bộ làm nhanh” rồi bằng cách nào đó lớn dần thành một hệ thống quan trọng — và không ai dám đụng vào vì sửa một chỗ là hỏng ba chỗ khác. Chính trải nghiệm đó khiến Clean Architecture trở thành điều không thể thiếu đối với tôi.

Go khuyến khích sự đơn giản — đó là một trong những điểm mạnh nhất của ngôn ngữ này. Nhưng chính sự đơn giản đó lại dễ cám dỗ lập trình viên bỏ qua cấu trúc ngay từ đầu. Bỏ qua đi, rồi bạn sẽ phải trả giá sau — thường là ngay trước deadline hoặc lúc 2 giờ sáng đang xử lý sự cố.

Tại Sao Các Dự Án Go Sụp Đổ: Nguyên Nhân Gốc Rễ

Hầu hết các dự án Go sụp đổ theo cùng một cách:

  • God package: Một package utils hay service duy nhất cứ tích tụ mọi thứ vào, không có ranh giới sở hữu rõ ràng.
  • Để lộ infrastructure concerns: Business logic import database/sql hoặc gọi thẳng một ORM cụ thể, khiến việc thay thế implementation trở nên bất khả thi.
  • Code không thể test: Các hàm mở kết nối database thật hoặc thực hiện HTTP call trực tiếp — nghĩa là bạn phải bỏ qua test hoặc viết integration test chậm và dễ vỡ cho tất cả mọi thứ.
  • Circular dependency: Package A import Package B, Package B lại import Package A. Compiler Go sẽ phát hiện ra điều này, nhưng bạn sẽ mất hàng giờ để tổ chức lại thay vì xây dựng tính năng.

Đây không phải là thất bại về kỷ luật — mà là thất bại về cấu trúc. Không có một kiến trúc rõ ràng, ngay cả những lập trình viên giàu kinh nghiệm cũng mặc định dùng tight coupling vì nó đơn giản là nhanh hơn tại thời điểm đó.

Clean Architecture vs. Layered Architecture Thông Thường: Điểm Khác Biệt Mấu Chốt

Hầu hết lập trình viên Go đều quen với cách tiếp cận phân tầng cơ bản: handler → service → repository. Tốt hơn là không có gì — nhưng cách này vẫn cho phép các tầng bên trong phụ thuộc vào tầng bên ngoài. Một service import một struct database cụ thể là ví dụ điển hình của sự rò rỉ này.

Clean Architecture thực thi một quy tắc nghiêm ngặt: dependency chỉ hướng vào trong. Business logic ở trung tâm không biết gì về database, HTTP, hay bất kỳ framework bên ngoài nào. Các tầng bên ngoài (HTTP handler, database adapter) phụ thuộc vào các tầng bên trong. Không bao giờ ngược lại.

Trong thực tế, các tầng này ánh xạ như sau trong Go:

  • Entities / Domain: Các Go struct thuần túy và business rule. Không import từ package bên ngoài.
  • Use Cases: Business logic đặc thù của ứng dụng. Chỉ phụ thuộc vào entities và định nghĩa interface.
  • Interface Adapters: Controller (HTTP handler), presenter, repository implementation.
  • Frameworks & Drivers: Tầng ngoài cùng — database driver, HTTP router, external API.

Cấu Trúc Dự Án Go Thực Tế

Dưới đây là cấu trúc thư mục tôi dùng cho các Go service xây dựng theo Clean Architecture:

myapp/
├── cmd/
│   └── api/
│       └── main.go          # Điểm khởi đầu, kết nối tất cả lại với nhau
├── internal/
│   ├── domain/
│   │   ├── user.go          # Entity: User struct, domain error
│   │   └── user_repository.go  # Interface: UserRepository
│   ├── usecase/
│   │   ├── user_usecase.go  # Business logic
│   │   └── user_usecase_test.go
│   ├── adapter/
│   │   ├── http/
│   │   │   └── user_handler.go  # HTTP handler
│   │   └── repository/
│   │       └── postgres_user_repo.go  # Triển khai DB
│   └── infrastructure/
│       └── db.go            # Thiết lập kết nối database
└── go.mod

Bước 1: Định Nghĩa Domain Layer

Domain layer chứa các entity cốt lõi và repository interface. Không được import gì từ bên ngoài ở đây.

// internal/domain/user.go
package domain

import "errors"

type User struct {
    ID    int64
    Name  string
    Email string
}

var (
    ErrUserNotFound    = errors.New("không tìm thấy người dùng")
    ErrDuplicateEmail  = errors.New("email đã tồn tại")
)

// internal/domain/user_repository.go
package domain

import "context"

type UserRepository interface {
    FindByID(ctx context.Context, id int64) (*User, error)
    Save(ctx context.Context, user *User) error
}

Lưu ý rằng UserRepository là một interface, không phải struct. Implementation cụ thể nằm ở adapter layer. Chính lựa chọn thiết kế đơn giản này là thứ khiến toàn bộ hệ thống có thể test được.

Bước 2: Viết Use Case Layer

Use case chứa business rule của ứng dụng. Chúng phụ thuộc vào domain interface — không phải bất kỳ implementation cụ thể nào.

// internal/usecase/user_usecase.go
package usecase

import (
    "context"
    "myapp/internal/domain"
)

type UserUseCase struct {
    repo domain.UserRepository
}

func NewUserUseCase(repo domain.UserRepository) *UserUseCase {
    return &UserUseCase{repo: repo}
}

func (uc *UserUseCase) GetUser(ctx context.Context, id int64) (*domain.User, error) {
    user, err := uc.repo.FindByID(ctx, id)
    if err != nil {
        return nil, err
    }
    return user, nil
}

func (uc *UserUseCase) RegisterUser(ctx context.Context, name, email string) (*domain.User, error) {
    user := &domain.User{Name: name, Email: email}
    if err := uc.repo.Save(ctx, user); err != nil {
        return nil, err
    }
    return user, nil
}

Bước 3: Triển Khai Repository Adapter

Implementation database cụ thể nằm ở adapter layer. Nó import domain interface và thỏa mãn interface đó. Nếu bạn chưa quen với các thao tác cơ bản với PostgreSQL, hãy đọc qua trước khi đi vào phần này.

// internal/adapter/repository/postgres_user_repo.go
package repository

import (
    "context"
    "database/sql"
    "myapp/internal/domain"
)

type PostgresUserRepo struct {
    db *sql.DB
}

func NewPostgresUserRepo(db *sql.DB) *PostgresUserRepo {
    return &PostgresUserRepo{db: db}
}

func (r *PostgresUserRepo) FindByID(ctx context.Context, id int64) (*domain.User, error) {
    user := &domain.User{}
    err := r.db.QueryRowContext(ctx,
        "SELECT id, name, email FROM users WHERE id = $1", id,
    ).Scan(&user.ID, &user.Name, &user.Email)
    if err == sql.ErrNoRows {
        return nil, domain.ErrUserNotFound
    }
    return user, err
}

func (r *PostgresUserRepo) Save(ctx context.Context, user *domain.User) error {
    return r.db.QueryRowContext(ctx,
        "INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id",
        user.Name, user.Email,
    ).Scan(&user.ID)
}

Bước 4: Kết Nối Tất Cả Trong main.go

Dependency injection xảy ra ở tầng ngoài cùng — main.go. Các implementation cụ thể được khởi tạo ở đây và inject vào bên trong.

// cmd/api/main.go
package main

import (
    "database/sql"
    "log"
    "net/http"

    _ "github.com/lib/pq"
    "myapp/internal/adapter/repository"
    httpAdapter "myapp/internal/adapter/http"
    "myapp/internal/usecase"
    "myapp/internal/infrastructure"
)

func main() {
    db, err := infrastructure.NewPostgresDB()
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // Kết nối dependency từ trong ra ngoài
    userRepo := repository.NewPostgresUserRepo(db)
    userUC := usecase.NewUserUseCase(userRepo)
    userHandler := httpAdapter.NewUserHandler(userUC)

    mux := http.NewServeMux()
    mux.HandleFunc("/users", userHandler.GetUser)

    log.Println("Khởi động server trên cổng :8080")
    log.Fatal(http.ListenAndServe(":8080", mux))
}

Lợi Ích Thực Sự: Test Không Cần Database

Đây là lúc kiến trúc thực sự chứng minh giá trị của nó. Vì UserUseCase phụ thuộc vào interface UserRepository, bạn có thể thay thế bằng mock khi test — không cần database thật.

// internal/usecase/user_usecase_test.go
package usecase_test

import (
    "context"
    "testing"
    "myapp/internal/domain"
    "myapp/internal/usecase"
)

type mockUserRepo struct {
    users map[int64]*domain.User
}

func (m *mockUserRepo) FindByID(_ context.Context, id int64) (*domain.User, error) {
    if u, ok := m.users[id]; ok {
        return u, nil
    }
    return nil, domain.ErrUserNotFound
}

func (m *mockUserRepo) Save(_ context.Context, user *domain.User) error {
    user.ID = int64(len(m.users) + 1)
    m.users[user.ID] = user
    return nil
}

func TestGetUser_NotFound(t *testing.T) {
    repo := &mockUserRepo{users: make(map[int64]*domain.User)}
    uc := usecase.NewUserUseCase(repo)

    _, err := uc.GetUser(context.Background(), 999)
    if err != domain.ErrUserNotFound {
        t.Errorf("mong đợi ErrUserNotFound, nhận được %v", err)
    }
}

Những test này chạy trong vài mili giây. Không cần Docker. Không cần setup database. Không có network call bất ổn. Chỉ là phản hồi nhanh, đáng tin cậy về business logic của bạn. Nếu bạn muốn đào sâu hơn về kỹ thuật viết test và TDD, bài viết về unit testing và TDD với Pytest trình bày nhiều nguyên tắc có thể áp dụng trực tiếp sang Go.

Những Sai Lầm Thường Gặp Cần Tránh

Sau khi áp dụng pattern này cho năm Go service production, đây là những sai lầm xuất hiện thường xuyên nhất:

  • Đặt validation trong HTTP handler: Validation business rule — “email phải là duy nhất”, “tuổi phải là số dương” — thuộc về use case hoặc domain layer. Handler chỉ nên validate định dạng đầu vào, không phải thực thi business rule.
  • Trả về database model từ use case: Nếu use case của bạn trả về *sql.Row thô hoặc ORM model, bạn đã để lộ infrastructure concerns lên trên. Hãy chuyển đổi sang domain struct tại ranh giới repository.
  • Over-engineer service nhỏ: Phân tầng đầy đủ tốn thêm chi phí thật sự. Với một microservice 3 endpoint chỉ đơn giản là proxy sang API khác, cấu trúc này có thể là quá mức cần thiết. Hãy áp dụng khi codebase sẽ tiếp tục phát triển.
  • Bỏ qua context propagation: Luôn truyền context.Context qua từng tầng. Nó cho phép hủy request và lan truyền deadline — cả hai đều rất quan trọng trong môi trường production.

Khi Nào Cấu Trúc Này Thực Sự Phát Huy Giá Trị

Ba tình huống khiến cấu trúc này xứng đáng với công thiết lập ban đầu:

  1. Chuyển đổi database: Di chuyển từ PostgreSQL sang MySQL — hoặc thêm tầng cache Redis — chỉ cần thay đổi ở adapter layer. Code use case không bị ảnh hưởng.
  2. Thêm delivery mechanism thứ hai: Cần endpoint gRPC bên cạnh REST API? Use case layer tái sử dụng nguyên vẹn. Bạn chỉ cần viết thêm gRPC handler mới.
  3. Onboard engineer mới: Ranh giới rõ ràng giúp mọi người dễ dàng biết code mới nên đặt ở đâu. Thành viên mới đóng góp nhanh hơn với ít rủi ro vô tình phá vỡ các phần không liên quan của hệ thống.

Ngày đầu, nó có cảm giác như gánh nặng thêm vào. Nhưng sáu tháng sau, khi bạn đang ship một tính năng lớn mà bộ test vẫn chạy xong trong dưới 10 giây — sự ma sát ban đầu đó sẽ hoàn toàn có lý.

Share: