🎯 什麼情境該想到我

★ 分工:這頁講「什麼東西該有身分、身分怎麼定義」(模型問題);
識別欄位 講「ID 型別怎麼選、誰產生、怎麼存」(實作問題)。

當你發現「這個東西就算屬性全變了,它還是同一個」——例如同一位客戶改了名字和地址仍是同一人、一筆訂單從下單到出貨一路都是那一筆——需要一個貫穿生命週期的唯一身分來追蹤時。

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

意圖:用「唯一識別(identity)」而非屬性值來定義一個有連續生命週期的物件。

  1. 判斷這個概念是否需要被「持續追蹤同一個」:若是,它就是實體。
  2. 給它一個穩定、唯一的識別(ID),且該 ID 在整個生命週期內不變。
  3. 相等性(equals)只比對 identity,不比對其他屬性——ID 相同即同一個實體。
  4. 把它的狀態變化與行為封裝在自己內部,維持自身不變式(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 的警告)。

★ 兄弟辨析

差別
值物件(DDD)★ 沒有身分,由屬性定義,不可變
聚合★ 一組實體與值物件的一致性邊界;實體可以是聚合根或內部實體
識別欄位★ ID 的型別選擇與持久化
純資料類別★ 貧血實體的壞味道

🧪 我實際套用的紀錄

  • (待填)

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

  • 不是每個物件都該是實體:若你只在乎「值是什麼」而不在乎「是不是同一個」,用值物件更簡單。

  • 濫用實體會讓系統到處是可變狀態與 ID,增加追蹤成本。

  • ID 的產生策略(資料庫序號、UUID、自然鍵)要及早決定,避免識別不穩。

  • ★★ == 對實體是錯的(比較所有欄位)。要提供明確的 SameAs/Equal 比較身分;== 是值物件的工具。

  • ★★ 實體沒有 SetID。 身分建立後不可變,這是最重要的一條紀律。

  • ★★ 狀態轉移要冪等(已取消再取消回 nil),否則重試會報錯而不是成功。

  • ★★ 同一個概念在不同上下文可以是實體或值物件(「地址」在訂單是值物件、在物流是實體)。這不是矛盾。

  • ★ 判準:業務端會說「那筆」「那個」嗎? 會 → 實體。

  • ★★ 貧血實體(有 ID 和欄位但沒有規則)讓不變式住在 service 裡,而另一個 service 會忘記檢查。

  • ★ 多數業務實體不該被真刪,而該有終態。DELETE 讓歷史永久消失。

  • ★ 應用程式產生 ID(ULID/uuid v7)讓重試安全;資料庫產生的做不到冪等。

  • 業務鍵絕不可當主鍵(會變、有格式變更、有測試資料)。

🔗 相關工具