🎯 什麼情境該想到我
★ 分工:這頁講「怎麼在模型裡辨認出值物件、為什麼它重要」(模型問題);
值物件(PoEAA)講「Go 怎麼實作、==/序列化/零值的細節」,
內嵌值 講「怎麼存進資料庫」。
當你只在乎「這個東西的值是什麼」、完全不在乎「它是不是同一個」時——例如金額 100 元、座標 (25.0, 121.5)、一段日期範圍——就把它做成沒有識別、不可變的值物件。
⚙️ 怎麼用(步驟 / 公式)
意圖:用屬性值來描述領域概念,物件本身無識別、不可變、可自由替換與共享。
- 確認它由屬性值定義、沒有需要追蹤的身分。
- 設計成不可變:所有屬性建立後不再改動,要「變」就產生一個新的值物件替換掉舊的。
- 相等性(equals)比對所有屬性值——值全相同即視為相等、可互換。
- 把相關的驗證與計算行為(如金額相加、範圍是否重疊)封裝進去,讓它成為有行為的小物件。
★★ 它最被低估的價值:值物件讓「不可能的狀態」無法被表達
// ❌★ 基本型別到處飛
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 做成值物件。
-
同一個概念在不同上下文可以是值物件或實體,那是正常的。