<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Domain-Event on Tarragon</title><link>https://tarrragon.github.io/blog/tags/domain-event/</link><description>Recent content in Domain-Event on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 20 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/domain-event/index.xml" rel="self" type="application/rss+xml"/><item><title>domain event 與狀態流</title><link>https://tarrragon.github.io/blog/ddd/domain-event-vs-state-stream/</link><pubDate>Thu, 16 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/domain-event-vs-state-stream/</guid><description>&lt;p>「資料變了、通知我」有兩種語意不同的載體。&lt;strong>&lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/domain-event/" data-link-title="Domain Event" data-link-desc="系統裡出現「為了讓某頁刷新而補發事件」或「監聽端掛全事件過濾器」時使用。domain event 是已發生的業務事實——過去式命名、發布後不可變、錯過代表事實遺失。">domain event&lt;/a>&lt;/strong> 記錄離散事實：一件業務上發生過的事、以過去式命名、發布後不可變、消費者關心「發生了什麼」。**&lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/state-stream/" data-link-title="State Stream（狀態流）" data-link-desc="畫面刷新靠補償、或考慮拿既有事件當刷新訊號時使用。狀態流是持續發布「資料當前值」的觀測載體——新值蓋過舊值、錯過中間值無代價，回答「現在是什麼」。">狀態流&lt;/a>**發布連續觀測：某份資料的當前值、新值蓋過舊值、錯過中間值無妨、消費者關心「現在是什麼」。選錯載體的系統照樣能動——代價潛伏在演進裡：涵蓋面開始靠枚舉維持、事件語意被消費端綁架。本章的判準一句話：&lt;strong>看消費者問的問題&lt;/strong>。問「發生了什麼」給事件、問「現在是什麼」給狀態流。&lt;/p>
&lt;h2 id="兩種載體的語意對照">兩種載體的語意對照&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>domain event&lt;/th>
 &lt;th>狀態流&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>語意單位&lt;/td>
 &lt;td>一件發生過的業務事實&lt;/td>
 &lt;td>一份資料的當前快照&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>時態&lt;/td>
 &lt;td>過去式（BookAdded、OrderPaid）&lt;/td>
 &lt;td>現在式（books 現在是這些）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>錯過的代價&lt;/td>
 &lt;td>事實遺失——審計斷檔、下游流程沒觸發&lt;/td>
 &lt;td>零——下一次快照涵蓋一切&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>值的關係&lt;/td>
 &lt;td>每個事件獨立、順序有意義&lt;/td>
 &lt;td>新值取代舊值、只有最新值有意義&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>典型消費者&lt;/td>
 &lt;td>審計日誌、跨 domain 流程、通知&lt;/td>
 &lt;td>畫面、快取、衍生視圖&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>消費者的問題&lt;/td>
 &lt;td>發生了什麼&lt;/td>
 &lt;td>現在是什麼&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>「錯過的代價」那一列是實務上最好用的分辨器。畫面剛好沒訂閱時漏掉一次書單更新，下一次更新全額補償——狀態流語意。稽核系統漏掉一筆「書籍已刪除」，那筆事實永遠消失——事件語意。同一個資料變更常常兩種消費者都有：刪一本書，審計要那個事實（event）、清單畫面要新的書單（狀態流）——這不是二選一，是兩條正交的出口。&lt;/p>
&lt;p>事件命名的過去式約定本身就是語意宣告（展開見 &lt;a href="https://tarrragon.github.io/blog/work-log/domain_event_naming_past_tense/" data-link-title="BookImported 不能寫成 ImportBook — 事件命名的過去式是語意類別、不是風格" data-link-desc="domain event 用過去式命名（BookImported）因為事件是已發生的事實；動詞開頭（ImportBook）是命令的形狀、訂閱者會誤讀成指令。字尾清單的自動檢查抓不住不規則動詞——偵測可機械化、判定要看語意。附一次版本終止：工作日誌宣稱的命名問題實際早已不存在、執行前驗前提省下整輪流程。">Domain Event 命名的過去式&lt;/a>）：過去式承諾「這是已發生的事實」。當事實開始被當成「請刷新」的訊號消費，這個承諾就被消費端改寫了——下面的案例走的正是這條路。&lt;/p>
&lt;h2 id="案例一條斜坡的三步">案例：一條斜坡的三步&lt;/h2>
&lt;p>一個書庫管理 App 的統計頁需要在資料變更後刷新。&lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/repository/" data-link-title="Repository" data-link-desc="查詢方法該留在 repository、還是該抽成獨立讀模型時使用。repository 是 aggregate 的存取抽象——回傳的形狀是 aggregate 的形狀，不是讀的形狀。">repository&lt;/a> 是純 pull 介面、沒有狀態流出口；但專案有現成的 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/event-bus/" data-link-title="EventBus" data-link-desc="行程內要把「發生了一件事」廣播給多個訂閱者、或懷疑它被拿去兼職變更通知管道時回來讀。EventBus 是行程內的發布／訂閱事件匯流排——把事件的發布點與訂閱點解耦。">EventBus&lt;/a>、也有數十個 domain event 發布點。「用既有的事件當刷新訊號」看起來是零成本的選擇——這是斜坡的入口。&lt;/p>
&lt;p>&lt;strong>第一步：橋接&lt;/strong>。用 &lt;code>StreamProvider&lt;/code> 包 EventBus、ViewModel 監聽事件流、任何事件進來就重新查詢統計。上線有效：匯入完成事件、同步事件都正確觸發刷新。&lt;/p>
&lt;p>&lt;strong>第二步：斷鏈暴露枚舉本質&lt;/strong>。搜尋加書與掃描加書路徑不刷新——盤點發現這兩條寫入路徑從未發布任何 domain event。事件的發布時機由業務語意決定（「匯入完成」值得記錄；「使用者加了一本書」當時沒有下游流程需要它），而刷新的涵蓋面要求「每條寫入路徑都有事件」。兩個需求的形狀本質不同：&lt;strong>事件的涵蓋面是業務事實的集合、狀態流的涵蓋面是寫入操作的集合&lt;/strong>。用前者服務後者，缺的部分只能靠枚舉補——為了刷新而補發事件。&lt;/p>
&lt;p>&lt;strong>第三步：語意綁架成形&lt;/strong>。補發事件的修法一旦開始，事件系統的演進被刷新需求綁住：新增寫入路徑的檢查清單多了「記得發事件、否則某頁不刷新」；想移除一個再無業務消費者的事件、要先確認沒有畫面靠它刷新；監聽端則因為「不知道哪些事件代表資料變了」而全事件監聽、任何無關事件都觸發一次重新查詢。每一步都合理、加總起來是兩個系統互相持有對方的隱含契約。&lt;/p>
&lt;p>這個專案在第三步前停下，把問題拉回載體選擇：統計頁的問題是「現在是什麼」（當前書庫統計）、不是「發生了什麼」。正確載體是狀態流——repository 補 &lt;code>Stream&amp;lt;List&amp;lt;Book&amp;gt;&amp;gt;&lt;/code> 觀測出口、畫面 &lt;code>ref.watch&lt;/code> 訂閱，涵蓋面天然等於寫入操作的集合（每個寫入方法尾端 emit），沒有「記得發」這個動作。演進全記錄在 &lt;a href="https://tarrragon.github.io/blog/work-log/flutter_riverpod_reactive_boundary_ref_watch/" data-link-title="加書後統計不刷新 — ref.watch 觀察的是 provider 圖、不是資料庫" data-link-desc="頁面用了 Riverpod 卻在資料寫入後不更新、或發現自己在導航返回點補 loadData()、用 EventBus 事件觸發 reload 時使用。ref.watch 的 reactive 範圍是 provider 圖上的狀態變化；資料庫寫入不在圖上，補償刷新的出現就是這個缺口的訊號。">ref.watch 觀察的是 provider 圖、不是資料庫&lt;/a>、分層落地在 &lt;a href="https://tarrragon.github.io/blog/ddd/observation-outlet-responsibility-split/" data-link-title="觀測出口的職責三分" data-link-desc="repository 要補「資料變了」的推送能力、卻不確定 Stream 介面放 domain 算不算洩漏時使用。歸屬判準是介面用什麼語言表達、不是需求來自誰：契約歸 domain、變更偵測歸 infrastructure、框架訂閱歸組裝層。">觀測出口的職責三分&lt;/a>。&lt;/p>
&lt;h2 id="正交性加了狀態流事件一個不動">正交性：加了狀態流、事件一個不動&lt;/h2>
&lt;p>載體分清楚之後最有力的驗證是改動面：狀態流落地時，既有事件發布點&lt;strong>零改動&lt;/strong>。事件仍然記錄業務事實、服務審計與跨 domain 通知；狀態流負責「資料變了」的觀測。兩者正交、互不取代：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>出口&lt;/th>
 &lt;th>回答&lt;/th>
 &lt;th>消費者&lt;/th>
 &lt;th>涵蓋面的維持方式&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>domain event&lt;/td>
 &lt;td>發生了什麼&lt;/td>
 &lt;td>審計、跨 domain 流程&lt;/td>
 &lt;td>業務語意決定發布點、逐事實設計&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>狀態流&lt;/td>
 &lt;td>現在是什麼&lt;/td>
 &lt;td>畫面、衍生視圖、快取&lt;/td>
 &lt;td>寫入操作集合、結構性涵蓋&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>「結構性涵蓋」與「逐事實設計」的差異就是兩種載體不可互換的原因。狀態流的 emit 掛在寫入方法上、新路徑必然經過、涵蓋不靠任何人記得；事件的發布點是設計決策、每個事件都該有業務理由——被迫「為涵蓋而發」的事件是沒有事實語意的雜訊，反過來稀釋整個事件系統的可信度（事件多到沒人知道哪些重要時，跨 domain 通訊的成本問題見 &lt;a href="https://tarrragon.github.io/blog/work-log/cross_domain_event_request_response_cost/" data-link-title="用事件做同步查詢、等於手工重建 RPC — 跨 domain 解耦的完整帳單" data-link-desc="domain 直接查另一個 domain 的 repository 違反依賴方向；改事件驅動的 request/response 解了耦、但帳單具體：correlation id 配對並發、timeout 機制、三條錯誤路徑——都是同步呼叫免費附贈的東西。判準是需要解耦「依賴方向」還是「時間與部署」：前者用消費端 Port 就夠、後者才值得付事件的價。">用事件做同步查詢等於手工重建 RPC&lt;/a>——同一族的載體誤用、方向相反：那篇是拿事件做請求回應、本篇是拿事件做狀態通知）。&lt;/p>
&lt;h2 id="判讀訊號">判讀訊號&lt;/h2>
&lt;p>斜坡的每一步都有可觀察的訊號，出現任何一個就回到載體判準重新選：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>判讀&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>出現「為了讓某頁刷新、在某路徑補發事件」的工作項&lt;/td>
 &lt;td>消費者要的是「現在是什麼」、載體卻是事件——該補的是狀態流&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>監聽端掛全事件監聽、再考慮加型別過濾白名單&lt;/td>
 &lt;td>消費者不關心事實內容、只把事件當「有東西變了」的鈴聲&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>移除事件前要先查「有沒有畫面靠它刷新」&lt;/td>
 &lt;td>事件語意已被消費端綁架、事實系統失去獨立演進能力&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>事件 payload 開始塞「當前完整狀態」&lt;/td>
 &lt;td>事件被迫模擬快照——狀態流語意穿著事件的衣服&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>第四個訊號值得單獨說明：事實的 payload 是「這件事的內容」（哪本書、什麼時間），快照的 payload 是「現在的全貌」。事件開始攜帶全量狀態、通常是因為消費者拿到事件後只想要最新值——那正是狀態流一次 emit 就給完的東西。這個訊號有適用邊界：它假設消費者與事件來源在同一進程、同一信任邊界內。跨服務情境下，刻意讓事件攜帶足量當前狀態是 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/event-carried-state-transfer/" data-link-title="Event-Carried State Transfer" data-link-desc="跨服務的 domain event payload 開始塞進足量當前狀態、要判斷這是正當設計還是載體誤用時使用。event-carried state transfer 是刻意讓事件攜帶足量狀態、讓下游服務不必回頭查詢來源的設計。">event-carried state transfer&lt;/a> 這個正當設計——目的是讓下游服務不必回頭查詢來源、避免同步耦合，那時「payload 帶全量狀態」是設計選擇、不是載體錯位。本案是單一 App、同進程，沒有跨服務查詢成本，才適用「該補的是狀態流」的判讀。&lt;/p>
&lt;p>四個訊號量的不是同一種東西。訊號一、二、四直接檢驗「消費者現在問的是哪種問題」；訊號三（移除事件前要先查有沒有畫面靠它刷新）量的是選錯載體之後累積的耦合成本——它是後果訊號（lagging indicator），等綁架已經長出來才觀察得到。把它跟前三個並列，是因為它同樣觸發「回到載體判準」，但它遲到、指向的是已經發生的綁架而非正在發生的誤用。&lt;/p></description><content:encoded><![CDATA[<p>「資料變了、通知我」有兩種語意不同的載體。<strong><a href="/blog/ddd/knowledge-cards/domain-event/" data-link-title="Domain Event" data-link-desc="系統裡出現「為了讓某頁刷新而補發事件」或「監聽端掛全事件過濾器」時使用。domain event 是已發生的業務事實——過去式命名、發布後不可變、錯過代表事實遺失。">domain event</a></strong> 記錄離散事實：一件業務上發生過的事、以過去式命名、發布後不可變、消費者關心「發生了什麼」。**<a href="/blog/ddd/knowledge-cards/state-stream/" data-link-title="State Stream（狀態流）" data-link-desc="畫面刷新靠補償、或考慮拿既有事件當刷新訊號時使用。狀態流是持續發布「資料當前值」的觀測載體——新值蓋過舊值、錯過中間值無代價，回答「現在是什麼」。">狀態流</a>**發布連續觀測：某份資料的當前值、新值蓋過舊值、錯過中間值無妨、消費者關心「現在是什麼」。選錯載體的系統照樣能動——代價潛伏在演進裡：涵蓋面開始靠枚舉維持、事件語意被消費端綁架。本章的判準一句話：<strong>看消費者問的問題</strong>。問「發生了什麼」給事件、問「現在是什麼」給狀態流。</p>
<h2 id="兩種載體的語意對照">兩種載體的語意對照</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>domain event</th>
          <th>狀態流</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>語意單位</td>
          <td>一件發生過的業務事實</td>
          <td>一份資料的當前快照</td>
      </tr>
      <tr>
          <td>時態</td>
          <td>過去式（BookAdded、OrderPaid）</td>
          <td>現在式（books 現在是這些）</td>
      </tr>
      <tr>
          <td>錯過的代價</td>
          <td>事實遺失——審計斷檔、下游流程沒觸發</td>
          <td>零——下一次快照涵蓋一切</td>
      </tr>
      <tr>
          <td>值的關係</td>
          <td>每個事件獨立、順序有意義</td>
          <td>新值取代舊值、只有最新值有意義</td>
      </tr>
      <tr>
          <td>典型消費者</td>
          <td>審計日誌、跨 domain 流程、通知</td>
          <td>畫面、快取、衍生視圖</td>
      </tr>
      <tr>
          <td>消費者的問題</td>
          <td>發生了什麼</td>
          <td>現在是什麼</td>
      </tr>
  </tbody>
</table>
<p>「錯過的代價」那一列是實務上最好用的分辨器。畫面剛好沒訂閱時漏掉一次書單更新，下一次更新全額補償——狀態流語意。稽核系統漏掉一筆「書籍已刪除」，那筆事實永遠消失——事件語意。同一個資料變更常常兩種消費者都有：刪一本書，審計要那個事實（event）、清單畫面要新的書單（狀態流）——這不是二選一，是兩條正交的出口。</p>
<p>事件命名的過去式約定本身就是語意宣告（展開見 <a href="/blog/work-log/domain_event_naming_past_tense/" data-link-title="BookImported 不能寫成 ImportBook — 事件命名的過去式是語意類別、不是風格" data-link-desc="domain event 用過去式命名（BookImported）因為事件是已發生的事實；動詞開頭（ImportBook）是命令的形狀、訂閱者會誤讀成指令。字尾清單的自動檢查抓不住不規則動詞——偵測可機械化、判定要看語意。附一次版本終止：工作日誌宣稱的命名問題實際早已不存在、執行前驗前提省下整輪流程。">Domain Event 命名的過去式</a>）：過去式承諾「這是已發生的事實」。當事實開始被當成「請刷新」的訊號消費，這個承諾就被消費端改寫了——下面的案例走的正是這條路。</p>
<h2 id="案例一條斜坡的三步">案例：一條斜坡的三步</h2>
<p>一個書庫管理 App 的統計頁需要在資料變更後刷新。<a href="/blog/ddd/knowledge-cards/repository/" data-link-title="Repository" data-link-desc="查詢方法該留在 repository、還是該抽成獨立讀模型時使用。repository 是 aggregate 的存取抽象——回傳的形狀是 aggregate 的形狀，不是讀的形狀。">repository</a> 是純 pull 介面、沒有狀態流出口；但專案有現成的 <a href="/blog/ddd/knowledge-cards/event-bus/" data-link-title="EventBus" data-link-desc="行程內要把「發生了一件事」廣播給多個訂閱者、或懷疑它被拿去兼職變更通知管道時回來讀。EventBus 是行程內的發布／訂閱事件匯流排——把事件的發布點與訂閱點解耦。">EventBus</a>、也有數十個 domain event 發布點。「用既有的事件當刷新訊號」看起來是零成本的選擇——這是斜坡的入口。</p>
<p><strong>第一步：橋接</strong>。用 <code>StreamProvider</code> 包 EventBus、ViewModel 監聽事件流、任何事件進來就重新查詢統計。上線有效：匯入完成事件、同步事件都正確觸發刷新。</p>
<p><strong>第二步：斷鏈暴露枚舉本質</strong>。搜尋加書與掃描加書路徑不刷新——盤點發現這兩條寫入路徑從未發布任何 domain event。事件的發布時機由業務語意決定（「匯入完成」值得記錄；「使用者加了一本書」當時沒有下游流程需要它），而刷新的涵蓋面要求「每條寫入路徑都有事件」。兩個需求的形狀本質不同：<strong>事件的涵蓋面是業務事實的集合、狀態流的涵蓋面是寫入操作的集合</strong>。用前者服務後者，缺的部分只能靠枚舉補——為了刷新而補發事件。</p>
<p><strong>第三步：語意綁架成形</strong>。補發事件的修法一旦開始，事件系統的演進被刷新需求綁住：新增寫入路徑的檢查清單多了「記得發事件、否則某頁不刷新」；想移除一個再無業務消費者的事件、要先確認沒有畫面靠它刷新；監聽端則因為「不知道哪些事件代表資料變了」而全事件監聽、任何無關事件都觸發一次重新查詢。每一步都合理、加總起來是兩個系統互相持有對方的隱含契約。</p>
<p>這個專案在第三步前停下，把問題拉回載體選擇：統計頁的問題是「現在是什麼」（當前書庫統計）、不是「發生了什麼」。正確載體是狀態流——repository 補 <code>Stream&lt;List&lt;Book&gt;&gt;</code> 觀測出口、畫面 <code>ref.watch</code> 訂閱，涵蓋面天然等於寫入操作的集合（每個寫入方法尾端 emit），沒有「記得發」這個動作。演進全記錄在 <a href="/blog/work-log/flutter_riverpod_reactive_boundary_ref_watch/" data-link-title="加書後統計不刷新 — ref.watch 觀察的是 provider 圖、不是資料庫" data-link-desc="頁面用了 Riverpod 卻在資料寫入後不更新、或發現自己在導航返回點補 loadData()、用 EventBus 事件觸發 reload 時使用。ref.watch 的 reactive 範圍是 provider 圖上的狀態變化；資料庫寫入不在圖上，補償刷新的出現就是這個缺口的訊號。">ref.watch 觀察的是 provider 圖、不是資料庫</a>、分層落地在 <a href="/blog/ddd/observation-outlet-responsibility-split/" data-link-title="觀測出口的職責三分" data-link-desc="repository 要補「資料變了」的推送能力、卻不確定 Stream 介面放 domain 算不算洩漏時使用。歸屬判準是介面用什麼語言表達、不是需求來自誰：契約歸 domain、變更偵測歸 infrastructure、框架訂閱歸組裝層。">觀測出口的職責三分</a>。</p>
<h2 id="正交性加了狀態流事件一個不動">正交性：加了狀態流、事件一個不動</h2>
<p>載體分清楚之後最有力的驗證是改動面：狀態流落地時，既有事件發布點<strong>零改動</strong>。事件仍然記錄業務事實、服務審計與跨 domain 通知；狀態流負責「資料變了」的觀測。兩者正交、互不取代：</p>
<table>
  <thead>
      <tr>
          <th>出口</th>
          <th>回答</th>
          <th>消費者</th>
          <th>涵蓋面的維持方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>domain event</td>
          <td>發生了什麼</td>
          <td>審計、跨 domain 流程</td>
          <td>業務語意決定發布點、逐事實設計</td>
      </tr>
      <tr>
          <td>狀態流</td>
          <td>現在是什麼</td>
          <td>畫面、衍生視圖、快取</td>
          <td>寫入操作集合、結構性涵蓋</td>
      </tr>
  </tbody>
</table>
<p>「結構性涵蓋」與「逐事實設計」的差異就是兩種載體不可互換的原因。狀態流的 emit 掛在寫入方法上、新路徑必然經過、涵蓋不靠任何人記得；事件的發布點是設計決策、每個事件都該有業務理由——被迫「為涵蓋而發」的事件是沒有事實語意的雜訊，反過來稀釋整個事件系統的可信度（事件多到沒人知道哪些重要時，跨 domain 通訊的成本問題見 <a href="/blog/work-log/cross_domain_event_request_response_cost/" data-link-title="用事件做同步查詢、等於手工重建 RPC — 跨 domain 解耦的完整帳單" data-link-desc="domain 直接查另一個 domain 的 repository 違反依賴方向；改事件驅動的 request/response 解了耦、但帳單具體：correlation id 配對並發、timeout 機制、三條錯誤路徑——都是同步呼叫免費附贈的東西。判準是需要解耦「依賴方向」還是「時間與部署」：前者用消費端 Port 就夠、後者才值得付事件的價。">用事件做同步查詢等於手工重建 RPC</a>——同一族的載體誤用、方向相反：那篇是拿事件做請求回應、本篇是拿事件做狀態通知）。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<p>斜坡的每一步都有可觀察的訊號，出現任何一個就回到載體判準重新選：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>出現「為了讓某頁刷新、在某路徑補發事件」的工作項</td>
          <td>消費者要的是「現在是什麼」、載體卻是事件——該補的是狀態流</td>
      </tr>
      <tr>
          <td>監聽端掛全事件監聽、再考慮加型別過濾白名單</td>
          <td>消費者不關心事實內容、只把事件當「有東西變了」的鈴聲</td>
      </tr>
      <tr>
          <td>移除事件前要先查「有沒有畫面靠它刷新」</td>
          <td>事件語意已被消費端綁架、事實系統失去獨立演進能力</td>
      </tr>
      <tr>
          <td>事件 payload 開始塞「當前完整狀態」</td>
          <td>事件被迫模擬快照——狀態流語意穿著事件的衣服</td>
      </tr>
  </tbody>
</table>
<p>第四個訊號值得單獨說明：事實的 payload 是「這件事的內容」（哪本書、什麼時間），快照的 payload 是「現在的全貌」。事件開始攜帶全量狀態、通常是因為消費者拿到事件後只想要最新值——那正是狀態流一次 emit 就給完的東西。這個訊號有適用邊界：它假設消費者與事件來源在同一進程、同一信任邊界內。跨服務情境下，刻意讓事件攜帶足量當前狀態是 <a href="/blog/ddd/knowledge-cards/event-carried-state-transfer/" data-link-title="Event-Carried State Transfer" data-link-desc="跨服務的 domain event payload 開始塞進足量當前狀態、要判斷這是正當設計還是載體誤用時使用。event-carried state transfer 是刻意讓事件攜帶足量狀態、讓下游服務不必回頭查詢來源的設計。">event-carried state transfer</a> 這個正當設計——目的是讓下游服務不必回頭查詢來源、避免同步耦合，那時「payload 帶全量狀態」是設計選擇、不是載體錯位。本案是單一 App、同進程，沒有跨服務查詢成本，才適用「該補的是狀態流」的判讀。</p>
<p>四個訊號量的不是同一種東西。訊號一、二、四直接檢驗「消費者現在問的是哪種問題」；訊號三（移除事件前要先查有沒有畫面靠它刷新）量的是選錯載體之後累積的耦合成本——它是後果訊號（lagging indicator），等綁架已經長出來才觀察得到。把它跟前三個並列，是因為它同樣觸發「回到載體判準」，但它遲到、指向的是已經發生的綁架而非正在發生的誤用。</p>
<p>以上訊號都指向同一個方向：把事件當成狀態流用。反方向的誤用同樣存在、訊號相反——消費者需要的是逐筆離散事實（每一次庫存調整、每一筆退款），系統卻只給得到最新快照。症狀是稽核回頭找「中間發生過什麼」時只有結果沒有過程、或下游流程因為錯過中間狀態而少觸發。此時該補的是 event、而不是把狀態流的快照硬拆成假事件。兩個方向共用同一條判準：問「發生了什麼」給事件、問「現在是什麼」給狀態流——判準對稱，誤用可以往兩邊倒。</p>
<h2 id="邊界">邊界</h2>
<p>本章的判準處理「資料變更通知」這一種需求的載體選擇。事件對另外兩種離散訊息（命令、查詢）的責任結構分界在 <a href="/blog/ddd/domain-event-vs-command-and-query/" data-link-title="domain event 與命令、查詢的分界" data-link-desc="事件類別以動詞開頭、或事件成對出現 Requested/Provided 帶 correlation id 時使用。事件只承載已發生的事實——命令有唯一處理者與成敗、查詢是一問一答的對話；把意圖或對話裝進事件通道、責任結構會跟著錯位。">domain event 與命令、查詢</a>；狀態流的實作選型（broadcast、初始值、生命週期）在 <a href="/blog/work-log/flutter_streamprovider_wraps_repository_watch/" data-link-title="StreamProvider 包 repository watch stream — broadcast、初始值、dispose 實作點" data-link-desc="repository 要補 Stream 觀測出口、接給 Riverpod 消費時使用。訂閱模型選 broadcast 還是單訂閱、新訂閱者拿不拿得到當下狀態、controller 誰負責關——每個問題各有一個會靜默失效的預設答案。">StreamProvider 包 repository watch stream</a>。另外，<a href="/blog/ddd/knowledge-cards/cqrs/" data-link-title="CQRS" data-link-desc="有人提議「上 CQRS」、或想知道讀寫分離該做到多徹底時使用。CQRS 是把讀操作與寫操作的模型拆開的架構決定——寫側守一致性、讀側服務查詢形狀，兩者可以各自有獨立的儲存與更新節奏。">CQRS</a> 階梯頂端「以事件同步讀模型」是事件的合法消費——那裡的消費者問的確實是「發生了什麼」（用事實重建投影），與把事件當刷新鈴聲不同層；階梯全貌見 <a href="/blog/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準</a>。這也標出本章「兩者正交」論述的架構前提：狀態存在獨立儲存、事件只是旁支通知。event-sourced 架構把這個前提拿掉——狀態流成為事件的投影、由同一條事件流重建，兩條管線不再獨立，正交性讓位給「讀模型由事件同步」的第四階語意。</p>
<h2 id="下一步">下一步</h2>
<p>決定用狀態流之後，先讀 <a href="/blog/ddd/observation-outlet-responsibility-split/" data-link-title="觀測出口的職責三分" data-link-desc="repository 要補「資料變了」的推送能力、卻不確定 Stream 介面放 domain 算不算洩漏時使用。歸屬判準是介面用什麼語言表達、不是需求來自誰：契約歸 domain、變更偵測歸 infrastructure、框架訂閱歸組裝層。">觀測出口的職責三分</a> 搞清楚契約、機制、組裝分別放哪一層，再進 <a href="/blog/work-log/flutter_riverpod_reactive_boundary_ref_watch/" data-link-title="加書後統計不刷新 — ref.watch 觀察的是 provider 圖、不是資料庫" data-link-desc="頁面用了 Riverpod 卻在資料寫入後不更新、或發現自己在導航返回點補 loadData()、用 EventBus 事件觸發 reload 時使用。ref.watch 的 reactive 範圍是 provider 圖上的狀態變化；資料庫寫入不在圖上，補償刷新的出現就是這個缺口的訊號。">ref.watch 觀察的是 provider 圖、不是資料庫</a> 看案例從導航補償到狀態流的完整演進。事件命名的過去式約定（為什麼叫 <code>BookAdded</code> 而不是 <code>AddBook</code>）展開在 <a href="/blog/work-log/domain_event_naming_past_tense/" data-link-title="BookImported 不能寫成 ImportBook — 事件命名的過去式是語意類別、不是風格" data-link-desc="domain event 用過去式命名（BookImported）因為事件是已發生的事實；動詞開頭（ImportBook）是命令的形狀、訂閱者會誤讀成指令。字尾清單的自動檢查抓不住不規則動詞——偵測可機械化、判定要看語意。附一次版本終止：工作日誌宣稱的命名問題實際早已不存在、執行前驗前提省下整輪流程。">Domain Event 命名的過去式</a>。讀側若走到階梯高處、要用事件同步投影的代價評估，見 <a href="/blog/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準</a>。</p>
]]></content:encoded></item><item><title>domain event 與命令、查詢的分界</title><link>https://tarrragon.github.io/blog/ddd/domain-event-vs-command-and-query/</link><pubDate>Thu, 16 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/domain-event-vs-command-and-query/</guid><description>&lt;p>跨模組的訊息有三種責任形狀。&lt;strong>命令&lt;/strong>（command）表達意圖：「去做這件事」，有唯一的處理者、可以拒絕、可以失敗。&lt;strong>&lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/domain-event/" data-link-title="Domain Event" data-link-desc="系統裡出現「為了讓某頁刷新而補發事件」或「監聽端掛全事件過濾器」時使用。domain event 是已發生的業務事實——過去式命名、發布後不可變、錯過代表事實遺失。">domain event&lt;/a>&lt;/strong> 記錄事實：「這件事已經發生」，有任意多的訂閱者、沒有拒絕的位置。&lt;strong>查詢&lt;/strong>（query）是對話：「告訴我這個」，一問一答、呼叫端要等到答案。三者的分界是責任結構、不是命名風格。本章的判準一句話：&lt;strong>看訊息對消費者的要求&lt;/strong>——要求執行、給命令；只供反應、給事件；要等答案、給查詢通道。&lt;/p>
&lt;h2 id="三種訊息的責任結構">三種訊息的責任結構&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>命令&lt;/th>
 &lt;th>domain event&lt;/th>
 &lt;th>查詢&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>語意&lt;/td>
 &lt;td>去做這件事&lt;/td>
 &lt;td>這件事已發生&lt;/td>
 &lt;td>告訴我這個&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>命名形狀&lt;/td>
 &lt;td>祈使（&lt;code>ImportBook&lt;/code>）&lt;/td>
 &lt;td>過去式（&lt;code>BookImported&lt;/code>）&lt;/td>
 &lt;td>疑問（&lt;code>getBookInfo()&lt;/code>）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>消費者&lt;/td>
 &lt;td>唯一處理者&lt;/td>
 &lt;td>任意多訂閱者&lt;/td>
 &lt;td>唯一回應者&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>失敗語意&lt;/td>
 &lt;td>可拒絕、可失敗、可重試&lt;/td>
 &lt;td>無——事實不可否認&lt;/td>
 &lt;td>有超時、有錯誤回傳&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>發送端等不等&lt;/td>
 &lt;td>視流程——常等處理結果&lt;/td>
 &lt;td>不等、fire and forget&lt;/td>
 &lt;td>必等——答案是流程的前提&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>「失敗語意」那一列是三者最深的差異。命令的處理者可以說不：庫存不足、狀態不允許、驗證失敗——失敗是命令語意的一部分、發送端要準備收到拒絕。事件沒有這個位置：&lt;code>BookImported&lt;/code> 發出時匯入已經完成，訂閱者遲到、出錯、根本不存在，都改變不了這個事實——訂閱者的失敗是訂閱者自己的問題、不回流到發布端。查詢的失敗又是另一種：查不到、等太久、通道斷了，每一種都要有回應路徑、因為呼叫端停在那裡等。&lt;/p>
&lt;p>責任結構決定演進自由度：事件的發布端不知道誰在聽、加訂閱者不改發布端；命令的發送端跟處理者是一對一契約、換處理者要重新對齊語意；查詢的兩端綁最緊、簽名一動兩邊都動。&lt;/p>
&lt;h2 id="命名是責任結構的第一道宣告">命名是責任結構的第一道宣告&lt;/h2>
&lt;p>事件用過去式命名、是把責任結構烙在讀者第一眼接觸的地方：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">class&lt;/span> &lt;span class="nc">BookImported&lt;/span> &lt;span class="kd">extends&lt;/span> &lt;span class="n">DomainEvent&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="c1">// 事實：已發生、不可否認
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">class&lt;/span> &lt;span class="nc">ImportBook&lt;/span> &lt;span class="kd">extends&lt;/span> &lt;span class="n">DomainEvent&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="o">//&lt;/span> &lt;span class="err">命令的形狀——「去匯入這本書」&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>ImportBook&lt;/code> 這個名字的問題超出風格：動詞開頭是命令的形狀，訂閱者會用命令的心智模型寫處理邏輯——以為自己能影響流程、以為失敗會被重試。過去式的 &lt;code>Imported&lt;/code> 宣告木已成舟，訂閱者知道自己只能對事實做反應。一個書籍管理 App 的命名規範修正記錄了這條分界的完整推導、以及過去式字尾自動檢查的脆弱性（不規則動詞全數漏接——偵測可以機械化、判定要看語意）：&lt;a href="https://tarrragon.github.io/blog/work-log/domain_event_naming_past_tense/" data-link-title="BookImported 不能寫成 ImportBook — 事件命名的過去式是語意類別、不是風格" data-link-desc="domain event 用過去式命名（BookImported）因為事件是已發生的事實；動詞開頭（ImportBook）是命令的形狀、訂閱者會誤讀成指令。字尾清單的自動檢查抓不住不規則動詞——偵測可機械化、判定要看語意。附一次版本終止：工作日誌宣稱的命名問題實際早已不存在、執行前驗前提省下整輪流程。">事件命名的過去式是語意類別、不是風格&lt;/a>。&lt;/p>
&lt;h2 id="查詢裝進事件通道手工重建-rpc">查詢裝進事件通道：手工重建 RPC&lt;/h2>
&lt;p>第二種錯位的方向相反：把對話裝進通知通道。同一個 App 的借閱功能要在建立借閱記錄前查書籍資訊，直接呼叫另一個 domain 的 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/repository/" data-link-title="Repository" data-link-desc="查詢方法該留在 repository、還是該抽成獨立讀模型時使用。repository 是 aggregate 的存取抽象——回傳的形狀是 aggregate 的形狀，不是讀的形狀。">repository&lt;/a> 違反依賴方向；修正方案選了事件驅動的 request/response——&lt;code>BookInfoRequested&lt;/code> 發出、&lt;code>BookInfoProvided&lt;/code> 回應。耦合確實解掉了，但設計規格裡跟著出現的每一項機制、都是同步呼叫免費附贈的東西：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>事件版要自己蓋的&lt;/th>
 &lt;th>同步呼叫的對應物&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/correlation-id/" data-link-title="Correlation ID" data-link-desc="說明跨事件或跨服務的關聯識別碼如何支援排障">correlation id&lt;/a> 配對請求回應&lt;/td>
 &lt;td>呼叫堆疊天然對應&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>waitFor + timeout&lt;/td>
 &lt;td>函式 return&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>「查無資料」的錯誤事件&lt;/td>
 &lt;td>回傳 null／拋例外&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>超時與通道故障路徑&lt;/td>
 &lt;td>不存在——同步呼叫沒有「沒人接」&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表的形狀有個名字：用事件通道手工重建 RPC。事件的天然語意是通知——fire and forget、任意多訂閱者、無所謂回應；一問一答的對話硬放上通知通道，RPC 的基礎設施就得自己蓋一遍、而且每個跨 domain 查詢點都要再蓋一遍。名字先洩漏了錯位：&lt;code>BookInfoRequested&lt;/code> 的「Requested」記錄的是「有人想要」這個請求、不是業務世界發生的事實——事件命名的過去式測試在這裡當了偵測器，兩條判準在名字上交會。&lt;/p>
&lt;p>事件真正買到、而更便宜的工具給不了的能力有兩樣：&lt;strong>時間解耦&lt;/strong>（發布者不等回應、雙方可以不同時在線）與&lt;strong>基數解耦&lt;/strong>（一個事件任意多訂閱者）。這個案例的查詢是同進程、一對一、呼叫端必須等到答案——三個特徵全部用不到事件的能力。解依賴方向有便宜一個量級的工具：消費端宣告自己需要的 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/port/" data-link-title="Port" data-link-desc="判斷介面該宣告在哪一層、依賴方向該朝哪時使用。port 是 domain 對外宣告的介面——需求用領域語言說完、技術細節留在實作端。">port&lt;/a>、對方提供實作、組裝層接線，依賴倒置同樣成立、呼叫還是一次普通的 await。完整比價（含 Port 版的介面設計）見 &lt;a href="https://tarrragon.github.io/blog/work-log/cross_domain_event_request_response_cost/" data-link-title="用事件做同步查詢、等於手工重建 RPC — 跨 domain 解耦的完整帳單" data-link-desc="domain 直接查另一個 domain 的 repository 違反依賴方向；改事件驅動的 request/response 解了耦、但帳單具體：correlation id 配對並發、timeout 機制、三條錯誤路徑——都是同步呼叫免費附贈的東西。判準是需要解耦「依賴方向」還是「時間與部署」：前者用消費端 Port 就夠、後者才值得付事件的價。">用事件做同步查詢等於手工重建 RPC&lt;/a>。&lt;/p>
&lt;p>判準收斂成兩行：&lt;/p>
&lt;ul>
&lt;li>要解的是&lt;strong>依賴方向&lt;/strong>（同進程、一對一、要等答案）→ 消費端 port、付一張介面的價&lt;/li>
&lt;li>要解的是&lt;strong>時間、部署或基數&lt;/strong>（跨進程、離線佇列、多方反應）→ 事件、correlation 與 timeout 是這個量級的合理成本&lt;/li>
&lt;/ul>
&lt;h2 id="判讀訊號">判讀訊號&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>判讀&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>事件類別以動詞開頭（&lt;code>ImportBook&lt;/code>）&lt;/td>
 &lt;td>命令穿著事件的衣服——訂閱者會以為自己能影響流程&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>事件成對出現 &lt;code>XxxRequested&lt;/code>／&lt;code>XxxProvided&lt;/code> + correlation id&lt;/td>
 &lt;td>對話穿著事件的衣服——在通知通道上手工重建 RPC&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>發布事件後 &lt;code>waitFor&lt;/code>／await 回應才能往下走&lt;/td>
 &lt;td>呼叫端語意是同步查詢、事件只是繞路&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>訂閱者不只一個、或處理可以離線延後&lt;/td>
 &lt;td>反向確認：這時事件才是本命、別退回 port&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>前三個訊號的修法方向一致：回到責任結構重新分類——是意圖就改命令通道（或直接呼叫 use case）、是對話就改查詢（消費端 port）、確認是事實才留在事件。第四個訊號防反向矯枉：把所有跨 domain 通訊都收回同步呼叫、會把真正需要時間與基數解耦的場景也一起收掉。&lt;/p>
&lt;h2 id="邊界">邊界&lt;/h2>
&lt;p>本章處理事件與命令、查詢的訊息類型分界——三者都是離散訊息、差在責任結構。事件與 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/state-stream/" data-link-title="State Stream（狀態流）" data-link-desc="畫面刷新靠補償、或考慮拿既有事件當刷新訊號時使用。狀態流是持續發布「資料當前值」的觀測載體——新值蓋過舊值、錯過中間值無代價，回答「現在是什麼」。">狀態流&lt;/a>（連續觀測）的分界是另一個維度：那裡兩邊都不要求執行、差在「發生了什麼」與「現在是什麼」的時間語意，見 &lt;a href="https://tarrragon.github.io/blog/ddd/domain-event-vs-state-stream/" data-link-title="domain event 與狀態流" data-link-desc="為了讓某個畫面刷新而補發事件、或監聽端掛著全事件過濾器時使用。事件記錄離散事實、狀態流發布連續觀測——判準是消費者問「發生了什麼」還是「現在是什麼」；載體借用的代價是涵蓋面靠枚舉維持。">domain event 與狀態流&lt;/a>。事件跨服務攜帶全量狀態的 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/event-carried-state-transfer/" data-link-title="Event-Carried State Transfer" data-link-desc="跨服務的 domain event payload 開始塞進足量當前狀態、要判斷這是正當設計還是載體誤用時使用。event-carried state transfer 是刻意讓事件攜帶足量狀態、讓下游服務不必回頭查詢來源的設計。">event-carried state transfer&lt;/a> 是分散式情境的正當設計、其適用邊界也在該章。&lt;/p>
&lt;h2 id="下一步">下一步&lt;/h2>
&lt;p>事件的另一條分界（對狀態流）在 &lt;a href="https://tarrragon.github.io/blog/ddd/domain-event-vs-state-stream/" data-link-title="domain event 與狀態流" data-link-desc="為了讓某個畫面刷新而補發事件、或監聽端掛著全事件過濾器時使用。事件記錄離散事實、狀態流發布連續觀測——判準是消費者問「發生了什麼」還是「現在是什麼」；載體借用的代價是涵蓋面靠枚舉維持。">domain event 與狀態流&lt;/a>——兩章合起來把 domain event 對三個鄰居（命令、查詢、狀態流）的邊界畫完。兩個 case 的完整記錄：&lt;a href="https://tarrragon.github.io/blog/work-log/domain_event_naming_past_tense/" data-link-title="BookImported 不能寫成 ImportBook — 事件命名的過去式是語意類別、不是風格" data-link-desc="domain event 用過去式命名（BookImported）因為事件是已發生的事實；動詞開頭（ImportBook）是命令的形狀、訂閱者會誤讀成指令。字尾清單的自動檢查抓不住不規則動詞——偵測可機械化、判定要看語意。附一次版本終止：工作日誌宣稱的命名問題實際早已不存在、執行前驗前提省下整輪流程。">事件命名的過去式&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/work-log/cross_domain_event_request_response_cost/" data-link-title="用事件做同步查詢、等於手工重建 RPC — 跨 domain 解耦的完整帳單" data-link-desc="domain 直接查另一個 domain 的 repository 違反依賴方向；改事件驅動的 request/response 解了耦、但帳單具體：correlation id 配對並發、timeout 機制、三條錯誤路徑——都是同步呼叫免費附贈的東西。判準是需要解耦「依賴方向」還是「時間與部署」：前者用消費端 Port 就夠、後者才值得付事件的價。">用事件做同步查詢等於手工重建 RPC&lt;/a>；port 解依賴的實戰在 &lt;a href="https://tarrragon.github.io/blog/work-log/flutter_port_interface_mock_hell_isp/" data-link-title="mock 要配置 55 個方法、實際只用 5 個 — 測試痛是介面設計痛的探針" data-link-desc="service 測試的 mock 負擔正比於它依賴的介面寬度：依賴四個大介面共 55 個方法、實際呼叫 5 個，91% 的 mock 配置是純浪費、還會炸 MissingStubError。修法是介面隔離——抽出只含實際使用方法的 Port，讓 mock 縮到跟真實依賴一樣窄；為未來預留的方法用 TODO 標記啟用時機。">mock 55 個方法只用 5 個&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>跨模組的訊息有三種責任形狀。<strong>命令</strong>（command）表達意圖：「去做這件事」，有唯一的處理者、可以拒絕、可以失敗。<strong><a href="/blog/ddd/knowledge-cards/domain-event/" data-link-title="Domain Event" data-link-desc="系統裡出現「為了讓某頁刷新而補發事件」或「監聽端掛全事件過濾器」時使用。domain event 是已發生的業務事實——過去式命名、發布後不可變、錯過代表事實遺失。">domain event</a></strong> 記錄事實：「這件事已經發生」，有任意多的訂閱者、沒有拒絕的位置。<strong>查詢</strong>（query）是對話：「告訴我這個」，一問一答、呼叫端要等到答案。三者的分界是責任結構、不是命名風格。本章的判準一句話：<strong>看訊息對消費者的要求</strong>——要求執行、給命令；只供反應、給事件；要等答案、給查詢通道。</p>
<h2 id="三種訊息的責任結構">三種訊息的責任結構</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>命令</th>
          <th>domain event</th>
          <th>查詢</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>語意</td>
          <td>去做這件事</td>
          <td>這件事已發生</td>
          <td>告訴我這個</td>
      </tr>
      <tr>
          <td>命名形狀</td>
          <td>祈使（<code>ImportBook</code>）</td>
          <td>過去式（<code>BookImported</code>）</td>
          <td>疑問（<code>getBookInfo()</code>）</td>
      </tr>
      <tr>
          <td>消費者</td>
          <td>唯一處理者</td>
          <td>任意多訂閱者</td>
          <td>唯一回應者</td>
      </tr>
      <tr>
          <td>失敗語意</td>
          <td>可拒絕、可失敗、可重試</td>
          <td>無——事實不可否認</td>
          <td>有超時、有錯誤回傳</td>
      </tr>
      <tr>
          <td>發送端等不等</td>
          <td>視流程——常等處理結果</td>
          <td>不等、fire and forget</td>
          <td>必等——答案是流程的前提</td>
      </tr>
  </tbody>
</table>
<p>「失敗語意」那一列是三者最深的差異。命令的處理者可以說不：庫存不足、狀態不允許、驗證失敗——失敗是命令語意的一部分、發送端要準備收到拒絕。事件沒有這個位置：<code>BookImported</code> 發出時匯入已經完成，訂閱者遲到、出錯、根本不存在，都改變不了這個事實——訂閱者的失敗是訂閱者自己的問題、不回流到發布端。查詢的失敗又是另一種：查不到、等太久、通道斷了，每一種都要有回應路徑、因為呼叫端停在那裡等。</p>
<p>責任結構決定演進自由度：事件的發布端不知道誰在聽、加訂閱者不改發布端；命令的發送端跟處理者是一對一契約、換處理者要重新對齊語意；查詢的兩端綁最緊、簽名一動兩邊都動。</p>
<h2 id="命名是責任結構的第一道宣告">命名是責任結構的第一道宣告</h2>
<p>事件用過去式命名、是把責任結構烙在讀者第一眼接觸的地方：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">class</span> <span class="nc">BookImported</span> <span class="kd">extends</span> <span class="n">DomainEvent</span> <span class="p">{</span> <span class="p">}</span>   <span class="c1">// 事實：已發生、不可否認
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kd">class</span> <span class="nc">ImportBook</span> <span class="kd">extends</span> <span class="n">DomainEvent</span> <span class="p">{</span> <span class="p">}</span>     <span class="o">//</span> <span class="err">命令的形狀——「去匯入這本書」</span></span></span></code></pre></div><p><code>ImportBook</code> 這個名字的問題超出風格：動詞開頭是命令的形狀，訂閱者會用命令的心智模型寫處理邏輯——以為自己能影響流程、以為失敗會被重試。過去式的 <code>Imported</code> 宣告木已成舟，訂閱者知道自己只能對事實做反應。一個書籍管理 App 的命名規範修正記錄了這條分界的完整推導、以及過去式字尾自動檢查的脆弱性（不規則動詞全數漏接——偵測可以機械化、判定要看語意）：<a href="/blog/work-log/domain_event_naming_past_tense/" data-link-title="BookImported 不能寫成 ImportBook — 事件命名的過去式是語意類別、不是風格" data-link-desc="domain event 用過去式命名（BookImported）因為事件是已發生的事實；動詞開頭（ImportBook）是命令的形狀、訂閱者會誤讀成指令。字尾清單的自動檢查抓不住不規則動詞——偵測可機械化、判定要看語意。附一次版本終止：工作日誌宣稱的命名問題實際早已不存在、執行前驗前提省下整輪流程。">事件命名的過去式是語意類別、不是風格</a>。</p>
<h2 id="查詢裝進事件通道手工重建-rpc">查詢裝進事件通道：手工重建 RPC</h2>
<p>第二種錯位的方向相反：把對話裝進通知通道。同一個 App 的借閱功能要在建立借閱記錄前查書籍資訊，直接呼叫另一個 domain 的 <a href="/blog/ddd/knowledge-cards/repository/" data-link-title="Repository" data-link-desc="查詢方法該留在 repository、還是該抽成獨立讀模型時使用。repository 是 aggregate 的存取抽象——回傳的形狀是 aggregate 的形狀，不是讀的形狀。">repository</a> 違反依賴方向；修正方案選了事件驅動的 request/response——<code>BookInfoRequested</code> 發出、<code>BookInfoProvided</code> 回應。耦合確實解掉了，但設計規格裡跟著出現的每一項機制、都是同步呼叫免費附贈的東西：</p>
<table>
  <thead>
      <tr>
          <th>事件版要自己蓋的</th>
          <th>同步呼叫的對應物</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/correlation-id/" data-link-title="Correlation ID" data-link-desc="說明跨事件或跨服務的關聯識別碼如何支援排障">correlation id</a> 配對請求回應</td>
          <td>呼叫堆疊天然對應</td>
      </tr>
      <tr>
          <td>waitFor + timeout</td>
          <td>函式 return</td>
      </tr>
      <tr>
          <td>「查無資料」的錯誤事件</td>
          <td>回傳 null／拋例外</td>
      </tr>
      <tr>
          <td>超時與通道故障路徑</td>
          <td>不存在——同步呼叫沒有「沒人接」</td>
      </tr>
  </tbody>
</table>
<p>這張表的形狀有個名字：用事件通道手工重建 RPC。事件的天然語意是通知——fire and forget、任意多訂閱者、無所謂回應；一問一答的對話硬放上通知通道，RPC 的基礎設施就得自己蓋一遍、而且每個跨 domain 查詢點都要再蓋一遍。名字先洩漏了錯位：<code>BookInfoRequested</code> 的「Requested」記錄的是「有人想要」這個請求、不是業務世界發生的事實——事件命名的過去式測試在這裡當了偵測器，兩條判準在名字上交會。</p>
<p>事件真正買到、而更便宜的工具給不了的能力有兩樣：<strong>時間解耦</strong>（發布者不等回應、雙方可以不同時在線）與<strong>基數解耦</strong>（一個事件任意多訂閱者）。這個案例的查詢是同進程、一對一、呼叫端必須等到答案——三個特徵全部用不到事件的能力。解依賴方向有便宜一個量級的工具：消費端宣告自己需要的 <a href="/blog/ddd/knowledge-cards/port/" data-link-title="Port" data-link-desc="判斷介面該宣告在哪一層、依賴方向該朝哪時使用。port 是 domain 對外宣告的介面——需求用領域語言說完、技術細節留在實作端。">port</a>、對方提供實作、組裝層接線，依賴倒置同樣成立、呼叫還是一次普通的 await。完整比價（含 Port 版的介面設計）見 <a href="/blog/work-log/cross_domain_event_request_response_cost/" data-link-title="用事件做同步查詢、等於手工重建 RPC — 跨 domain 解耦的完整帳單" data-link-desc="domain 直接查另一個 domain 的 repository 違反依賴方向；改事件驅動的 request/response 解了耦、但帳單具體：correlation id 配對並發、timeout 機制、三條錯誤路徑——都是同步呼叫免費附贈的東西。判準是需要解耦「依賴方向」還是「時間與部署」：前者用消費端 Port 就夠、後者才值得付事件的價。">用事件做同步查詢等於手工重建 RPC</a>。</p>
<p>判準收斂成兩行：</p>
<ul>
<li>要解的是<strong>依賴方向</strong>（同進程、一對一、要等答案）→ 消費端 port、付一張介面的價</li>
<li>要解的是<strong>時間、部署或基數</strong>（跨進程、離線佇列、多方反應）→ 事件、correlation 與 timeout 是這個量級的合理成本</li>
</ul>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>事件類別以動詞開頭（<code>ImportBook</code>）</td>
          <td>命令穿著事件的衣服——訂閱者會以為自己能影響流程</td>
      </tr>
      <tr>
          <td>事件成對出現 <code>XxxRequested</code>／<code>XxxProvided</code> + correlation id</td>
          <td>對話穿著事件的衣服——在通知通道上手工重建 RPC</td>
      </tr>
      <tr>
          <td>發布事件後 <code>waitFor</code>／await 回應才能往下走</td>
          <td>呼叫端語意是同步查詢、事件只是繞路</td>
      </tr>
      <tr>
          <td>訂閱者不只一個、或處理可以離線延後</td>
          <td>反向確認：這時事件才是本命、別退回 port</td>
      </tr>
  </tbody>
</table>
<p>前三個訊號的修法方向一致：回到責任結構重新分類——是意圖就改命令通道（或直接呼叫 use case）、是對話就改查詢（消費端 port）、確認是事實才留在事件。第四個訊號防反向矯枉：把所有跨 domain 通訊都收回同步呼叫、會把真正需要時間與基數解耦的場景也一起收掉。</p>
<h2 id="邊界">邊界</h2>
<p>本章處理事件與命令、查詢的訊息類型分界——三者都是離散訊息、差在責任結構。事件與 <a href="/blog/ddd/knowledge-cards/state-stream/" data-link-title="State Stream（狀態流）" data-link-desc="畫面刷新靠補償、或考慮拿既有事件當刷新訊號時使用。狀態流是持續發布「資料當前值」的觀測載體——新值蓋過舊值、錯過中間值無代價，回答「現在是什麼」。">狀態流</a>（連續觀測）的分界是另一個維度：那裡兩邊都不要求執行、差在「發生了什麼」與「現在是什麼」的時間語意，見 <a href="/blog/ddd/domain-event-vs-state-stream/" data-link-title="domain event 與狀態流" data-link-desc="為了讓某個畫面刷新而補發事件、或監聽端掛著全事件過濾器時使用。事件記錄離散事實、狀態流發布連續觀測——判準是消費者問「發生了什麼」還是「現在是什麼」；載體借用的代價是涵蓋面靠枚舉維持。">domain event 與狀態流</a>。事件跨服務攜帶全量狀態的 <a href="/blog/ddd/knowledge-cards/event-carried-state-transfer/" data-link-title="Event-Carried State Transfer" data-link-desc="跨服務的 domain event payload 開始塞進足量當前狀態、要判斷這是正當設計還是載體誤用時使用。event-carried state transfer 是刻意讓事件攜帶足量狀態、讓下游服務不必回頭查詢來源的設計。">event-carried state transfer</a> 是分散式情境的正當設計、其適用邊界也在該章。</p>
<h2 id="下一步">下一步</h2>
<p>事件的另一條分界（對狀態流）在 <a href="/blog/ddd/domain-event-vs-state-stream/" data-link-title="domain event 與狀態流" data-link-desc="為了讓某個畫面刷新而補發事件、或監聽端掛著全事件過濾器時使用。事件記錄離散事實、狀態流發布連續觀測——判準是消費者問「發生了什麼」還是「現在是什麼」；載體借用的代價是涵蓋面靠枚舉維持。">domain event 與狀態流</a>——兩章合起來把 domain event 對三個鄰居（命令、查詢、狀態流）的邊界畫完。兩個 case 的完整記錄：<a href="/blog/work-log/domain_event_naming_past_tense/" data-link-title="BookImported 不能寫成 ImportBook — 事件命名的過去式是語意類別、不是風格" data-link-desc="domain event 用過去式命名（BookImported）因為事件是已發生的事實；動詞開頭（ImportBook）是命令的形狀、訂閱者會誤讀成指令。字尾清單的自動檢查抓不住不規則動詞——偵測可機械化、判定要看語意。附一次版本終止：工作日誌宣稱的命名問題實際早已不存在、執行前驗前提省下整輪流程。">事件命名的過去式</a>、<a href="/blog/work-log/cross_domain_event_request_response_cost/" data-link-title="用事件做同步查詢、等於手工重建 RPC — 跨 domain 解耦的完整帳單" data-link-desc="domain 直接查另一個 domain 的 repository 違反依賴方向；改事件驅動的 request/response 解了耦、但帳單具體：correlation id 配對並發、timeout 機制、三條錯誤路徑——都是同步呼叫免費附贈的東西。判準是需要解耦「依賴方向」還是「時間與部署」：前者用消費端 Port 就夠、後者才值得付事件的價。">用事件做同步查詢等於手工重建 RPC</a>；port 解依賴的實戰在 <a href="/blog/work-log/flutter_port_interface_mock_hell_isp/" data-link-title="mock 要配置 55 個方法、實際只用 5 個 — 測試痛是介面設計痛的探針" data-link-desc="service 測試的 mock 負擔正比於它依賴的介面寬度：依賴四個大介面共 55 個方法、實際呼叫 5 個，91% 的 mock 配置是純浪費、還會炸 MissingStubError。修法是介面隔離——抽出只含實際使用方法的 Port，讓 mock 縮到跟真實依賴一樣窄；為未來預留的方法用 TODO 標記啟用時機。">mock 55 個方法只用 5 個</a>。</p>
]]></content:encoded></item><item><title>Domain Event</title><link>https://tarrragon.github.io/blog/ddd/knowledge-cards/domain-event/</link><pubDate>Thu, 16 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/knowledge-cards/domain-event/</guid><description>&lt;p>domain event 是一筆已發生的業務事實的記錄：過去式命名（&lt;code>BookAdded&lt;/code>、&lt;code>OrderPaid&lt;/code>）、發布後不可變、消費者關心「發生了什麼」。跟 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/snapshot/" data-link-title="Snapshot" data-link-desc="歷史記錄是否應該凍結當時狀態時使用。Snapshot 是某一時刻的狀態複本——歷史不隨現在的資料漂移。">snapshot&lt;/a> 的差別在時間語意：snapshot 記的是某時刻的狀態（現在式），event 記的是某時刻發生的事（過去式）。與狀態流的差別在錯過的代價：event 錯過代表事實遺失（審計斷檔、下游流程沒觸發），狀態流錯過無代價（下一次快照涵蓋一切）。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>domain event 是 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/aggregate-root/" data-link-title="Aggregate Root" data-link-desc="跨物件一致性的邊界設計時使用。聚合根是對外代表一組資料一致性的邊界物件——外部只跟它互動、它保證內部的不變式。">aggregate root&lt;/a> 執行業務操作後的副產品——aggregate 保證一致性、event 把「剛才發生的事」通知外界。event 的消費者是跨 domain 流程、審計、通知等「需要知道發生了什麼」的角色。它跟 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="查詢該由 repository 回 aggregate、還是該有自己的查詢側模型時使用。read model 是為讀需求的形狀而建的模型——回答「畫面需要什麼形狀」、與 aggregate 的形狀分離。">read model&lt;/a> 在 CQRS 第四階交會：讀模型由事件同步時，消費者問的確實是「發生了什麼」（用事實重建投影），這是 event 的合法消費。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>event 被借用成 UI 刷新訊號時會出現四個判讀訊號：為了讓某頁刷新而補發事件、監聽端掛全事件過濾器、移除事件前要查有沒有畫面靠它刷新、event payload 開始塞當前完整狀態。任一出現，回到載體判準重新選——消費者問「現在是什麼」時，正確載體是狀態流。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>domain event 記錄業務事實、服務審計與跨 domain 通訊。event 的發布時機由業務語意決定（「這件事值得記錄、有下游需要它」），涵蓋面是業務事實的集合——跟狀態流的涵蓋面（寫入操作的集合，結構性涵蓋）正交。載體選用判準、案例演進與判讀訊號展開在 &lt;a href="https://tarrragon.github.io/blog/ddd/domain-event-vs-state-stream/" data-link-title="domain event 與狀態流" data-link-desc="為了讓某個畫面刷新而補發事件、或監聽端掛著全事件過濾器時使用。事件記錄離散事實、狀態流發布連續觀測——判準是消費者問「發生了什麼」還是「現在是什麼」；載體借用的代價是涵蓋面靠枚舉維持。">domain event 與狀態流&lt;/a>；事件對命令與查詢的責任結構分界展開在 &lt;a href="https://tarrragon.github.io/blog/ddd/domain-event-vs-command-and-query/" data-link-title="domain event 與命令、查詢的分界" data-link-desc="事件類別以動詞開頭、或事件成對出現 Requested/Provided 帶 correlation id 時使用。事件只承載已發生的事實——命令有唯一處理者與成敗、查詢是一問一答的對話；把意圖或對話裝進事件通道、責任結構會跟著錯位。">domain event 與命令、查詢&lt;/a>。命名的過去式約定展開在 &lt;a href="https://tarrragon.github.io/blog/work-log/domain_event_naming_past_tense/" data-link-title="BookImported 不能寫成 ImportBook — 事件命名的過去式是語意類別、不是風格" data-link-desc="domain event 用過去式命名（BookImported）因為事件是已發生的事實；動詞開頭（ImportBook）是命令的形狀、訂閱者會誤讀成指令。字尾清單的自動檢查抓不住不規則動詞——偵測可機械化、判定要看語意。附一次版本終止：工作日誌宣稱的命名問題實際早已不存在、執行前驗前提省下整輪流程。">Domain Event 命名的過去式&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>domain event 是一筆已發生的業務事實的記錄：過去式命名（<code>BookAdded</code>、<code>OrderPaid</code>）、發布後不可變、消費者關心「發生了什麼」。跟 <a href="/blog/ddd/knowledge-cards/snapshot/" data-link-title="Snapshot" data-link-desc="歷史記錄是否應該凍結當時狀態時使用。Snapshot 是某一時刻的狀態複本——歷史不隨現在的資料漂移。">snapshot</a> 的差別在時間語意：snapshot 記的是某時刻的狀態（現在式），event 記的是某時刻發生的事（過去式）。與狀態流的差別在錯過的代價：event 錯過代表事實遺失（審計斷檔、下游流程沒觸發），狀態流錯過無代價（下一次快照涵蓋一切）。</p>
<h2 id="概念位置">概念位置</h2>
<p>domain event 是 <a href="/blog/ddd/knowledge-cards/aggregate-root/" data-link-title="Aggregate Root" data-link-desc="跨物件一致性的邊界設計時使用。聚合根是對外代表一組資料一致性的邊界物件——外部只跟它互動、它保證內部的不變式。">aggregate root</a> 執行業務操作後的副產品——aggregate 保證一致性、event 把「剛才發生的事」通知外界。event 的消費者是跨 domain 流程、審計、通知等「需要知道發生了什麼」的角色。它跟 <a href="/blog/ddd/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="查詢該由 repository 回 aggregate、還是該有自己的查詢側模型時使用。read model 是為讀需求的形狀而建的模型——回答「畫面需要什麼形狀」、與 aggregate 的形狀分離。">read model</a> 在 CQRS 第四階交會：讀模型由事件同步時，消費者問的確實是「發生了什麼」（用事實重建投影），這是 event 的合法消費。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>event 被借用成 UI 刷新訊號時會出現四個判讀訊號：為了讓某頁刷新而補發事件、監聽端掛全事件過濾器、移除事件前要查有沒有畫面靠它刷新、event payload 開始塞當前完整狀態。任一出現，回到載體判準重新選——消費者問「現在是什麼」時，正確載體是狀態流。</p>
<h2 id="設計責任">設計責任</h2>
<p>domain event 記錄業務事實、服務審計與跨 domain 通訊。event 的發布時機由業務語意決定（「這件事值得記錄、有下游需要它」），涵蓋面是業務事實的集合——跟狀態流的涵蓋面（寫入操作的集合，結構性涵蓋）正交。載體選用判準、案例演進與判讀訊號展開在 <a href="/blog/ddd/domain-event-vs-state-stream/" data-link-title="domain event 與狀態流" data-link-desc="為了讓某個畫面刷新而補發事件、或監聽端掛著全事件過濾器時使用。事件記錄離散事實、狀態流發布連續觀測——判準是消費者問「發生了什麼」還是「現在是什麼」；載體借用的代價是涵蓋面靠枚舉維持。">domain event 與狀態流</a>；事件對命令與查詢的責任結構分界展開在 <a href="/blog/ddd/domain-event-vs-command-and-query/" data-link-title="domain event 與命令、查詢的分界" data-link-desc="事件類別以動詞開頭、或事件成對出現 Requested/Provided 帶 correlation id 時使用。事件只承載已發生的事實——命令有唯一處理者與成敗、查詢是一問一答的對話；把意圖或對話裝進事件通道、責任結構會跟著錯位。">domain event 與命令、查詢</a>。命名的過去式約定展開在 <a href="/blog/work-log/domain_event_naming_past_tense/" data-link-title="BookImported 不能寫成 ImportBook — 事件命名的過去式是語意類別、不是風格" data-link-desc="domain event 用過去式命名（BookImported）因為事件是已發生的事實；動詞開頭（ImportBook）是命令的形狀、訂閱者會誤讀成指令。字尾清單的自動檢查抓不住不規則動詞——偵測可機械化、判定要看語意。附一次版本終止：工作日誌宣稱的命名問題實際早已不存在、執行前驗前提省下整輪流程。">Domain Event 命名的過去式</a>。</p>
]]></content:encoded></item><item><title>Bounded Context</title><link>https://tarrragon.github.io/blog/ddd/knowledge-cards/bounded-context/</link><pubDate>Mon, 20 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/knowledge-cards/bounded-context/</guid><description>&lt;p>bounded context 是一個模型與詞彙維持一致的邊界：邊界內，同一個詞（例如「訂單」）只有一種意義、同一套業務規則統一適用；跨過邊界，同一個詞可能換了意義、規則跟著換。&lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/aggregate-root/" data-link-title="Aggregate Root" data-link-desc="跨物件一致性的邊界設計時使用。聚合根是對外代表一組資料一致性的邊界物件——外部只跟它互動、它保證內部的不變式。">aggregate root&lt;/a> 守的一致性邊界在單一 aggregate 內；bounded context 守的是更大一層——一整套模型、詞彙與規則在哪裡開始、哪裡結束。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>單一 bounded context 內的架構判準，跨過邊界不必然照搬。讀模型升級的四階梯（消費端投影 → 抽讀 port → 專用投影 → 事件同步的獨立儲存）假設操作發生在單一 bounded context 內；報表服務訂閱多個 domain 的事件建置共享視圖，是跨 bounded context 的讀模型，這時引入的關注點——跨服務事件契約穩定性、schema 演進、跨信任邊界的最終一致性——不是「多買一種能力」能概括，完整推導見 &lt;a href="https://tarrragon.github.io/blog/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準&lt;/a>。這類跨邊界溝通的常見載體是 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/domain-event/" data-link-title="Domain Event" data-link-desc="系統裡出現「為了讓某頁刷新而補發事件」或「監聽端掛全事件過濾器」時使用。domain event 是已發生的業務事實——過去式命名、發布後不可變、錯過代表事實遺失。">domain event&lt;/a>，其中攜帶足量狀態給下游的作法見 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/event-carried-state-transfer/" data-link-title="Event-Carried State Transfer" data-link-desc="跨服務的 domain event payload 開始塞進足量當前狀態、要判斷這是正當設計還是載體誤用時使用。event-carried state transfer 是刻意讓事件攜帶足量狀態、讓下游服務不必回頭查詢來源的設計。">event-carried state transfer&lt;/a>。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>一個判準句在單一服務內成立、換到跨服務場景就開始要補額外的機制（契約版本、schema 遷移、補償流程），是踩到 bounded context 邊界的訊號——原本的判準沒有錯，是它的適用範圍被劃在邊界內，邊界外要重新論證。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>bounded context 的設計責任是替模型畫出「在哪裡可以直接假設一致」的邊界，邊界外的互動改走顯式的契約與轉換，不能沿用邊界內的內部語意。這條邊界也是判斷同一套架構結論能不能套用的分界——邊界內成立的推導，邊界外要重新論證，不是預設繼續有效。&lt;/p></description><content:encoded><![CDATA[<p>bounded context 是一個模型與詞彙維持一致的邊界：邊界內，同一個詞（例如「訂單」）只有一種意義、同一套業務規則統一適用；跨過邊界，同一個詞可能換了意義、規則跟著換。<a href="/blog/ddd/knowledge-cards/aggregate-root/" data-link-title="Aggregate Root" data-link-desc="跨物件一致性的邊界設計時使用。聚合根是對外代表一組資料一致性的邊界物件——外部只跟它互動、它保證內部的不變式。">aggregate root</a> 守的一致性邊界在單一 aggregate 內；bounded context 守的是更大一層——一整套模型、詞彙與規則在哪裡開始、哪裡結束。</p>
<h2 id="概念位置">概念位置</h2>
<p>單一 bounded context 內的架構判準，跨過邊界不必然照搬。讀模型升級的四階梯（消費端投影 → 抽讀 port → 專用投影 → 事件同步的獨立儲存）假設操作發生在單一 bounded context 內；報表服務訂閱多個 domain 的事件建置共享視圖，是跨 bounded context 的讀模型，這時引入的關注點——跨服務事件契約穩定性、schema 演進、跨信任邊界的最終一致性——不是「多買一種能力」能概括，完整推導見 <a href="/blog/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準</a>。這類跨邊界溝通的常見載體是 <a href="/blog/ddd/knowledge-cards/domain-event/" data-link-title="Domain Event" data-link-desc="系統裡出現「為了讓某頁刷新而補發事件」或「監聽端掛全事件過濾器」時使用。domain event 是已發生的業務事實——過去式命名、發布後不可變、錯過代表事實遺失。">domain event</a>，其中攜帶足量狀態給下游的作法見 <a href="/blog/ddd/knowledge-cards/event-carried-state-transfer/" data-link-title="Event-Carried State Transfer" data-link-desc="跨服務的 domain event payload 開始塞進足量當前狀態、要判斷這是正當設計還是載體誤用時使用。event-carried state transfer 是刻意讓事件攜帶足量狀態、讓下游服務不必回頭查詢來源的設計。">event-carried state transfer</a>。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>一個判準句在單一服務內成立、換到跨服務場景就開始要補額外的機制（契約版本、schema 遷移、補償流程），是踩到 bounded context 邊界的訊號——原本的判準沒有錯，是它的適用範圍被劃在邊界內，邊界外要重新論證。</p>
<h2 id="設計責任">設計責任</h2>
<p>bounded context 的設計責任是替模型畫出「在哪裡可以直接假設一致」的邊界，邊界外的互動改走顯式的契約與轉換，不能沿用邊界內的內部語意。這條邊界也是判斷同一套架構結論能不能套用的分界——邊界內成立的推導，邊界外要重新論證，不是預設繼續有效。</p>
]]></content:encoded></item><item><title>EventBus</title><link>https://tarrragon.github.io/blog/ddd/knowledge-cards/event-bus/</link><pubDate>Mon, 20 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/knowledge-cards/event-bus/</guid><description>&lt;p>需要在同一個行程內把「發生了一件事」廣播給多個訂閱者時，用的是 EventBus——行程內的發布／訂閱事件匯流排。發布端呼叫 publish、不需要知道誰在聽；訂閱端註冊監聽器、不需要知道是誰發的。這個解耦讓 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/domain-event/" data-link-title="Domain Event" data-link-desc="系統裡出現「為了讓某頁刷新而補發事件」或「監聽端掛全事件過濾器」時使用。domain event 是已發生的業務事實——過去式命名、發布後不可變、錯過代表事實遺失。">domain event&lt;/a> 的發布點跟消費點可以獨立增減，不必逐一牽線。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>EventBus 是 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/domain-event/" data-link-title="Domain Event" data-link-desc="系統裡出現「為了讓某頁刷新而補發事件」或「監聽端掛全事件過濾器」時使用。domain event 是已發生的業務事實——過去式命名、發布後不可變、錯過代表事實遺失。">domain event&lt;/a> 的傳輸機制、不是事件本身：event 是「發生了什麼」的事實記錄，EventBus 是讓這個事實從發布端跑到訂閱端的管道。EventBus 只解決「怎麼送達」，送達之後訂閱端該把事件當事實通知還是當變更信號來用，是另一個判準，見 &lt;a href="https://tarrragon.github.io/blog/ddd/domain-event-vs-state-stream/" data-link-title="domain event 與狀態流" data-link-desc="為了讓某個畫面刷新而補發事件、或監聽端掛著全事件過濾器時使用。事件記錄離散事實、狀態流發布連續觀測——判準是消費者問「發生了什麼」還是「現在是什麼」；載體借用的代價是涵蓋面靠枚舉維持。">domain event 與狀態流&lt;/a>。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>訂閱端掛上「監聽全部事件、任何事件進來就重新查詢」這種全事件過濾器，是把 EventBus 兼職成變更通知管道的訊號——這條路徑上線初期有效，但涵蓋面等於「有沒有人記得發事件」，跟寫入操作的集合不天然相等，多個視圖各自解「怎麼知道資料變了」時容易補出交叉且不完整的補償策略。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>EventBus 只承擔「發布／訂閱」這一層機制、不承擔涵蓋保證——涵蓋是靠每個寫入路徑記得呼叫 publish 換來的，EventBus 本身不驗證有沒有漏發。需要涵蓋面天然等於寫入操作集合的場景，正確載體是 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/observation-outlet/" data-link-title="Observation Outlet（觀測出口）" data-link-desc="repository 只有 pull 介面、衍生視圖靠補償刷新，考慮補「資料變了」的推送能力時使用。觀測出口是 pull 介面的 push 對應——能力橫跨三層、歸屬由各層的表達語言決定。">observation outlet&lt;/a> 的狀態流、不是把 EventBus 的監聽範圍擴大，完整案例見 &lt;a href="https://tarrragon.github.io/blog/ddd/observation-outlet-responsibility-split/" data-link-title="觀測出口的職責三分" data-link-desc="repository 要補「資料變了」的推送能力、卻不確定 Stream 介面放 domain 算不算洩漏時使用。歸屬判準是介面用什麼語言表達、不是需求來自誰：契約歸 domain、變更偵測歸 infrastructure、框架訂閱歸組裝層。">觀測出口的職責三分&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>需要在同一個行程內把「發生了一件事」廣播給多個訂閱者時，用的是 EventBus——行程內的發布／訂閱事件匯流排。發布端呼叫 publish、不需要知道誰在聽；訂閱端註冊監聽器、不需要知道是誰發的。這個解耦讓 <a href="/blog/ddd/knowledge-cards/domain-event/" data-link-title="Domain Event" data-link-desc="系統裡出現「為了讓某頁刷新而補發事件」或「監聽端掛全事件過濾器」時使用。domain event 是已發生的業務事實——過去式命名、發布後不可變、錯過代表事實遺失。">domain event</a> 的發布點跟消費點可以獨立增減，不必逐一牽線。</p>
<h2 id="概念位置">概念位置</h2>
<p>EventBus 是 <a href="/blog/ddd/knowledge-cards/domain-event/" data-link-title="Domain Event" data-link-desc="系統裡出現「為了讓某頁刷新而補發事件」或「監聽端掛全事件過濾器」時使用。domain event 是已發生的業務事實——過去式命名、發布後不可變、錯過代表事實遺失。">domain event</a> 的傳輸機制、不是事件本身：event 是「發生了什麼」的事實記錄，EventBus 是讓這個事實從發布端跑到訂閱端的管道。EventBus 只解決「怎麼送達」，送達之後訂閱端該把事件當事實通知還是當變更信號來用，是另一個判準，見 <a href="/blog/ddd/domain-event-vs-state-stream/" data-link-title="domain event 與狀態流" data-link-desc="為了讓某個畫面刷新而補發事件、或監聽端掛著全事件過濾器時使用。事件記錄離散事實、狀態流發布連續觀測——判準是消費者問「發生了什麼」還是「現在是什麼」；載體借用的代價是涵蓋面靠枚舉維持。">domain event 與狀態流</a>。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>訂閱端掛上「監聽全部事件、任何事件進來就重新查詢」這種全事件過濾器，是把 EventBus 兼職成變更通知管道的訊號——這條路徑上線初期有效，但涵蓋面等於「有沒有人記得發事件」，跟寫入操作的集合不天然相等，多個視圖各自解「怎麼知道資料變了」時容易補出交叉且不完整的補償策略。</p>
<h2 id="設計責任">設計責任</h2>
<p>EventBus 只承擔「發布／訂閱」這一層機制、不承擔涵蓋保證——涵蓋是靠每個寫入路徑記得呼叫 publish 換來的，EventBus 本身不驗證有沒有漏發。需要涵蓋面天然等於寫入操作集合的場景，正確載體是 <a href="/blog/ddd/knowledge-cards/observation-outlet/" data-link-title="Observation Outlet（觀測出口）" data-link-desc="repository 只有 pull 介面、衍生視圖靠補償刷新，考慮補「資料變了」的推送能力時使用。觀測出口是 pull 介面的 push 對應——能力橫跨三層、歸屬由各層的表達語言決定。">observation outlet</a> 的狀態流、不是把 EventBus 的監聽範圍擴大，完整案例見 <a href="/blog/ddd/observation-outlet-responsibility-split/" data-link-title="觀測出口的職責三分" data-link-desc="repository 要補「資料變了」的推送能力、卻不確定 Stream 介面放 domain 算不算洩漏時使用。歸屬判準是介面用什麼語言表達、不是需求來自誰：契約歸 domain、變更偵測歸 infrastructure、框架訂閱歸組裝層。">觀測出口的職責三分</a>。</p>
]]></content:encoded></item><item><title>Event-Carried State Transfer</title><link>https://tarrragon.github.io/blog/ddd/knowledge-cards/event-carried-state-transfer/</link><pubDate>Mon, 20 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/knowledge-cards/event-carried-state-transfer/</guid><description>&lt;p>event-carried state transfer 是刻意讓 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/domain-event/" data-link-title="Domain Event" data-link-desc="系統裡出現「為了讓某頁刷新而補發事件」或「監聽端掛全事件過濾器」時使用。domain event 是已發生的業務事實——過去式命名、發布後不可變、錯過代表事實遺失。">domain event&lt;/a> 的 payload 攜帶足量的當前狀態、不只是「發生了什麼」的事實本身。目的是讓下游服務收到事件後就能拿到完整資訊、不必回頭同步查詢來源系統——payload 帶全量狀態在這裡是設計選擇，不是把事件當狀態通知的誤用。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>這個設計只在跨服務、跨信任邊界的情境下成立。同進程情境下，事件開始攜帶全量狀態通常是載體錯位的訊號——消費者真正要問的是「現在是什麼」，正確載體是 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/state-stream/" data-link-title="State Stream（狀態流）" data-link-desc="畫面刷新靠補償、或考慮拿既有事件當刷新訊號時使用。狀態流是持續發布「資料當前值」的觀測載體——新值蓋過舊值、錯過中間值無代價，回答「現在是什麼」。">state stream&lt;/a> 而非塞胖的事件；跨服務時「回頭查詢來源」本身有同步耦合的成本，event-carried state transfer 用胖 payload 換掉這個成本，是同一個訊號在不同信任邊界下的相反判讀，完整推導見 &lt;a href="https://tarrragon.github.io/blog/ddd/domain-event-vs-state-stream/" data-link-title="domain event 與狀態流" data-link-desc="為了讓某個畫面刷新而補發事件、或監聽端掛著全事件過濾器時使用。事件記錄離散事實、狀態流發布連續觀測——判準是消費者問「發生了什麼」還是「現在是什麼」；載體借用的代價是涵蓋面靠枚舉維持。">domain event 與狀態流&lt;/a>。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>事件的 payload 從「哪本書、什麼時間」這類事實欄位，長出「現在的全貌」這類當前狀態欄位，是判讀的觸發點；判讀不看 payload 胖不胖，看消費者是不是跟事件來源在同一個信任邊界——邊界內是錯位、邊界外是正當設計。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>event-carried state transfer 換來的是下游服務不必為了拿到完整資訊而同步呼叫來源，代價是事件 payload 的 schema 跟著狀態演進、下游要一起處理相容性。這個代價只有在真的省下跨服務同步查詢時才划算，同進程情境沒有這筆成本可省，應優先考慮 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/state-stream/" data-link-title="State Stream（狀態流）" data-link-desc="畫面刷新靠補償、或考慮拿既有事件當刷新訊號時使用。狀態流是持續發布「資料當前值」的觀測載體——新值蓋過舊值、錯過中間值無代價，回答「現在是什麼」。">state stream&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>event-carried state transfer 是刻意讓 <a href="/blog/ddd/knowledge-cards/domain-event/" data-link-title="Domain Event" data-link-desc="系統裡出現「為了讓某頁刷新而補發事件」或「監聽端掛全事件過濾器」時使用。domain event 是已發生的業務事實——過去式命名、發布後不可變、錯過代表事實遺失。">domain event</a> 的 payload 攜帶足量的當前狀態、不只是「發生了什麼」的事實本身。目的是讓下游服務收到事件後就能拿到完整資訊、不必回頭同步查詢來源系統——payload 帶全量狀態在這裡是設計選擇，不是把事件當狀態通知的誤用。</p>
<h2 id="概念位置">概念位置</h2>
<p>這個設計只在跨服務、跨信任邊界的情境下成立。同進程情境下，事件開始攜帶全量狀態通常是載體錯位的訊號——消費者真正要問的是「現在是什麼」，正確載體是 <a href="/blog/ddd/knowledge-cards/state-stream/" data-link-title="State Stream（狀態流）" data-link-desc="畫面刷新靠補償、或考慮拿既有事件當刷新訊號時使用。狀態流是持續發布「資料當前值」的觀測載體——新值蓋過舊值、錯過中間值無代價，回答「現在是什麼」。">state stream</a> 而非塞胖的事件；跨服務時「回頭查詢來源」本身有同步耦合的成本，event-carried state transfer 用胖 payload 換掉這個成本，是同一個訊號在不同信任邊界下的相反判讀，完整推導見 <a href="/blog/ddd/domain-event-vs-state-stream/" data-link-title="domain event 與狀態流" data-link-desc="為了讓某個畫面刷新而補發事件、或監聽端掛著全事件過濾器時使用。事件記錄離散事實、狀態流發布連續觀測——判準是消費者問「發生了什麼」還是「現在是什麼」；載體借用的代價是涵蓋面靠枚舉維持。">domain event 與狀態流</a>。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>事件的 payload 從「哪本書、什麼時間」這類事實欄位，長出「現在的全貌」這類當前狀態欄位，是判讀的觸發點；判讀不看 payload 胖不胖，看消費者是不是跟事件來源在同一個信任邊界——邊界內是錯位、邊界外是正當設計。</p>
<h2 id="設計責任">設計責任</h2>
<p>event-carried state transfer 換來的是下游服務不必為了拿到完整資訊而同步呼叫來源，代價是事件 payload 的 schema 跟著狀態演進、下游要一起處理相容性。這個代價只有在真的省下跨服務同步查詢時才划算，同進程情境沒有這筆成本可省，應優先考慮 <a href="/blog/ddd/knowledge-cards/state-stream/" data-link-title="State Stream（狀態流）" data-link-desc="畫面刷新靠補償、或考慮拿既有事件當刷新訊號時使用。狀態流是持續發布「資料當前值」的觀測載體——新值蓋過舊值、錯過中間值無代價，回答「現在是什麼」。">state stream</a>。</p>
]]></content:encoded></item><item><title>BookImported 不能寫成 ImportBook — 事件命名的過去式是語意類別、不是風格</title><link>https://tarrragon.github.io/blog/work-log/domain_event_naming_past_tense/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/domain_event_naming_past_tense/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter 書籍管理 App 的事件驅動架構修正系列排了一張票：統一 use case 流程圖裡的事件命名——記錄顯示 UC-02 用了 &lt;code>book_imported&lt;/code>、UC-04 用了 &lt;code>data_exported&lt;/code>，違反 PascalCase + 過去式規範
&lt;strong>疑問來源&lt;/strong>：事件命名規範裡「過去式」這條，是風格偏好還是有語意依據？以及這張票最後的結局為什麼是「終止」？
&lt;strong>整理目的&lt;/strong>：記下事件 vs 命令的命名分界、過去式自動檢查的脆弱性、以及「執行前驗前提」的止損實錄
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.12-C.4 的設計與終止記錄；這張票停在設計階段、命名規範本身是它留下的產物&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="過去式不是風格它標記這是事實不是指令">過去式不是風格：它標記「這是事實、不是指令」&lt;/h2>
&lt;p>規範的反例清單值得逐個讀，因為四個反例的病因不同：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">class&lt;/span> &lt;span class="nc">BookImported&lt;/span> &lt;span class="kd">extends&lt;/span> &lt;span class="n">DomainEvent&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="c1">// 正確：已發生的事實
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">class&lt;/span> &lt;span class="nc">book_imported&lt;/span> &lt;span class="kd">extends&lt;/span> &lt;span class="n">DomainEvent&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="c1">// 錯：風格（snake_case）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">class&lt;/span> &lt;span class="nc">BookImport&lt;/span> &lt;span class="kd">extends&lt;/span> &lt;span class="n">DomainEvent&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="c1">// 錯：現在式、看不出已發生
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">class&lt;/span> &lt;span class="nc">ImportBook&lt;/span> &lt;span class="kd">extends&lt;/span> &lt;span class="n">DomainEvent&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="c1">// 錯：動詞開頭——這是命令的形狀
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">class&lt;/span> &lt;span class="nc">book&lt;/span> &lt;span class="kd">extends&lt;/span> &lt;span class="n">DomainEvent&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="o">//&lt;/span> &lt;span class="err">錯：沒有業務語意&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>第一個反例是純風格問題、lint 就能管。真正的語意分界在第三個：&lt;strong>&lt;code>ImportBook&lt;/code> 是命令（command）的命名形狀&lt;/strong>——「去匯入這本書」，接收者要對它做事、可以拒絕、可以失敗；&lt;code>BookImported&lt;/code> 是事件（event）——「這本書已經被匯入了」，它是不可否認的歷史事實，訂閱者只能對事實做反應、沒有拒絕的位置。&lt;/p>
&lt;p>兩種訊息的責任結構完全不同（命令有唯一的處理者與成敗、事件有任意多的訂閱者且無所謂失敗），而讀者第一眼接觸的就是名字。動詞開頭的「事件」會讓訂閱者用命令的心智模型寫處理邏輯——以為自己能影響流程、以為失敗會被重試。過去式把類別烙在名字上：&lt;strong>看到 &lt;code>Imported&lt;/code> 就知道木已成舟&lt;/strong>。&lt;/p>
&lt;h2 id="字尾清單抓不住語意偵測與判定的老分界">字尾清單抓不住語意：偵測與判定的老分界&lt;/h2>
&lt;p>規劃中的自動檢查用字尾清單判定過去式：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">final&lt;/span> &lt;span class="n">pastTenseEndings&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;ed&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;ted&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;ned&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;ched&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;ded&amp;#39;&lt;/span>&lt;span class="p">];&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個啟發式的兩面漏洞都很典型：不規則動詞全部漏接（&lt;code>Sent&lt;/code>、&lt;code>Built&lt;/code>、&lt;code>Run&lt;/code> 的過去式形態沒有 ed 字尾）、而碰巧以 ed 結尾的非過去式會被誤放。它能當&lt;strong>候選過濾器&lt;/strong>（抓出 &lt;code>BookImport&lt;/code> 這類明顯缺字尾的）、當不了&lt;strong>判決&lt;/strong>——「這個名字是不是過去式的事實陳述」終究是語意判斷。這跟寫作 lint 的 &lt;a href="https://tarrragon.github.io/blog/report/keyword-bank-hit-is-candidate-not-verdict/" data-link-title="字句層 review：keyword bank 命中是候選、不是判決" data-link-desc="跑了字句層 grep keyword bank、命中『不是 A 而是 B』、卻把它判成『可接受的反例對照』放行；違規由讀者 catch。揭露 keyword bank 的第二層盲點：偵測（grep 命中）跟判定（這個命中是不是違規）是兩個認知步驟、reviewer 容易把命中合理化成合規而放行。判定準則：否定句構若在『建立核心概念』就改正向、只有在『明示反例段落』才保留。另有一類贅語（訴諸群體『很多人卡在』）連關鍵詞都沒有、keyword bank 結構上抓不到、要靠 reader-simulation 語意 pass。">keyword bank 命中是候選不是判決&lt;/a>、跟&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_function_decomposition_split_vs_keep/" data-link-title="88 行拆成 13 個函式、90 行決定不拆 — 函式長度是症狀、職責才是診斷" data-link-desc="同一個團隊、同一條 5-10 行規範、兩個 90 行上下的函式，一個拆一個保留、兩個決定都對。判準在行數之外：職責混雜（初始化/迴圈/儲存/統計擠一起、巢狀五層）拆之，完整業務流程（步驟多但答案只有一個）留之。含 _ImportProgress 收斂參數列與測試耦合行為的守護。">函式長度規則是觸發器&lt;/a>是同一條分界的第三個現場：&lt;strong>可機械化的是偵測、不可機械化的是判定&lt;/strong>，把前者當後者用就會又漏又誤。&lt;/p>
&lt;h2 id="版本的結局前提不存在03-小時止損">版本的結局：前提不存在、0.3 小時止損&lt;/h2>
&lt;p>這張票最後沒有修任何檔案——Phase 1 的設計審查去核對「待修正」的流程圖時發現：UC-02 的流程圖實際寫的是 &lt;code>BookImportedEvent&lt;/code>、UC-04 是 &lt;code>ExportCompletedEvent&lt;/code>，&lt;strong>早就符合規範&lt;/strong>。票面宣稱的 snake_case 問題來自過時的工作日誌記錄，實際問題不存在。決策是終止版本、總耗時 0.3 小時。&lt;/p>
&lt;p>這是「執行前驗前提」最便宜的一次勝利：如果跳過核對直接進 TDD 四階段，會為一個不存在的問題走完測試設計與實作流程。它跟 &lt;a href="https://tarrragon.github.io/blog/work-log/flutter_renderflex_overflow_prevention_spec/" data-link-title="溢出 714px、22 個測試同時紅 — 單點修復與規範化的分界" data-link-desc="Row 裡未受 Flexible 包裹的固定尺寸元件在窄約束下溢出；同型失敗一次爆 22 個時，正確的產出不是 22 個修復、是一份 overflow 預防規範（反模式清單 &amp;#43; 決策樹 &amp;#43; 測試檢查項）。含測試環境小尺寸是 feature、以及 stale ticket 先考古再執行的教訓。">stale ticket 考古&lt;/a>（58 天票、路徑與數量全漂移）是同一條紀律的兩次實證——&lt;strong>工作項的內文是建立當下的快照、執行前重驗每一個事實聲明&lt;/strong>。而終止決策記錄本身也做對了一件事：把「為什麼不做」跟兩個選項的否決理由寫下來（改成 Event 後綴標準化的提案被否決：後綴是風格、不符架構修正系列的定位），下一個看到這張票的人不會重開一輪。&lt;/p>
&lt;p>附帶的懸念也誠實地留著：實際文件用了 &lt;code>Event&lt;/code> 後綴（&lt;code>BookImportedEvent&lt;/code>）、規範範例沒有（&lt;code>BookImported&lt;/code>）——這個不一致被看見、被評估為風格層、被明確地不處理。跟靜默的不一致相比，「已知、已評估、不處理」是完全不同的狀態。&lt;/p>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;ul>
&lt;li>事件類別的名字以動詞開頭——它長成了命令，訂閱者會用錯誤的心智模型消費它&lt;/li>
&lt;li>事件與命令在同一個 bus / 同一個目錄裡混居且命名無法區分——先立命名分界、再談架構&lt;/li>
&lt;li>命名檢查用字尾 / 前綴清單且被當成判決——降級為候選過濾器、命中後人工判定&lt;/li>
&lt;li>修正類工作項的「問題描述」超過幾週沒被重驗——執行前先花十分鐘核對問題還在不在&lt;/li>
&lt;/ul>
&lt;h2 id="相關閱讀">相關閱讀&lt;/h2>
&lt;ul>
&lt;li>同紀律的另一現場：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_renderflex_overflow_prevention_spec/" data-link-title="溢出 714px、22 個測試同時紅 — 單點修復與規範化的分界" data-link-desc="Row 裡未受 Flexible 包裹的固定尺寸元件在窄約束下溢出；同型失敗一次爆 22 個時，正確的產出不是 22 個修復、是一份 overflow 預防規範（反模式清單 &amp;#43; 決策樹 &amp;#43; 測試檢查項）。含測試環境小尺寸是 feature、以及 stale ticket 先考古再執行的教訓。">溢出 714px 的 stale ticket 考古&lt;/a>——票面快照 vs 現實的漂移&lt;/li>
&lt;li>偵測 / 判定分界的原則層：&lt;a href="https://tarrragon.github.io/blog/report/keyword-bank-hit-is-candidate-not-verdict/" data-link-title="字句層 review：keyword bank 命中是候選、不是判決" data-link-desc="跑了字句層 grep keyword bank、命中『不是 A 而是 B』、卻把它判成『可接受的反例對照』放行；違規由讀者 catch。揭露 keyword bank 的第二層盲點：偵測（grep 命中）跟判定（這個命中是不是違規）是兩個認知步驟、reviewer 容易把命中合理化成合規而放行。判定準則：否定句構若在『建立核心概念』就改正向、只有在『明示反例段落』才保留。另有一類贅語（訴諸群體『很多人卡在』）連關鍵詞都沒有、keyword bank 結構上抓不到、要靠 reader-simulation 語意 pass。">#149 keyword bank 命中是候選、不是判決&lt;/a>&lt;/li>
&lt;li>概念地基：&lt;a href="https://tarrragon.github.io/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南&lt;/a>——事件建模章節；事件與命令的責任結構差異是「從操作推導領域」的一部分&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter 書籍管理 App 的事件驅動架構修正系列排了一張票：統一 use case 流程圖裡的事件命名——記錄顯示 UC-02 用了 <code>book_imported</code>、UC-04 用了 <code>data_exported</code>，違反 PascalCase + 過去式規範
<strong>疑問來源</strong>：事件命名規範裡「過去式」這條，是風格偏好還是有語意依據？以及這張票最後的結局為什麼是「終止」？
<strong>整理目的</strong>：記下事件 vs 命令的命名分界、過去式自動檢查的脆弱性、以及「執行前驗前提」的止損實錄
<strong>本文邊界</strong>：素材是該專案 v0.12-C.4 的設計與終止記錄；這張票停在設計階段、命名規範本身是它留下的產物</p></blockquote>
<hr>
<h2 id="過去式不是風格它標記這是事實不是指令">過去式不是風格：它標記「這是事實、不是指令」</h2>
<p>規範的反例清單值得逐個讀，因為四個反例的病因不同：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">class</span> <span class="nc">BookImported</span> <span class="kd">extends</span> <span class="n">DomainEvent</span> <span class="p">{</span> <span class="p">}</span>   <span class="c1">// 正確：已發生的事實
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kd">class</span> <span class="nc">book_imported</span> <span class="kd">extends</span> <span class="n">DomainEvent</span> <span class="p">{</span> <span class="p">}</span>  <span class="c1">// 錯：風格（snake_case）
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="kd">class</span> <span class="nc">BookImport</span> <span class="kd">extends</span> <span class="n">DomainEvent</span> <span class="p">{</span> <span class="p">}</span>     <span class="c1">// 錯：現在式、看不出已發生
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="kd">class</span> <span class="nc">ImportBook</span> <span class="kd">extends</span> <span class="n">DomainEvent</span> <span class="p">{</span> <span class="p">}</span>     <span class="c1">// 錯：動詞開頭——這是命令的形狀
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="kd">class</span> <span class="nc">book</span> <span class="kd">extends</span> <span class="n">DomainEvent</span> <span class="p">{</span> <span class="p">}</span>           <span class="o">//</span> <span class="err">錯：沒有業務語意</span></span></span></code></pre></div><p>第一個反例是純風格問題、lint 就能管。真正的語意分界在第三個：<strong><code>ImportBook</code> 是命令（command）的命名形狀</strong>——「去匯入這本書」，接收者要對它做事、可以拒絕、可以失敗；<code>BookImported</code> 是事件（event）——「這本書已經被匯入了」，它是不可否認的歷史事實，訂閱者只能對事實做反應、沒有拒絕的位置。</p>
<p>兩種訊息的責任結構完全不同（命令有唯一的處理者與成敗、事件有任意多的訂閱者且無所謂失敗），而讀者第一眼接觸的就是名字。動詞開頭的「事件」會讓訂閱者用命令的心智模型寫處理邏輯——以為自己能影響流程、以為失敗會被重試。過去式把類別烙在名字上：<strong>看到 <code>Imported</code> 就知道木已成舟</strong>。</p>
<h2 id="字尾清單抓不住語意偵測與判定的老分界">字尾清單抓不住語意：偵測與判定的老分界</h2>
<p>規劃中的自動檢查用字尾清單判定過去式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">final</span> <span class="n">pastTenseEndings</span> <span class="o">=</span> <span class="p">[</span><span class="s1">&#39;ed&#39;</span><span class="p">,</span> <span class="s1">&#39;ted&#39;</span><span class="p">,</span> <span class="s1">&#39;ned&#39;</span><span class="p">,</span> <span class="s1">&#39;ched&#39;</span><span class="p">,</span> <span class="s1">&#39;ded&#39;</span><span class="p">];</span></span></span></code></pre></div><p>這個啟發式的兩面漏洞都很典型：不規則動詞全部漏接（<code>Sent</code>、<code>Built</code>、<code>Run</code> 的過去式形態沒有 ed 字尾）、而碰巧以 ed 結尾的非過去式會被誤放。它能當<strong>候選過濾器</strong>（抓出 <code>BookImport</code> 這類明顯缺字尾的）、當不了<strong>判決</strong>——「這個名字是不是過去式的事實陳述」終究是語意判斷。這跟寫作 lint 的 <a href="/blog/report/keyword-bank-hit-is-candidate-not-verdict/" data-link-title="字句層 review：keyword bank 命中是候選、不是判決" data-link-desc="跑了字句層 grep keyword bank、命中『不是 A 而是 B』、卻把它判成『可接受的反例對照』放行；違規由讀者 catch。揭露 keyword bank 的第二層盲點：偵測（grep 命中）跟判定（這個命中是不是違規）是兩個認知步驟、reviewer 容易把命中合理化成合規而放行。判定準則：否定句構若在『建立核心概念』就改正向、只有在『明示反例段落』才保留。另有一類贅語（訴諸群體『很多人卡在』）連關鍵詞都沒有、keyword bank 結構上抓不到、要靠 reader-simulation 語意 pass。">keyword bank 命中是候選不是判決</a>、跟<a href="/blog/work-log/flutter_function_decomposition_split_vs_keep/" data-link-title="88 行拆成 13 個函式、90 行決定不拆 — 函式長度是症狀、職責才是診斷" data-link-desc="同一個團隊、同一條 5-10 行規範、兩個 90 行上下的函式，一個拆一個保留、兩個決定都對。判準在行數之外：職責混雜（初始化/迴圈/儲存/統計擠一起、巢狀五層）拆之，完整業務流程（步驟多但答案只有一個）留之。含 _ImportProgress 收斂參數列與測試耦合行為的守護。">函式長度規則是觸發器</a>是同一條分界的第三個現場：<strong>可機械化的是偵測、不可機械化的是判定</strong>，把前者當後者用就會又漏又誤。</p>
<h2 id="版本的結局前提不存在03-小時止損">版本的結局：前提不存在、0.3 小時止損</h2>
<p>這張票最後沒有修任何檔案——Phase 1 的設計審查去核對「待修正」的流程圖時發現：UC-02 的流程圖實際寫的是 <code>BookImportedEvent</code>、UC-04 是 <code>ExportCompletedEvent</code>，<strong>早就符合規範</strong>。票面宣稱的 snake_case 問題來自過時的工作日誌記錄，實際問題不存在。決策是終止版本、總耗時 0.3 小時。</p>
<p>這是「執行前驗前提」最便宜的一次勝利：如果跳過核對直接進 TDD 四階段，會為一個不存在的問題走完測試設計與實作流程。它跟 <a href="/blog/work-log/flutter_renderflex_overflow_prevention_spec/" data-link-title="溢出 714px、22 個測試同時紅 — 單點修復與規範化的分界" data-link-desc="Row 裡未受 Flexible 包裹的固定尺寸元件在窄約束下溢出；同型失敗一次爆 22 個時，正確的產出不是 22 個修復、是一份 overflow 預防規範（反模式清單 &#43; 決策樹 &#43; 測試檢查項）。含測試環境小尺寸是 feature、以及 stale ticket 先考古再執行的教訓。">stale ticket 考古</a>（58 天票、路徑與數量全漂移）是同一條紀律的兩次實證——<strong>工作項的內文是建立當下的快照、執行前重驗每一個事實聲明</strong>。而終止決策記錄本身也做對了一件事：把「為什麼不做」跟兩個選項的否決理由寫下來（改成 Event 後綴標準化的提案被否決：後綴是風格、不符架構修正系列的定位），下一個看到這張票的人不會重開一輪。</p>
<p>附帶的懸念也誠實地留著：實際文件用了 <code>Event</code> 後綴（<code>BookImportedEvent</code>）、規範範例沒有（<code>BookImported</code>）——這個不一致被看見、被評估為風格層、被明確地不處理。跟靜默的不一致相比，「已知、已評估、不處理」是完全不同的狀態。</p>
<h2 id="判讀徵兆">判讀徵兆</h2>
<ul>
<li>事件類別的名字以動詞開頭——它長成了命令，訂閱者會用錯誤的心智模型消費它</li>
<li>事件與命令在同一個 bus / 同一個目錄裡混居且命名無法區分——先立命名分界、再談架構</li>
<li>命名檢查用字尾 / 前綴清單且被當成判決——降級為候選過濾器、命中後人工判定</li>
<li>修正類工作項的「問題描述」超過幾週沒被重驗——執行前先花十分鐘核對問題還在不在</li>
</ul>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>同紀律的另一現場：<a href="/blog/work-log/flutter_renderflex_overflow_prevention_spec/" data-link-title="溢出 714px、22 個測試同時紅 — 單點修復與規範化的分界" data-link-desc="Row 裡未受 Flexible 包裹的固定尺寸元件在窄約束下溢出；同型失敗一次爆 22 個時，正確的產出不是 22 個修復、是一份 overflow 預防規範（反模式清單 &#43; 決策樹 &#43; 測試檢查項）。含測試環境小尺寸是 feature、以及 stale ticket 先考古再執行的教訓。">溢出 714px 的 stale ticket 考古</a>——票面快照 vs 現實的漂移</li>
<li>偵測 / 判定分界的原則層：<a href="/blog/report/keyword-bank-hit-is-candidate-not-verdict/" data-link-title="字句層 review：keyword bank 命中是候選、不是判決" data-link-desc="跑了字句層 grep keyword bank、命中『不是 A 而是 B』、卻把它判成『可接受的反例對照』放行；違規由讀者 catch。揭露 keyword bank 的第二層盲點：偵測（grep 命中）跟判定（這個命中是不是違規）是兩個認知步驟、reviewer 容易把命中合理化成合規而放行。判定準則：否定句構若在『建立核心概念』就改正向、只有在『明示反例段落』才保留。另有一類贅語（訴諸群體『很多人卡在』）連關鍵詞都沒有、keyword bank 結構上抓不到、要靠 reader-simulation 語意 pass。">#149 keyword bank 命中是候選、不是判決</a></li>
<li>概念地基：<a href="/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南</a>——事件建模章節；事件與命令的責任結構差異是「從操作推導領域」的一部分</li>
</ul>
]]></content:encoded></item><item><title>用事件做同步查詢、等於手工重建 RPC — 跨 domain 解耦的完整帳單</title><link>https://tarrragon.github.io/blog/work-log/cross_domain_event_request_response_cost/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/cross_domain_event_request_response_cost/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter 書籍管理 App 的借閱功能（Loan domain）建立借閱記錄前要查書籍資訊，流程圖直白地畫著 &lt;code>LoanService --&amp;gt; BookRepository&lt;/code>——Loan domain 直接摸進 Library domain 的 repository
&lt;strong>疑問來源&lt;/strong>：架構修正把它改成事件驅動的 request/response（&lt;code>BookInfoRequested&lt;/code> / &lt;code>BookInfoProvided&lt;/code>）。解耦成立了，但設計規格裡多出 requestId、waitFor、timeout、三條錯誤路徑——這些複雜度是必要代價、還是選錯工具的訊號？
&lt;strong>整理目的&lt;/strong>：把「事件驅動解耦」的帳單攤開、跟替代選項（消費端 Port）比價、收斂出選擇判準
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.12-C.2 的設計規格（流程圖與事件介面層級、未及實作）——本文是架構決策推導、不是實測事故&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="問題依賴方向錯了">問題：依賴方向錯了&lt;/h2>
&lt;p>&lt;code>LoanService&lt;/code> 直接呼叫 &lt;code>BookRepository&lt;/code>（Library domain 的資產）的問題有三層：Library 的變更會波及 Loan、Loan 的測試被迫載入 Library 的實作、兩個 domain 的邊界在這條呼叫上糊掉。這是依賴倒置（DIP）的標準違反現場——高層的業務流程直接抓住了別人家的低層實作。&lt;/p>
&lt;p>要修的東西很明確。值得慢下來的是&lt;strong>修法的選項空間&lt;/strong>，因為這次選的路線把帳單放大了。&lt;/p>
&lt;h2 id="事件驅動版每一項都是同步呼叫免費附贈的">事件驅動版：每一項都是同步呼叫免費附贈的&lt;/h2>
&lt;p>修正設計把一次查詢改成一趟事件往返：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">LoanService --&amp;gt; EventBus: 發布 BookInfoRequested(bookId, requestId)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">EventBus --&amp;gt; LibraryService: 訂閱並處理
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">LibraryService --&amp;gt; EventBus: 發布 BookInfoProvided(bookInfo, requestId)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">EventBus --&amp;gt; LoanService: waitFor 收到回應（5 秒 timeout）&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>耦合確實消失了——Library 完全不知道 Loan 存在。但設計規格裡跟著出現的機制、逐項對照同步呼叫：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>事件版要自己做的&lt;/th>
 &lt;th>同步呼叫的對應物&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>requestId&lt;/code>（UUID）配對&lt;/td>
 &lt;td>呼叫堆疊天然對應請求與回傳&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>waitFor&lt;/code> + 5 秒 timeout&lt;/td>
 &lt;td>函式 return（要等多久是呼叫語意）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>書籍不存在的錯誤事件&lt;/td>
 &lt;td>回傳 null / 拋例外&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>超時路徑&lt;/td>
 &lt;td>不存在（同步呼叫不會「沒人接」）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>EventBus 故障路徑&lt;/td>
 &lt;td>不存在&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表的形狀有個名字：&lt;strong>用事件通道手工重建 RPC&lt;/strong>。事件的天然語意是通知——「事情發生了」、fire and forget、任意多訂閱者、無所謂回應；request/response 的語意是對話——一問一答、有超時、有失敗。把對話硬放上通知通道，correlation、timeout、錯誤回傳這些 RPC 基礎設施就得自己蓋一遍，而且每個跨 domain 查詢點都要再蓋一遍。&lt;/p>
&lt;h2 id="便宜的替代消費端-port">便宜的替代：消費端 Port&lt;/h2>
&lt;p>解「依賴方向」有更便宜的工具、而且同專案後來自己用過——&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_port_interface_mock_hell_isp/" data-link-title="mock 要配置 55 個方法、實際只用 5 個 — 測試痛是介面設計痛的探針" data-link-desc="service 測試的 mock 負擔正比於它依賴的介面寬度：依賴四個大介面共 55 個方法、實際呼叫 5 個，91% 的 mock 配置是純浪費、還會炸 MissingStubError。修法是介面隔離——抽出只含實際使用方法的 Port，讓 mock 縮到跟真實依賴一樣窄；為未來預留的方法用 TODO 標記啟用時機。">Port 介面&lt;/a>：Loan domain 宣告自己需要的能力、Library 提供實作、組裝層接線：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// Loan domain 內：宣告需求（依賴方向：Library 實作向 Loan 的介面靠）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">abstract&lt;/span> &lt;span class="kd">class&lt;/span> &lt;span class="nc">BookInfoPort&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="n">Future&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">BookInfo&lt;/span>&lt;span class="o">?&amp;gt;&lt;/span> &lt;span class="n">getBookInfo&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">String&lt;/span> &lt;span class="n">bookId&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>DIP 同樣成立（Loan 不認識 Library、只認識自己定義的 Port）、測試同樣乾淨（mock 一個單方法介面）、而呼叫還是一次普通的 await——requestId、timeout、事件錯誤路徑全部不需要。&lt;/p>
&lt;p>那事件版買到的是什麼？兩樣 Port 給不了的：&lt;strong>時間解耦&lt;/strong>（發布者不等回應、雙方可以不同時在線——分散式或背景處理的前提）跟&lt;strong>基數解耦&lt;/strong>（一個事件任意多訂閱者）。判準因此收得很乾淨：&lt;/p>
&lt;ul>
&lt;li>要解的是&lt;strong>依賴方向&lt;/strong>（單機、同進程、一問一答）→ Port，付介面一張的價&lt;/li>
&lt;li>要解的是&lt;strong>時間或部署&lt;/strong>（跨進程、離線佇列、多方反應）→ 事件，correlation 與 timeout 是這個量級問題的合理成本&lt;/li>
&lt;/ul>
&lt;p>這個 case 的查詢是同進程、一對一、呼叫端必須等到答案才能往下走——三個特徵全部指向 Port。事件版不是錯（它也真的解了耦）、是&lt;strong>用了下一個量級的工具付了下一個量級的帳&lt;/strong>。&lt;/p>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;ul>
&lt;li>事件名成對出現 &lt;code>XxxRequested&lt;/code> / &lt;code>XxxProvided&lt;/code> 且帶 correlation id——你在事件通道上做 RPC，先問「需要時間解耦嗎」&lt;/li>
&lt;li>發布事件之後 &lt;code>waitFor&lt;/code> / await 回應才能繼續——呼叫端語意上是同步的，事件只是繞路&lt;/li>
&lt;li>跨 domain 呼叫的替代方案清單裡沒有「消費端 Port」——選項空間漏了最便宜的一格&lt;/li>
&lt;li>反向確認：訂閱者不只一個、或處理可以離線 / 延後——這時事件才是本命，別退回 Port&lt;/li>
&lt;/ul>
&lt;h2 id="相關閱讀">相關閱讀&lt;/h2>
&lt;ul>
&lt;li>便宜選項的實戰：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_port_interface_mock_hell_isp/" data-link-title="mock 要配置 55 個方法、實際只用 5 個 — 測試痛是介面設計痛的探針" data-link-desc="service 測試的 mock 負擔正比於它依賴的介面寬度：依賴四個大介面共 55 個方法、實際呼叫 5 個，91% 的 mock 配置是純浪費、還會炸 MissingStubError。修法是介面隔離——抽出只含實際使用方法的 Port，讓 mock 縮到跟真實依賴一樣窄；為未來預留的方法用 TODO 標記啟用時機。">mock 55 個方法只用 5 個：Port 介面&lt;/a>——同專案後期用 Port 解依賴的完整記錄&lt;/li>
&lt;li>事件的正確場合：&lt;a href="https://tarrragon.github.io/blog/work-log/domain_event_naming_past_tense/" data-link-title="BookImported 不能寫成 ImportBook — 事件命名的過去式是語意類別、不是風格" data-link-desc="domain event 用過去式命名（BookImported）因為事件是已發生的事實；動詞開頭（ImportBook）是命令的形狀、訂閱者會誤讀成指令。字尾清單的自動檢查抓不住不規則動詞——偵測可機械化、判定要看語意。附一次版本終止：工作日誌宣稱的命名問題實際早已不存在、執行前驗前提省下整輪流程。">Domain Event 命名的過去式&lt;/a>——事件是已發生的事實、&lt;code>BookInfoRequested&lt;/code> 這個名字本身就洩漏了它其實是請求不是事實&lt;/li>
&lt;li>概念地基：&lt;a href="https://tarrragon.github.io/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南&lt;/a>——跨領域邊界與事件建模；工具量級的選擇也是「從操作推導」的一部分&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter 書籍管理 App 的借閱功能（Loan domain）建立借閱記錄前要查書籍資訊，流程圖直白地畫著 <code>LoanService --&gt; BookRepository</code>——Loan domain 直接摸進 Library domain 的 repository
<strong>疑問來源</strong>：架構修正把它改成事件驅動的 request/response（<code>BookInfoRequested</code> / <code>BookInfoProvided</code>）。解耦成立了，但設計規格裡多出 requestId、waitFor、timeout、三條錯誤路徑——這些複雜度是必要代價、還是選錯工具的訊號？
<strong>整理目的</strong>：把「事件驅動解耦」的帳單攤開、跟替代選項（消費端 Port）比價、收斂出選擇判準
<strong>本文邊界</strong>：素材是該專案 v0.12-C.2 的設計規格（流程圖與事件介面層級、未及實作）——本文是架構決策推導、不是實測事故</p></blockquote>
<hr>
<h2 id="問題依賴方向錯了">問題：依賴方向錯了</h2>
<p><code>LoanService</code> 直接呼叫 <code>BookRepository</code>（Library domain 的資產）的問題有三層：Library 的變更會波及 Loan、Loan 的測試被迫載入 Library 的實作、兩個 domain 的邊界在這條呼叫上糊掉。這是依賴倒置（DIP）的標準違反現場——高層的業務流程直接抓住了別人家的低層實作。</p>
<p>要修的東西很明確。值得慢下來的是<strong>修法的選項空間</strong>，因為這次選的路線把帳單放大了。</p>
<h2 id="事件驅動版每一項都是同步呼叫免費附贈的">事件驅動版：每一項都是同步呼叫免費附贈的</h2>
<p>修正設計把一次查詢改成一趟事件往返：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">LoanService  --&gt; EventBus: 發布 BookInfoRequested(bookId, requestId)
</span></span><span class="line"><span class="ln">2</span><span class="cl">EventBus     --&gt; LibraryService: 訂閱並處理
</span></span><span class="line"><span class="ln">3</span><span class="cl">LibraryService --&gt; EventBus: 發布 BookInfoProvided(bookInfo, requestId)
</span></span><span class="line"><span class="ln">4</span><span class="cl">EventBus     --&gt; LoanService: waitFor 收到回應（5 秒 timeout）</span></span></code></pre></div><p>耦合確實消失了——Library 完全不知道 Loan 存在。但設計規格裡跟著出現的機制、逐項對照同步呼叫：</p>
<table>
  <thead>
      <tr>
          <th>事件版要自己做的</th>
          <th>同步呼叫的對應物</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>requestId</code>（UUID）配對</td>
          <td>呼叫堆疊天然對應請求與回傳</td>
      </tr>
      <tr>
          <td><code>waitFor</code> + 5 秒 timeout</td>
          <td>函式 return（要等多久是呼叫語意）</td>
      </tr>
      <tr>
          <td>書籍不存在的錯誤事件</td>
          <td>回傳 null / 拋例外</td>
      </tr>
      <tr>
          <td>超時路徑</td>
          <td>不存在（同步呼叫不會「沒人接」）</td>
      </tr>
      <tr>
          <td>EventBus 故障路徑</td>
          <td>不存在</td>
      </tr>
  </tbody>
</table>
<p>這張表的形狀有個名字：<strong>用事件通道手工重建 RPC</strong>。事件的天然語意是通知——「事情發生了」、fire and forget、任意多訂閱者、無所謂回應；request/response 的語意是對話——一問一答、有超時、有失敗。把對話硬放上通知通道，correlation、timeout、錯誤回傳這些 RPC 基礎設施就得自己蓋一遍，而且每個跨 domain 查詢點都要再蓋一遍。</p>
<h2 id="便宜的替代消費端-port">便宜的替代：消費端 Port</h2>
<p>解「依賴方向」有更便宜的工具、而且同專案後來自己用過——<a href="/blog/work-log/flutter_port_interface_mock_hell_isp/" data-link-title="mock 要配置 55 個方法、實際只用 5 個 — 測試痛是介面設計痛的探針" data-link-desc="service 測試的 mock 負擔正比於它依賴的介面寬度：依賴四個大介面共 55 個方法、實際呼叫 5 個，91% 的 mock 配置是純浪費、還會炸 MissingStubError。修法是介面隔離——抽出只含實際使用方法的 Port，讓 mock 縮到跟真實依賴一樣窄；為未來預留的方法用 TODO 標記啟用時機。">Port 介面</a>：Loan domain 宣告自己需要的能力、Library 提供實作、組裝層接線：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// Loan domain 內：宣告需求（依賴方向：Library 實作向 Loan 的介面靠）
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kd">abstract</span> <span class="kd">class</span> <span class="nc">BookInfoPort</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="n">Future</span><span class="o">&lt;</span><span class="n">BookInfo</span><span class="o">?&gt;</span> <span class="n">getBookInfo</span><span class="p">(</span><span class="kt">String</span> <span class="n">bookId</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>DIP 同樣成立（Loan 不認識 Library、只認識自己定義的 Port）、測試同樣乾淨（mock 一個單方法介面）、而呼叫還是一次普通的 await——requestId、timeout、事件錯誤路徑全部不需要。</p>
<p>那事件版買到的是什麼？兩樣 Port 給不了的：<strong>時間解耦</strong>（發布者不等回應、雙方可以不同時在線——分散式或背景處理的前提）跟<strong>基數解耦</strong>（一個事件任意多訂閱者）。判準因此收得很乾淨：</p>
<ul>
<li>要解的是<strong>依賴方向</strong>（單機、同進程、一問一答）→ Port，付介面一張的價</li>
<li>要解的是<strong>時間或部署</strong>（跨進程、離線佇列、多方反應）→ 事件，correlation 與 timeout 是這個量級問題的合理成本</li>
</ul>
<p>這個 case 的查詢是同進程、一對一、呼叫端必須等到答案才能往下走——三個特徵全部指向 Port。事件版不是錯（它也真的解了耦）、是<strong>用了下一個量級的工具付了下一個量級的帳</strong>。</p>
<h2 id="判讀徵兆">判讀徵兆</h2>
<ul>
<li>事件名成對出現 <code>XxxRequested</code> / <code>XxxProvided</code> 且帶 correlation id——你在事件通道上做 RPC，先問「需要時間解耦嗎」</li>
<li>發布事件之後 <code>waitFor</code> / await 回應才能繼續——呼叫端語意上是同步的，事件只是繞路</li>
<li>跨 domain 呼叫的替代方案清單裡沒有「消費端 Port」——選項空間漏了最便宜的一格</li>
<li>反向確認：訂閱者不只一個、或處理可以離線 / 延後——這時事件才是本命，別退回 Port</li>
</ul>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>便宜選項的實戰：<a href="/blog/work-log/flutter_port_interface_mock_hell_isp/" data-link-title="mock 要配置 55 個方法、實際只用 5 個 — 測試痛是介面設計痛的探針" data-link-desc="service 測試的 mock 負擔正比於它依賴的介面寬度：依賴四個大介面共 55 個方法、實際呼叫 5 個，91% 的 mock 配置是純浪費、還會炸 MissingStubError。修法是介面隔離——抽出只含實際使用方法的 Port，讓 mock 縮到跟真實依賴一樣窄；為未來預留的方法用 TODO 標記啟用時機。">mock 55 個方法只用 5 個：Port 介面</a>——同專案後期用 Port 解依賴的完整記錄</li>
<li>事件的正確場合：<a href="/blog/work-log/domain_event_naming_past_tense/" data-link-title="BookImported 不能寫成 ImportBook — 事件命名的過去式是語意類別、不是風格" data-link-desc="domain event 用過去式命名（BookImported）因為事件是已發生的事實；動詞開頭（ImportBook）是命令的形狀、訂閱者會誤讀成指令。字尾清單的自動檢查抓不住不規則動詞——偵測可機械化、判定要看語意。附一次版本終止：工作日誌宣稱的命名問題實際早已不存在、執行前驗前提省下整輪流程。">Domain Event 命名的過去式</a>——事件是已發生的事實、<code>BookInfoRequested</code> 這個名字本身就洩漏了它其實是請求不是事實</li>
<li>概念地基：<a href="/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南</a>——跨領域邊界與事件建模；工具量級的選擇也是「從操作推導」的一部分</li>
</ul>
]]></content:encoded></item></channel></rss>