208 lines
7.2 KiB
Markdown
208 lines
7.2 KiB
Markdown
|
|
---
|
|||
|
|
name: golang-clean-arch
|
|||
|
|
description: 當你在撰寫 Go 的新模組、新功能或新服務時,請遵循此 Clean Architecture(潔淨架構)目錄結構與分層規範。此架構由實戰經驗提煉而成。
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# Go Clean Architecture 模組結構規範
|
|||
|
|
|
|||
|
|
## 目錄結構
|
|||
|
|
|
|||
|
|
所有業務模組應路徑應為 `internal/module/<模組名稱>/`,其內部結構如下:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
internal/module/<模組名稱>/
|
|||
|
|
├── const.go # 模組層級常數(Package 名稱使用模組名)
|
|||
|
|
├── domain/
|
|||
|
|
│ ├── errors.go # 哨兵錯誤 (Sentinel Errors,如 ErrNotFound 等)
|
|||
|
|
│ ├── constants.go # 領域層 (Domain) 常數(如狀態值字串等)
|
|||
|
|
│ ├── entity/ # 資料結構(對應資料庫資料表)
|
|||
|
|
│ │ ├── <名稱>.go
|
|||
|
|
│ │ └── <名稱>_helper.go # 與實體 (Entity) 相關的純函式 (Pure Functions)
|
|||
|
|
│ ├── repository/ # 倉儲 (Repository) 介面定義
|
|||
|
|
│ │ └── <名稱>.go
|
|||
|
|
│ └── usecase/ # 使用案例 (UseCase) 介面與 Req/Resp 型別定義
|
|||
|
|
│ └── <名稱>.go
|
|||
|
|
├── repository/ # 倉儲實作(依賴資料庫驅動程式)
|
|||
|
|
│ ├── <名稱>.go
|
|||
|
|
│ ├── <名稱>_test.go
|
|||
|
|
│ └── test_helper.go
|
|||
|
|
└── usecase/ # 使用案例實作(處理業務邏輯)
|
|||
|
|
├── <名稱>.go
|
|||
|
|
├── <名稱>_test.go
|
|||
|
|
└── mock/
|
|||
|
|
└── mock_repositories.go # 手寫的 Mock 物件,供 UseCase 測試使用
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 各層分工與職責
|
|||
|
|
|
|||
|
|
### `domain/entity/`
|
|||
|
|
- 對應資料庫資料表 (DB Table) 的結構體 (Struct),僅包含欄位與 `TableName()` 方法。
|
|||
|
|
- 除了資料庫驅動程式的特定型別(如 `gocql.UUID`)外,不應依賴任何外部套件。
|
|||
|
|
- `helper` 檔案應僅包含工具性質的純函式(不帶接收者 (Receiver) 的函式)。
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
type Job struct {
|
|||
|
|
JobID gocql.UUID `db:"job_id" partition_key:"true"`
|
|||
|
|
Status string `db:"status"`
|
|||
|
|
// ... 其他欄位
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (j Job) TableName() string { return "jobs" }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### `domain/repository/`
|
|||
|
|
- **僅定義介面 (Interface)**,不包含任何具體實作。
|
|||
|
|
- 方法簽名的第一個參數應固定為 `context.Context`。
|
|||
|
|
- 僅依賴 `domain/entity/` 中定義的型別。
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
type JobRepository interface {
|
|||
|
|
Create(ctx context.Context, job *entity.Job) error
|
|||
|
|
Get(ctx context.Context, jobID string) (*entity.Job, error)
|
|||
|
|
Update(ctx context.Context, job *entity.Job) error
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### `domain/usecase/`
|
|||
|
|
- **僅定義介面與 Req/Resp 結構體**,不包含具體實作。
|
|||
|
|
- 每個介面方法應對應一組 `XxxReq` 與 `XxxResp`。
|
|||
|
|
- Req/Resp 應使用數值型別 (Value Type) 而非指標 (Pointer),可選欄位則使用指標。
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
type GetJobReq struct {
|
|||
|
|
JobID string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type GetJobResp struct {
|
|||
|
|
Job *entity.Job
|
|||
|
|
Steps []entity.JobStep
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type JobUseCase interface {
|
|||
|
|
GetJob(ctx context.Context, req GetJobReq) (*GetJobResp, error)
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### `domain/errors.go`
|
|||
|
|
- 僅定義哨兵錯誤,建議使用 `errors.New`。
|
|||
|
|
- UseCase 實作層應使用 `errors.Is(err, domain.ErrNotFound)` 來進行錯誤判斷。
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
var (
|
|||
|
|
ErrNotFound = errors.New("找不到指定資料 (not found)")
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### `repository/`(實作層)
|
|||
|
|
- 結構體名稱使用小寫(如 `jobRepository`),不對外公開。
|
|||
|
|
- 提供兩種建構子:`NewXxx`(會回傳 error)與 `MustXxx`(發生錯誤時 panic)。
|
|||
|
|
- 建構子應回傳 **`domain/repository` 中定義的介面**,而非結構體指標。
|
|||
|
|
- 將底層資料庫驅動回傳的查無資料錯誤轉換為 `domain.ErrNotFound`。
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
func NewJobRepository(db *cassandra.DB, keyspace string) (repository.JobRepository, error) {
|
|||
|
|
// ... 實作細節
|
|||
|
|
return &jobRepository{...}, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func MustJobRepository(param JobRepositoryParam) repository.JobRepository {
|
|||
|
|
r, err := NewJobRepository(param.DB, param.Keyspace)
|
|||
|
|
if err != nil {
|
|||
|
|
panic(fmt.Sprintf("建立 Job Repository 失敗: %v", err))
|
|||
|
|
}
|
|||
|
|
return r
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### `usecase/`(實作層)
|
|||
|
|
- 結構體名稱使用小寫(如 `jobUseCase`),不對外公開。
|
|||
|
|
- 採用依賴注入 (Dependency Injection):所需的 Repository 皆透過建構子傳入(介面型別)。
|
|||
|
|
- 建構子應回傳 **`domain/usecase` 中定義的介面**。
|
|||
|
|
- 錯誤處理:將 Domain Error 轉換為應用層錯誤(如 `errs.ResNotFoundError`)。
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
type jobUseCase struct {
|
|||
|
|
jobRepo repository.JobRepository
|
|||
|
|
templateRepo templateRepo.TemplateRepository
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func NewJobUseCase(
|
|||
|
|
jobRepo repository.JobRepository,
|
|||
|
|
templateRepo templateRepo.TemplateRepository,
|
|||
|
|
) domainUseCase.JobUseCase {
|
|||
|
|
return &jobUseCase{
|
|||
|
|
jobRepo: jobRepo,
|
|||
|
|
templateRepo: templateRepo,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### `usecase/mock/`
|
|||
|
|
- 手寫 Mock 對象(建議配合 `github.com/stretchr/testify/mock` 使用)。
|
|||
|
|
- 在檔案開頭加入註釋 `// Code generated by hand for testing. DO NOT EDIT.`。
|
|||
|
|
- 每個 Mock 結構體應對應一個 Repository 介面。
|
|||
|
|
- 進行型別斷言 (Type Assertion) 前應先判斷回傳值是否為 nil。
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
type MockJobRepository struct {
|
|||
|
|
mock.Mock
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (m *MockJobRepository) Get(ctx context.Context, jobID string) (*entity.Job, error) {
|
|||
|
|
args := m.Called(ctx, jobID)
|
|||
|
|
if args.Get(0) == nil {
|
|||
|
|
return nil, args.Error(1)
|
|||
|
|
}
|
|||
|
|
return args.Get(0).(*entity.Job), args.Error(1)
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 測試撰寫規範
|
|||
|
|
|
|||
|
|
### 使用案例測試 (`usecase/<名稱>_test.go`)
|
|||
|
|
- 套件名稱使用 `package usecase`(白箱測試)。
|
|||
|
|
- 使用表驅動測試 (Table-Driven Tests),每個案例應對應一條驗收準則 (Acceptance Criteria, AC)。
|
|||
|
|
- 命名慣例:`AC-序號: 情境描述` (如 `AC-J01: 成功取得 Job`)。
|
|||
|
|
- 每個測試案例應配備獨立的 `setupMocks` 函式。
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
tests := []struct {
|
|||
|
|
name string
|
|||
|
|
req domainUseCase.GetJobReq
|
|||
|
|
setupMocks func(*mock.MockJobRepository)
|
|||
|
|
wantErr bool
|
|||
|
|
}{
|
|||
|
|
{
|
|||
|
|
name: "AC-J01: 成功取得 Job",
|
|||
|
|
req: domainUseCase.GetJobReq{JobID: "xxx"},
|
|||
|
|
setupMocks: func(jobRepo *mock.MockJobRepository) {
|
|||
|
|
jobRepo.On("Get", ctx, "xxx").Return(&entity.Job{...}, nil)
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 倉儲測試 (`repository/<名稱>_test.go`)
|
|||
|
|
- 屬於整合測試 (Integration Test),需連線至真實資料庫。
|
|||
|
|
- 共用 `test_helper.go` 以提供資料庫的 Setup 與 Teardown(環境建立與清理)。
|
|||
|
|
|
|||
|
|
## 匯入別名對照表 (Alias Convention)
|
|||
|
|
|
|||
|
|
在進行跨模組引用時,請使用具備明確語義的別名以避免衝突:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
import (
|
|||
|
|
jobDomainUseCase "myapp/internal/module/job/domain/usecase"
|
|||
|
|
templateRepo "myapp/internal/module/template/domain/repository"
|
|||
|
|
templateDomain "myapp/internal/module/template/domain"
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 新增模組檢查清單 (Checklist)
|
|||
|
|
|
|||
|
|
1. 依照上方規範建立目錄結構。
|
|||
|
|
2. 介面先行:依序撰寫 `domain/entity/` → `domain/repository/` → `domain/usecase/`。
|
|||
|
|
3. 撰寫基礎支撐:完成 `repository/` 實作以及 `usecase/mock/`。
|
|||
|
|
4. 撰寫核心邏輯:完成 `usecase/` 實作。
|
|||
|
|
5. 補齊測試:在 `usecase/<名稱>_test.go` 中實作表驅動測試,並遵循 AC 命名規則。
|