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

209 lines
6.2 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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 命名)