🎯 什麼情境該想到我
★ 分工:這頁講「什麼東西該有身分、身分怎麼定義」(模型問題);
識別欄位 講「ID 型別怎麼選、誰產生、怎麼存」(實作問題)。
當你發現「這個東西就算屬性全變了,它還是同一個」——例如同一位客戶改了名字和地址仍是同一人、一筆訂單從下單到出貨一路都是那一筆——需要一個貫穿生命週期的唯一身分來追蹤時。
⚙️ 怎麼用(步驟 / 公式)
意圖:用「唯一識別(identity)」而非屬性值來定義一個有連續生命週期的物件。
- 判斷這個概念是否需要被「持續追蹤同一個」:若是,它就是實體。
- 給它一個穩定、唯一的識別(ID),且該 ID 在整個生命週期內不變。
- 相等性(equals)只比對 identity,不比對其他屬性——ID 相同即同一個實體。
- 把它的狀態變化與行為封裝在自己內部,維持自身不變式(invariant)。
★★ 判準只有一條:它的「身分」會延續嗎
★ 問:★ 把它換成一個「所有屬性都相同」的實例,有差別嗎?
★ 有差別(那是「另一張訂單」)→ ★★ 實體
★ 沒差別(100 元就是 100 元)→ [[值物件(DDD)]]
★ 換一個問法(更實用):★ 業務端會說「那筆」「那個」嗎?
★ 「把那筆訂單取消」「那個使用者停權」→ ★★ 實體
★ 「金額改成 200」→ ★ 值物件(★ 你不會說「把那個 100 元改成 200」)
★★ 同一個概念在不同上下文可以是不同的東西:
★ 「地址」在訂單上下文 → ★ 值物件(★ 下單當時的收件地址,不會變)
★ 「地址」在物流上下文 → ★★ 實體(★ 要追蹤配送狀態、要能修正、要有歷史)
→ ★ 這不是矛盾,是 [[限界上下文]] 的正常現象
★★ Go 的實作:身分要在型別系統裡,不是一個 string
package order
type ID string // ★ 具名型別,防止 ID 搞混(見 [[識別欄位]])
type Order struct {
id ID // ★★ 未匯出且「沒有 setter」—— 身分建立後不可變
status Status
lines []Line
version int64
}
func (o *Order) ID() ID { return o.id } // ★ 只讀
// ★★ 沒有 SetID —— 這是實體最重要的一條紀律
// ★★ 相等性看身分,不看屬性
func (o *Order) SameAs(other *Order) bool {
return other != nil && o.id == other.id // ★ 不要用 == 比整個 struct
}★★ == 對實體是錯的:
o1 == o2 // ★★ 比較所有欄位 —— 兩張同 ID 但狀態不同的訂單會被判定不相等
// ★ 而「同一張訂單的兩個版本」在領域上是同一個實體★ 所以實體要提供明確的 SameAs/Equal 方法,而值物件才用 ==。
⚠️ 含 slice 的 struct 本來就不可比較(== 編譯錯誤)——★ 這反而幫了你。
★★ 身分的三種來源,決定了「重試能不能安全」
// ① ★ 資料庫產生(bigserial)
// → ★★ INSERT 之前你沒有 ID ⇒ ★ 無法先組好整個聚合、無法做冪等
// ★★ ② 應用程式產生(ULID / uuid v7)—— ★ 推薦
id := ID(ulid.Make().String())
// → ★★ 重試同一個操作用同一個 ID ⇒ 撞唯一鍵 ⇒ ★ 你知道它已經成功了
// ★ ③ 業務鍵(訂單編號、身分證號)
// → ★★ 絕不可當主鍵(會變、會有格式變更、會有測試資料)
// → ★ 用代理主鍵 + 業務鍵的唯一索引★ 完整討論見 識別欄位。
★★ 實體最容易被做錯的地方:它變成一個貧血的資料袋
// ❌★★ 貧血實體:有 ID 和欄位,但沒有任何規則
type Order struct {
ID string
Status string // ★ 任何人可以設成任何值
Total float64
}
// → ★★ 於是「已出貨不可取消」這條規則住在 service 裡,
// 而★ 另一個 service 忘記檢查(見 [[純資料類別]]、[[表格模組]])
// ✅ ★ 規則住在實體上
func (o *Order) Cancel(r CancelReason) error {
switch o.status {
case StatusShipped: return ErrAlreadyShipped // ★★ 不變式的守點
case StatusCancelled: return nil // ★ 冪等:已取消就是成功
}
o.status = StatusCancelled
o.events = append(o.events, OrderCancelled{OrderID: o.id, Reason: r})
return nil
}★★ case StatusCancelled: return nil 這一行是實務上的關鍵:
★ 狀態轉移要冪等,否則重試(訊息重送、使用者連點兩次)會報錯而不是成功
(見 冪等接收者)。
★★ 實體的生命週期:四件必須明確決定的事
① ★★ 誰能建立它? → ★ 建構函式 or [[工廠]](★ 建立需要複雜規則時)
② ★★ 它能不能被刪除? → ★ 多數業務實體不該被真刪,而該有終態(cancelled / archived)
③ ★★ 誰能修改它? → ★ 只有它自己的方法(未匯出欄位 + package 邊界)
④ ★★ 它的變更誰知道? → ★ [[領域事件]],而事件要和狀態變更在同一個交易(Outbox)
★ ② 最常被忽略:★★ DELETE FROM orders 讓「這張訂單發生過什麼」永久消失,
而業務幾乎總是需要歷史(見 外鍵對應 對 CASCADE 的警告)。
★ 兄弟辨析
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
不是每個物件都該是實體:若你只在乎「值是什麼」而不在乎「是不是同一個」,用值物件更簡單。
-
濫用實體會讓系統到處是可變狀態與 ID,增加追蹤成本。
-
ID 的產生策略(資料庫序號、UUID、自然鍵)要及早決定,避免識別不穩。
-
★★
==對實體是錯的(比較所有欄位)。要提供明確的SameAs/Equal比較身分;==是值物件的工具。 -
★★ 實體沒有
SetID。 身分建立後不可變,這是最重要的一條紀律。 -
★★ 狀態轉移要冪等(已取消再取消回 nil),否則重試會報錯而不是成功。
-
★★ 同一個概念在不同上下文可以是實體或值物件(「地址」在訂單是值物件、在物流是實體)。這不是矛盾。
-
★ 判準:業務端會說「那筆」「那個」嗎? 會 → 實體。
-
★★ 貧血實體(有 ID 和欄位但沒有規則)讓不變式住在 service 裡,而另一個 service 會忘記檢查。
-
★ 多數業務實體不該被真刪,而該有終態。
DELETE讓歷史永久消失。 -
★ 應用程式產生 ID(ULID/uuid v7)讓重試安全;資料庫產生的做不到冪等。
-
業務鍵絕不可當主鍵(會變、有格式變更、有測試資料)。