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