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ư
dighayinject) 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/taskcho 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.yamlvà biến môi trường. -
Tự động chạy migration SQL nhúng từ
assets/migrationsbằngembed.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
Makefiletự độ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