0

Bài 05: Thực hành Clean Architecture kết hợp Standard Project Layout

1. Mục tiêu bài học

  • Áp dụng nguyên tắc Dependency Rule của Clean Architecture vào hệ thống thư mục Go mà không gây cồng kềnh (over-engineering).

  • Thiết kế domain core hoàn toàn độc lập với framework, cơ sở dữ liệu, và giao thức truyền tải (HTTP/gRPC).

  • Tách bạch 4 tầng kiến trúc: Domain Entities, Use Cases (Application), Interface Adapters, và Infrastructure bên trong /internal.

2. Nội dung chi tiết

  • Phần 1: Ánh xạ Clean Architecture vào Standard Project Layout

    • Tầng Domain (Core): Chứa pure business logic và enterprise entities, không phụ thuộc bất kỳ thư viện bên thứ ba nào.

    • Tầng Use Case (App Logic): Điều phối luồng nghiệp vụ, định nghĩa các Inbound Ports (hàm nghiệp vụ) và Outbound Ports (repository/service interfaces).

    • Tầng Interface Adapters: Chuyển đổi dữ liệu cho Controller, HTTP Handlers, Presenters.

    • Tầng Infrastructure: Triển khai database drivers (PostgreSQL, Redis), message brokers, external HTTP clients.

  • Phần 2: Quy tắc phụ thuộc (The Dependency Rule) trong Go

    • Mã nguồn chỉ được trỏ vào bên trong: Infrastructure -> Adapters -> Use Cases -> Domain.

    • Khai báo interface tại tầng tiêu thụ (Consumer Interfaces) thay vì định nghĩa interface bên cạnh implementation.

  • Phần 3: Dependency Injection (DI) thủ công tại /cmd

    • Tại sao không lạm dụng reflect-based DI frameworks (như dig hay inject) khi dự án chưa đủ lớn.

    • Kỹ thuật "Pure DI": Tự khởi tạo và lắp ráp các dependency từ dưới lên trên trong main.go.

3. Cấu trúc thư mục minh họa

Plaintext

clean-layout/
├── cmd/
│   └── api/
│       └── main.go          # Nơi duy nhất lắp ráp (Wire/DI) toàn bộ hệ thống
├── internal/
│   ├── domain/              # Lớp lõi: Entity & Business Rules thuần túy
│   │   ├── wallet.go
│   │   └── errors.go
│   ├── usecase/             # Lớp ứng dụng: Điều phối nghiệp vụ & Interfaces
│   │   ├── wallet.go
│   │   └── repository.go    # Outbound port: Định nghĩa interface cần dùng
│   ├── adapter/
│   │   ├── handler/         # Inbound adapter: HTTP Controller
│   │   │   └── wallet.go
│   │   └── repository/      # Outbound adapter: PostgreSQL Implementation
│   │       └── postgres_wallet.go
├── go.mod
└── go.sum

4. Ví dụ code minh họa

internal/domain/wallet.go (Tầng Domain - Không phụ thuộc package ngoài):

Go

package domain

import "errors"

var ErrInsufficientFunds = errors.New("số dư không đủ để thực hiện giao dịch")

type Wallet struct {
	ID      string
	Balance int64
}

func (w *Wallet) Deduct(amount int64) error {
	if amount <= 0 {
		return errors.New("số tiền trừ phải lớn hơn 0")
	}
	if w.Balance < amount {
		return ErrInsufficientFunds
	}
	w.Balance -= amount
	return nil
}

internal/usecase/repository.go & internal/usecase/wallet.go (Tầng Use Case):

Go

package usecase

import (
	"context"
	"clean-layout/internal/domain"
)

// WalletRepository là Outbound Port do Use Case định nghĩa
type WalletRepository interface {
	GetByID(ctx context.Context, id string) (*domain.Wallet, error)
	Update(ctx context.Context, wallet *domain.Wallet) error
}

type WalletUseCase struct {
	repo WalletRepository
}

func NewWalletUseCase(repo WalletRepository) *WalletUseCase {
	return &WalletUseCase{repo: repo}
}

func (uc *WalletUseCase) Withdraw(ctx context.Context, walletID string, amount int64) error {
	wallet, err := uc.repo.GetByID(ctx context.Context, walletID)
	if err != nil {
		return err
	}

	if err := wallet.Deduct(amount); err != nil {
		return err
	}

	return uc.repo.Update(ctx, wallet)
}

internal/adapter/repository/postgres_wallet.go (Tầng Infrastructure / Outbound Adapter):

Go

package repository

import (
	"context"
	"clean-layout/internal/domain"
)

type PostgresWalletRepository struct {
	// Giả lập kết nối cơ sở dữ liệu (ví dụ: *sql.DB hoặc *pgxpool.Pool)
}

func NewPostgresWalletRepository() *PostgresWalletRepository {
	return &PostgresWalletRepository{}
}

func (r *PostgresWalletRepository) GetByID(ctx context.Context, id string) (*domain.Wallet, error) {
	// Truy vấn DB thực tế...
	return &domain.Wallet{ID: id, Balance: 500000}, nil
}

func (r *PostgresWalletRepository) Update(ctx context.Context, wallet *domain.Wallet) error {
	// Thực thi câu lệnh SQL UPDATE...
	return nil
}

cmd/api/main.go (Lắp ráp tất cả lại với nhau):

Go

package main

import (
	"context"
	"log"
	"clean-layout/internal/adapter/repository"
	"clean-layout/internal/usecase"
)

func main() {
	// 1. Khởi tạo tầng hạ tầng (Adapters)
	walletRepo := repository.NewPostgresWalletRepository()

	// 2. Tiêm hạ tầng vào tầng nghiệp vụ (Use Cases)
	walletUC := usecase.NewWalletUseCase(walletRepo)

	// 3. Thực thi nghiệp vụ thông qua handler/controller
	err := walletUC.Withdraw(context.Background(), "WAL-001", 100000)
	if err != nil {
		log.Fatalf("Giao dịch thất bại: %v", err)
	}

	log.Println("Giao dịch rút tiền thành công qua Clean Architecture!")
}

Bài 10: Xây dựng dự án mẫu hoàn chỉnh (REST API + CLI) từ con số 0

1. Mục tiêu bài học

  • Tổng hợp toàn bộ kiến thức trong series vào một sản phẩm thực tế: Task Management System.

  • Hỗ trợ 2 binary trong /cmd: Một HTTP REST API Server và một dòng lệnh CLI Client cùng chia sẻ logic domain.

  • Tích hợp cấu hình (/configs), migrations tĩnh (/assets), container hóa (/build), và script điều phối (/scripts).

2. Nội dung chi tiết

  • Phần 1: Phân tích yêu cầu và kiến trúc repository

    • Binary 1: cmd/api – Chạy server HTTP nhận request tạo/đọc Task.

    • Binary 2: cmd/cli – Công cụ dòng lệnh dành cho admin chạy backup/export Task.

    • Tái sử dụng internal/task cho cả hai binary mà không trùng lặp code.

  • Phần 2: Xây dựng luồng thực tế (End-to-End)

    • Load cấu hình từ file configs/config.yaml và biến môi trường.

    • Tự động chạy migration SQL nhúng từ assets/migrations bằng embed.FS.

    • Xử lý Graceful Shutdown khi nhận tín hiệu hệ điều hành (SIGINT, SIGTERM).

  • Phần 3: Tối ưu hoá quy trình đóng gói và kiểm thử

    • Viết Makefile tự động hóa: make build, make test, make run-api, make run-cli.

    • Viết multi-stage Dockerfile phát hành image siêu nhẹ (<20MB).

3. Cấu trúc thư mục dự án hoàn chỉnh

Plaintext

task-master/
├── assets/
│   └── migrations/
│       └── 0001_create_tasks_table.sql
├── configs/
│   ├── config.yaml
│   └── config.yaml.example
├── cmd/
│   ├── api/
│   │   └── main.go           # Entrypoint REST API Server
│   └── cli/
│       └── main.go           # Entrypoint Command Line Tool
├── internal/
│   ├── config/
│   │   └── config.go
│   ├── task/
│   │   ├── model.go
│   │   ├── repository.go
│   │   └── service.go
│   └── platform/
│       └── database/
│           └── db.go
├── build/
│   └── package/
│       └── Dockerfile
├── Makefile
├── go.mod
└── go.sum

4. Ví dụ code minh họa

internal/task/service.go (Business Service dùng chung cho cả API và CLI):

Go

package task

import (
	"context"
	"errors"
	"time"
)

type Task struct {
	ID        string    `json:"id"`
	Title     string    `json:"title"`
	CreatedAt time.Time `json:"created_at"`
}

type MemoryStore struct {
	tasks map[string]*Task
}

func NewMemoryStore() *MemoryStore {
	return &MemoryStore{tasks: make(map[string]*Task)}
}

func (s *MemoryStore) Save(ctx context.Context, t *Task) error {
	s.tasks[t.ID] = t
	return nil
}

func (s *MemoryStore) ListAll(ctx context.Context) ([]*Task, error) {
	var result []*Task
	for _, t := range s.tasks {
		result = append(result, t)
	}
	return result, nil
}

type Service struct {
	store *MemoryStore
}

func NewService(store *MemoryStore) *Service {
	return &Service{store: store}
}

func (s *Service) CreateTask(ctx context.Context, id, title string) (*Task, error) {
	if title == "" {
		return nil, errors.New("tiêu đề task không được để trống")
	}
	t := &Task{ID: id, Title: title, CreatedAt: time.Now()}
	if err := s.store.Save(ctx, t); err != nil {
		return nil, err
	}
	return t, nil
}

func (s *Service) GetAllTasks(ctx context.Context) ([]*Task, error) {
	return s.store.ListAll(ctx)
}

cmd/api/main.go (Binary 1: REST API Server với Graceful Shutdown):

Go

package main

import (
	"context"
	"encoding/json"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"task-master/internal/task"
)

func main() {
	store := task.NewMemoryStore()
	taskSvc := task.NewService(store)

	mux := http.NewServeMux()
	mux.HandleFunc("/tasks", func(w http.ResponseWriter, r *http.Request) {
		if r.Method == http.MethodPost {
			var body struct{ ID, Title string }
			if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
				http.Error(w, err.Error(), http.StatusBadRequest)
				return
			}
			t, err := taskSvc.CreateTask(r.Context(), body.ID, body.Title)
			if err != nil {
				http.Error(w, err.Error(), http.StatusBadRequest)
				return
			}
			w.Header().Set("Content-Type", "application/json")
			json.NewEncoder(w).Encode(t)
		}
	})

	server := &http.Server{Addr: ":8080", Handler: mux}

	go func() {
		log.Println("[API] Server đang chạy trên cổng :8080...")
		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatalf("[API] Lỗi khởi động: %v", err)
		}
	}()

	// Graceful shutdown listener
	stop := make(chan os.Signal, 1)
	signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
	<-stop

	log.Println("[API] Đang dừng server an toàn...")
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	_ = server.Shutdown(ctx)
	log.Println("[API] Server đã tắt hoàn toàn.")
}

cmd/cli/main.go (Binary 2: CLI dùng chung logic từ /internal/task):

Go

package main

import (
	"context"
	"flag"
	"fmt"
	"log"

	"task-master/internal/task"
)

func main() {
	action := flag.String("action", "add", "Hành động: add hoặc list")
	taskID := flag.String("id", "", "Mã task")
	taskTitle := flag.String("title", "", "Tiêu đề task")
	flag.Parse()

	store := task.NewMemoryStore()
	taskSvc := task.NewService(store)

	ctx := context.Background()

	switch *action {
	case "add":
		if *taskID == "" || *taskTitle == "" {
			log.Fatal("Lỗi: Yêu cầu cung cấp cả -id và -title")
		}
		t, err := taskSvc.CreateTask(ctx, *taskID, *taskTitle)
		if err != nil {
			log.Fatalf("Tạo task thất bại: %v", err)
		}
		fmt.Printf("[CLI] Đã tạo thành công task #%s: %s\n", t.ID, t.Title)

	default:
		fmt.Println("Hành động không hợp lệ. Sử dụng -action=add")
	}
}

All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.