🎯 什麼情境該想到我

★ 分工:這頁講「事件在領域模型裡的位置、怎麼從聚合發出」;
事件訊息 講「跨行程的投遞語義與四種用法」,
工具-Transactional-Outbox-Pattern 講「怎麼保證它不丟」。

當領域中「發生過某件重要的事」需要被記錄或讓其他部分做出反應——例如「訂單已付款」「帳戶已凍結」——而你不想讓發起方直接耦合去呼叫所有相關方時。

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

意圖:把領域中發生過的重要事實顯性建模成事件,用於解耦、稽核與跨聚合最終一致。

  1. 用過去式命名事件,表達已發生的事實(如 OrderPaid、InventoryReserved)。
  2. 事件是不可變的值:帶上發生時間與必要資料,一旦建立不再更動。
  3. 由聚合在狀態改變時發布事件;有興趣的處理者訂閱並各自反應。
  4. 用事件驅動跨聚合的最終一致——一個聚合的改變透過事件觸發另一個聚合更新。
  5. 保留事件流可作為稽核軌跡,甚至作為事件溯源(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 就破壞下游
// ✅ ★ 事件有自己的扁平型別(見 [[資料傳輸物件]])

★ 什麼時候不需要領域事件

  • ★★ 只有一個消費者,而且它在同一個交易裡 → ★ 直接呼叫它。
    ★ 事件的價值是「解耦 + 非同步」,兩者都不需要時它是純成本。
  • ★ 你其實需要的是「命令」(★ 必須有人做這件事)→ 見 命令訊息。
    ★★ 發事件期望某人反應,而沒有訂閱者時靜默不發生、沒有錯誤。
  • ★ 聚合內部的協調 → 直接呼叫方法。

★ 兄弟辨析

差別
事件訊息★ 跨行程的投遞語義、四種用法
命令訊息★ 「請做」vs「已發生」
聚合★ 事件由聚合產生,跨聚合一致性靠它
發布訂閱通道★ 事件的正確通道
觀察者模式★ 行程內的同步版本

🧪 我實際套用的紀錄

  • (待填)

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

  • 最終一致意味著短暫的不一致視窗,需求要求即時強一致時不適用。

  • 事件過多會讓流程變得難以追蹤(隱式跳轉),要節制並保持語意清晰。

  • 事件應描述「發生的事實」,不要塞成命令或摻入接收方的邏輯。

  • ★★ 不要在聚合方法裡直接 publish。 那是雙寫問題:rollback 了事件已發出,或 commit 後 publish 失敗永久遺失。用 Outbox。

  • ★★ TakeEvents 要清空,否則同一個聚合實例 Save 兩次就發出重複事件。

  • ★★ OrderUpdated 是最常見的命名錯誤 —— 它把「判斷發生了什麼」的責任推給每個下游。用 OrderCancelled 這種業務語言的過去式。

  • ★★ 事件要帶 Version,讓下游用 WHERE version < $new 條件更新 —— 亂序與重複都無害且可並行。

  • ★★ 「薄事件」(只有 ID)把非同步變回同步依賴,而且來源承受扇出倍數的負載。

  • ★★ 不要用領域型別當事件 payload。重構領域模型就破壞下游契約。

  • ★ 事件是不可變的歷史事實。 發錯了要發「更正事件」,不是改舊的。

  • ★ 發事件期望某人反應,而沒有訂閱者時是靜默不發生、沒有錯誤。 必須有人做 → 發命令。

  • 只有一個消費者且在同一交易裡 → 直接呼叫它,事件是純成本。

🔗 相關工具