「資料變了、通知我」有兩種語意不同的載體。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 命名的過去式。讀側若走到階梯高處、要用事件同步投影的代價評估,見 讀模型的升級判準