🎯 什麼情境該想到我

當你「想讓領域層像操作一個記憶體集合一樣存取物件,不必直接面對資料庫」的時候。

⚙️ 怎麼用(步驟 / 公式)

意圖:在領域層與資料對應層之間,提供一個像「記憶體物件集合」的介面,隔離領域邏輯與資料存取細節。

做法要點:

  • 對外提供類似集合的方法(新增、移除、依條件取得領域物件)。
  • 用戶端用領域語彙描述要什麼,不必知道背後是 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 寫入是聚合邊界劃錯的訊號。

🔗 相關工具