🎯 什麼情境該想到我

當你「想讓領域物件自己內建存取資料庫的方法,物件幾乎等於一列」的時候。

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

意圖:讓一個物件同時包住一列資料、該列的存取方法,以及部分領域邏輯,物件自己負責存取自己。

做法要點:

  • 物件欄位對應資料表欄位;物件上直接有 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 服務上仍然最快。不要因為「架構純度」而否定它。

🔗 相關工具