6.2 KiB
6.2 KiB
| name | description |
|---|---|
| golang-clean-arch | 當你在寫 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 的工具函式)
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/的型別
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
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)判斷
var (
ErrNotFound = errors.New("not found")
)
repository/(實作層)
- struct 名用小寫(
jobRepository),不對外暴露 - 建構子提供兩種:
NewXxx(回傳 error)和MustXxx(panic on error) - 建構子回傳 domain/repository 的 interface,不是 struct
- 將 DB 層的 not found 錯誤轉換為
domain.ErrNotFound
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)
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 回傳值要做型別斷言前先判斷
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函式
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 別名慣例
跨模組引用時,用有意義的別名避免衝突:
import (
jobDomainUseCase "myapp/internal/module/job/domain/usecase"
templateRepo "myapp/internal/module/template/domain/repository"
templateDomain "myapp/internal/module/template/domain"
)
新增模組 Checklist
- 建立目錄結構(參考上方樹狀圖)
- 先寫
domain/entity/→domain/repository/→domain/usecase/(介面先行) - 再寫
repository/實作 +usecase/mock/ - 最後寫
usecase/實作 - 同步補
usecase/<name>_test.go(table-driven + AC 命名)