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

6.2 KiB
Raw Blame History

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 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 的工具函式)
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,不含任何實作
  • 每個方法對應一個 XxxReqXxxResp
  • 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 errorserrors.New
  • usecase 實作層用 errors.Is(err, domain.ErrNotFound) 判斷
var (
    ErrNotFound = errors.New("not found")
)

repository/(實作層)

  • struct 名用小寫(jobRepository),不對外暴露
  • 建構子提供兩種:NewXxx(回傳 errorMustXxxpanic 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 errorerrs.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/

  • 手寫 mockgithub.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

  1. 建立目錄結構(參考上方樹狀圖)
  2. 先寫 domain/entity/domain/repository/domain/usecase/(介面先行)
  3. 再寫 repository/ 實作 + usecase/mock/
  4. 最後寫 usecase/ 實作
  5. 同步補 usecase/<name>_test.gotable-driven + AC 命名)