template-monorepo/docs/model.md

21 KiB
Raw Permalink Blame History

Model 分層規範

本文件定義 Gateway 專案中 業務模型internal/model/{module}/)的目錄結構與撰寫約定,參考 Clean Architecture 分層。

Gateway 的 HTTP 層handler / logic / types仍由 goctl 生成;本規範適用於 internal/model/{module}/(例如 internal/model/member/internal/model/notification/)。不使用 pkg/

專案總覽與錯誤分層見 README.md

目錄結構(domain/ + 外層實作)

新模組與重構中的模組notificationmemberpermission 等)採 domain 分包:契約與領域型別在 domain/Mongo / Redis / Provider / embed 等實作在模組根下對應目錄。對齊 app-cloudep-notification-service/pkg/domain/ 慣例。

路徑前綴:internal/model/{module}/

internal/model/
└── {module}/                    # 例notification、member、permission
    ├── domain/                  # 純領域介面、實體、列舉、DTO、模組級定義
    │   ├── entity/              # Mongo document 結構 + CollectionName()
    │   ├── enum/                # Channel、Status、Platform…
    │   ├── repository/          # Repository / Cache 介面 only
    │   ├── usecase/             # UseCase 介面 + Request/Response DTO
    │   ├── template/            # 可選:模板 Spec、Registry、Renderer 介面notification
    │   ├── errors.go            # 模組 sentinelpackage domain
    │   ├── const.go             # BSON 欄位名、模組常數package domain
    │   └── redis.go             # Redis key 命名package domain
    ├── repository/              # domain/repository 的 Mongo / Redis / memory 實作
    ├── usecase/                 # domain/usecase 的實作 + factory 組裝
    ├── template/                # 可選go:embed、DefaultRegistry、Renderer 實作
    ├── provider/                # 可選:僅本模組用的 Senderemail/sms不放 library/
    ├── totp/、xxx/              # 可選:模組專屬純函式 library不放 internal/library/
    ├── config/                  # 模組設定 struct嵌入 gateway Config
    └── mock/                    # mockgen路徑對應 domain/
        ├── repository/
        └── usecase/

定義類errors / const / redis key統一放 domain/caller 端以 member "gateway/internal/model/{module}/domain" 取用,引用形式仍為 member.ErrXxx / member.Get…RedisKey,但這些 sentinel 與 key helper 都在 package domain,與 domain/entity 等子套件平行。

internal/library/ 只放跨模組真正共用的東西(如 library/errorslibrary/mongolibrary/redislibrary/crypto)。僅某個模組會用的純函式 / 演算法(例如 member 的 RFC 6238 TOTP helper應落在該模組底下例如 internal/model/member/totp/,避免污染 library 命名空間。

參考實作: internal/model/notification/N0N5 核心已完成;流程圖與設定見 notification README)。

路徑 內容
領域契約 domain/entitydomain/enum 實體、值物件
領域契約 domain/repository XxxRepositoryIdempotencyCacheinterface
領域契約 domain/usecase XxxUseCaseSendRequestinterface + DTO
基礎設施 repository/ NewXxxRepository、index migration
應用服務 usecase/ MustXxxUseCaseNewXxxUseCaseFromParam
模組專用整合 provider/template/ 不進 internal/library/

依賴方向

domain/entity、domain/enum     → 僅標準庫 / 列舉底層型別

domain/repository、domain/usecase、domain/template
                               → domain/entity、domain/enum彼此不 import 實作層)

usecase實作                → domain/usecase實作介面
                               → domain/repository介面
                               → repository/、provider/、template/(具體型別)

repository實作             → domain/repository實作介面
                               → domain/entity

internal/logic、其他 model     → domain/usecase 介面 only
                               (不 import repository 實作、entity、provider

Import 範例notification

import (
    domusecase "gateway/internal/model/notification/domain/usecase"
    "gateway/internal/model/notification/usecase"
)

// 業務模組(如 member只依賴介面
var _ domusecase.NotifierUseCase = (*usecase.notifierUseCase)(nil)

// ServiceContext 組裝實作
notifier, err := usecase.NewNotifierUseCaseFromParam(usecase.FactoryParam{...})

基礎設施連線Mongo / Redis

一個 Pod一個進程連線在 internal/svc/service_context.go 建立一次,再注入各 module。

資源 建立位置 共用方式 模組內
Mongo library/mongo + go-zero mon 同一 URI → 一個 *mongo.Client poolmonclientManager 每 collection 一個 DocumentDB / repository各自 Connect
Redis library/redis.NewClient + go-zero redis.MustNewRedis 同一 Addr一個 connection pool 只收 *redis.Client禁止在 factory 內 go-redis.NewClient
// internal/svc/service_context.go示意
rds, _ := redislib.NewClient(c.Redis)   // 全進程共用
notifier, _ := notifusecase.NewNotifierUseCaseFromParam(notifusecase.FactoryParam{
    MongoConf: &c.Mongo,
    Redis:     rds,                    // 注入,非 RedisConf
    Config:    c.Notification,
})
  • ServiceContext.Redis*library/redis.ClientHost 空則 nil(模組可 fallback memory
  • 之後 member / permission 的 factory、MustXxxUseCase 必須接受 *redislib.Client,不得在 module 內新建 Redis 連線。
  • 模組 redis.go 只定義 key 前綴,不建立 client。

與舊版扁平目錄的關係

早期 scaffold 可能仍為 member/entity(無 domain/)。新增 permission、重構 member 時請遷移到上表結構,與 notification 一致。遷移步驟:先搬 entity/enumdomain/,再搬 repository/usecase 介面domain/,最後保留外層實作並修正 import。

1. Entitydomain/entity/

每個 MongoDB collection 對應一個 struct放在 internal/model/{module}/domain/entity/

規則:

  • 檔名使用 snake_case 對應 collection 語意,如 account.gouser.go
  • struct 名稱使用 PascalCase 單數,如 AccountUser
  • 必須實作 CollectionName() string,回傳 MongoDB collection 名稱。
  • 欄位 tagbson 必填;對外 JSON 序列化才加 json
  • 主鍵使用 MongoDB driver v2bson.ObjectIDgo.mongodb.org/mongo-driver/v2/bsontag 為 `bson:"_id,omitempty" json:"id,omitempty"`。舊模組若仍為 primitive.ObjectID,遷移時一併改為 v2。
  • 時間戳記統一用 *int64,欄位名 CreateAt / UpdateAt,值為 UTC nanoseconds。
  • 可選欄位用指標型別(*string*int64)。
  • 領域列舉引用 domain/enum/ 下的型別,不在 entity 內重複定義。

範例:

package entity

import (
    "gateway/internal/model/member/domain/enum"
    "go.mongodb.org/mongo-driver/v2/bson"
)

type Account struct {
    ID       bson.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
    LoginID  string             `bson:"login_id"`
    Token    string             `bson:"token"`
    Platform enum.Platform      `bson:"platform"`
    UpdateAt *int64             `bson:"update_at,omitempty" json:"update_at,omitempty"`
    CreateAt *int64             `bson:"create_at,omitempty" json:"create_at,omitempty"`
}

func (a *Account) CollectionName() string {
    return "account"
}

2. 值物件 / 列舉(domain/enum/

業務列舉、狀態碼等放在 internal/model/{module}/domain/enum/

規則:

  • 以具名型別包裝底層型別(type Status int32)。
  • 常數集中定義,附中文或英文註解說明語意。
  • 提供轉換方法:ToInt32()ToInt64()ToString()CodeToString() 等。
  • 字串 ↔ 列舉映射用 private map + public getter未知值回傳零值或 sentinel。

範例:

package enum

type Platform int8

const (
    Digimon Platform = 1 + iota
    Google
    Line
    Apple
)

func (p Platform) ToInt64() int64 { return int64(p) }
func (p Platform) ToString() string { /* map lookup */ }

同一檔案或目錄下可放 *_test.go 驗證轉換邏輯。

3. Repository 介面(domain/repository/

規則:

  • 一個 entity 一個 XxxRepository interface檔案放在 domain/repository/
  • 方法第一個參數固定為 context.Context
  • 參數 / 回傳值使用 domain/entity 型別,不暴露 driver 細節(除 index migration 等必要場景)。
  • Index migration 以獨立 interface 嵌入,命名 {Entity}IndexUP,方法名含版本號,如 Index20241226001UP
  • 此目錄僅介面:不含 NewXxxRepository、不含 mongo / redis import。

範例:

package repository

import (
    "context"
    "gateway/internal/model/member/domain/entity"
)

type AccountRepository interface {
    Insert(ctx context.Context, data *entity.Account) error
    FindOne(ctx context.Context, id string) (*entity.Account, error)
    Update(ctx context.Context, data *entity.Account) error
    Delete(ctx context.Context, id string) (int64, error)
}

輔助介面(冪等、配額等)亦放在 domain/repository/,由外層 repository/redis_store.go 等實作。

4. Repository 實作(repository/

規則:

  • struct 名稱 {entity}Repository(小寫)或 {Entity}Repository,建構子 New{Entity}Repository(param {Entity}RepositoryParam)回傳型別為 domain/repository 的 interface
  • Param struct 集中注入 *mongo.Conf 等;實作 import domain/entitydomain/repository
  • 建構時以 domain/entityCollectionName() 初始化 DocumentDB失敗時 panic(啟動期錯誤)。
  • CRUD 透過 gateway/internal/library/mongo 的 DocumentDB helper搭配模組 redis.go 的 key helper。
  • InsertID 為 zero 時自動產生 ObjectID 並寫入 CreateAt / UpdateAt
  • Update:自動更新 UpdateAt
  • FindOne / Delete:無效 ObjectID → *errs.ErrorResInvalidMeasureID)或模組 ErrInvalidObjectID;查無資料 → 模組 ErrNotFound(見第 7 節錯誤)。
  • 整合測試放在同 package 的 *_test.go,可用 testcontainer + miniredis。

範例:

import (
    domentity "gateway/internal/model/member/domain/entity"
    domrepo "gateway/internal/model/member/domain/repository"
)

type accountRepository struct {
    db mongo.DocumentDBUseCase
}

func NewAccountRepository(param AccountRepositoryParam) domrepo.AccountRepository {
    e := domentity.Account{}
    documentDB, err := mongo.NewDocumentDB(param.Conf, e.CollectionName())
    if err != nil {
        panic(err)
    }
    return &accountRepository{db: documentDB}
}

5. UseCase 介面與 DTOdomain/usecase/

規則:

  • 業務入口定義為 interfaceNotifierUseCaseAccountUseCase,放在 domain/usecase/
  • Request / Response struct 與 interface 同 package,命名 {Action}RequestNotificationDTO
  • DTO 只含 json tag若需序列化不含 bson tag。
  • 更新類 Request 的可選欄位用指標,以便區分「未傳入」與「傳入零值」。

範例:

package usecase

import (
    "context"
    "gateway/internal/model/notification/domain/enum"
)

type NotifierUseCase interface {
    Send(ctx context.Context, req *SendRequest) (*NotificationDTO, error)
    Enqueue(ctx context.Context, req *SendRequest) (*NotificationDTO, error)
    Get(ctx context.Context, tenantID, id string) (*NotificationDTO, error)
}

type SendRequest struct {
    TenantID string
    Channel  enum.Channel
    // ...
}

6. UseCase 實作(usecase/

規則:

  • 實作 struct 如 notifierUseCasememberUseCase,放在模組根 usecase/
  • {Name}UseCaseParam 注入 domain/repository 介面、provider、renderer、config
  • 建構子 Must{Name}UseCase(param) domusecase.XxxUseCase,回傳 domain interface。
  • 跨模組組裝可用 New{Name}UseCaseFromParam / factory.go(見 notification/usecase/factory.go)。
  • 方法簽名與 domain/usecase interface 一致;內部組裝 domain/entity,呼叫 repository 介面。
  • 錯誤一律回傳 gateway/internal/library/errors*errs.Error(見第 7 節)。
  • 可測性:將難 mock 的純函式抽成 package 級變數(如 var HashPasswordFunc = HashPassword)。

6.1 UseCase 互不呼叫atomic-only

強制規則UseCase 是 atomic primitive禁止在 usecase 內部呼叫其他 usecase不論是同模組或跨模組

  • usecase struct 的依賴只能domain/repository 介面、provider/template/、library helper、config
  • 不可XxxUseCaseParam 出現另一個 domain/usecase.XxxUseCase 欄位。
  • 需要把多個 atomic 串成一個業務流程例如「OTP.Generate → Notifier.Send → Profile.SetVerified」編排在 internal/logic/logic handler 持有多個 usecase interface 並負責順序、補償、rate-limit、step-up 守門。
  • CLI / drivercmd/notify-test/)扮演 logic 同等角色:直接組 atomic不應該被包成 composite usecase。

這條規則優先於 identity-member-design.md §5.2 提到的 Composite UseCase該節保留為「邏輯流的描述」,不代表 domain/usecase 會出現 composite interface。

範例:

import (
    domrepo "gateway/internal/model/member/domain/repository"
    domusecase "gateway/internal/model/member/domain/usecase"
)

type MemberUseCaseParam struct {
    Account domrepo.AccountRepository
    User    domrepo.UserRepository
    Config  config.Config
}

type memberUseCase struct {
    MemberUseCaseParam
}

func MustMemberUseCase(param MemberUseCaseParam) domusecase.AccountUseCase {
    return &memberUseCase{param}
}

7. 錯誤處理

全專案對外只使用 gateway/internal/library/errors。各模組綁定對應 scopecode.Auth(28)code.Member(29)code.Notification(30)handler 層 parse/validate 使用 code.Facade(10)response.RequestErrScope)。domain/errors.go 只放 sentinel,不另建 8 碼常數表。

7.1 模組 sentineldomain/errors.go

package domain

import "fmt"

var (
    ErrNotFound        = fmt.Errorf("member: not found")
    ErrInvalidObjectID = fmt.Errorf("member: invalid object id")
)

專案慣例sentinel 一律以 fmt.Errorf 定義,便於 %w 包裝。caller 端 member "gateway/internal/model/member/domain" 後即可 member.ErrNotFound。)

7.2 Repository

狀況 回傳
mongo.ErrNoDocuments ErrNotFound(由 usecase 轉 ResNotFound
ObjectID 格式錯 errb.ResInvalidMeasureID(...)ErrInvalidObjectID
duplicate key errb.DBDuplicate(...)
連線 / 暫時不可用 errb.DBUnavailable(...).WithCause(err)
其他 driver 錯 errb.DBError(...).WithCause(err)

基礎設施錯誤須在 repository 邊界對照完成,並以 WithCause 保留原始 err 供 errlog 使用。

7.3 UseCase

  • 業務規則:errb.ResAlreadyExisterrb.ResInvalidStateerrb.AuthForbidden 等。
  • errors.Is(err, member.ErrNotFound)errb.ResNotFound("member", id).WithCause(err)
  • 已是 *errs.Error 且語意正確(如 DBDuplicate)→ 原樣 return err

7.4 Logic / Handler

  • LogicInputMissingRequired 等 HTTP 輸入錯誤usecase 回傳的 err 不二次包裝
  • Handlerresponse.Write,由 errs.FromError 決定 HTTP 狀態與 body。

8. 模組共用檔案

檔案 用途
domain/errors.go 模組 sentinelErrNotFound 等,package domain
domain/const.go 模組字面常數(package domain
domain/redis.go Redis key 型別、With() 組合、GetXxxRedisKey() helperpackage domain
config/config.go UseCase 需要的設定 struct不含 go-zero RestConf

Redis key 統一帶業務 prefix避免跨服務衝突

package domain

type RedisKey string

const AccountRedisKey RedisKey = "member:account"

func (key RedisKey) With(s ...string) RedisKey { /* join with ":" */ }
func GetAccountRedisKey(id string) string {
    return AccountRedisKey.With(id).String()
}

Caller 端:

import (
    member "gateway/internal/model/member/domain"
)

// 使用member.GetAccountRedisKey(id)、member.ErrNotFound

9. Mockmock/ + gomock

方案 A本專案採用

測試對象 做法
domain/repository 契約行為冪等重複、FindByIdempotency repository/*_test.go + in-memory 實作(如 MemoryNotificationRepository
usecase 編排、分支、配額 gomock domain/repository 介面,EXPECT / DoAndReturn
logic 呼叫其他模組 gomock domain/usecase
Provider Chain 保留 provider/*/mock_sender.go記錄呼叫、failover

介面變更後執行:

make gen-mock

domain/repository/generate.go 範例:

//go:generate go run go.uber.org/mock/mockgen@latest -typed -destination=../../mock/repository/repository_mock.go -package=mocknotifrepo gateway/internal/model/{module}/domain/repository NotificationRepository,IdempotencyCache,QuotaCounter
  • 產物在 mock/repository/mock/usecase/不要手改
  • 在 usecase test 手寫 70 行 fake repo有狀態的儲存邏輯放在 repository/memory_*.go 並由 repository 層測試 覆蓋。

10. 命名對照表

概念 命名
Entity struct AccountUser
Collection 方法 CollectionName()
Repository 介面 AccountRepository
Repository 實作 struct accountRepositoryAccountRepository
Repository 建構子 NewAccountRepository
Repository 參數 AccountRepositoryParam
UseCase 介面 AccountUseCase
UseCase 實作 MemberUseCase
UseCase 建構子 MustMemberUseCase
請求 DTO CreateLoginUserRequest
回應 DTO GetUIDByAccountResponse
Index migration Index{YYYYMMDD}{seq}UP
測試檔 與被測檔案同目錄,*_test.go

11. 新增模組 / Model 檢查清單

  1. 建立 internal/model/{module}/domain/{entity,enum,repository,usecase}/ 與外層 repository/usecase/
  2. domain/entity/ 新增 struct + CollectionName()
  3. domain/enum/ 定義值物件(若有)。
  4. domain/repository/ 宣告 interfacerepository/ 實作 CRUD + index + *_test.go
  5. domain/usecase/ 宣告 interface + DTOusecase/ 實作 + 單元測試(可 fake domain/repository)。
  6. 模組專用整合放 provider/template/(勿放入 library/)。
  7. errors.goconst.goredis.goconfig/ 按需補齊。
  8. 執行 make gen-mockgo:generatedomain/repository/generate.go 等)。
  9. internal/config/config.go 嵌入模組 configetc/gateway.yaml 加區塊。
  10. internal/svc/service_context.go 建立 共用 *redislib.Client,再注入 NewXxxUseCaseFromParamMongo / Redis 未配置時對應欄位可為 nil)。
  11. generate/api/ 定義路由,make gen-apiinternal/logic/ import domain/usecase
  12. make gen-docgo test ./...

Notification 模組進度(參考): N0N5 核心 (含 RetryWorkerAdminNotifierUseCase);文件見 notification README。待做HTTP admin APIgoctl

Member 模組進度P3.5 atomic primitives OTPUseCaseGenerate/Verify/Invalidate+ TOTPUseCaseenroll/verify/backup/disable+ VerifyRateStore + ProfileRepository usecase 之間不互相呼叫:「業務 email/phone 驗證 = OTP.Generate → Notifier.Send → Profile.SetXxxVerified」的編排由 logic 層負責(尚未實作);參考實作見 cmd/notify-test/main.go::startMemberVerifydriver 等同 logic 角色。後續HTTP APIgoctl+ logic 層編排(含 rate-limit + step-up 守門)。

12. 與 Gateway HTTP 層的關係

HTTP Request
    ↓
handlergoctl 生成)→ response.Write
    ↓
logicgoctl 生成框架,手寫映射)
    ↓ 轉換 types ↔ usecase DTO
usecase 介面internal/model/{module}/domain/usecase
    ↓
usecase 實作internal/model/{module}/usecase
    ↓
repository 實作internal/model/{module}/repository
    ↓ 實作 domain/repository 介面
    ↓
MongoDB / Redis
  • internal/typesHTTP 請求 / 回應型別,由 .api 生成。
  • internal/model/{module}/domain/usecase DTO業務層資料結構logic 負責與 types 映射。
  • 錯誤自 usecase 以 *errs.Error 往上冒泡logic 原樣傳遞handler 經 response.Write 輸出 8 碼 JSON。