🎯 什麼情境該想到我
★ 分工:這頁講「事件在領域模型裡的位置、怎麼從聚合發出」;
事件訊息 講「跨行程的投遞語義與四種用法」,
工具-Transactional-Outbox-Pattern 講「怎麼保證它不丟」。
當領域中「發生過某件重要的事」需要被記錄或讓其他部分做出反應——例如「訂單已付款」「帳戶已凍結」——而你不想讓發起方直接耦合去呼叫所有相關方時。
⚙️ 怎麼用(步驟 / 公式)
意圖:把領域中發生過的重要事實顯性建模成事件,用於解耦、稽核與跨聚合最終一致。
- 用過去式命名事件,表達已發生的事實(如
OrderPaid、InventoryReserved)。 - 事件是不可變的值:帶上發生時間與必要資料,一旦建立不再更動。
- 由聚合在狀態改變時發布事件;有興趣的處理者訂閱並各自反應。
- 用事件驅動跨聚合的最終一致——一個聚合的改變透過事件觸發另一個聚合更新。
- 保留事件流可作為稽核軌跡,甚至作為事件溯源(Event Sourcing)的基礎。
★★ 領域事件與「訊息」是兩件事,中間有一道交易邊界
★★ 三個階段,各自的失敗模式不同:
① ★ 聚合內部產生事件(純記憶體)
o.Cancel() → ★ o.events = append(o.events, OrderCancelled{…})
→ ★★ 這一步不可能失敗,也還沒有任何人知道
② ★★ 事件與狀態一起 commit(★ 這一步決定「不丟」)
★ 同一個交易裡:UPDATE orders … + INSERT INTO outbox …
→ ★★ 兩者原子 ⇒ ★ 「狀態改了但事件沒發」不可能發生
③ ★ relay 把 outbox 的事件發到訊息系統
→ ★★ 這一步可能重複(★ 先發送、後標記)⇒ 消費端必須冪等
★★ 最常見的錯誤是跳過 ②:
★ 在o.Cancel()裡直接publisher.Publish(...)→
交易 rollback 了,但事件已經發出去了 ⇒ 下游看到一個不存在的取消。
★ 反過來(先 commit 再 publish)→ publish 失敗就永久遺失。
→ ★★ 這就是雙寫問題,而 Outbox 是唯一乾淨的解。
★★ Go 的實作:事件由聚合產生、由服務層收割
package order
type Order struct {
id ID
status Status
events []Event // ★★ 未匯出:只有聚合自己能 append
}
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, OccurredAt: o.now(), // ★★ 時鐘注入 → 可測
})
return nil
}
// ★★ 收割:★ 回傳並清空(一次性)
func (o *Order) TakeEvents() []Event {
e := o.events
o.events = nil
return e
}// ★ 服務層:把事件和狀態放進同一個交易
func (s *Service) Cancel(ctx context.Context, id order.ID, r order.CancelReason) error {
tx, err := s.db.BeginTx(ctx, nil); …
defer tx.Rollback()
o, err := s.orders.Get(ctx, tx, id); …
if err := o.Cancel(r); err != nil { return err }
if err := s.orders.Save(ctx, tx, o); err != nil { return err }
if err := s.outbox.Enqueue(ctx, tx, o.TakeEvents()...); err != nil { return err } // ★★ 同一交易
return tx.Commit()
}★★ TakeEvents 的「清空」語義很重要:
★ 若只是回傳不清空,同一個聚合實例被 Save 兩次就會發出重複事件。
★★ 事件該帶多少資料:這決定了下游要不要回頭問
// ★ ① 薄事件(只有 ID)
type OrderCancelled struct{ OrderID ID }
// → ★★ 下游收到後必須回頭查 ⇒ ★ 你把非同步又變回同步依賴
// 而且★★ N 個訂閱者同時回頭查 ⇒ 來源承受扇出倍數的負載
// ★★ ② 事件攜帶狀態(★ 多數情況的正確答案)
type OrderCancelled struct {
OrderID ID
CustomerID CustomerID
Reason CancelReason
RefundDue Money // ★ 下游需要的資料直接帶上
OccurredAt time.Time // ★★ 業務時間,不是送出時間
Version int64 // ★★ 讓下游能丟棄過舊的事件
}★★ Version 是最容易被漏掉、也最有價值的一個欄位:
★ 下游用 WHERE version < $new 做條件更新 →
即使亂序、即使重複,結果都正確,而且可以完全並行(見 訊息序列)。
★★ 事件命名與內容的三條紀律
① ★★ 過去式,而且是業務語言
✅ OrderCancelled、PaymentCaptured、StockReserved
❌ ★ OrderUpdated(★★ 什麼被更新了?下游只能自己 diff)
❌ ★ OrderStatusChangedFrom2To5(★★ 洩漏實作,而且沒有業務含意)
② ★★ 一個事件對應一個「業務上有名字的事情」
★ 太細:OrderFieldChanged{field, old, new} → ★ 下游要自己拼出業務含意
★ 太粗:OrderChanged{整個聚合快照} → ★★ 下游要 diff
→ ★ 判準:★★ 這個事件名能對業務人員講得通嗎?
③ ★★ 事件是不可變的歷史事實
→ ★ 不可以有 setter、不可以「修正」已發出的事件
→ ★★ 發錯了要發一個「更正事件」(OrderCancellationReverted),不是改舊的
★ ①的 OrderUpdated 是實務上最常見的錯誤:
★★ 它把「判斷發生了什麼」的責任推給每一個下游。
★★ 事件的 schema 是公共契約,而訂閱者你可能不認識
★ 三條相容性規則(見 [[工具-資料編碼與演進]]):
① ★ 只新增可選欄位,不改既有欄位的意義
② ★ 不重用 protobuf 欄號、不改型別
③ ★★ 不相容的變更 → 開一條 v2 通道並行,直到舊消費者下線
⚠️ 一個 Go 特有的陷阱:★★ 用領域型別直接當事件 payload。
// ❌★★ 領域型別變成公開契約
type OrderCancelled struct{ Order order.Order } // ★ 重構 Order 就破壞下游
// ✅ ★ 事件有自己的扁平型別(見 [[資料傳輸物件]])★ 什麼時候不需要領域事件
- ★★ 只有一個消費者,而且它在同一個交易裡 → ★ 直接呼叫它。
★ 事件的價值是「解耦 + 非同步」,兩者都不需要時它是純成本。 - ★ 你其實需要的是「命令」(★ 必須有人做這件事)→ 見 命令訊息。
★★ 發事件期望某人反應,而沒有訂閱者時靜默不發生、沒有錯誤。 - ★ 聚合內部的協調 → 直接呼叫方法。
★ 兄弟辨析
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
最終一致意味著短暫的不一致視窗,需求要求即時強一致時不適用。
-
事件過多會讓流程變得難以追蹤(隱式跳轉),要節制並保持語意清晰。
-
事件應描述「發生的事實」,不要塞成命令或摻入接收方的邏輯。
-
★★ 不要在聚合方法裡直接 publish。 那是雙寫問題:rollback 了事件已發出,或 commit 後 publish 失敗永久遺失。用 Outbox。
-
★★
TakeEvents要清空,否則同一個聚合實例 Save 兩次就發出重複事件。 -
★★
OrderUpdated是最常見的命名錯誤 —— 它把「判斷發生了什麼」的責任推給每個下游。用OrderCancelled這種業務語言的過去式。 -
★★ 事件要帶
Version,讓下游用WHERE version < $new條件更新 —— 亂序與重複都無害且可並行。 -
★★ 「薄事件」(只有 ID)把非同步變回同步依賴,而且來源承受扇出倍數的負載。
-
★★ 不要用領域型別當事件 payload。重構領域模型就破壞下游契約。
-
★ 事件是不可變的歷史事實。 發錯了要發「更正事件」,不是改舊的。
-
★ 發事件期望某人反應,而沒有訂閱者時是靜默不發生、沒有錯誤。 必須有人做 → 發命令。
-
只有一個消費者且在同一交易裡 → 直接呼叫它,事件是純成本。