blogbyAndrew

Clean Architecture in Go

November 24, 2025

Clean Architecture trong Go - Thiết kế ứng dụng sạch và bảo trì

Tổng quan

Clean Architecture là một pattern thiết kế được giới thiệu bởi Robert C. Martin (Uncle Bob), giúp tạo ra các ứng dụng có cấu trúc rõ ràng, dễ test, dễ bảo trì và độc lập với frameworks. Trong Go, Clean Architecture được áp dụng rất hiệu quả.

Nguyên tắc Clean Architecture

1. Dependency Rule

2. Separation of Concerns

Cấu trúc Clean Architecture

text
┌─────────────────────────────────────┐
│           Presentation Layer        │
│         (HTTP, CLI, gRPC)           │
├─────────────────────────────────────┤
│           Business Layer            │
│        (Use Cases, Entities)        │
├─────────────────────────────────────┤
│         Infrastructure Layer        │
│    (Database, External APIs)        │
└─────────────────────────────────────┘

Project Structure

text
myapp/
├── cmd/
│   └── server/
│       └── main.go
├── internal/
│   ├── domain/
│   │   ├── entity/
│   │   └── repository/
│   ├── usecase/
│   ├── delivery/
│   │   ├── http/
│   │   └── grpc/
│   └── infrastructure/
│       ├── database/
│       └── external/
├── pkg/
└── go.mod

Domain Layer (Entities)

Entity - Core Business Objects

go
// internal/domain/entity/user.go
package entity
 
import (
    "time"
    "errors"
)
 
type User struct {
    ID        string    `json:"id"`
    Email     string    `json:"email"`
    Name      string    `json:"name"`
    Password  string    `json:"-"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
}
 
// Business rules
func (u *User) Validate() error {
    if u.Email == "" {
        return errors.New("email is required")
    }
    
    if u.Name == "" {
        return errors.New("name is required")
    }
    
    if len(u.Password) < 6 {
        return errors.New("password must be at least 6 characters")
    }
    
    return nil
}
 
func (u *User) IsValid() bool {
    return u.Validate() == nil
}

Repository Interface

go
// internal/domain/repository/user_repository.go
package repository
 
import "myapp/internal/domain/entity"
 
type UserRepository interface {
    Create(user *entity.User) error
    GetByID(id string) (*entity.User, error)
    GetByEmail(email string) (*entity.User, error)
    Update(user *entity.User) error
    Delete(id string) error
    List(limit, offset int) ([]*entity.User, error)
}

Use Case Layer (Business Logic)

Use Case Implementation

go
// internal/usecase/user_usecase.go
package usecase
 
import (
    "context"
    "errors"
    "time"
    
    "myapp/internal/domain/entity"
    "myapp/internal/domain/repository"
)
 
type UserUseCase struct {
    userRepo repository.UserRepository
}
 
func NewUserUseCase(userRepo repository.UserRepository) *UserUseCase {
    return &UserUseCase{
        userRepo: userRepo,
    }
}
 
// CreateUser implements business logic for user creation
func (uc *UserUseCase) CreateUser(ctx context.Context, email, name, password string) (*entity.User, error) {
    // Check if user already exists
    existingUser, err := uc.userRepo.GetByEmail(email)
    if err == nil && existingUser != nil {
        return nil, errors.New("user already exists")
    }
    
    // Create new user
    user := &entity.User{
        ID:        generateID(),
        Email:     email,
        Name:      name,
        Password:  hashPassword(password),
        CreatedAt: time.Now(),
        UpdatedAt: time.Now(),
    }
    
    // Validate user
    if err := user.Validate(); err != nil {
        return nil, err
    }
    
    // Save to repository
    if err := uc.userRepo.Create(user); err != nil {
        return nil, err
    }
    
    return user, nil
}
 
// GetUserByID implements business logic for getting user
func (uc *UserUseCase) GetUserByID(ctx context.Context, id string) (*entity.User, error) {
    if id == "" {
        return nil, errors.New("user ID is required")
    }
    
    user, err := uc.userRepo.GetByID(id)
    if err != nil {
        return nil, err
    }
    
    if user == nil {
        return nil, errors.New("user not found")
    }
    
    return user, nil
}
 
// UpdateUser implements business logic for updating user
func (uc *UserUseCase) UpdateUser(ctx context.Context, id, name, email string) (*entity.User, error) {
    user, err := uc.userRepo.GetByID(id)
    if err != nil {
        return nil, err
    }
    
    if user == nil {
        return nil, errors.New("user not found")
    }
    
    // Update fields
    if name != "" {
        user.Name = name
    }
    if email != "" {
        user.Email = email
    }
    user.UpdatedAt = time.Now()
    
    // Validate updated user
    if err := user.Validate(); err != nil {
        return nil, err
    }
    
    // Save changes
    if err := uc.userRepo.Update(user); err != nil {
        return nil, err
    }
    
    return user, nil
}
 
// ListUsers implements business logic for listing users
func (uc *UserUseCase) ListUsers(ctx context.Context, limit, offset int) ([]*entity.User, error) {
    if limit <= 0 {
        limit = 10 // Default limit
    }
    if offset < 0 {
        offset = 0
    }
    
    return uc.userRepo.List(limit, offset)
}

Delivery Layer (Presentation)

HTTP Handler

go
// internal/delivery/http/user_handler.go
package http
 
import (
    "encoding/json"
    "net/http"
    "strconv"
    
    "myapp/internal/usecase"
    "myapp/internal/domain/entity"
)
 
type UserHandler struct {
    userUseCase *usecase.UserUseCase
}
 
func NewUserHandler(userUseCase *usecase.UserUseCase) *UserHandler {
    return &UserHandler{
        userUseCase: userUseCase,
    }
}
 
type CreateUserRequest struct {
    Email    string `json:"email"`
    Name     string `json:"name"`
    Password string `json:"password"`
}
 
type CreateUserResponse struct {
    User *entity.User `json:"user"`
}
 
// CreateUser handles HTTP POST /users
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }
    
    // Parse request
    var req CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid request body", http.StatusBadRequest)
        return
    }
    
    // Call use case
    user, err := h.userUseCase.CreateUser(r.Context(), req.Email, req.Name, req.Password)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    // Return response
    response := CreateUserResponse{User: user}
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(response)
}
 
// GetUser handles HTTP GET /users/{id}
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodGet {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }
    
    // Extract user ID from URL
    userID := r.URL.Query().Get("id")
    if userID == "" {
        http.Error(w, "User ID is required", http.StatusBadRequest)
        return
    }
    
    // Call use case
    user, err := h.userUseCase.GetUserByID(r.Context(), userID)
    if err != nil {
        http.Error(w, err.Error(), http.StatusNotFound)
        return
    }
    
    // Return response
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}
 
// ListUsers handles HTTP GET /users
func (h *UserHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodGet {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }
    
    // Parse query parameters
    limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
    offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
    
    // Call use case
    users, err := h.userUseCase.ListUsers(r.Context(), limit, offset)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    
    // Return response
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(users)
}

Router Setup

go
// internal/delivery/http/router.go
package http
 
import (
    "net/http"
    
    "myapp/internal/usecase"
)
 
func SetupRouter(userUseCase *usecase.UserUseCase) http.Handler {
    userHandler := NewUserHandler(userUseCase)
    
    mux := http.NewServeMux()
    
    // User routes
    mux.HandleFunc("/users", userHandler.CreateUser)
    mux.HandleFunc("/users/list", userHandler.ListUsers)
    mux.HandleFunc("/users/get", userHandler.GetUser)
    
    return mux
}

Infrastructure Layer

Database Implementation

go
// internal/infrastructure/database/postgres_user_repository.go
package database
 
import (
    "database/sql"
    "time"
    
    "myapp/internal/domain/entity"
    "myapp/internal/domain/repository"
)
 
type PostgresUserRepository struct {
    db *sql.DB
}
 
func NewPostgresUserRepository(db *sql.DB) repository.UserRepository {
    return &PostgresUserRepository{db: db}
}
 
func (r *PostgresUserRepository) Create(user *entity.User) error {
    query := `
        INSERT INTO users (id, email, name, password, created_at, updated_at)
        VALUES ($1, $2, $3, $4, $5, $6)
    `
    
    _, err := r.db.Exec(query, user.ID, user.Email, user.Name, user.Password, 
                       user.CreatedAt, user.UpdatedAt)
    return err
}
 
func (r *PostgresUserRepository) GetByID(id string) (*entity.User, error) {
    query := `
        SELECT id, email, name, password, created_at, updated_at
        FROM users WHERE id = $1
    `
    
    user := &entity.User{}
    err := r.db.QueryRow(query, id).Scan(
        &user.ID, &user.Email, &user.Name, &user.Password,
        &user.CreatedAt, &user.UpdatedAt,
    )
    
    if err == sql.ErrNoRows {
        return nil, nil
    }
    
    return user, err
}
 
func (r *PostgresUserRepository) GetByEmail(email string) (*entity.User, error) {
    query := `
        SELECT id, email, name, password, created_at, updated_at
        FROM users WHERE email = $1
    `
    
    user := &entity.User{}
    err := r.db.QueryRow(query, email).Scan(
        &user.ID, &user.Email, &user.Name, &user.Password,
        &user.CreatedAt, &user.UpdatedAt,
    )
    
    if err == sql.ErrNoRows {
        return nil, nil
    }
    
    return user, err
}
 
func (r *PostgresUserRepository) Update(user *entity.User) error {
    query := `
        UPDATE users 
        SET email = $1, name = $2, password = $3, updated_at = $4
        WHERE id = $5
    `
    
    _, err := r.db.Exec(query, user.Email, user.Name, user.Password, 
                       user.UpdatedAt, user.ID)
    return err
}
 
func (r *PostgresUserRepository) Delete(id string) error {
    query := `DELETE FROM users WHERE id = $1`
    _, err := r.db.Exec(query, id)
    return err
}
 
func (r *PostgresUserRepository) List(limit, offset int) ([]*entity.User, error) {
    query := `
        SELECT id, email, name, password, created_at, updated_at
        FROM users 
        ORDER BY created_at DESC
        LIMIT $1 OFFSET $2
    `
    
    rows, err := r.db.Query(query, limit, offset)
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    
    var users []*entity.User
    for rows.Next() {
        user := &entity.User{}
        err := rows.Scan(
            &user.ID, &user.Email, &user.Name, &user.Password,
            &user.CreatedAt, &user.UpdatedAt,
        )
        if err != nil {
            return nil, err
        }
        users = append(users, user)
    }
    
    return users, nil
}

Database Connection

go
// internal/infrastructure/database/connection.go
package database
 
import (
    "database/sql"
    _ "github.com/lib/pq"
)
 
func NewPostgresConnection(dsn string) (*sql.DB, error) {
    db, err := sql.Open("postgres", dsn)
    if err != nil {
        return nil, err
    }
    
    if err := db.Ping(); err != nil {
        return nil, err
    }
    
    return db, nil
}

Dependency Injection

Wire Setup

go
// internal/wire.go
package internal
 
import (
    "myapp/internal/delivery/http"
    "myapp/internal/infrastructure/database"
    "myapp/internal/usecase"
    
    "github.com/google/wire"
)
 
func InitializeAPI(db *database.PostgresUserRepository) (*http.UserHandler, error) {
    wire.Build(
        usecase.NewUserUseCase,
        http.NewUserHandler,
    )
    return &http.UserHandler{}, nil
}

Main Application

go
// cmd/server/main.go
package main
 
import (
    "log"
    "net/http"
    
    "myapp/internal/delivery/http"
    "myapp/internal/infrastructure/database"
    "myapp/internal/usecase"
)
 
func main() {
    // Initialize database
    db, err := database.NewPostgresConnection("postgres://user:pass@localhost/dbname?sslmode=disable")
    if err != nil {
        log.Fatal("Failed to connect to database:", err)
    }
    defer db.Close()
    
    // Initialize repository
    userRepo := database.NewPostgresUserRepository(db)
    
    // Initialize use case
    userUseCase := usecase.NewUserUseCase(userRepo)
    
    // Initialize handler
    userHandler := http.NewUserHandler(userUseCase)
    
    // Setup router
    router := http.SetupRouter(userUseCase)
    
    // Start server
    log.Println("Server starting on :8080")
    log.Fatal(http.ListenAndServe(":8080", router))
}

Testing

Use Case Testing

go
// internal/usecase/user_usecase_test.go
package usecase
 
import (
    "context"
    "testing"
    
    "myapp/internal/domain/entity"
    "myapp/internal/domain/repository"
)
 
// Mock repository for testing
type MockUserRepository struct {
    users map[string]*entity.User
}
 
func NewMockUserRepository() repository.UserRepository {
    return &MockUserRepository{
        users: make(map[string]*entity.User),
    }
}
 
func (m *MockUserRepository) Create(user *entity.User) error {
    m.users[user.ID] = user
    return nil
}
 
func (m *MockUserRepository) GetByID(id string) (*entity.User, error) {
    user, exists := m.users[id]
    if !exists {
        return nil, nil
    }
    return user, nil
}
 
func (m *MockUserRepository) GetByEmail(email string) (*entity.User, error) {
    for _, user := range m.users {
        if user.Email == email {
            return user, nil
        }
    }
    return nil, nil
}
 
func (m *MockUserRepository) Update(user *entity.User) error {
    m.users[user.ID] = user
    return nil
}
 
func (m *MockUserRepository) Delete(id string) error {
    delete(m.users, id)
    return nil
}
 
func (m *MockUserRepository) List(limit, offset int) ([]*entity.User, error) {
    var users []*entity.User
    count := 0
    for _, user := range m.users {
        if count >= offset && len(users) < limit {
            users = append(users, user)
        }
        count++
    }
    return users, nil
}
 
func TestUserUseCase_CreateUser(t *testing.T) {
    // Arrange
    mockRepo := NewMockUserRepository()
    useCase := NewUserUseCase(mockRepo)
    ctx := context.Background()
    
    // Act
    user, err := useCase.CreateUser(ctx, "test@example.com", "Test User", "password123")
    
    // Assert
    if err != nil {
        t.Errorf("Expected no error, got %v", err)
    }
    
    if user == nil {
        t.Error("Expected user to be created")
    }
    
    if user.Email != "test@example.com" {
        t.Errorf("Expected email 'test@example.com', got %s", user.Email)
    }
}
 
func TestUserUseCase_CreateUser_DuplicateEmail(t *testing.T) {
    // Arrange
    mockRepo := NewMockUserRepository()
    useCase := NewUserUseCase(mockRepo)
    ctx := context.Background()
    
    // Create first user
    _, err := useCase.CreateUser(ctx, "test@example.com", "Test User", "password123")
    if err != nil {
        t.Fatal(err)
    }
    
    // Act - Try to create user with same email
    _, err = useCase.CreateUser(ctx, "test@example.com", "Another User", "password456")
    
    // Assert
    if err == nil {
        t.Error("Expected error for duplicate email")
    }
    
    if err.Error() != "user already exists" {
        t.Errorf("Expected 'user already exists' error, got %v", err)
    }
}

Handler Testing

go
// internal/delivery/http/user_handler_test.go
package http
 
import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
    
    "myapp/internal/usecase"
)
 
func TestUserHandler_CreateUser(t *testing.T) {
    // Arrange
    mockRepo := NewMockUserRepository()
    useCase := usecase.NewUserUseCase(mockRepo)
    handler := NewUserHandler(useCase)
    
    requestBody := CreateUserRequest{
        Email:    "test@example.com",
        Name:     "Test User",
        Password: "password123",
    }
    
    body, _ := json.Marshal(requestBody)
    req := httptest.NewRequest("POST", "/users", bytes.NewBuffer(body))
    w := httptest.NewRecorder()
    
    // Act
    handler.CreateUser(w, req)
    
    // Assert
    if w.Code != http.StatusCreated {
        t.Errorf("Expected status %d, got %d", http.StatusCreated, w.Code)
    }
    
    var response CreateUserResponse
    json.Unmarshal(w.Body.Bytes(), &response)
    
    if response.User == nil {
        t.Error("Expected user in response")
    }
    
    if response.User.Email != "test@example.com" {
        t.Errorf("Expected email 'test@example.com', got %s", response.User.Email)
    }
}

Best Practices

1. Interface Segregation

go
// Define small, focused interfaces
type UserReader interface {
    GetByID(id string) (*entity.User, error)
    GetByEmail(email string) (*entity.User, error)
}
 
type UserWriter interface {
    Create(user *entity.User) error
    Update(user *entity.User) error
    Delete(id string) error
}
 
type UserRepository interface {
    UserReader
    UserWriter
    List(limit, offset int) ([]*entity.User, error)
}

2. Error Handling

go
// Define custom errors
var (
    ErrUserNotFound = errors.New("user not found")
    ErrUserExists   = errors.New("user already exists")
    ErrInvalidInput = errors.New("invalid input")
)
 
// Use error wrapping
func (uc *UserUseCase) GetUserByID(ctx context.Context, id string) (*entity.User, error) {
    user, err := uc.userRepo.GetByID(id)
    if err != nil {
        return nil, fmt.Errorf("failed to get user: %w", err)
    }
    
    if user == nil {
        return nil, ErrUserNotFound
    }
    
    return user, nil
}

3. Context Usage

go
// Pass context through all layers
func (uc *UserUseCase) CreateUser(ctx context.Context, email, name, password string) (*entity.User, error) {
    // Check for cancellation
    select {
    case <-ctx.Done():
        return nil, ctx.Err()
    default:
    }
    
    // Business logic...
    return user, nil
}

4. Validation

go
// Use validation tags
type CreateUserRequest struct {
    Email    string `json:"email" validate:"required,email"`
    Name     string `json:"name" validate:"required,min=2,max=50"`
    Password string `json:"password" validate:"required,min=6"`
}
 
// Validate in handler
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid request body", http.StatusBadRequest)
        return
    }
    
    if err := validator.New().Struct(req); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    // Continue with use case...
}

Kết luận

Clean Architecture là pattern không thể thiếu cho các ứng dụng Go enterprise-level.