domain event 與狀態流
「資料變了、通知我」有兩種語意不同的載體。domain event 記錄離散事實:一件業務上發生過的事、以過去式命名、發布後不可變、消費者關心「發生了什麼」。**狀態流**發布連續觀測:某份資料的當前值、新值蓋過舊值、錯過中間值無妨、消費者關心「現在是什麼」。選錯載體的系統照樣能動——代價潛伏在演進裡:涵蓋面開始靠枚舉維持、事件語意被消費端綁架。本章的判準一句話:看消費者問的問題。問「發生了什麼」給事件、問「現在是什麼」給狀態流。
兩種載體的語意對照
| 維度 | domain event | 狀態流 |
|---|---|---|
| 語意單位 | 一件發生過的業務事實 | 一份資料的當前快照 |
| 時態 | 過去式(BookAdded、OrderPaid) | 現在式(books 現在是這些) |
| 錯過的代價 | 事實遺失——審計斷檔、下游流程沒觸發 | 零——下一次快照涵蓋一切 |
| 值的關係 | 每個事件獨立、順序有意義 | 新值取代舊值、只有最新值有意義 |
| 典型消費者 | 審計日誌、跨 domain 流程、通知 | 畫面、快取、衍生視圖 |
| 消費者的問題 | 發生了什麼 | 現在是什麼 |
「錯過的代價」那一列是實務上最好用的分辨器。畫面剛好沒訂閱時漏掉一次書單更新,下一次更新全額補償——狀態流語意。稽核系統漏掉一筆「書籍已刪除」,那筆事實永遠消失——事件語意。同一個資料變更常常兩種消費者都有:刪一本書,審計要那個事實(event)、清單畫面要新的書單(狀態流)——這不是二選一,是兩條正交的出口。
事件命名的過去式約定本身就是語意宣告(展開見 Domain Event 命名的過去式):過去式承諾「這是已發生的事實」。當事實開始被當成「請刷新」的訊號消費,這個承諾就被消費端改寫了——下面的案例走的正是這條路。
案例:一條斜坡的三步
一個書庫管理 App 的統計頁需要在資料變更後刷新。repository 是純 pull 介面、沒有狀態流出口;但專案有現成的 EventBus、也有數十個 domain event 發布點。「用既有的事件當刷新訊號」看起來是零成本的選擇——這是斜坡的入口。
第一步:橋接。用 StreamProvider 包 EventBus、ViewModel 監聽事件流、任何事件進來就重新查詢統計。上線有效:匯入完成事件、同步事件都正確觸發刷新。
第二步:斷鏈暴露枚舉本質。搜尋加書與掃描加書路徑不刷新——盤點發現這兩條寫入路徑從未發布任何 domain event。事件的發布時機由業務語意決定(「匯入完成」值得記錄;「使用者加了一本書」當時沒有下游流程需要它),而刷新的涵蓋面要求「每條寫入路徑都有事件」。兩個需求的形狀本質不同:事件的涵蓋面是業務事實的集合、狀態流的涵蓋面是寫入操作的集合。用前者服務後者,缺的部分只能靠枚舉補——為了刷新而補發事件。
第三步:語意綁架成形。補發事件的修法一旦開始,事件系統的演進被刷新需求綁住:新增寫入路徑的檢查清單多了「記得發事件、否則某頁不刷新」;想移除一個再無業務消費者的事件、要先確認沒有畫面靠它刷新;監聽端則因為「不知道哪些事件代表資料變了」而全事件監聽、任何無關事件都觸發一次重新查詢。每一步都合理、加總起來是兩個系統互相持有對方的隱含契約。
這個專案在第三步前停下,把問題拉回載體選擇:統計頁的問題是「現在是什麼」(當前書庫統計)、不是「發生了什麼」。正確載體是狀態流——repository 補 Stream<List<Book>> 觀測出口、畫面 ref.watch 訂閱,涵蓋面天然等於寫入操作的集合(每個寫入方法尾端 emit),沒有「記得發」這個動作。演進全記錄在 ref.watch 觀察的是 provider 圖、不是資料庫、分層落地在 觀測出口的職責三分。
正交性:加了狀態流、事件一個不動
載體分清楚之後最有力的驗證是改動面:狀態流落地時,既有事件發布點零改動。事件仍然記錄業務事實、服務審計與跨 domain 通知;狀態流負責「資料變了」的觀測。兩者正交、互不取代:
| 出口 | 回答 | 消費者 | 涵蓋面的維持方式 |
|---|---|---|---|
| domain event | 發生了什麼 | 審計、跨 domain 流程 | 業務語意決定發布點、逐事實設計 |
| 狀態流 | 現在是什麼 | 畫面、衍生視圖、快取 | 寫入操作集合、結構性涵蓋 |
「結構性涵蓋」與「逐事實設計」的差異就是兩種載體不可互換的原因。狀態流的 emit 掛在寫入方法上、新路徑必然經過、涵蓋不靠任何人記得;事件的發布點是設計決策、每個事件都該有業務理由——被迫「為涵蓋而發」的事件是沒有事實語意的雜訊,反過來稀釋整個事件系統的可信度(事件多到沒人知道哪些重要時,跨 domain 通訊的成本問題見 用事件做同步查詢等於手工重建 RPC——同一族的載體誤用、方向相反:那篇是拿事件做請求回應、本篇是拿事件做狀態通知)。
判讀訊號
斜坡的每一步都有可觀察的訊號,出現任何一個就回到載體判準重新選:
| 訊號 | 判讀 |
|---|---|
| 出現「為了讓某頁刷新、在某路徑補發事件」的工作項 | 消費者要的是「現在是什麼」、載體卻是事件——該補的是狀態流 |
| 監聽端掛全事件監聽、再考慮加型別過濾白名單 | 消費者不關心事實內容、只把事件當「有東西變了」的鈴聲 |
| 移除事件前要先查「有沒有畫面靠它刷新」 | 事件語意已被消費端綁架、事實系統失去獨立演進能力 |
| 事件 payload 開始塞「當前完整狀態」 | 事件被迫模擬快照——狀態流語意穿著事件的衣服 |
第四個訊號值得單獨說明:事實的 payload 是「這件事的內容」(哪本書、什麼時間),快照的 payload 是「現在的全貌」。事件開始攜帶全量狀態、通常是因為消費者拿到事件後只想要最新值——那正是狀態流一次 emit 就給完的東西。這個訊號有適用邊界:它假設消費者與事件來源在同一進程、同一信任邊界內。跨服務情境下,刻意讓事件攜帶足量當前狀態是 event-carried state transfer 這個正當設計——目的是讓下游服務不必回頭查詢來源、避免同步耦合,那時「payload 帶全量狀態」是設計選擇、不是載體錯位。本案是單一 App、同進程,沒有跨服務查詢成本,才適用「該補的是狀態流」的判讀。
四個訊號量的不是同一種東西。訊號一、二、四直接檢驗「消費者現在問的是哪種問題」;訊號三(移除事件前要先查有沒有畫面靠它刷新)量的是選錯載體之後累積的耦合成本——它是後果訊號(lagging indicator),等綁架已經長出來才觀察得到。把它跟前三個並列,是因為它同樣觸發「回到載體判準」,但它遲到、指向的是已經發生的綁架而非正在發生的誤用。
以上訊號都指向同一個方向:把事件當成狀態流用。反方向的誤用同樣存在、訊號相反——消費者需要的是逐筆離散事實(每一次庫存調整、每一筆退款),系統卻只給得到最新快照。症狀是稽核回頭找「中間發生過什麼」時只有結果沒有過程、或下游流程因為錯過中間狀態而少觸發。此時該補的是 event、而不是把狀態流的快照硬拆成假事件。兩個方向共用同一條判準:問「發生了什麼」給事件、問「現在是什麼」給狀態流——判準對稱,誤用可以往兩邊倒。
邊界
本章的判準處理「資料變更通知」這一種需求的載體選擇。事件對另外兩種離散訊息(命令、查詢)的責任結構分界在 domain event 與命令、查詢;狀態流的實作選型(broadcast、初始值、生命週期)在 StreamProvider 包 repository watch stream。另外,CQRS 階梯頂端「以事件同步讀模型」是事件的合法消費——那裡的消費者問的確實是「發生了什麼」(用事實重建投影),與把事件當刷新鈴聲不同層;階梯全貌見 讀模型的升級判準。這也標出本章「兩者正交」論述的架構前提:狀態存在獨立儲存、事件只是旁支通知。event-sourced 架構把這個前提拿掉——狀態流成為事件的投影、由同一條事件流重建,兩條管線不再獨立,正交性讓位給「讀模型由事件同步」的第四階語意。
下一步
決定用狀態流之後,先讀 觀測出口的職責三分 搞清楚契約、機制、組裝分別放哪一層,再進 ref.watch 觀察的是 provider 圖、不是資料庫 看案例從導航補償到狀態流的完整演進。事件命名的過去式約定(為什麼叫 BookAdded 而不是 AddBook)展開在 Domain Event 命名的過去式。讀側若走到階梯高處、要用事件同步投影的代價評估,見 讀模型的升級判準。