🎯 什麼情境該想到我
當你「想讓領域物件自己內建存取資料庫的方法,物件幾乎等於一列」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:讓一個物件同時包住一列資料、該列的存取方法,以及部分領域邏輯,物件自己負責存取自己。
做法要點:
- 物件欄位對應資料表欄位;物件上直接有 insert、update、delete、find 等方法。
- 物件結構通常與資料表結構高度一致(一物件對一列)。
- 開發快、直覺,很多框架(如 Rails ActiveRecord)採用此模式。
- 適合業務邏輯不太複雜、且與資料庫結構相符的情況。
★★ 它的真實評價:不是「初階」,而是「在特定複雜度區間內最划算」
★ 它今天的代表:Rails ActiveRecord、Django ORM、Laravel Eloquent、Go 的 GORM
★ 為什麼它主宰了 Web 開發十五年:
① ★ 從「有一張表」到「能 CRUD」只需要幾行
② ★ scaffold/admin 介面可以自動生成
③ ★ 對「資料表結構 ≈ 領域概念」的系統,額外的對應層是純成本
★★ 判準不是「複雜不複雜」,而是「物件結構和資料表結構有多接近」。
接近 → 主動記錄的對應層是零成本。
差很多(一個領域物件跨三張表、有繼承、有值物件)→ 你會一直在跟它打架。
★★ Go 的情況:GORM 可用,但社群偏向不用
// ★ GORM(主動記錄)
type Order struct {
gorm.Model // ★ 嵌入 ID/CreatedAt/UpdatedAt/DeletedAt
CustomerID string
Lines []Line // ★ 關聯 —— 延遲載入的來源
}
db.Preload("Lines").First(&order, id) // ★ 忘了 Preload 就是 N+1
order.Status = "paid"
db.Save(&order) // ★★ Save 會寫回所有欄位★★ Go 社群對它的三個具體批評:
① ★★ 反射 + 字串 → 型別安全在執行期才驗
db.Where("staus = ?", s) ← ★ 欄位名打錯,編譯完全通過
② ★★ 隱形查詢:忘記 Preload = N+1;而它不會警告你
③ ★ gorm.Model 的 DeletedAt 讓「軟刪除」變成預設行為
→ ★★ 所有查詢自動加 WHERE deleted_at IS NULL,
而你手寫的 Raw SQL 不會加 ⇒ 兩套查詢看到不同資料
★ 所以 Go 的主流是 表格資料閘道(sqlc)——用 codegen 換回編譯期型別安全。
⚠️ 但這不代表 GORM 是錯的:內部工具、admin 後台、CRUD 為主的服務,它仍然最快。
★★ 主動記錄真正的斷點:三個具體訊號
① ★★ 物件結構開始和資料表結構打架
一個 Order 聚合要跨 orders + order_lines + shipping_addresses 三張表
→ ★ 主動記錄的「一物件一列」假設破了
② ★★ 你開始在模型上寫「不該由這一列負責」的邏輯
order.ChargeCard() → ★ 模型開始依賴金流 SDK
→ ★ 這是 [[過大類別]]:一個型別同時管持久化、業務規則、外部整合
③ ★★ 測試必須連資料庫
★ 因為業務邏輯和 SQL 在同一個方法裡,你無法只測規則
→ ★ 這是最可靠的訊號(見 [[工具-打破依賴以便測試]])
★ 演進路徑(不必一次換掉 ORM):
第 1 步 ★ 把純規則抽成不碰 DB 的函式/值物件
func canCancel(status Status, paidAt *time.Time) bool
→ ★ 這一步就讓規則可以被單元測試,成本極低
第 2 步 ★ 模型只留欄位與持久化,規則全在領域層
→ ★★ 這時模型退化成 row struct,你已經在用 [[表格資料閘道]] + [[領域模型]] 了
★★ 一個高頻的坑:Save 寫回所有欄位
// ★★ 兩個請求同時載入同一列,各改一個不同欄位
// A: order.Status = "paid"; db.Save(&order) // ★ 寫回全部欄位
// B: order.Note = "urgent"; db.Save(&order) // ★ 寫回全部欄位 → Status 被覆蓋回舊值
// → ★★ 這是 lost update,而且完全沒有錯誤★ 兩個解法:只更新有變的欄位(db.Model(&o).Update("status", …)),
或加版本欄位做樂觀鎖(見 樂觀離線鎖)。
★★ 主動記錄最容易出這個問題,因為「載入 → 改 → 存」的寫法太自然。
★ 兄弟辨析
| 差別 | |
|---|---|
| 列資料閘道 | ★ 刻意不放業務邏輯(但這條界線守不住) |
| 表格資料閘道 | ★ 一個型別服務整張表,回傳純資料;★ Go 的主流 |
| 資料對應器 | ★ 領域物件完全不知道資料庫;複雜領域的答案 |
| 領域模型 | ★ 主動記錄可以承載簡單的領域模型,但複雜的會撐不住 |
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
混合了持久化與業務邏輯,領域複雜時耦合會拖累維護。
-
物件結構與資料表結構若差異大,會很綁手。
-
領域模型複雜、想解耦資料庫時,改用資料對應器。
-
★★ 判準是「物件結構與資料表結構有多接近」,不是「複雜不複雜」。接近 → 主動記錄最划算。
-
★★
Save寫回所有欄位會造成 lost update,而且完全沒有錯誤。要只更新有變的欄位,或加版本欄位。 -
★★ GORM 的欄位名是字串,打錯編譯完全通過。這是 Go 社群偏向
sqlc的主因。 -
★★ 忘記
Preload就是 N+1,而它不會警告你。 -
★★
gorm.Model的軟刪除是預設行為:所有 ORM 查詢自動加deleted_at IS NULL,但你手寫的 Raw SQL 不會 ⇒ 兩套查詢看到不同資料。 -
★ 斷點的最可靠訊號是「測試必須連資料庫」——業務邏輯和 SQL 在同一個方法裡。
-
★ 演進第一步是把純規則抽成不碰 DB 的函式,不必一次換掉 ORM。
-
不要在模型上放外部整合(
order.ChargeCard()),那讓模型依賴金流 SDK。 -
它在內部工具、admin 後台、CRUD 服務上仍然最快。不要因為「架構純度」而否定它。
🔗 相關工具
- 列資料閘道 Row Data Gateway(相似但刻意不放業務邏輯)
- 資料對應器 Data Mapper(複雜領域時更乾淨的替代方案)
- 企業應用架構模式
- 列資料閘道 —— 刻意不放業務邏輯的版本
- 表格資料閘道 —— ★ Go 的主流做法
- 資料對應器 —— 複雜領域的答案
- 領域模型 —— 規則複雜時的落點
- 樂觀離線鎖 —— 防 Save 造成的 lost update
- 延遲載入 —— Preload 與 N+1
- 工具-物件關聯映射模式 —— 三種資料源模式的選擇
- 工具-打破依賴以便測試 —— 把規則從 SQL 裡拆出來