<?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>Reactive on Tarragon</title><link>https://tarrragon.github.io/blog/tags/reactive/</link><description>Recent content in Reactive on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Thu, 16 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/reactive/index.xml" rel="self" type="application/rss+xml"/><item><title>Riverpod 的 reactive 邊界</title><link>https://tarrragon.github.io/blog/flutter/riverpod-reactive-boundary/</link><pubDate>Thu, 16 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/flutter/riverpod-reactive-boundary/</guid><description>&lt;p>Riverpod 的 reactive 保證有明確的涵蓋範圍：&lt;strong>provider 圖的內部&lt;/strong>。&lt;code>ref.watch&lt;/code> 建立的依賴、&lt;code>StreamProvider&lt;/code> 的推送、Notifier 的狀態轉換——這些機制在圖上的節點之間運作可靠；圖外的變化（資料庫寫入、外部容器的操作、已 dispose 節點上的寫入）不觸發任何 reactive 行為、也多半不報錯。「有用 Riverpod」跟「會對變化反應」是兩件事，中間差的就是這條邊界。&lt;/p>
&lt;p>本章把邊界拆成四個方向，每個方向由一個實際踩過的 case 支撐。排查判準收成三問：&lt;strong>這個變化是 provider 圖上某個節點的狀態變化嗎？在哪個容器的圖上？節點還活著嗎？&lt;/strong>&lt;/p>
&lt;h2 id="心智模型配方廚房圖">心智模型：配方、廚房、圖&lt;/h2>
&lt;p>provider 的全域宣告是&lt;strong>配方&lt;/strong>——描述狀態怎麼建、怎麼變化；狀態本身活在&lt;strong>容器&lt;/strong>（&lt;code>ProviderContainer&lt;/code>／&lt;code>ProviderScope&lt;/code>）裡，同一份配方在兩個容器裡煮出兩份互不相干的狀態。容器內的 provider 依 &lt;code>ref.watch&lt;/code> 的依賴關係連成&lt;strong>圖&lt;/strong>：節點是 provider 的狀態、邊是 watch 依賴，狀態變化沿著邊傳播、觸發下游重建。&lt;/p>
&lt;p>reactive 的全部機制都建立在這張圖上。四個邊界各是圖的一個面向：圖屬於誰（空間）、圖上有什麼（涵蓋）、圖外的變化怎麼進來（接入）、節點活多久（時間）。&lt;/p>
&lt;h2 id="空間邊界狀態屬於容器不屬於宣告">空間邊界：狀態屬於容器、不屬於宣告&lt;/h2>
&lt;p>&lt;code>main()&lt;/code> 自建 &lt;code>ProviderContainer&lt;/code> 觸發初始化、UI 跑在 &lt;code>runApp&lt;/code> 的 &lt;code>ProviderScope&lt;/code> 裡——兩個容器各持一份狀態，初始化改的是外部容器那份、UI 監聽的是 Scope 那份，App 永遠停在載入畫面。跨容器操作不報錯、只是安靜地作用在預期之外的地方，每段程式碼單獨看都正確。&lt;/p>
&lt;p>判準：&lt;strong>一個 App 裡活著的容器數量應該是一&lt;/strong>，每多一個都要能說出它為什麼必須隔離（測試的 &lt;code>ProviderContainer(overrides:)&lt;/code> 是正當隔離）。全專案搜 &lt;code>ProviderContainer(&lt;/code>、逐一問「它跟 UI 的 Scope 是同一個嗎」。確實需要在 &lt;code>runApp&lt;/code> 前操作 provider 時（如讀取啟動設定），用 &lt;code>UncontrolledProviderScope&lt;/code> 讓兩邊共用同一個容器。完整機制與修法：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_riverpod_dual_container_state_desync/" data-link-title="App 永遠卡在載入畫面 — Riverpod 的 provider 是配方、容器才持有狀態" data-link-desc="main() 自建 ProviderContainer 對它觸發初始化、UI 跑在 runApp 的 ProviderScope 裡——兩個容器各持一份 provider 狀態、互不相通，UI 監聽的那份永遠停在初始值。Riverpod 的全域 provider 宣告只是配方、狀態屬於容器實例；跨容器操作是靜默的無效操作。">App 永遠卡在載入畫面&lt;/a>。&lt;/p>
&lt;h2 id="涵蓋邊界圖上只有-provider-的狀態">涵蓋邊界：圖上只有 provider 的狀態&lt;/h2>
&lt;p>&lt;code>ref.watch(bookRepositoryProvider)&lt;/code> 對單例 &lt;code>Provider&amp;lt;BookRepository&amp;gt;&lt;/code> 建立的依賴永遠不觸發——這個 provider 的狀態是「repository 物件參考」、整個生命週期不變；SQLite 寫入了一百本書，物件參考一動不動。資料庫的變化不在圖上，於是刷新被推到圖外解決：導航返回點補 &lt;code>loadData()&lt;/code>、EventBus 橋接、多個視圖各自維護 load 時機——補償刷新的出現就是涵蓋缺口的訊號。&lt;/p>
&lt;p>判準：要讓畫面對某個變化反應，&lt;strong>那個變化本身必須是圖上某個節點的狀態變化&lt;/strong>。修法方向一致——把資料變更做成一級節點（repository 補 stream 出口、&lt;code>StreamProvider&lt;/code> 包成 provider），視圖回到純 &lt;code>ref.watch&lt;/code>。三段補償演進與判讀訊號表：&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;/p>
&lt;h2 id="接入邊界圖外的變化要立節點才進圖">接入邊界：圖外的變化要立節點才進圖&lt;/h2>
&lt;p>把 repository 的 stream 接成圖上節點、是涵蓋缺口的結構性修法，接入處有自己的實作契約。三個問題各有一個會靜默失效的預設答案：訂閱模型（多個視圖同時聽、&lt;code>StreamController()&lt;/code> 預設單訂閱、第二個訂閱者執行期 throw）、初始值（broadcast 不補送歷史、不處理的話畫面空到下一次寫入）、dispose（controller 的關閉責任跟著持有者走）。三個實作點的完整落地：&lt;a href="https://tarrragon.github.io/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&lt;/a>。&lt;/p>
&lt;p>其中訂閱模型的選擇值得單獨記：&lt;code>StreamController()&lt;/code> vs &lt;code>.broadcast()&lt;/code> 是零成本差異，選限制更高的單訂閱版本、限制在只有一個訂閱者期間完全沉默、第二個訂閱者出現才爆。在零成本差異下把「會有多個觀察者」的領域先驗寫死成單訂閱，是設計缺陷、不是需求演化。單訂閱與 broadcast 的行為差異全表（buffer、pause、重新訂閱）：&lt;a href="https://tarrragon.github.io/blog/work-log/dart_stream_controller_single_vs_broadcast/" data-link-title="Dart StreamController：single-subscription vs broadcast 的設計選型問題" data-link-desc="Dart `Bad state: Stream has already been listened to.` 的根因：預設單訂閱在第二個訂閱者出現時才爆。StreamController vs .broadcast() 修復決策、與 Rx / .obs 的比較。">StreamController single vs broadcast&lt;/a>。&lt;/p>
&lt;h2 id="時間邊界節點的生命有兩頭界線">時間邊界：節點的生命有兩頭界線&lt;/h2>
&lt;p>async 函式的每個 &lt;code>await&lt;/code> 都是一個 gap：等待期間使用者可能離開頁面、Notifier 被 dispose，await 回來再碰 &lt;code>ref&lt;/code> 就炸 &lt;code>UnmountedRefException&lt;/code>。修法可機械化——&lt;strong>每個 await 之後、第一次碰 &lt;code>ref&lt;/code> 之前&lt;/strong>檢查 &lt;code>ref.mounted&lt;/code>；而且刻意不抽成 helper：guard 的價值在「它在哪」，明確的檢查點讓 review 用眼睛掃就能驗證每個 gap 有沒有守。長流程、可離開的頁面（匯入、同步、批次處理）風險最高。完整機制與「評估必跑、可決定不重構」的技術債處置：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_unmounted_ref_async_gap/" data-link-title="await 回來的時候、頁面已經關了 — UnmountedRefException 與 16 個不抽象的檢查點" data-link-desc="長 async 流程的每個 await 都是一個 gap：等待期間使用者可能離開、Notifier 被 dispose、回來再寫 state 就炸 UnmountedRefException。修法是每個 gap 後檢查 ref.mounted——而且刻意不抽成 helper：明確的檢查點讓 review 看得見哪個 gap 有守。含「評估必跑、可決定不重構」的技術債處置。">await 回來的時候、頁面已經關了&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Riverpod 的 reactive 保證有明確的涵蓋範圍：<strong>provider 圖的內部</strong>。<code>ref.watch</code> 建立的依賴、<code>StreamProvider</code> 的推送、Notifier 的狀態轉換——這些機制在圖上的節點之間運作可靠；圖外的變化（資料庫寫入、外部容器的操作、已 dispose 節點上的寫入）不觸發任何 reactive 行為、也多半不報錯。「有用 Riverpod」跟「會對變化反應」是兩件事，中間差的就是這條邊界。</p>
<p>本章把邊界拆成四個方向，每個方向由一個實際踩過的 case 支撐。排查判準收成三問：<strong>這個變化是 provider 圖上某個節點的狀態變化嗎？在哪個容器的圖上？節點還活著嗎？</strong></p>
<h2 id="心智模型配方廚房圖">心智模型：配方、廚房、圖</h2>
<p>provider 的全域宣告是<strong>配方</strong>——描述狀態怎麼建、怎麼變化；狀態本身活在<strong>容器</strong>（<code>ProviderContainer</code>／<code>ProviderScope</code>）裡，同一份配方在兩個容器裡煮出兩份互不相干的狀態。容器內的 provider 依 <code>ref.watch</code> 的依賴關係連成<strong>圖</strong>：節點是 provider 的狀態、邊是 watch 依賴，狀態變化沿著邊傳播、觸發下游重建。</p>
<p>reactive 的全部機制都建立在這張圖上。四個邊界各是圖的一個面向：圖屬於誰（空間）、圖上有什麼（涵蓋）、圖外的變化怎麼進來（接入）、節點活多久（時間）。</p>
<h2 id="空間邊界狀態屬於容器不屬於宣告">空間邊界：狀態屬於容器、不屬於宣告</h2>
<p><code>main()</code> 自建 <code>ProviderContainer</code> 觸發初始化、UI 跑在 <code>runApp</code> 的 <code>ProviderScope</code> 裡——兩個容器各持一份狀態，初始化改的是外部容器那份、UI 監聽的是 Scope 那份，App 永遠停在載入畫面。跨容器操作不報錯、只是安靜地作用在預期之外的地方，每段程式碼單獨看都正確。</p>
<p>判準：<strong>一個 App 裡活著的容器數量應該是一</strong>，每多一個都要能說出它為什麼必須隔離（測試的 <code>ProviderContainer(overrides:)</code> 是正當隔離）。全專案搜 <code>ProviderContainer(</code>、逐一問「它跟 UI 的 Scope 是同一個嗎」。確實需要在 <code>runApp</code> 前操作 provider 時（如讀取啟動設定），用 <code>UncontrolledProviderScope</code> 讓兩邊共用同一個容器。完整機制與修法：<a href="/blog/work-log/flutter_riverpod_dual_container_state_desync/" data-link-title="App 永遠卡在載入畫面 — Riverpod 的 provider 是配方、容器才持有狀態" data-link-desc="main() 自建 ProviderContainer 對它觸發初始化、UI 跑在 runApp 的 ProviderScope 裡——兩個容器各持一份 provider 狀態、互不相通，UI 監聽的那份永遠停在初始值。Riverpod 的全域 provider 宣告只是配方、狀態屬於容器實例；跨容器操作是靜默的無效操作。">App 永遠卡在載入畫面</a>。</p>
<h2 id="涵蓋邊界圖上只有-provider-的狀態">涵蓋邊界：圖上只有 provider 的狀態</h2>
<p><code>ref.watch(bookRepositoryProvider)</code> 對單例 <code>Provider&lt;BookRepository&gt;</code> 建立的依賴永遠不觸發——這個 provider 的狀態是「repository 物件參考」、整個生命週期不變；SQLite 寫入了一百本書，物件參考一動不動。資料庫的變化不在圖上，於是刷新被推到圖外解決：導航返回點補 <code>loadData()</code>、EventBus 橋接、多個視圖各自維護 load 時機——補償刷新的出現就是涵蓋缺口的訊號。</p>
<p>判準：要讓畫面對某個變化反應，<strong>那個變化本身必須是圖上某個節點的狀態變化</strong>。修法方向一致——把資料變更做成一級節點（repository 補 stream 出口、<code>StreamProvider</code> 包成 provider），視圖回到純 <code>ref.watch</code>。三段補償演進與判讀訊號表：<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>。</p>
<h2 id="接入邊界圖外的變化要立節點才進圖">接入邊界：圖外的變化要立節點才進圖</h2>
<p>把 repository 的 stream 接成圖上節點、是涵蓋缺口的結構性修法，接入處有自己的實作契約。三個問題各有一個會靜默失效的預設答案：訂閱模型（多個視圖同時聽、<code>StreamController()</code> 預設單訂閱、第二個訂閱者執行期 throw）、初始值（broadcast 不補送歷史、不處理的話畫面空到下一次寫入）、dispose（controller 的關閉責任跟著持有者走）。三個實作點的完整落地：<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>。</p>
<p>其中訂閱模型的選擇值得單獨記：<code>StreamController()</code> vs <code>.broadcast()</code> 是零成本差異，選限制更高的單訂閱版本、限制在只有一個訂閱者期間完全沉默、第二個訂閱者出現才爆。在零成本差異下把「會有多個觀察者」的領域先驗寫死成單訂閱，是設計缺陷、不是需求演化。單訂閱與 broadcast 的行為差異全表（buffer、pause、重新訂閱）：<a href="/blog/work-log/dart_stream_controller_single_vs_broadcast/" data-link-title="Dart StreamController：single-subscription vs broadcast 的設計選型問題" data-link-desc="Dart `Bad state: Stream has already been listened to.` 的根因：預設單訂閱在第二個訂閱者出現時才爆。StreamController vs .broadcast() 修復決策、與 Rx / .obs 的比較。">StreamController single vs broadcast</a>。</p>
<h2 id="時間邊界節點的生命有兩頭界線">時間邊界：節點的生命有兩頭界線</h2>
<p>async 函式的每個 <code>await</code> 都是一個 gap：等待期間使用者可能離開頁面、Notifier 被 dispose，await 回來再碰 <code>ref</code> 就炸 <code>UnmountedRefException</code>。修法可機械化——<strong>每個 await 之後、第一次碰 <code>ref</code> 之前</strong>檢查 <code>ref.mounted</code>；而且刻意不抽成 helper：guard 的價值在「它在哪」，明確的檢查點讓 review 用眼睛掃就能驗證每個 gap 有沒有守。長流程、可離開的頁面（匯入、同步、批次處理）風險最高。完整機制與「評估必跑、可決定不重構」的技術債處置：<a href="/blog/work-log/flutter_unmounted_ref_async_gap/" data-link-title="await 回來的時候、頁面已經關了 — UnmountedRefException 與 16 個不抽象的檢查點" data-link-desc="長 async 流程的每個 await 都是一個 gap：等待期間使用者可能離開、Notifier 被 dispose、回來再寫 state 就炸 UnmountedRefException。修法是每個 gap 後檢查 ref.mounted——而且刻意不抽成 helper：明確的檢查點讓 review 看得見哪個 gap 有守。含「評估必跑、可決定不重構」的技術債處置。">await 回來的時候、頁面已經關了</a>。</p>
<p>生命週期的另一頭是 build 期間：widget tree 建置中直接改 provider 狀態同樣是非法時機，觸發點要用 <code>addPostFrameCallback</code> 延到首幀之後。<code>ref</code> 的合法視窗兩頭都有界——await 之後可能太晚、build 之中太早。</p>
<h2 id="排查判準">排查判準</h2>
<p>reactive 失靈時沿三問走，每一問對應一個邊界：</p>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>對應邊界</th>
          <th>常見答案與訊號</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>變化在圖上嗎？</td>
          <td>涵蓋、接入</td>
          <td>資料庫寫入不在圖上——<code>ref.watch</code> 對象是單例 <code>Provider&lt;Repository&gt;</code> 時 watch 永不觸發</td>
      </tr>
      <tr>
          <td>在哪個容器的圖上？</td>
          <td>空間</td>
          <td>狀態「永遠是初始值」指向無人操作這份實例——數容器、查操作方作用在哪份</td>
      </tr>
      <tr>
          <td>節點還活著嗎？</td>
          <td>時間</td>
          <td>崩潰 stack 指向 await 之後的 state 寫入——async gap 沒守</td>
      </tr>
  </tbody>
</table>
<p>三問都過、reactive 仍不對時，回到接入層的實作契約查：訂閱模型（<code>Bad state</code> = 單訂閱撞多訂閱）、初始值（畫面空到下次寫入 = broadcast 沒補當前值）、mock 與真實實作的 stream 契約對齊。</p>
<h2 id="邊界">邊界</h2>
<p>本章處理 Riverpod 這個框架的 reactive 機制邊界，是實作層知識。「觀測能力該放哪一層」（契約歸 domain、機制歸 infrastructure、框架訂閱歸組裝層）是理論層的歸屬判準、與框架無關，見 <a href="/blog/ddd/observation-outlet-responsibility-split/" data-link-title="觀測出口的職責三分" data-link-desc="repository 要補「資料變了」的推送能力、卻不確定 Stream 介面放 domain 算不算洩漏時使用。歸屬判準是介面用什麼語言表達、不是需求來自誰：契約歸 domain、變更偵測歸 infrastructure、框架訂閱歸組裝層。">觀測出口的職責三分</a>；「事件與狀態流哪個當通知載體」的選擇也在理論層，見 <a href="/blog/ddd/domain-event-vs-state-stream/" data-link-title="domain event 與狀態流" data-link-desc="為了讓某個畫面刷新而補發事件、或監聽端掛著全事件過濾器時使用。事件記錄離散事實、狀態流發布連續觀測——判準是消費者問「發生了什麼」還是「現在是什麼」；載體借用的代價是涵蓋面靠枚舉維持。">domain event 與狀態流</a>。本章假設載體與分層已定、只管 Riverpod 端怎麼把它接對。</p>
<h2 id="下一步">下一步</h2>
<p>圖的邊界都守住之後，剩下的深化方向各有一篇 case 可讀：容器與作用域的空間問題在 <a href="/blog/work-log/flutter_riverpod_dual_container_state_desync/" data-link-title="App 永遠卡在載入畫面 — Riverpod 的 provider 是配方、容器才持有狀態" data-link-desc="main() 自建 ProviderContainer 對它觸發初始化、UI 跑在 runApp 的 ProviderScope 裡——兩個容器各持一份 provider 狀態、互不相通，UI 監聽的那份永遠停在初始值。Riverpod 的全域 provider 宣告只是配方、狀態屬於容器實例；跨容器操作是靜默的無效操作。">雙容器狀態脫節</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>、接入實作在 <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/work-log/dart_stream_controller_single_vs_broadcast/" data-link-title="Dart StreamController：single-subscription vs broadcast 的設計選型問題" data-link-desc="Dart `Bad state: Stream has already been listened to.` 的根因：預設單訂閱在第二個訂閱者出現時才爆。StreamController vs .broadcast() 修復決策、與 Rx / .obs 的比較。">StreamController single vs broadcast</a>、生命週期在 <a href="/blog/work-log/flutter_unmounted_ref_async_gap/" data-link-title="await 回來的時候、頁面已經關了 — UnmountedRefException 與 16 個不抽象的檢查點" data-link-desc="長 async 流程的每個 await 都是一個 gap：等待期間使用者可能離開、Notifier 被 dispose、回來再寫 state 就炸 UnmountedRefException。修法是每個 gap 後檢查 ref.mounted——而且刻意不抽成 helper：明確的檢查點讓 review 看得見哪個 gap 有守。含「評估必跑、可決定不重構」的技術債處置。">await 回來的時候、頁面已經關了</a>。理論地基從 <a href="/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 指南的讀側與觀測路線</a> 進。</p>
]]></content:encoded></item><item><title>觀測出口的職責三分</title><link>https://tarrragon.github.io/blog/ddd/observation-outlet-responsibility-split/</link><pubDate>Thu, 16 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/observation-outlet-responsibility-split/</guid><description>&lt;p>&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 對應——能力橫跨三層、歸屬由各層的表達語言決定。">觀測出口&lt;/a>是 &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;code>getAllBooks()&lt;/code> 回 &lt;code>Future&lt;/code>）的 push 對應（&lt;code>watchBooks()&lt;/code> 回 &lt;code>Stream&lt;/code>）。UI 框架要 reactive 觀察 domain 資料時，這個能力橫跨三層，職責三分：&lt;strong>契約&lt;/strong>（介面宣告，歸 domain）、&lt;strong>機制&lt;/strong>（變更偵測，歸 infrastructure）、&lt;strong>組裝&lt;/strong>（框架訂閱，歸 DI／presentation 層）。三分的判準只有一條：&lt;strong>每一層的產出用什麼語言表達、就歸屬表達那種語言的層&lt;/strong>。&lt;/p>
&lt;p>這條判準值得成章，因為它跟一個直覺衝突：觀測出口的需求完全來自消費端——是 UI 框架想觀察、domain 自己沒有這個需要。「誰需要就放誰那層」的直覺會把 Stream 出口做進 presentation 的某個 service，讓 domain 保持「純淨」；本章論證這個直覺用錯了判準的位置。&lt;/p>
&lt;h2 id="案例六個視圖兩種補償一個缺口">案例：六個視圖、兩種補償、一個缺口&lt;/h2>
&lt;p>一個書庫管理 App 的 repository 是純 &lt;code>Future&lt;/code> pull 介面。盤點 presentation 層讀書庫資料的六個衍生視圖，只有一個有 reactive 機制（且僅涵蓋部分路徑），其餘全靠命令式載入加補償：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>視圖&lt;/th>
 &lt;th>刷新方式&lt;/th>
 &lt;th>過期風險&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>書庫清單&lt;/td>
 &lt;td>命令式 &lt;code>loadBooks()&lt;/code>、外部觸發&lt;/td>
 &lt;td>高：跨頁加書後無自動刷新&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>資料統計&lt;/td>
 &lt;td>EventBus 全事件監聽 + 導航補償&lt;/td>
 &lt;td>中：未發事件的寫入路徑斷鏈&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>待補完列表&lt;/td>
 &lt;td>衍生自一次性 &lt;code>FutureProvider&lt;/code>&lt;/td>
 &lt;td>高：上游無失效機制&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>其餘三個視圖&lt;/td>
 &lt;td>各自命令式載入&lt;/td>
 &lt;td>低到中：同頁操作後手動 reload&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>根因是同一個：repository 沒有變更通知出口，六個視圖各自在圖外解「怎麼知道資料變了」這一題，解出兩種補償策略（導航返回點重載、經 &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;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;/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="n">Stream&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">List&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">Book&lt;/span>&lt;span class="o">&amp;gt;&amp;gt;&lt;/span> &lt;span class="n">watchBooks&lt;/span>&lt;span class="p">();&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>它該放 domain repository 介面、還是該為了「不污染 domain」放外層？判準是逐一檢查簽名裡的型別屬於哪種語言：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>簽名裡的型別&lt;/th>
 &lt;th>語言歸屬&lt;/th>
 &lt;th>洩漏判定&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>Stream&amp;lt;T&amp;gt;&lt;/code>（&lt;code>dart:async&lt;/code>）&lt;/td>
 &lt;td>語言標準庫&lt;/td>
 &lt;td>不算洩漏&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Book&lt;/code>（domain entity）&lt;/td>
 &lt;td>domain 自有語言&lt;/td>
 &lt;td>不算洩漏&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>StreamProvider&lt;/code> / &lt;code>Ref&lt;/code>（Riverpod）&lt;/td>
 &lt;td>框架語言&lt;/td>
 &lt;td>洩漏&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Database&lt;/code>（SQLite）&lt;/td>
 &lt;td>infrastructure 語言&lt;/td>
 &lt;td>洩漏&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>語言標準庫的非同步原語跟 domain 的關係、和 &lt;code>Future&lt;/code> 完全等價：repository 介面回 &lt;code>Future&amp;lt;List&amp;lt;Book&amp;gt;&amp;gt;&lt;/code> 從來沒人覺得是洩漏，&lt;code>Stream&lt;/code> 是同一個標準庫裡「多值版的 Future」、地位等同。&lt;code>watchBooks()&lt;/code> 全句只用標準庫加 domain entity——它說的是 domain 的語言，放 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/port/" data-link-title="Port" data-link-desc="判斷介面該宣告在哪一層、依賴方向該朝哪時使用。port 是 domain 對外宣告的介面——需求用領域語言說完、技術細節留在實作端。">port&lt;/a> 所在的 domain 介面，跟 &lt;code>getAllBooks()&lt;/code> 形成 pull／push 對稱。&lt;/p>
&lt;p>這裡就是與直覺衝突的位置。&lt;strong>需求從消費者出發、決定介面該不該存在；介面放哪一層、由表達語言決定&lt;/strong>。兩個問題常被壓成一個：&lt;/p>
&lt;ul>
&lt;li>「UI 想觀察資料」是消費端需求——它回答「要不要有 &lt;code>watchBooks()&lt;/code>」。介面設計本來就該從消費者需求出發，port 的形狀由呼叫方的需要定義。&lt;/li>
&lt;li>「&lt;code>watchBooks()&lt;/code> 歸誰」是歸屬問題——它由簽名語言回答。簽名說 domain 的語言，介面就是 domain 的；哪天簽名裡出現 &lt;code>AsyncValue&amp;lt;List&amp;lt;Book&amp;gt;&amp;gt;&lt;/code>（框架型別），才是把消費端的語言帶進了 domain、才需要擋。&lt;/li>
&lt;/ul>
&lt;p>用需求來源判歸屬會兩頭錯：把說 domain 語言的介面推到外層，domain 的資料能力（「我管理的資料變了、我能告訴你」）散落到 infrastructure 或 presentation 的 service 裡；或者反過來，以「需求是 domain 相關」為由把帶框架型別的介面塞進 domain。&lt;/p>
&lt;p>契約層還有一個二擇：放既有 repository 介面、還是抽獨立的查詢 port？本案放既有介面——讀需求只有「完整書單流」一條、六個視圖都能從書單流投影，為一個方法抽 port 是介面碎片化。這個「何時該抽」的判準有自己的一章：&lt;a href="https://tarrragon.github.io/blog/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準&lt;/a>。讀 port 一旦抽出，仍屬契約層的延伸——簽名同樣只用 domain 語言，機制層與組裝層的三分模型對它同樣適用。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/ddd/knowledge-cards/observation-outlet/" data-link-title="Observation Outlet（觀測出口）" data-link-desc="repository 只有 pull 介面、衍生視圖靠補償刷新，考慮補「資料變了」的推送能力時使用。觀測出口是 pull 介面的 push 對應——能力橫跨三層、歸屬由各層的表達語言決定。">觀測出口</a>是 <a href="/blog/ddd/knowledge-cards/repository/" data-link-title="Repository" data-link-desc="查詢方法該留在 repository、還是該抽成獨立讀模型時使用。repository 是 aggregate 的存取抽象——回傳的形狀是 aggregate 的形狀，不是讀的形狀。">repository</a> 對外提供的「資料變了」持續通知能力——pull 介面（<code>getAllBooks()</code> 回 <code>Future</code>）的 push 對應（<code>watchBooks()</code> 回 <code>Stream</code>）。UI 框架要 reactive 觀察 domain 資料時，這個能力橫跨三層，職責三分：<strong>契約</strong>（介面宣告，歸 domain）、<strong>機制</strong>（變更偵測，歸 infrastructure）、<strong>組裝</strong>（框架訂閱，歸 DI／presentation 層）。三分的判準只有一條：<strong>每一層的產出用什麼語言表達、就歸屬表達那種語言的層</strong>。</p>
<p>這條判準值得成章，因為它跟一個直覺衝突：觀測出口的需求完全來自消費端——是 UI 框架想觀察、domain 自己沒有這個需要。「誰需要就放誰那層」的直覺會把 Stream 出口做進 presentation 的某個 service，讓 domain 保持「純淨」；本章論證這個直覺用錯了判準的位置。</p>
<h2 id="案例六個視圖兩種補償一個缺口">案例：六個視圖、兩種補償、一個缺口</h2>
<p>一個書庫管理 App 的 repository 是純 <code>Future</code> pull 介面。盤點 presentation 層讀書庫資料的六個衍生視圖，只有一個有 reactive 機制（且僅涵蓋部分路徑），其餘全靠命令式載入加補償：</p>
<table>
  <thead>
      <tr>
          <th>視圖</th>
          <th>刷新方式</th>
          <th>過期風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>書庫清單</td>
          <td>命令式 <code>loadBooks()</code>、外部觸發</td>
          <td>高：跨頁加書後無自動刷新</td>
      </tr>
      <tr>
          <td>資料統計</td>
          <td>EventBus 全事件監聽 + 導航補償</td>
          <td>中：未發事件的寫入路徑斷鏈</td>
      </tr>
      <tr>
          <td>待補完列表</td>
          <td>衍生自一次性 <code>FutureProvider</code></td>
          <td>高：上游無失效機制</td>
      </tr>
      <tr>
          <td>其餘三個視圖</td>
          <td>各自命令式載入</td>
          <td>低到中：同頁操作後手動 reload</td>
      </tr>
  </tbody>
</table>
<p>根因是同一個：repository 沒有變更通知出口，六個視圖各自在圖外解「怎麼知道資料變了」這一題，解出兩種補償策略（導航返回點重載、經 <a href="/blog/ddd/knowledge-cards/event-bus/" data-link-title="EventBus" data-link-desc="行程內要把「發生了一件事」廣播給多個訂閱者、或懷疑它被拿去兼職變更通知管道時回來讀。EventBus 是行程內的發布／訂閱事件匯流排——把事件的發布點與訂閱點解耦。">EventBus</a>——行程內的發布／訂閱事件匯流排——的 domain event 橋接）、職責交叉且涵蓋不完整。補償演進的完整記錄在 <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>；本章從這個案例抽三層歸屬的判準。</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="n">Stream</span><span class="o">&lt;</span><span class="n">List</span><span class="o">&lt;</span><span class="n">Book</span><span class="o">&gt;&gt;</span> <span class="n">watchBooks</span><span class="p">();</span></span></span></code></pre></div><p>它該放 domain repository 介面、還是該為了「不污染 domain」放外層？判準是逐一檢查簽名裡的型別屬於哪種語言：</p>
<table>
  <thead>
      <tr>
          <th>簽名裡的型別</th>
          <th>語言歸屬</th>
          <th>洩漏判定</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>Stream&lt;T&gt;</code>（<code>dart:async</code>）</td>
          <td>語言標準庫</td>
          <td>不算洩漏</td>
      </tr>
      <tr>
          <td><code>Book</code>（domain entity）</td>
          <td>domain 自有語言</td>
          <td>不算洩漏</td>
      </tr>
      <tr>
          <td><code>StreamProvider</code> / <code>Ref</code>（Riverpod）</td>
          <td>框架語言</td>
          <td>洩漏</td>
      </tr>
      <tr>
          <td><code>Database</code>（SQLite）</td>
          <td>infrastructure 語言</td>
          <td>洩漏</td>
      </tr>
  </tbody>
</table>
<p>語言標準庫的非同步原語跟 domain 的關係、和 <code>Future</code> 完全等價：repository 介面回 <code>Future&lt;List&lt;Book&gt;&gt;</code> 從來沒人覺得是洩漏，<code>Stream</code> 是同一個標準庫裡「多值版的 Future」、地位等同。<code>watchBooks()</code> 全句只用標準庫加 domain entity——它說的是 domain 的語言，放 <a href="/blog/ddd/knowledge-cards/port/" data-link-title="Port" data-link-desc="判斷介面該宣告在哪一層、依賴方向該朝哪時使用。port 是 domain 對外宣告的介面——需求用領域語言說完、技術細節留在實作端。">port</a> 所在的 domain 介面，跟 <code>getAllBooks()</code> 形成 pull／push 對稱。</p>
<p>這裡就是與直覺衝突的位置。<strong>需求從消費者出發、決定介面該不該存在；介面放哪一層、由表達語言決定</strong>。兩個問題常被壓成一個：</p>
<ul>
<li>「UI 想觀察資料」是消費端需求——它回答「要不要有 <code>watchBooks()</code>」。介面設計本來就該從消費者需求出發，port 的形狀由呼叫方的需要定義。</li>
<li>「<code>watchBooks()</code> 歸誰」是歸屬問題——它由簽名語言回答。簽名說 domain 的語言，介面就是 domain 的；哪天簽名裡出現 <code>AsyncValue&lt;List&lt;Book&gt;&gt;</code>（框架型別），才是把消費端的語言帶進了 domain、才需要擋。</li>
</ul>
<p>用需求來源判歸屬會兩頭錯：把說 domain 語言的介面推到外層，domain 的資料能力（「我管理的資料變了、我能告訴你」）散落到 infrastructure 或 presentation 的 service 裡；或者反過來，以「需求是 domain 相關」為由把帶框架型別的介面塞進 domain。</p>
<p>契約層還有一個二擇：放既有 repository 介面、還是抽獨立的查詢 port？本案放既有介面——讀需求只有「完整書單流」一條、六個視圖都能從書單流投影，為一個方法抽 port 是介面碎片化。這個「何時該抽」的判準有自己的一章：<a href="/blog/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準</a>。讀 port 一旦抽出，仍屬契約層的延伸——簽名同樣只用 domain 語言，機制層與組裝層的三分模型對它同樣適用。</p>
<p>語言歸屬判準處理的是「詞彙是否洩漏」這一種反對意見。DDD 文獻裡存在另一派更根本的立場：即使簽名純淨，Repository 模式在 Evans / Vernon 的原始定義裡是集合式存取，reactive 訂閱能力本質上服務查詢端、屬於讀側的關注點，不論詞彙是否純淨都不該掛在寫側 aggregate 的 repository 介面上。這個立場涉及讀寫分離的組織方式（讀 port 何時值得獨立、<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>。</p>
<h2 id="機制層變更偵測是-adapter-的實作細節">機制層：變更偵測是 adapter 的實作細節</h2>
<p>機制層回答「怎麼知道資料變了」。這層的語言是 controller、資料庫 hook、交易——全是 infrastructure 詞彙，所以歸 <a href="/blog/ddd/knowledge-cards/adapter/" data-link-title="Adapter" data-link-desc="把領域需求翻譯成具體技術操作的實作該放哪、跟領域的邊界在哪時使用。adapter 是 port 的具體實作——技術細節被擋在六角形之外的位置。">adapter</a>。本案的二擇：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>寫入點 emit</th>
          <th>儲存層 update hook</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>確定性</td>
          <td>高：明確知道哪些操作觸發通知</td>
          <td>低：任何 row 變更都觸發、含 migration</td>
      </tr>
      <tr>
          <td>粒度</td>
          <td>精確：只在領域寫入方法尾端</td>
          <td>粗：要再過濾 table 與操作類型</td>
      </tr>
      <tr>
          <td>平台依賴</td>
          <td>無：純標準庫 controller</td>
          <td>有：依賴儲存引擎的 hook API</td>
      </tr>
      <tr>
          <td>裝飾層相容性</td>
          <td>好：decorator 直接轉發 stream</td>
          <td>要處理快取失效與 hook 的時序</td>
      </tr>
  </tbody>
</table>
<p>本案選寫入點 emit：repository 在每個實際執行寫入的方法尾端發出最新書單。它的維護成本（新增寫入方法要記得掛 emit）留在單一類別內部、被測試釘住；update hook 的 false positive 則會流出去變成消費端的雜訊。關鍵的架構事實是：<strong>這整個表格的內容都不出現在契約層</strong>——選哪個、換哪個，介面簽名一個字不動。這正是三分成立的證據：機制可替換、契約穩定。</p>
<h2 id="組裝層框架型別止步的位置">組裝層：框架型別止步的位置</h2>
<p>組裝層把 domain 的 <code>Stream</code> 翻譯成框架的觀察原語。本案是 Riverpod 的 <code>StreamProvider</code>：</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">watchBooksProvider</span> <span class="o">=</span> <span class="n">StreamProvider</span><span class="o">&lt;</span><span class="n">List</span><span class="o">&lt;</span><span class="n">Book</span><span class="o">&gt;&gt;</span><span class="p">((</span><span class="n">ref</span><span class="p">)</span> <span class="kd">async</span><span class="o">*</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">final</span> <span class="n">repository</span> <span class="o">=</span> <span class="n">ref</span><span class="p">.</span><span class="n">watch</span><span class="p">(</span><span class="n">bookRepositoryProvider</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kd">yield</span> <span class="kd">await</span> <span class="n">repository</span><span class="p">.</span><span class="n">getAllBooks</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">yield</span><span class="o">*</span> <span class="n">repository</span><span class="p">.</span><span class="n">watchBooks</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="p">});</span></span></span></code></pre></div><p><code>StreamProvider</code>、<code>Ref</code>、<code>AsyncValue</code> 這些框架型別在這層第一次出現、也只在這層出現。組裝層同時吸收「呈現需求」性質的行為——訂閱當下要先看到當前值，是消費端的需要、不是「變更通知」語意的一部分，所以那兩行 <code>yield</code> 住在這裡而非 repository 裡。組裝層的其他責任（誰插上誰、插上了沒有的證言）見 <a href="/blog/ddd/composition-root-reachability/" data-link-title="組裝層的可達性" data-link-desc="行為測試全綠、功能在實機上沒有入口的失效形態出現時使用。mock 換掉的正是組裝，組裝完成與否在行為測試裡沒有證言；把可達性當成組裝層的不變式，在測試、發版與設計文件各給一個強制點。">組裝層的可達性</a>；本案的 Flutter 實作細節見 <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>。</p>
<h2 id="三分總表">三分總表</h2>
<table>
  <thead>
      <tr>
          <th>層</th>
          <th>產出</th>
          <th>表達語言</th>
          <th>歸屬</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>契約</td>
          <td><code>Stream&lt;List&lt;Book&gt;&gt; watchBooks()</code> 宣告</td>
          <td>語言標準庫 + domain entity</td>
          <td>domain repository 介面</td>
      </tr>
      <tr>
          <td>機制</td>
          <td>broadcast controller + 寫入點 emit</td>
          <td>infrastructure 內部詞彙</td>
          <td>adapter（SQLite 實作類）</td>
      </tr>
      <tr>
          <td>組裝</td>
          <td><code>StreamProvider</code> 包裝 + <code>ref.watch</code></td>
          <td>框架語言</td>
          <td>DI／presentation 層</td>
      </tr>
  </tbody>
</table>
<p>三列共用同一條判準：看那一層的產出用什麼語言說話。這條判準的機械性有明確範圍——它機械地回答「一個已寫好的簽名該歸哪一層」：打開簽名、逐型別問「這是誰的詞彙」，答案不依賴對「純淨」的品味爭論。它不回答「一段新行為該寫進哪一層」（如初始值那兩行 <code>yield</code>）——那是語意歸屬決策，要判斷行為屬於誰的語意，跟機制層選 emit 時機一樣是設計判斷、不是型別檢查。把兩者混為一談、以為整篇的歸屬都能靠型別自動判定，正是這條判準最容易被過度推廣的地方。</p>
<h2 id="邊界">邊界</h2>
<p>觀測出口通知的是「資料現在長什麼樣」；domain event 記錄的是「發生過什麼業務事實」。兩者正交、互不取代——本案落地觀測出口時，既有的事件發布點零改動。這個正交性有一個架構前提：狀態存在獨立的資料庫、事件只是旁支通知。event-sourced 架構下狀態由事件重建、沒有獨立持久化的當前值，機制層要生出 <code>watchBooks()</code> 只能訂閱同一條事件流做投影——那時變更偵測與 domain event 不再是兩條獨立管線、「零改動」的論證不成立。把 event 借用成刷新訊號的代價、以及兩種載體的選用判準，見 <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>三分的判準確定之後，下一個問題通常是「要不要抽讀 port」——先讀 <a href="/blog/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準</a> 再做這個決策。還沒建觀測出口、仍在用事件或導航補償的話，回頭讀 <a href="/blog/ddd/domain-event-vs-state-stream/" data-link-title="domain event 與狀態流" data-link-desc="為了讓某個畫面刷新而補發事件、或監聽端掛著全事件過濾器時使用。事件記錄離散事實、狀態流發布連續觀測——判準是消費者問「發生了什麼」還是「現在是什麼」；載體借用的代價是涵蓋面靠枚舉維持。">domain event 與狀態流</a> 先確認載體選擇，確認要用狀態流再進本章。</p>
<ul>
<li>補償演進與 Riverpod reactive 邊界的案例全文 → <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></li>
<li>機制層與組裝層的實作點（broadcast、初始值、dispose）→ <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></li>
</ul>
]]></content:encoded></item><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>State Stream（狀態流）</title><link>https://tarrragon.github.io/blog/ddd/knowledge-cards/state-stream/</link><pubDate>Thu, 16 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/knowledge-cards/state-stream/</guid><description>&lt;p>狀態流是持續發布「某份資料當前值」的觀測載體：新值蓋過舊值、只有最新值有意義、錯過中間值無代價——下一次快照涵蓋一切。與 &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 回答「發生了什麼」。與 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/snapshot/" data-link-title="Snapshot" data-link-desc="歷史記錄是否應該凍結當時狀態時使用。Snapshot 是某一時刻的狀態複本——歷史不隨現在的資料漂移。">snapshot&lt;/a> 的差別在時間方向：snapshot 凍結某一刻、保證歷史不隨現在漂移；狀態流永遠指向現在、舊值被覆蓋是設計語意。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>狀態流是 &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 對應——能力橫跨三層、歸屬由各層的表達語言決定。">觀測出口&lt;/a> 的載體形態：觀測出口定義「資料變了、我能告訴你」這個能力歸屬哪一層、狀態流定義這個通知的語意——連續快照、不是離散事實。它的涵蓋面是寫入操作的集合：emit 掛在每個寫入方法尾端、新路徑必然經過、涵蓋不靠任何人記得發——與 domain event「業務語意決定發布點、逐事實設計」的涵蓋方式正交。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>系統缺狀態流出口時，補償的形狀是訊號：導航返回點出現手動 reload、為了讓某頁刷新而補發事件、監聽端掛全事件過濾器。事件被誤用成狀態流的完整判讀訊號表在 &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>狀態流承擔「畫面、快取、衍生視圖對資料當前值的觀測」，錯過的代價為零是它跟 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>、實作選型（broadcast、初始值、dispose）見 &lt;a href="https://tarrragon.github.io/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&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>狀態流是持續發布「某份資料當前值」的觀測載體：新值蓋過舊值、只有最新值有意義、錯過中間值無代價——下一次快照涵蓋一切。與 <a href="/blog/ddd/knowledge-cards/domain-event/" data-link-title="Domain Event" data-link-desc="系統裡出現「為了讓某頁刷新而補發事件」或「監聽端掛全事件過濾器」時使用。domain event 是已發生的業務事實——過去式命名、發布後不可變、錯過代表事實遺失。">domain event</a> 的分界在消費者的問題：狀態流回答「現在是什麼」、event 回答「發生了什麼」。與 <a href="/blog/ddd/knowledge-cards/snapshot/" data-link-title="Snapshot" data-link-desc="歷史記錄是否應該凍結當時狀態時使用。Snapshot 是某一時刻的狀態複本——歷史不隨現在的資料漂移。">snapshot</a> 的差別在時間方向：snapshot 凍結某一刻、保證歷史不隨現在漂移；狀態流永遠指向現在、舊值被覆蓋是設計語意。</p>
<h2 id="概念位置">概念位置</h2>
<p>狀態流是 <a href="/blog/ddd/knowledge-cards/observation-outlet/" data-link-title="Observation Outlet（觀測出口）" data-link-desc="repository 只有 pull 介面、衍生視圖靠補償刷新，考慮補「資料變了」的推送能力時使用。觀測出口是 pull 介面的 push 對應——能力橫跨三層、歸屬由各層的表達語言決定。">觀測出口</a> 的載體形態：觀測出口定義「資料變了、我能告訴你」這個能力歸屬哪一層、狀態流定義這個通知的語意——連續快照、不是離散事實。它的涵蓋面是寫入操作的集合：emit 掛在每個寫入方法尾端、新路徑必然經過、涵蓋不靠任何人記得發——與 domain event「業務語意決定發布點、逐事實設計」的涵蓋方式正交。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>系統缺狀態流出口時，補償的形狀是訊號：導航返回點出現手動 reload、為了讓某頁刷新而補發事件、監聽端掛全事件過濾器。事件被誤用成狀態流的完整判讀訊號表在 <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>狀態流承擔「畫面、快取、衍生視圖對資料當前值的觀測」，錯過的代價為零是它跟 event 不可互換的原因——需要逐筆事實（審計、下游流程）的消費者拿快照會斷檔。載體選用判準展開見 <a href="/blog/ddd/domain-event-vs-state-stream/" data-link-title="domain event 與狀態流" data-link-desc="為了讓某個畫面刷新而補發事件、或監聽端掛著全事件過濾器時使用。事件記錄離散事實、狀態流發布連續觀測——判準是消費者問「發生了什麼」還是「現在是什麼」；載體借用的代價是涵蓋面靠枚舉維持。">domain event 與狀態流</a>、實作選型（broadcast、初始值、dispose）見 <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>。</p>
]]></content:encoded></item><item><title>Observation Outlet（觀測出口）</title><link>https://tarrragon.github.io/blog/ddd/knowledge-cards/observation-outlet/</link><pubDate>Thu, 16 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/knowledge-cards/observation-outlet/</guid><description>&lt;p>觀測出口是 repository 對外提供的「資料變了」持續通知能力——pull 介面（&lt;code>getAllBooks()&lt;/code> 回 &lt;code>Future&lt;/code>）的 push 對應（&lt;code>watchBooks()&lt;/code> 回 &lt;code>Stream&lt;/code>）。它的載體是 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/state-stream/" data-link-title="State Stream（狀態流）" data-link-desc="畫面刷新靠補償、或考慮拿既有事件當刷新訊號時使用。狀態流是持續發布「資料當前值」的觀測載體——新值蓋過舊值、錯過中間值無代價，回答「現在是什麼」。">狀態流&lt;/a>：通知的內容是資料當前值、不是業務事實。能力橫跨三層——契約（介面宣告）、機制（變更偵測）、組裝（框架訂閱），每一層的歸屬由該層產出的表達語言決定。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>觀測出口的契約層是一種 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/port/" data-link-title="Port" data-link-desc="判斷介面該宣告在哪一層、依賴方向該朝哪時使用。port 是 domain 對外宣告的介面——需求用領域語言說完、技術細節留在實作端。">port&lt;/a>：簽名只用語言標準庫與 domain entity、放 domain repository 介面，與 pull 方法形成對稱。機制層（broadcast controller、寫入點 emit）歸 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/adapter/" data-link-title="Adapter" data-link-desc="把領域需求翻譯成具體技術操作的實作該放哪、跟領域的邊界在哪時使用。adapter 是 port 的具體實作——技術細節被擋在六角形之外的位置。">adapter&lt;/a>；組裝層（框架 provider 包裝）歸 DI／presentation。三層歸屬的完整判準與「需求來源不決定歸屬」的推導見 &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>repository 缺觀測出口時，每個衍生視圖各自解「怎麼知道資料變了」：導航返回點補 reload、EventBus 橋接刷新、多個視圖各自維護 load 時機。補償策略的交叉與涵蓋缺口是這個能力該補的訊號。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>觀測出口讓「資料變更」成為可訂閱的一級節點，涵蓋面等於寫入操作的集合——emit 掛在寫入方法尾端、新路徑自動涵蓋。它通知「現在是什麼」、不記錄「發生了什麼」——後者是 &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> 的責任，兩者正交。落地的實作點（broadcast、初始值、dispose）見 &lt;a href="https://tarrragon.github.io/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&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>觀測出口是 repository 對外提供的「資料變了」持續通知能力——pull 介面（<code>getAllBooks()</code> 回 <code>Future</code>）的 push 對應（<code>watchBooks()</code> 回 <code>Stream</code>）。它的載體是 <a href="/blog/ddd/knowledge-cards/state-stream/" data-link-title="State Stream（狀態流）" data-link-desc="畫面刷新靠補償、或考慮拿既有事件當刷新訊號時使用。狀態流是持續發布「資料當前值」的觀測載體——新值蓋過舊值、錯過中間值無代價，回答「現在是什麼」。">狀態流</a>：通知的內容是資料當前值、不是業務事實。能力橫跨三層——契約（介面宣告）、機制（變更偵測）、組裝（框架訂閱），每一層的歸屬由該層產出的表達語言決定。</p>
<h2 id="概念位置">概念位置</h2>
<p>觀測出口的契約層是一種 <a href="/blog/ddd/knowledge-cards/port/" data-link-title="Port" data-link-desc="判斷介面該宣告在哪一層、依賴方向該朝哪時使用。port 是 domain 對外宣告的介面——需求用領域語言說完、技術細節留在實作端。">port</a>：簽名只用語言標準庫與 domain entity、放 domain repository 介面，與 pull 方法形成對稱。機制層（broadcast controller、寫入點 emit）歸 <a href="/blog/ddd/knowledge-cards/adapter/" data-link-title="Adapter" data-link-desc="把領域需求翻譯成具體技術操作的實作該放哪、跟領域的邊界在哪時使用。adapter 是 port 的具體實作——技術細節被擋在六角形之外的位置。">adapter</a>；組裝層（框架 provider 包裝）歸 DI／presentation。三層歸屬的完整判準與「需求來源不決定歸屬」的推導見 <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>repository 缺觀測出口時，每個衍生視圖各自解「怎麼知道資料變了」：導航返回點補 reload、EventBus 橋接刷新、多個視圖各自維護 load 時機。補償策略的交叉與涵蓋缺口是這個能力該補的訊號。</p>
<h2 id="設計責任">設計責任</h2>
<p>觀測出口讓「資料變更」成為可訂閱的一級節點，涵蓋面等於寫入操作的集合——emit 掛在寫入方法尾端、新路徑自動涵蓋。它通知「現在是什麼」、不記錄「發生了什麼」——後者是 <a href="/blog/ddd/knowledge-cards/domain-event/" data-link-title="Domain Event" data-link-desc="系統裡出現「為了讓某頁刷新而補發事件」或「監聽端掛全事件過濾器」時使用。domain event 是已發生的業務事實——過去式命名、發布後不可變、錯過代表事實遺失。">domain event</a> 的責任，兩者正交。落地的實作點（broadcast、初始值、dispose）見 <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>。</p>
]]></content:encoded></item></channel></rss>