21 KiB
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/ + 外層實作)
新模組與重構中的模組(notification、member、permission 等)採 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 # 模組 sentinel(package 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/ # 可選:僅本模組用的 Sender(email/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/errors、library/mongo、library/redis、library/crypto)。僅某個模組會用的純函式 / 演算法(例如 member 的 RFC 6238 TOTP helper)應落在該模組底下,例如internal/model/member/totp/,避免污染 library 命名空間。
參考實作: internal/model/notification/(N0–N5 核心已完成;流程圖與設定見 notification README)。
| 層 | 路徑 | 內容 |
|---|---|---|
| 領域契約 | domain/entity、domain/enum |
實體、值物件 |
| 領域契約 | domain/repository |
XxxRepository、IdempotencyCache 等 interface |
| 領域契約 | domain/usecase |
XxxUseCase、SendRequest 等 interface + DTO |
| 基礎設施 | repository/ |
NewXxxRepository、index migration |
| 應用服務 | usecase/ |
MustXxxUseCase、NewXxxUseCaseFromParam |
| 模組專用整合 | 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 pool(mon 的 clientManager) |
每 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.Client,Host空則nil(模組可 fallback memory)。- 之後 member / permission 的 factory、
MustXxxUseCase必須接受*redislib.Client,不得在 module 內新建 Redis 連線。 - 模組
redis.go只定義 key 前綴,不建立 client。
與舊版扁平目錄的關係
早期 scaffold 可能仍為 member/entity(無 domain/)。新增 permission、重構 member 時請遷移到上表結構,與 notification 一致。遷移步驟:先搬 entity/enum → domain/,再搬 repository/usecase 介面 → domain/,最後保留外層實作並修正 import。
1. Entity(domain/entity/)
每個 MongoDB collection 對應一個 struct,放在 internal/model/{module}/domain/entity/。
規則:
- 檔名使用 snake_case 對應 collection 語意,如
account.go、user.go。 - struct 名稱使用 PascalCase 單數,如
Account、User。 - 必須實作
CollectionName() string,回傳 MongoDB collection 名稱。 - 欄位 tag:
bson必填;對外 JSON 序列化才加json。 - 主鍵使用 MongoDB driver v2 的
bson.ObjectID(go.mongodb.org/mongo-driver/v2/bson),tag 為`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 一個
XxxRepositoryinterface,檔案放在domain/repository/。 - 方法第一個參數固定為
context.Context。 - 參數 / 回傳值使用
domain/entity型別,不暴露 driver 細節(除 index migration 等必要場景)。 - Index migration 以獨立 interface 嵌入,命名
{Entity}IndexUP,方法名含版本號,如Index20241226001UP。 - 此目錄僅介面:不含
NewXxxRepository、不含mongo/redisimport。
範例:
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等;實作 importdomain/entity、domain/repository。 - 建構時以
domain/entity的CollectionName()初始化 DocumentDB;失敗時panic(啟動期錯誤)。 - CRUD 透過
gateway/internal/library/mongo的 DocumentDB helper,搭配模組redis.go的 key helper。 Insert:ID 為 zero 時自動產生 ObjectID 並寫入CreateAt/UpdateAt。Update:自動更新UpdateAt。FindOne/Delete:無效 ObjectID →*errs.Error(ResInvalidMeasureID)或模組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 介面與 DTO(domain/usecase/)
規則:
- 業務入口定義為 interface,如
NotifierUseCase、AccountUseCase,放在domain/usecase/。 - Request / Response struct 與 interface 同 package,命名
{Action}Request、NotificationDTO。 - DTO 只含
jsontag(若需序列化),不含 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 如
notifierUseCase、memberUseCase,放在模組根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/usecaseinterface 一致;內部組裝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 / driver(如
cmd/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(var errb = errs.For(code.Facade))。domain/errors.go 只放 sentinel,不另建 8 碼常數表。
7.1 模組 sentinel(domain/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.ResAlreadyExist、errb.ResInvalidState、errb.AuthForbidden等。 errors.Is(err, member.ErrNotFound)→errb.ResNotFound("member", id).WithCause(err)。- 已是
*errs.Error且語意正確(如DBDuplicate)→ 原樣return err。
7.4 Logic / Handler
- Logic:僅
InputMissingRequired等 HTTP 輸入錯誤;usecase 回傳的 err 不二次包裝。 - Handler:
response.Write,由errs.FromError決定 HTTP 狀態與 body。
8. 模組共用檔案
| 檔案 | 用途 |
|---|---|
domain/errors.go |
模組 sentinel(ErrNotFound 等,package domain) |
domain/const.go |
模組字面常數(package domain) |
domain/redis.go |
Redis key 型別、With() 組合、GetXxxRedisKey() helper(package 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. Mock(mock/ + 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 | Account、User |
| Collection 方法 | CollectionName() |
| Repository 介面 | AccountRepository |
| Repository 實作 struct | accountRepository 或 AccountRepository |
| Repository 建構子 | NewAccountRepository |
| Repository 參數 | AccountRepositoryParam |
| UseCase 介面 | AccountUseCase |
| UseCase 實作 | MemberUseCase |
| UseCase 建構子 | MustMemberUseCase |
| 請求 DTO | CreateLoginUserRequest |
| 回應 DTO | GetUIDByAccountResponse |
| Index migration | Index{YYYYMMDD}{seq}UP |
| 測試檔 | 與被測檔案同目錄,*_test.go |
11. 新增模組 / Model 檢查清單
- 建立
internal/model/{module}/domain/{entity,enum,repository,usecase}/與外層repository/、usecase/。 - 在
domain/entity/新增 struct +CollectionName()。 - 在
domain/enum/定義值物件(若有)。 - 在
domain/repository/宣告 interface;於repository/實作 CRUD + index +*_test.go。 - 在
domain/usecase/宣告 interface + DTO;於usecase/實作 + 單元測試(可 fakedomain/repository)。 - 模組專用整合放
provider/、template/(勿放入library/)。 errors.go、const.go、redis.go、config/按需補齊。- 執行
make gen-mock(go:generate在domain/repository/generate.go等)。 - 在
internal/config/config.go嵌入模組config;etc/gateway.yaml加區塊。 - 在
internal/svc/service_context.go建立 共用*redislib.Client,再注入NewXxxUseCaseFromParam(Mongo / Redis 未配置時對應欄位可為nil)。 - 在
generate/api/定義路由,make gen-api;internal/logic/只 importdomain/usecase。 make gen-doc、go test ./...。
Notification 模組進度(參考): N0–N5 核心 ✅(含 RetryWorker、AdminNotifierUseCase);文件見 notification README。待做:HTTP admin API(goctl)。
Member 模組進度(P3.5): atomic primitives OTPUseCase(Generate/Verify/Invalidate)+ TOTPUseCase(enroll/verify/backup/disable)+ VerifyRateStore + ProfileRepository ✅。usecase 之間不互相呼叫:「業務 email/phone 驗證 = OTP.Generate → Notifier.Send → Profile.SetXxxVerified」的編排由 logic 層負責(尚未實作);參考實作見 cmd/notify-test/main.go::startMemberVerify(driver 等同 logic 角色)。後續:HTTP API(goctl)+ logic 層編排(含 rate-limit + step-up 守門)。
12. 與 Gateway HTTP 層的關係
HTTP Request
↓
handler(goctl 生成)→ response.Write
↓
logic(goctl 生成框架,手寫映射)
↓ 轉換 types ↔ usecase DTO
usecase 介面(internal/model/{module}/domain/usecase)
↓
usecase 實作(internal/model/{module}/usecase)
↓
repository 實作(internal/model/{module}/repository)
↓ 實作 domain/repository 介面
↓
MongoDB / Redis
internal/types:HTTP 請求 / 回應型別,由.api生成。internal/model/{module}/domain/usecaseDTO:業務層資料結構,logic 負責與types映射。- 錯誤自 usecase 以
*errs.Error往上冒泡;logic 原樣傳遞,handler 經response.Write輸出 8 碼 JSON。