🎯 什麼情境該想到我

★ 分工:這頁講「怎麼在模型裡辨認出值物件、為什麼它重要」(模型問題);
值物件(PoEAA)講「Go 怎麼實作、==/序列化/零值的細節」,
內嵌值 講「怎麼存進資料庫」。

當你只在乎「這個東西的值是什麼」、完全不在乎「它是不是同一個」時——例如金額 100 元、座標 (25.0, 121.5)、一段日期範圍——就把它做成沒有識別、不可變的值物件。

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

意圖:用屬性值來描述領域概念,物件本身無識別、不可變、可自由替換與共享。

  1. 確認它由屬性值定義、沒有需要追蹤的身分。
  2. 設計成不可變:所有屬性建立後不再改動,要「變」就產生一個新的值物件替換掉舊的。
  3. 相等性(equals)比對所有屬性值——值全相同即視為相等、可互換。
  4. 把相關的驗證與計算行為(如金額相加、範圍是否重疊)封裝進去,讓它成為有行為的小物件。

★★ 它最被低估的價值:值物件讓「不可能的狀態」無法被表達

// ❌★ 基本型別到處飛
func Transfer(from, to string, amount float64, currency string) error
// ★★ 可以傳進來的無效組合:
//   ★ amount 為負、★ currency 是 "usd"(小寫)、★ from == to
//   ★ from 和 to 傳反了(★ 同型別,編譯通過)
 
// ✅ ★ 值物件讓無效狀態不可構造
func Transfer(from AccountID, to AccountID, amount Money) error
// ★★ Money 的建構函式保證:金額非負、幣別合法、精度正確
// → ★ 所以 Transfer 內部不必再驗證這些

★★ 「把驗證從每個使用點移到一個建構點」是值物件的核心收益——
★ 而它的效果是累積的:五十個使用點,五十次不必重複的檢查。

★★ 怎麼「找到」值物件:四個訊號

① ★★ 一組欄位總是一起出現([[資料泥團]])
   ★ startDate + endDate、street + city + zip、amount + currency
② ★★ 同一段驗證或計算被複製到多處
   ★ if end.Before(start) 出現在三個地方,★ 而其中一個寫成 !end.After(start)
③ ★★ 你在說「這個 string 其實是一個 email」
   → ★ 那個「其實是」就是一個值物件在敲門
④ ★ 一個基本型別帶著隱含的單位或格式
   ★ timeoutMs int、priceCents int64 → ★★ 單位藏在變數名裡

★ ② 是最有力的訊號,因為它已經造成了不一致(見 以物件取代基本型別)。

★★ 不可變性:它不只是「安全」,它讓推理變簡單

// ★ 可變的值 → ★★ 你必須追蹤「現在它是什麼」
addr := NewAddress(…)
saveOrder(addr)          // ★ 存進訂單 A
addr.City = "台北"        // ★★ 訂單 A 的地址也變了?要看有沒有複製
saveOrder2(addr)
 
// ✅ ★ 不可變 → ★★ 一旦拿到它,它永遠是那個值
addr := NewAddress(…)    // ★ 之後不可能變
addr2 := addr.WithCity("台北")   // ★ 要變就產生新的

★★ Go 的優勢:struct 天然是值(複製語義),
★ 但只要有一個 slice/map/指標欄位就退化成淺複製——
★★ 這是 Go 裡「以為不可變但其實共用」的唯一來源(見 值物件、可變資料)。

★★ 值物件會長出行為,而那是它真正的價值

// ★ 一開始它只是兩個欄位
type DateRange struct{ start, end time.Time }
 
// ★★ 然後它吸收了散落在各處的邏輯
func (r DateRange) Contains(t time.Time) bool
func (r DateRange) Overlaps(o DateRange) bool     // ★ 原本三個地方各寫一次,邊界條件不同
func (r DateRange) Days() int                      // ★ 原本有人忘記處理 DST
func (r DateRange) Split(at time.Time) (DateRange, DateRange, error)

★★ 判準:這個型別有沒有「吸收」原本散落的邏輯?
沒有(只有欄位 + getter)→ ★ 你只是把長參數列換成長欄位列,白做了
(見 引入參數物件 的同一個警告)。

★★ 什麼時候「不要」做成值物件

① ★★ 它其實有身分 → [[實體]]
   ★ 判準:有人會說「把那個地址改一下」嗎?
② ★ 純粹是「不想搞混」→ ★★ 具名型別就夠(type UserID string)
   → ★ 不必 struct + 建構函式 + Scanner/Valuer 那一整套
③ ★ 它只在一個函式內部存在 → ★ 區域變數就好
④ ★★ 它是 wire format 的一部分 → ★ DTO 該保持笨(見 [[資料傳輸物件]])

★★ ② 是最常見的過度工程:★ 為了「型別安全」而給每個 string 包一個
帶建構函式、MarshalJSON、Scan、Value 的 struct——
而你真正需要的只是 type UserID string。

★ 兄弟辨析

差別
實體★ 有身分,身分會延續
值物件(PoEAA)★ Go 的實作細節:==、零值、序列化
內嵌值★ 持久化:攤平成父表的欄位
金錢★ 最經典的值物件,細節多到值得自己一頁
以物件取代基本型別★ 重構手法:怎麼從 string 走到這裡

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • 若你需要追蹤「同一個」隨時間演變,那應該是實體,不是值物件。

  • 不可變是關鍵:一旦允許就地修改,共享的值物件會在別處被改壞。

  • 別為省事把該有身分的東西降級成值物件而弄丟了生命週期追蹤。

  • ★★ 值物件的核心收益是「把驗證從每個使用點移到一個建構點」,而效果是累積的。

  • ★★ 判準:這個型別有沒有吸收原本散落的邏輯? 沒有(只有欄位 + getter)→ 你只是把長參數列換成長欄位列。

  • ★★ Go 的 struct 天然是值,但一個 slice/map/指標欄位就退化成淺複製 —— 這是「以為不可變但其實共用」的唯一來源。

  • ★★ 「純粹不想搞混」→ 具名型別就夠(type UserID string),不必 struct + 建構函式 + Scanner/Valuer 整套。這是最常見的過度工程。

  • ★ 判準:有人會說「把那個 X 改一下」嗎? 會 → 它有身分,是 實體。

  • ★ 最有力的發現訊號是「同一段驗證被複製到多處,而其中一個寫錯了」。

  • ★ wire format 的型別該保持笨,不要把 DTO 做成值物件。

  • 同一個概念在不同上下文可以是值物件或實體,那是正常的。

🔗 相關工具