--- name: golang-clean-arch description: 當你在寫 Go 的新模組、新功能、或新 service 時,使用這套 Clean Architecture 目錄結構與分層規範。這是從 orbit-jobs-manager 專案提煉出來的實戰架構。 --- # Go Clean Architecture 模組結構 ## 目錄結構 每個業務模組放在 `internal/module/<模組名>/` 下,結構如下: ``` internal/module// ├── const.go # 模組層級常數(package 名用模組名) ├── domain/ │ ├── errors.go # sentinel errors(ErrNotFound 等) │ ├── constants.go # domain 層常數(狀態值字串等) │ ├── entity/ # 資料結構(對應 DB table) │ │ ├── .go │ │ └── _helper.go # entity 相關的純函式(FlattenSteps 等) │ ├── repository/ # Repository 介面定義 │ │ └── .go │ └── usecase/ # UseCase 介面 + Req/Resp 型別定義 │ └── .go ├── repository/ # Repository 實作(依賴 DB driver) │ ├── .go │ ├── _test.go │ └── test_helper.go └── usecase/ # UseCase 實作(業務邏輯) ├── .go ├── _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/_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/_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/_test.go`(table-driven + AC 命名)