209 lines
6.2 KiB
Markdown
209 lines
6.2 KiB
Markdown
---
|
||
name: golang-clean-arch
|
||
description: 當你在寫 Go 的新模組、新功能、或新 service 時,使用這套 Clean Architecture 目錄結構與分層規範。這是從 orbit-jobs-manager 專案提煉出來的實戰架構。
|
||
---
|
||
|
||
# Go Clean Architecture 模組結構
|
||
|
||
## 目錄結構
|
||
|
||
每個業務模組放在 `internal/module/<模組名>/` 下,結構如下:
|
||
|
||
```
|
||
internal/module/<name>/
|
||
├── const.go # 模組層級常數(package 名用模組名)
|
||
├── domain/
|
||
│ ├── errors.go # sentinel errors(ErrNotFound 等)
|
||
│ ├── constants.go # domain 層常數(狀態值字串等)
|
||
│ ├── entity/ # 資料結構(對應 DB table)
|
||
│ │ ├── <name>.go
|
||
│ │ └── <name>_helper.go # entity 相關的純函式(FlattenSteps 等)
|
||
│ ├── repository/ # Repository 介面定義
|
||
│ │ └── <name>.go
|
||
│ └── usecase/ # UseCase 介面 + Req/Resp 型別定義
|
||
│ └── <name>.go
|
||
├── repository/ # Repository 實作(依賴 DB driver)
|
||
│ ├── <name>.go
|
||
│ ├── <name>_test.go
|
||
│ └── test_helper.go
|
||
└── usecase/ # UseCase 實作(業務邏輯)
|
||
├── <name>.go
|
||
├── <name>_test.go
|
||
└── mock/
|
||
└── mock_repositories.go # 手寫 mock,供 usecase 測試用
|
||
```
|
||
|
||
## 各層職責
|
||
|
||
### `domain/entity/`
|
||
- 對應 DB table 的 struct,只有欄位和 `TableName()` 方法
|
||
- 不依賴任何外部套件(除了 DB driver 的型別如 `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/`
|
||
- **只有 interface + Req/Resp struct**,不含任何實作
|
||
- 每個方法對應一個 `XxxReq` 和 `XxxResp`
|
||
- Req/Resp 用 value type(不是 pointer),可選欄位用 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`
|
||
- 只放 sentinel errors,用 `errors.New`
|
||
- usecase 實作層用 `errors.Is(err, domain.ErrNotFound)` 判斷
|
||
|
||
```go
|
||
var (
|
||
ErrNotFound = errors.New("not found")
|
||
)
|
||
```
|
||
|
||
### `repository/`(實作層)
|
||
- struct 名用小寫(`jobRepository`),不對外暴露
|
||
- 建構子提供兩種:`NewXxx`(回傳 error)和 `MustXxx`(panic on error)
|
||
- 建構子回傳 **domain/repository 的 interface**,不是 struct
|
||
- 將 DB 層的 not found 錯誤轉換為 `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("failed to create job repository: %v", err))
|
||
}
|
||
return r
|
||
}
|
||
```
|
||
|
||
### `usecase/`(實作層)
|
||
- struct 名用小寫(`jobUseCase`),不對外暴露
|
||
- 依賴注入:所有 repository 都透過建構子傳入(interface 型別)
|
||
- 建構子回傳 **domain/usecase 的 interface**
|
||
- 錯誤處理:domain error → 轉換為 application 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 struct 對應一個 repository interface
|
||
- 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 測試(`usecase/<name>_test.go`)
|
||
- package 用 `package usecase`(白箱測試)
|
||
- 用 table-driven tests,每個 case 對應一個 AC(驗收條件)
|
||
- 命名格式:`AC-X01: 描述`
|
||
- 每個 test case 有獨立的 `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 測試(`repository/<name>_test.go`)
|
||
- 整合測試,需要真實 DB 連線
|
||
- 共用 `test_helper.go` 提供 DB setup/teardown
|
||
|
||
## Import 別名慣例
|
||
|
||
跨模組引用時,用有意義的別名避免衝突:
|
||
|
||
```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/<name>_test.go`(table-driven + AC 命名)
|
||
|