claude-code/claude/skills/golang-clean-arch/SKILL.md

209 lines
6.2 KiB
Markdown
Raw Normal View History

2026-02-27 13:44:09 +00:00
---
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 errorsErrNotFound 等)
│ ├── 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 命名)