🎯 什麼情境該想到我
當你「想讓領域層像操作一個記憶體集合一樣存取物件,不必直接面對資料庫」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:在領域層與資料對應層之間,提供一個像「記憶體物件集合」的介面,隔離領域邏輯與資料存取細節。
做法要點:
- 對外提供類似集合的方法(新增、移除、依條件取得領域物件)。
- 用戶端用領域語彙描述要什麼,不必知道背後是 SQL 還是其他儲存。
- 內部常以查詢物件組裝查詢條件,再交給資料對應層執行。
- 讓領域程式碼讀起來像純物件操作,強化領域模型的純粹性。
★★ 這是最被過度使用、也最常被做錯的一個模式
// ❌★★ Go 裡最常見的無用儲存庫
type OrderRepository interface {
Create(ctx context.Context, o *Order) error
GetByID(ctx context.Context, id string) (*Order, error)
Update(ctx context.Context, o *Order) error
Delete(ctx context.Context, id string) error
List(ctx context.Context, limit, offset int) ([]*Order, error)
}
type postgresOrderRepository struct{ q *db.Queries } // ★ 唯一的實作★★ 這不是儲存庫,是「
sqlc的Queries再包一層,方法名換成 CRUD」。
它沒有帶來任何東西:
★ 只有一個實作(★ 不會有第二個——你不會真的換掉 Postgres);
★ 測試用它換成 fake,那個 fake 又要自己維護一份行為;
★ 而它遮蔽了 SQL,讓效能問題更難追。
→ ★ 這就是 冗員元素 的介面污染(見 工具-Go介面小而隱式)。
★★ 真正的儲存庫是什麼:三個必要條件
★ Fowler/DDD 的原意是「像記憶體中的集合一樣操作領域物件」,所以:
① ★★ 它的介面用「領域語言」,不是 CRUD
✅ FindOverdueOrders(ctx, asOf time.Time) ([]*Order, error)
✅ Save(ctx, tx, *Order) error
❌ Update(ctx, *Order) error ← ★ CRUD 動詞 = 沒有領域意圖
② ★★ 它回傳「領域物件」,不是 row struct
→ ★ 要做 reconstitute(見 [[資料對應器]]);否則它只是閘道的別名
③ ★★ 它的邊界是「一個聚合」
→ ★ 一個 OrderRepo 負責 Order 這個聚合(含 lines),不負責 Customer
→ ★★ 一個交易只改一個聚合 ⇒ 一個交易通常只用一個 repo 寫入
★★ 判準:拿掉這個介面,程式碼會變糟嗎?
不會 → 不要有它,直接用 表格資料閘道。
會(因為它承載了 reconstitute 與領域語言的查詢)→ 它是真的儲存庫。
★ Go 的正確寫法:具體型別 + 消費方定義的小介面
// ★ 實作是具體型別,不是介面
package orderrepo
type Repo struct{ /* 無狀態 */ }
func (r *Repo) Get(ctx context.Context, q db.Querier, id order.ID) (*order.Order, error)
func (r *Repo) Save(ctx context.Context, q db.Querier, o *order.Order) error
func (r *Repo) FindOverdue(ctx context.Context, q db.Querier, asOf time.Time) ([]*order.Order, error)
// ★★ 需要替換時,介面定義在「使用它的那一側」,只放用到的方法
package settlement
type orderStore interface { // ★ 未匯出,只有兩個方法
Get(context.Context, db.Querier, order.ID) (*order.Order, error)
Save(context.Context, db.Querier, *order.Order) error
}★★ 兩個關鍵:
① Querier 當參數傳 → 同一個 repo 可用在交易內或外(見 表格資料閘道)。
② 介面在消費方、未匯出、只放用到的方法 → 不會有「十個方法的介面只有一個實作」
(見 工具-針對介面編程)。
★★ 讀寫分離:儲存庫不該服務所有查詢
★★ 最重要的一條:★ 報表與列表查詢不要經過儲存庫。
① 儲存庫回傳完整的聚合 → ★ 列表頁只要五個欄位,卻載入了一萬個完整物件
② 聚合查詢(GROUP BY、統計)根本沒有對應的領域物件
→ ✅ 分成兩條路:
寫側:Repo(領域物件、reconstitute、樂觀鎖)
★★ 讀側:直接用 [[表格資料閘道]] / [[查詢物件]] 回傳扁平 DTO
★★ 這是 CQRS 最務實的一半——不需要兩個資料庫、不需要事件溯源,
只需要「讀和寫用不同的型別與不同的查詢」。
★ 而這也解掉了「儲存庫的介面無限膨脹」的問題:
每加一個列表頁就加一個FindByXxx→ 儲存庫變成 過大類別。
★ 兄弟辨析
| 差別 | |
|---|---|
| 資料對應器 | ★ 儲存庫 = 對應器 + 領域語言的查詢介面 |
| 表格資料閘道 | ★ 回傳 row struct;★ 讀側該直接用它 |
| 查詢物件 | 常作為儲存庫的查詢參數 |
| 中繼資料對應 | ORM 式的元資料驅動 |
| 登錄器 | ★ 全域查找;儲存庫是注入的依賴 |
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
對簡單應用是額外一層抽象,可能過度設計。
-
完整實作(支援任意查詢)成本不低,通常需框架支援。
-
若直接用 ORM 的查詢介面已足夠,未必要再包一層。
-
★★ 「CRUD 方法名 + 唯一實作」的介面不是儲存庫,是介面污染。 拿掉它程式碼不會變糟 → 就不要有它。
-
★★ 三個必要條件:介面用領域語言(非 CRUD 動詞)、回傳領域物件(非 row struct)、邊界是一個聚合。
-
★★ 報表與列表查詢不要經過儲存庫。 讀側直接用 表格資料閘道 回傳扁平 DTO——這是 CQRS 最務實的一半。
-
★★ 每加一個列表頁就加一個
FindByXxx→ 儲存庫變成 過大類別。 -
★ 實作要是具體型別;需要替換時介面定義在消費方、未匯出、只放用到的方法。
-
★
Querier要當參數傳,否則 repo 無法參與呼叫端開的交易。 -
不要用儲存庫「隱藏資料庫」。你不會真的換掉 Postgres,而遮蔽 SQL 讓效能問題更難追。
-
一個交易只改一個聚合 ⇒ 一個交易通常只用一個 repo 寫入。 一個交易用三個 repo 寫入是聚合邊界劃錯的訊號。
🔗 相關工具
- 查詢物件 Query Object(儲存庫內部用它來描述與組合查詢條件)
- 中繼資料對應 Metadata Mapping(提供底層物件與資料表的對應)
- 企業應用架構模式
- 資料對應器 —— 儲存庫的核心
- 表格資料閘道 —— ★ 讀側該直接用它
- 查詢物件 —— 查詢參數
- 中繼資料對應 —— ORM 式做法
- 領域模型 —— 儲存庫服務的對象
- 冗員元素 —— 只有一個實作的介面
- 工具-Go介面小而隱式、工具-針對介面編程 —— 介面該多小、定義在哪一邊