<?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>Repository on Tarragon</title><link>https://tarrragon.github.io/blog/tags/repository/</link><description>Recent content in Repository 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/repository/index.xml" rel="self" type="application/rss+xml"/><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>讀模型的升級判準</title><link>https://tarrragon.github.io/blog/ddd/read-model-upgrade-signals/</link><pubDate>Thu, 16 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/read-model-upgrade-signals/</guid><description>&lt;p>&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 的形狀分離。">讀模型&lt;/a>（read model）是為讀需求的形狀而建的查詢側模型：它回答「畫面或報表需要什麼形狀的資料」、而 &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> 回答「&lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/aggregate-root/" data-link-title="Aggregate Root" data-link-desc="跨物件一致性的邊界設計時使用。聚合根是對外代表一組資料一致性的邊界物件——外部只跟它互動、它保證內部的不變式。">aggregate&lt;/a> 長什麼形狀」。讀側的設計是一道階梯、不是「要不要 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/cqrs/" data-link-title="CQRS" data-link-desc="有人提議「上 CQRS」、或想知道讀寫分離該做到多徹底時使用。CQRS 是把讀操作與寫操作的模型拆開的架構決定——寫側守一致性、讀側服務查詢形狀，兩者可以各自有獨立的儲存與更新節奏。">CQRS&lt;/a>」的開關——多數專案的正確位置在階梯低處，升級由訊號驅動。本章給出階梯的四階、升級的五個訊號、以及一句可機械執行的自檢問句：&lt;/p>
&lt;blockquote>
&lt;p>這個查詢回傳的是&lt;strong>讀的形狀&lt;/strong>、還是 &lt;strong>aggregate 的形狀&lt;/strong>？&lt;/p>&lt;/blockquote>
&lt;p>回傳 aggregate 形狀（entity 或 entity 集合）的查詢屬於 repository；回傳讀的形狀（統計值、扁平列表、跨 aggregate 拼裝）的查詢是讀模型的候選。問句每次新增查詢方法時問一次，答案累積起來就是五訊號的量測值。&lt;/p>
&lt;h2 id="階梯四階與每階的代價">階梯：四階與每階的代價&lt;/h2>
&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>一&lt;/td>
 &lt;td>repository 查詢方法（pull 或 push、回 aggregate 形狀）&lt;/td>
 &lt;td>消費端（ViewModel／service）自行投影&lt;/td>
 &lt;td>零：用既有介面&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>二&lt;/td>
 &lt;td>讀 port 抽離（獨立查詢介面、同一儲存）&lt;/td>
 &lt;td>port 實作內&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>CQRS 全套（讀寫獨立儲存、事件同步）&lt;/td>
 &lt;td>事件消費端&lt;/td>
 &lt;td>同步管線 + 最終一致性&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每一階都比上一階多買一種能力、也多付一種持續成本。第一階的能力是簡單：所有讀需求共用一條資料通路，消費端各自把 aggregate 形狀折成自己要的樣子。第二階買到介面隔離：讀需求多到 repository 介面開始臃腫時，把查詢集合抽成獨立 port，寫側介面回到精簡。第三階買到形狀與效能的自由：讀的形狀在儲存或快取層物化，查詢不再每次從 aggregate 折算。第四階買到讀寫各自極致最佳化，代價是最終一致性進入系統語意——畫面可能短暫顯示舊值、而且這是設計內行為。&lt;/p>
&lt;p>階梯的方向性很重要：&lt;strong>每一階的介面簽名對消費端穩定&lt;/strong>。從第一階爬到第二階時，查詢方法從 repository 介面遷到讀 port、簽名不變、呼叫端只改注入來源。這讓「先停在低階」是安全決策而非技術債——升級路徑不會被低階選擇堵死。&lt;/p>
&lt;h2 id="五訊號">五訊號&lt;/h2>
&lt;p>以下五個訊號涵蓋技術與效能維度、各自獨立量測，每個訊號指向階梯上的一個目標階：訊號一指向第二階（介面隔離）、訊號二指向第三階（形狀物化）、訊號三與訊號四指向第三階（獨立快取與新鮮度分級）、訊號五指向第三到第四階（獨立演進延伸到讀寫分離）。命中越多、且指向的階越高，往上爬的理由越強。但爬幾階沒有可套的公式：本章案例只完整走過「零命中、停第一階」這一個決策，多訊號命中時的爬升幅度要按每個訊號背後的業務代價個案衡量，而不是把命中數當分數累加。技術訊號之外還有一類驅動——組織與團隊擁有權邊界——性質與這五個正交，單獨成段在五訊號之後。&lt;/p>
&lt;h3 id="訊號一讀需求增生">訊號一：讀需求增生&lt;/h3>
&lt;p>專用查詢方法累積到三條以上、且形狀彼此不同（一條回統計、一條回扁平列表、一條回分頁切片），repository 介面開始為讀需求膨脹。這是「第一階 → 第二階」的典型訊號：查詢集合已經大到值得一個自己的介面。三條是硬性起點：達到三條就把「介面裡讀方法與寫方法的比例」列入下次 review 的觀察項。讀方法數量超過寫方法的兩倍時，介面已經在為讀側服務——這是這個訊號的行動門檻。&lt;/p>
&lt;h3 id="訊號二讀的形狀偏離-aggregate-形狀">訊號二：讀的形狀偏離 aggregate 形狀&lt;/h3>
&lt;p>自檢問句的直接輸出。統計值（總數、分組計數）、跨 aggregate 的拼裝（書 + 借閱人 + 標籤樹的合成畫面）、為排序或搜尋而反正規化的扁平投影——這些形狀讓消費端的「自行投影」從幾行 &lt;code>map&lt;/code> 長成一段業務邏輯。投影邏輯值得有自己的家（第三階），而不是散在每個 ViewModel 裡各寫一份。&lt;/p>
&lt;h3 id="訊號三讀寫負載特性分歧">訊號三：讀寫負載特性分歧&lt;/h3>
&lt;p>讀高頻寫低頻（商品目錄）或寫高頻讀低頻（事件記錄）到同一模型無法同時服務兩邊時，讀側需要自己的快取或儲存策略。分歧要用量測支撐——「感覺查詢很多」不是訊號，慢查詢記錄和快取命中率才是。這個訊號沒有通用閾值：「命中率低到多少該行動」由服務的 SLA 決定（電商結帳頁和內部報表的容忍度差一個數量級），量測工具對了就夠用、門檻要帶服務脈絡才有意義。&lt;/p>
&lt;h3 id="訊號四讀側可接受的新鮮度不同">訊號四：讀側可接受的新鮮度不同&lt;/h3>
&lt;p>報表可以慢十分鐘、交易畫面必須即時——同一份資料的不同讀者對「多舊算舊」的答案不同時，用一個模型服務所有人會被最嚴格的需求綁架。新鮮度分級是第三、四階才買得到的能力：投影可以有自己的更新節奏。&lt;/p>
&lt;h3 id="訊號五讀模型需要獨立演進">訊號五：讀模型需要獨立演進&lt;/h3>
&lt;p>不同消費者要不同版本的視圖（對外 API 的公開形狀 vs 內部畫面的完整形狀）、或讀形狀的變更頻率遠高於 aggregate 本身。讀側從此有自己的版本生命週期，跟 aggregate 綁在一起只會互相拖累。&lt;/p>
&lt;h2 id="技術訊號之外團隊與交付邊界">技術訊號之外：團隊與交付邊界&lt;/h2>
&lt;p>五個訊號涵蓋技術與效能維度。另一類常見且獨立的升級驅動是組織邊界：讀側與寫側由不同團隊擁有、需要獨立部署節奏與獨立資料契約。即使五個技術訊號全部零命中，團隊自治的壓力仍可能合理地把系統推上第三或第四階——讀側的模型、儲存、部署由另一個團隊全權管理，Conway&amp;rsquo;s Law 讓組織結構成為架構的驅動力，跟負載分歧或形狀偏離這類技術維度完全正交。本章不展開組織驅動（涉及團隊拓撲與交付流程），只在此標記它的存在：把五訊號當成升級的全部理由，會漏掉組織維度的命中。&lt;/p>
&lt;h2 id="階梯的適用範圍">階梯的適用範圍&lt;/h2>
&lt;p>四階假設在單一 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/bounded-context/" data-link-title="Bounded Context" data-link-desc="同一套架構判準跨到另一個服務還站不站得住？bounded context 是模型與詞彙保持一致的邊界——邊界內的推導在邊界外不必然成立。">bounded context&lt;/a> 內操作。跨服務聯合讀模型（報表服務訂閱多個 domain 的事件建置共享視圖）引入的關注點——跨服務事件契約穩定性、schema 演進、跨信任邊界的最終一致性——不是「多買一種能力」可概括，超出本階梯的覆蓋範圍。&lt;/p>
&lt;h2 id="案例停在第一階的決策">案例：停在第一階的決策&lt;/h2>
&lt;p>書庫管理 App 為 repository 補了觀測出口 &lt;code>watchBooks()&lt;/code>（背景與三層歸屬見 &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;code>watchBooks()&lt;/code> 放既有 repository 介面、還是抽一個獨立的讀 port？&lt;/p>
&lt;p>用五訊號量測當時的狀態：&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;/td>
 &lt;td>否&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>形狀偏離&lt;/td>
 &lt;td>&lt;code>watchBooks()&lt;/code> 回 &lt;code>Stream&amp;lt;List&amp;lt;Book&amp;gt;&amp;gt;&lt;/code>——正是 aggregate 的形狀&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>所有衍生視圖都要即時&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>五訊號零命中、自檢問句答「aggregate 的形狀」——結論是停在第一階：&lt;code>watchBooks()&lt;/code> 進既有 repository 介面、與 &lt;code>getAllBooks()&lt;/code> 形成 pull／push 對稱，各衍生視圖在 ViewModel 層各自投影（統計頁算總數、待補完列表過濾欄位缺漏）。抽讀 port 在此刻是為一個方法建一個介面、買不到任何一階的能力、只付碎片化的成本。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/ddd/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="查詢該由 repository 回 aggregate、還是該有自己的查詢側模型時使用。read model 是為讀需求的形狀而建的模型——回答「畫面需要什麼形狀」、與 aggregate 的形狀分離。">讀模型</a>（read model）是為讀需求的形狀而建的查詢側模型：它回答「畫面或報表需要什麼形狀的資料」、而 <a href="/blog/ddd/knowledge-cards/repository/" data-link-title="Repository" data-link-desc="查詢方法該留在 repository、還是該抽成獨立讀模型時使用。repository 是 aggregate 的存取抽象——回傳的形狀是 aggregate 的形狀，不是讀的形狀。">repository</a> 回答「<a href="/blog/ddd/knowledge-cards/aggregate-root/" data-link-title="Aggregate Root" data-link-desc="跨物件一致性的邊界設計時使用。聚合根是對外代表一組資料一致性的邊界物件——外部只跟它互動、它保證內部的不變式。">aggregate</a> 長什麼形狀」。讀側的設計是一道階梯、不是「要不要 <a href="/blog/ddd/knowledge-cards/cqrs/" data-link-title="CQRS" data-link-desc="有人提議「上 CQRS」、或想知道讀寫分離該做到多徹底時使用。CQRS 是把讀操作與寫操作的模型拆開的架構決定——寫側守一致性、讀側服務查詢形狀，兩者可以各自有獨立的儲存與更新節奏。">CQRS</a>」的開關——多數專案的正確位置在階梯低處，升級由訊號驅動。本章給出階梯的四階、升級的五個訊號、以及一句可機械執行的自檢問句：</p>
<blockquote>
<p>這個查詢回傳的是<strong>讀的形狀</strong>、還是 <strong>aggregate 的形狀</strong>？</p></blockquote>
<p>回傳 aggregate 形狀（entity 或 entity 集合）的查詢屬於 repository；回傳讀的形狀（統計值、扁平列表、跨 aggregate 拼裝）的查詢是讀模型的候選。問句每次新增查詢方法時問一次，答案累積起來就是五訊號的量測值。</p>
<h2 id="階梯四階與每階的代價">階梯：四階與每階的代價</h2>
<table>
  <thead>
      <tr>
          <th>階</th>
          <th>形態</th>
          <th>讀的形狀在哪裡產生</th>
          <th>新增代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>一</td>
          <td>repository 查詢方法（pull 或 push、回 aggregate 形狀）</td>
          <td>消費端（ViewModel／service）自行投影</td>
          <td>零：用既有介面</td>
      </tr>
      <tr>
          <td>二</td>
          <td>讀 port 抽離（獨立查詢介面、同一儲存）</td>
          <td>port 實作內</td>
          <td>一個介面 + 注入點</td>
      </tr>
      <tr>
          <td>三</td>
          <td>專用讀模型（獨立投影、可獨立快取）</td>
          <td>投影建構器內</td>
          <td>投影邏輯 + 失效策略</td>
      </tr>
      <tr>
          <td>四</td>
          <td>CQRS 全套（讀寫獨立儲存、事件同步）</td>
          <td>事件消費端</td>
          <td>同步管線 + 最終一致性</td>
      </tr>
  </tbody>
</table>
<p>每一階都比上一階多買一種能力、也多付一種持續成本。第一階的能力是簡單：所有讀需求共用一條資料通路，消費端各自把 aggregate 形狀折成自己要的樣子。第二階買到介面隔離：讀需求多到 repository 介面開始臃腫時，把查詢集合抽成獨立 port，寫側介面回到精簡。第三階買到形狀與效能的自由：讀的形狀在儲存或快取層物化，查詢不再每次從 aggregate 折算。第四階買到讀寫各自極致最佳化，代價是最終一致性進入系統語意——畫面可能短暫顯示舊值、而且這是設計內行為。</p>
<p>階梯的方向性很重要：<strong>每一階的介面簽名對消費端穩定</strong>。從第一階爬到第二階時，查詢方法從 repository 介面遷到讀 port、簽名不變、呼叫端只改注入來源。這讓「先停在低階」是安全決策而非技術債——升級路徑不會被低階選擇堵死。</p>
<h2 id="五訊號">五訊號</h2>
<p>以下五個訊號涵蓋技術與效能維度、各自獨立量測，每個訊號指向階梯上的一個目標階：訊號一指向第二階（介面隔離）、訊號二指向第三階（形狀物化）、訊號三與訊號四指向第三階（獨立快取與新鮮度分級）、訊號五指向第三到第四階（獨立演進延伸到讀寫分離）。命中越多、且指向的階越高，往上爬的理由越強。但爬幾階沒有可套的公式：本章案例只完整走過「零命中、停第一階」這一個決策，多訊號命中時的爬升幅度要按每個訊號背後的業務代價個案衡量，而不是把命中數當分數累加。技術訊號之外還有一類驅動——組織與團隊擁有權邊界——性質與這五個正交，單獨成段在五訊號之後。</p>
<h3 id="訊號一讀需求增生">訊號一：讀需求增生</h3>
<p>專用查詢方法累積到三條以上、且形狀彼此不同（一條回統計、一條回扁平列表、一條回分頁切片），repository 介面開始為讀需求膨脹。這是「第一階 → 第二階」的典型訊號：查詢集合已經大到值得一個自己的介面。三條是硬性起點：達到三條就把「介面裡讀方法與寫方法的比例」列入下次 review 的觀察項。讀方法數量超過寫方法的兩倍時，介面已經在為讀側服務——這是這個訊號的行動門檻。</p>
<h3 id="訊號二讀的形狀偏離-aggregate-形狀">訊號二：讀的形狀偏離 aggregate 形狀</h3>
<p>自檢問句的直接輸出。統計值（總數、分組計數）、跨 aggregate 的拼裝（書 + 借閱人 + 標籤樹的合成畫面）、為排序或搜尋而反正規化的扁平投影——這些形狀讓消費端的「自行投影」從幾行 <code>map</code> 長成一段業務邏輯。投影邏輯值得有自己的家（第三階），而不是散在每個 ViewModel 裡各寫一份。</p>
<h3 id="訊號三讀寫負載特性分歧">訊號三：讀寫負載特性分歧</h3>
<p>讀高頻寫低頻（商品目錄）或寫高頻讀低頻（事件記錄）到同一模型無法同時服務兩邊時，讀側需要自己的快取或儲存策略。分歧要用量測支撐——「感覺查詢很多」不是訊號，慢查詢記錄和快取命中率才是。這個訊號沒有通用閾值：「命中率低到多少該行動」由服務的 SLA 決定（電商結帳頁和內部報表的容忍度差一個數量級），量測工具對了就夠用、門檻要帶服務脈絡才有意義。</p>
<h3 id="訊號四讀側可接受的新鮮度不同">訊號四：讀側可接受的新鮮度不同</h3>
<p>報表可以慢十分鐘、交易畫面必須即時——同一份資料的不同讀者對「多舊算舊」的答案不同時，用一個模型服務所有人會被最嚴格的需求綁架。新鮮度分級是第三、四階才買得到的能力：投影可以有自己的更新節奏。</p>
<h3 id="訊號五讀模型需要獨立演進">訊號五：讀模型需要獨立演進</h3>
<p>不同消費者要不同版本的視圖（對外 API 的公開形狀 vs 內部畫面的完整形狀）、或讀形狀的變更頻率遠高於 aggregate 本身。讀側從此有自己的版本生命週期，跟 aggregate 綁在一起只會互相拖累。</p>
<h2 id="技術訊號之外團隊與交付邊界">技術訊號之外：團隊與交付邊界</h2>
<p>五個訊號涵蓋技術與效能維度。另一類常見且獨立的升級驅動是組織邊界：讀側與寫側由不同團隊擁有、需要獨立部署節奏與獨立資料契約。即使五個技術訊號全部零命中，團隊自治的壓力仍可能合理地把系統推上第三或第四階——讀側的模型、儲存、部署由另一個團隊全權管理，Conway&rsquo;s Law 讓組織結構成為架構的驅動力，跟負載分歧或形狀偏離這類技術維度完全正交。本章不展開組織驅動（涉及團隊拓撲與交付流程），只在此標記它的存在：把五訊號當成升級的全部理由，會漏掉組織維度的命中。</p>
<h2 id="階梯的適用範圍">階梯的適用範圍</h2>
<p>四階假設在單一 <a href="/blog/ddd/knowledge-cards/bounded-context/" data-link-title="Bounded Context" data-link-desc="同一套架構判準跨到另一個服務還站不站得住？bounded context 是模型與詞彙保持一致的邊界——邊界內的推導在邊界外不必然成立。">bounded context</a> 內操作。跨服務聯合讀模型（報表服務訂閱多個 domain 的事件建置共享視圖）引入的關注點——跨服務事件契約穩定性、schema 演進、跨信任邊界的最終一致性——不是「多買一種能力」可概括，超出本階梯的覆蓋範圍。</p>
<h2 id="案例停在第一階的決策">案例：停在第一階的決策</h2>
<p>書庫管理 App 為 repository 補了觀測出口 <code>watchBooks()</code>（背景與三層歸屬見 <a href="/blog/ddd/observation-outlet-responsibility-split/" data-link-title="觀測出口的職責三分" data-link-desc="repository 要補「資料變了」的推送能力、卻不確定 Stream 介面放 domain 算不算洩漏時使用。歸屬判準是介面用什麼語言表達、不是需求來自誰：契約歸 domain、變更偵測歸 infrastructure、框架訂閱歸組裝層。">觀測出口的職責三分</a>）。落地時面對的正是本章的二擇：<code>watchBooks()</code> 放既有 repository 介面、還是抽一個獨立的讀 port？</p>
<p>用五訊號量測當時的狀態：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>量測值</th>
          <th>命中</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>讀需求增生</td>
          <td>推送型讀需求只有「完整書單流」一條</td>
          <td>否</td>
      </tr>
      <tr>
          <td>形狀偏離</td>
          <td><code>watchBooks()</code> 回 <code>Stream&lt;List&lt;Book&gt;&gt;</code>——正是 aggregate 的形狀</td>
          <td>否</td>
      </tr>
      <tr>
          <td>負載分歧</td>
          <td>單人離線書庫、讀寫皆低頻</td>
          <td>否</td>
      </tr>
      <tr>
          <td>新鮮度分級</td>
          <td>所有衍生視圖都要即時</td>
          <td>否</td>
      </tr>
      <tr>
          <td>獨立演進</td>
          <td>消費者全是自家畫面、一個形狀通吃</td>
          <td>否</td>
      </tr>
  </tbody>
</table>
<p>五訊號零命中、自檢問句答「aggregate 的形狀」——結論是停在第一階：<code>watchBooks()</code> 進既有 repository 介面、與 <code>getAllBooks()</code> 形成 pull／push 對稱，各衍生視圖在 ViewModel 層各自投影（統計頁算總數、待補完列表過濾欄位缺漏）。抽讀 port 在此刻是為一個方法建一個介面、買不到任何一階的能力、只付碎片化的成本。</p>
<p>決策同時綁了升級 trigger：推送型讀需求增生（專用統計投影、分頁查詢流之類累積到三條）時再抽讀 port，屆時 <code>watchBooks()</code> 遷入讀 port、簽名不變、呼叫端只改注入來源。「停在低階」與「記錄何時升級」是同一個決策的兩半——少了後半、低階會在訊號早已命中之後仍靠慣性維持。</p>
<h2 id="判準防的兩種錯">判準防的兩種錯</h2>
<p>五訊號同時防兩個方向的失誤，兩邊在真實專案都常見：</p>
<p><strong>太早抽</strong>：還沒有第二條讀需求就先建讀 port、投影層、甚至讀寫分離骨架——每一層都是要餵養的抽象。訊號零命中時的高階結構，日常成本是每個新查詢都要穿過多一層介面、而買到的能力沒有消費者。</p>
<p><strong>太晚抽</strong>：repository 介面長滿 <code>getBooksGroupedByTag()</code>、<code>getMonthlyStatistics()</code>、<code>searchWithPagination()</code>——每條都「只是加一個方法」，直到介面的讀方法數量淹過寫方法、每個 mock 都要 stub 幾十個查詢。訊號早已命中、但沒有量測動作讓命中被看見。自檢問句的價值就在這裡：它把「要不要升級」從一次性的架構辯論、變成每次加方法時的例行量測。</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/ddd/domain-event-vs-state-stream/" data-link-title="domain event 與狀態流" data-link-desc="為了讓某個畫面刷新而補發事件、或監聽端掛著全事件過濾器時使用。事件記錄離散事實、狀態流發布連續觀測——判準是消費者問「發生了什麼」還是「現在是什麼」；載體借用的代價是涵蓋面靠枚舉維持。">domain event 與狀態流</a>。太晚抽的日常代價（介面臃腫、mock 爆炸）有實證：<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>。術語定義見 <a href="/blog/ddd/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="查詢該由 repository 回 aggregate、還是該有自己的查詢側模型時使用。read model 是為讀需求的形狀而建的模型——回答「畫面需要什麼形狀」、與 aggregate 的形狀分離。">Read Model</a>。</p>
]]></content:encoded></item><item><title>Read Model</title><link>https://tarrragon.github.io/blog/ddd/knowledge-cards/read-model/</link><pubDate>Thu, 16 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/knowledge-cards/read-model/</guid><description>&lt;p>read model 是為讀需求的形狀而建的查詢側模型：畫面或報表要什麼形狀（統計值、扁平列表、跨 aggregate 拼裝）、它就長什麼形狀。repository 回傳 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/aggregate-root/" data-link-title="Aggregate Root" data-link-desc="跨物件一致性的邊界設計時使用。聚合根是對外代表一組資料一致性的邊界物件——外部只跟它互動、它保證內部的不變式。">aggregate&lt;/a> 的形狀、守一致性邊界；read model 回傳讀的形狀、不承擔寫入責任。兩者的分工判準是一句自檢問句：「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。讀側的介面宣告是一種 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/port/" data-link-title="Port" data-link-desc="判斷介面該宣告在哪一層、依賴方向該朝哪時使用。port 是 domain 對外宣告的介面——需求用領域語言說完、技術細節留在實作端。">port&lt;/a>——同樣以領域語言表達、由查詢方的需要定義形狀。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>read model 是 CQRS 讀寫分離的讀側，與 &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 守一致性邊界、read model 服務讀的形狀。它的存在不以 CQRS 全套為前提——讀側是一道階梯：消費端自行投影、讀 port 抽離（獨立的 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/port/" data-link-title="Port" data-link-desc="判斷介面該宣告在哪一層、依賴方向該朝哪時使用。port 是 domain 對外宣告的介面——需求用領域語言說完、技術細節留在實作端。">port&lt;/a>）、專用投影、事件同步的獨立儲存，每一階都是 read model 概念的某種深度。低階的 read model 可以只是 ViewModel 裡幾行 &lt;code>map&lt;/code>；高階的 read model 有自己的儲存與更新節奏，接受與寫側的最終一致性。階梯橫跨 ephemeral（ViewModel 內聯投影，沒有獨立生命週期）到 durable（獨立儲存、事件同步），但各階共享同一個設計責任——「讀的形狀由讀需求定義、不由 aggregate 形狀決定」——因此視為同一概念的深度變體。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>repository 介面長出回傳統計值、分頁切片、反正規化投影的方法，是讀的形狀開始混進 aggregate 介面的訊號。反向的訊號同樣可觀察：還沒有第二條讀需求就先建投影層，抽象沒有消費者、每個新查詢多穿一層介面。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>read model 定義「讀側要什麼形狀」，升級到哪一階由訊號決定、不由架構偏好決定——五個升級訊號（讀需求增生、形狀偏離、負載分歧、新鮮度分級、獨立演進）與階梯各階的代價，教學層展開見 &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;/p></description><content:encoded><![CDATA[<p>read model 是為讀需求的形狀而建的查詢側模型：畫面或報表要什麼形狀（統計值、扁平列表、跨 aggregate 拼裝）、它就長什麼形狀。repository 回傳 <a href="/blog/ddd/knowledge-cards/aggregate-root/" data-link-title="Aggregate Root" data-link-desc="跨物件一致性的邊界設計時使用。聚合根是對外代表一組資料一致性的邊界物件——外部只跟它互動、它保證內部的不變式。">aggregate</a> 的形狀、守一致性邊界；read model 回傳讀的形狀、不承擔寫入責任。兩者的分工判準是一句自檢問句：「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。讀側的介面宣告是一種 <a href="/blog/ddd/knowledge-cards/port/" data-link-title="Port" data-link-desc="判斷介面該宣告在哪一層、依賴方向該朝哪時使用。port 是 domain 對外宣告的介面——需求用領域語言說完、技術細節留在實作端。">port</a>——同樣以領域語言表達、由查詢方的需要定義形狀。</p>
<h2 id="概念位置">概念位置</h2>
<p>read model 是 CQRS 讀寫分離的讀側，與 <a href="/blog/ddd/knowledge-cards/aggregate-root/" data-link-title="Aggregate Root" data-link-desc="跨物件一致性的邊界設計時使用。聚合根是對外代表一組資料一致性的邊界物件——外部只跟它互動、它保證內部的不變式。">aggregate root</a> 的寫側形成互補：aggregate 守一致性邊界、read model 服務讀的形狀。它的存在不以 CQRS 全套為前提——讀側是一道階梯：消費端自行投影、讀 port 抽離（獨立的 <a href="/blog/ddd/knowledge-cards/port/" data-link-title="Port" data-link-desc="判斷介面該宣告在哪一層、依賴方向該朝哪時使用。port 是 domain 對外宣告的介面——需求用領域語言說完、技術細節留在實作端。">port</a>）、專用投影、事件同步的獨立儲存，每一階都是 read model 概念的某種深度。低階的 read model 可以只是 ViewModel 裡幾行 <code>map</code>；高階的 read model 有自己的儲存與更新節奏，接受與寫側的最終一致性。階梯橫跨 ephemeral（ViewModel 內聯投影，沒有獨立生命週期）到 durable（獨立儲存、事件同步），但各階共享同一個設計責任——「讀的形狀由讀需求定義、不由 aggregate 形狀決定」——因此視為同一概念的深度變體。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>repository 介面長出回傳統計值、分頁切片、反正規化投影的方法，是讀的形狀開始混進 aggregate 介面的訊號。反向的訊號同樣可觀察：還沒有第二條讀需求就先建投影層，抽象沒有消費者、每個新查詢多穿一層介面。</p>
<h2 id="設計責任">設計責任</h2>
<p>read model 定義「讀側要什麼形狀」，升級到哪一階由訊號決定、不由架構偏好決定——五個升級訊號（讀需求增生、形狀偏離、負載分歧、新鮮度分級、獨立演進）與階梯各階的代價，教學層展開見 <a href="/blog/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準</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><item><title>Repository</title><link>https://tarrragon.github.io/blog/ddd/knowledge-cards/repository/</link><pubDate>Mon, 20 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/knowledge-cards/repository/</guid><description>&lt;p>repository 把「怎麼存、怎麼查」包裝成領域語言的介面：呼叫端看到的是存書、查書這類操作，看不到底層是資料庫、檔案還是記憶體。它是一種 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/port/" data-link-title="Port" data-link-desc="判斷介面該宣告在哪一層、依賴方向該朝哪時使用。port 是 domain 對外宣告的介面——需求用領域語言說完、技術細節留在實作端。">port&lt;/a>——依賴方向朝內、簽名只用領域型別——差別在 repository 專職 &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> 的存取。repository 回傳的形狀是 aggregate 的形狀（entity 或 entity 集合），這條界線是它跟 &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> 分工的起點。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>repository 預設是 pull 介面：呼叫端主動問「現在的資料長怎樣」，一次拿到一份 aggregate 形狀的答案。它不天生具備「資料變了通知我」的推送能力——這條能力屬於 &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>，是 repository 介面之上的另一層職責，需要另外設計才會出現。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>repository 介面開始長出 &lt;code>getMonthlyStatistics()&lt;/code>、&lt;code>searchWithPagination()&lt;/code> 這類回傳統計值或反正規化形狀的方法，是讀的形狀混進 aggregate 介面的訊號——查詢集合已經大到值得思考該不該抽獨立的讀側介面，量測與升級路徑見 &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;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>repository 的設計責任是守住 aggregate 一致性邊界的存取入口，不是最佳化每一種讀需求的效能與形狀——後者是 read model 的責任。repository 是否該同時扛下變更通知，判準不是「需求來自誰」而是介面用什麼語言表達，完整推導見 &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>repository 把「怎麼存、怎麼查」包裝成領域語言的介面：呼叫端看到的是存書、查書這類操作，看不到底層是資料庫、檔案還是記憶體。它是一種 <a href="/blog/ddd/knowledge-cards/port/" data-link-title="Port" data-link-desc="判斷介面該宣告在哪一層、依賴方向該朝哪時使用。port 是 domain 對外宣告的介面——需求用領域語言說完、技術細節留在實作端。">port</a>——依賴方向朝內、簽名只用領域型別——差別在 repository 專職 <a href="/blog/ddd/knowledge-cards/aggregate-root/" data-link-title="Aggregate Root" data-link-desc="跨物件一致性的邊界設計時使用。聚合根是對外代表一組資料一致性的邊界物件——外部只跟它互動、它保證內部的不變式。">aggregate root</a> 的存取。repository 回傳的形狀是 aggregate 的形狀（entity 或 entity 集合），這條界線是它跟 <a href="/blog/ddd/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="查詢該由 repository 回 aggregate、還是該有自己的查詢側模型時使用。read model 是為讀需求的形狀而建的模型——回答「畫面需要什麼形狀」、與 aggregate 的形狀分離。">read model</a> 分工的起點。</p>
<h2 id="概念位置">概念位置</h2>
<p>repository 預設是 pull 介面：呼叫端主動問「現在的資料長怎樣」，一次拿到一份 aggregate 形狀的答案。它不天生具備「資料變了通知我」的推送能力——這條能力屬於 <a href="/blog/ddd/knowledge-cards/observation-outlet/" data-link-title="Observation Outlet（觀測出口）" data-link-desc="repository 只有 pull 介面、衍生視圖靠補償刷新，考慮補「資料變了」的推送能力時使用。觀測出口是 pull 介面的 push 對應——能力橫跨三層、歸屬由各層的表達語言決定。">observation outlet</a>，是 repository 介面之上的另一層職責，需要另外設計才會出現。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>repository 介面開始長出 <code>getMonthlyStatistics()</code>、<code>searchWithPagination()</code> 這類回傳統計值或反正規化形狀的方法，是讀的形狀混進 aggregate 介面的訊號——查詢集合已經大到值得思考該不該抽獨立的讀側介面，量測與升級路徑見 <a href="/blog/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準</a>。</p>
<h2 id="設計責任">設計責任</h2>
<p>repository 的設計責任是守住 aggregate 一致性邊界的存取入口，不是最佳化每一種讀需求的效能與形狀——後者是 read model 的責任。repository 是否該同時扛下變更通知，判準不是「需求來自誰」而是介面用什麼語言表達，完整推導見 <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>CQRS</title><link>https://tarrragon.github.io/blog/ddd/knowledge-cards/cqrs/</link><pubDate>Mon, 20 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/knowledge-cards/cqrs/</guid><description>&lt;p>多數系統預設讀寫共用同一個模型：同一組 entity 既承接寫入的一致性檢查、也承接查詢的形狀需求。CQRS（Command Query Responsibility Segregation）是把這兩個責任拆開的架構決定——寫側走 &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>、守住不變式與一致性邊界；讀側走 &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>、只服務查詢要的形狀，不承擔寫入責任。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>CQRS 是一道讀側設計階梯的頂端、而非二選一的開關。讀側的設計從「消費端自行投影 aggregate 形狀」開始，中間經過「抽獨立讀 port」「查詢形狀專用投影」，到頂端才是 CQRS 全套——讀寫獨立儲存、由 &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>讀寫分離的討論從「查詢方法該不該搬出 repository」，逐漸變成「讀模型需不需要自己的儲存、能不能接受跟寫側短暫不一致」，是逼近階梯頂端的訊號——第四階的代價是最終一致性進入系統語意，畫面可能短暫顯示舊值，而這是設計內行為，不是 bug。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>要不要上 CQRS 全套，不看「這個模式聽起來很適合」，看五個具體訊號（讀需求增生、形狀偏離、負載分歧、新鮮度分級、獨立演進）是否命中——本卡只回答「CQRS 是什麼、階梯怎麼分階」，訊號的完整推導與升級路徑是 &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;/p></description><content:encoded><![CDATA[<p>多數系統預設讀寫共用同一個模型：同一組 entity 既承接寫入的一致性檢查、也承接查詢的形狀需求。CQRS（Command Query Responsibility Segregation）是把這兩個責任拆開的架構決定——寫側走 <a href="/blog/ddd/knowledge-cards/aggregate-root/" data-link-title="Aggregate Root" data-link-desc="跨物件一致性的邊界設計時使用。聚合根是對外代表一組資料一致性的邊界物件——外部只跟它互動、它保證內部的不變式。">aggregate root</a>、守住不變式與一致性邊界；讀側走 <a href="/blog/ddd/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="查詢該由 repository 回 aggregate、還是該有自己的查詢側模型時使用。read model 是為讀需求的形狀而建的模型——回答「畫面需要什麼形狀」、與 aggregate 的形狀分離。">read model</a>、只服務查詢要的形狀，不承擔寫入責任。</p>
<h2 id="概念位置">概念位置</h2>
<p>CQRS 是一道讀側設計階梯的頂端、而非二選一的開關。讀側的設計從「消費端自行投影 aggregate 形狀」開始，中間經過「抽獨立讀 port」「查詢形狀專用投影」，到頂端才是 CQRS 全套——讀寫獨立儲存、由 <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>讀寫分離的討論從「查詢方法該不該搬出 repository」，逐漸變成「讀模型需不需要自己的儲存、能不能接受跟寫側短暫不一致」，是逼近階梯頂端的訊號——第四階的代價是最終一致性進入系統語意，畫面可能短暫顯示舊值，而這是設計內行為，不是 bug。</p>
<h2 id="設計責任">設計責任</h2>
<p>要不要上 CQRS 全套，不看「這個模式聽起來很適合」，看五個具體訊號（讀需求增生、形狀偏離、負載分歧、新鮮度分級、獨立演進）是否命中——本卡只回答「CQRS 是什麼、階梯怎麼分階」，訊號的完整推導與升級路徑是 <a href="/blog/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準</a> 這篇的內容，要判斷自己的專案該停在哪一階、讀那篇。</p>
]]></content:encoded></item><item><title>StreamProvider 包 repository watch stream — broadcast、初始值、dispose 實作點</title><link>https://tarrragon.github.io/blog/work-log/flutter_streamprovider_wraps_repository_watch/</link><pubDate>Thu, 16 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_streamprovider_wraps_repository_watch/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：書庫管理 App 的 repository 原本是純 &lt;code>Future&lt;/code> pull 介面，衍生視圖靠補償刷新（背景在 &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>）。決策定向後要落地：repository 補 &lt;code>watchBooks()&lt;/code> Stream 出口、用 &lt;code>StreamProvider&lt;/code> 接進 Riverpod。
&lt;strong>本篇範圍&lt;/strong>：落地時要答對的三個實作點，每一個的預設答案都會靜默失效。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="分層落點">分層落點&lt;/h2>
&lt;p>實作橫跨三層、每層只說自己那層的語言（歸屬判準的推導見 &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;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>domain 契約&lt;/td>
 &lt;td>介面方法 &lt;code>Stream&amp;lt;List&amp;lt;Book&amp;gt;&amp;gt; watchBooks()&lt;/code>&lt;/td>
 &lt;td>&lt;code>dart:async&lt;/code> + domain entity&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>infrastructure&lt;/td>
 &lt;td>&lt;code>StreamController.broadcast()&lt;/code> + 寫入點 emit&lt;/td>
 &lt;td>SQLite、controller 細節&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>DI／presentation&lt;/td>
 &lt;td>&lt;code>watchBooksProvider&lt;/code>（&lt;code>StreamProvider&lt;/code>）&lt;/td>
 &lt;td>Riverpod 型別&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&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="c1">/// 提供書單變更的 Stream 出口，取代衍生視圖各自補償刷新。
&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">/// 約束：僅純 dart:async + domain entity，禁止框架型別進入此介面。
&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="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 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="k">throw&lt;/span> &lt;span class="n">UnimplementedError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;watchBooks 未在此 repository 實作&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="實作點一訂閱模型選-broadcast">實作點一：訂閱模型選 broadcast&lt;/h2>
&lt;p>因為書庫清單、統計頁、待補完列表都要同時觀察同一份資料，這個觀測出口有多個訂閱者。&lt;code>StreamController()&lt;/code> 預設建構子是單訂閱、第二個訂閱者出現時直接 throw &lt;code>Bad state&lt;/code>；這個選型的完整分析（含單訂閱在只有一個訂閱者期間完全沉默的潛伏機制）在 &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;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">SQLiteBookRepository&lt;/span> &lt;span class="kd">implements&lt;/span> &lt;span class="n">BookRepository&lt;/span> &lt;span class="p">{&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">/// 全部寫入方法完成後透過此 controller emit 最新完整書單。
&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">final&lt;/span> &lt;span class="n">StreamController&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">_booksController&lt;/span> &lt;span class="o">=&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="n">StreamController&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="p">.&lt;/span>&lt;span class="n">broadcast&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="err">@&lt;/span>&lt;span class="n">override&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&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 class="o">=&amp;gt;&lt;/span> &lt;span class="n">_booksController&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">stream&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>emit 集中在一個私有方法、掛在每個寫入方法尾端：&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">Future&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="kt">void&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">_emitCurrentBooks&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">_booksController&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">isClosed&lt;/span>&lt;span class="p">)&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="k">return&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// repository 已 close：靜默略過，不讓通知失敗中斷寫入流程
&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">books&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">getAllBooks&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="n">_booksController&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">isClosed&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="n">_booksController&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">books&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>兩個容易漏的細節：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>委派方法不重複 emit&lt;/strong>。介面上的相容性方法（&lt;code>saveBook&lt;/code> 內部委派 &lt;code>addBook&lt;/code>、&lt;code>deleteBookById&lt;/code> 委派 &lt;code>deleteBook&lt;/code>）走到底層寫入方法時已經 emit 過；在委派層再掛一次會讓一次寫入發兩次通知。emit 的掛載點是「實際執行寫入的方法集合」、不是「介面上所有看起來會寫入的方法」。&lt;/li>
&lt;li>&lt;strong>&lt;code>isClosed&lt;/code> 要查兩次&lt;/strong>。&lt;code>getAllBooks()&lt;/code> 是 async——查詢期間 repository 可能被 close，&lt;code>add&lt;/code> 前不再確認就會對已關閉的 controller 拋例外，而且是從寫入方法的尾端拋出來、污染寫入本身的成功語意。&lt;/li>
&lt;/ol>
&lt;h2 id="實作點二初始值broadcast-不補送歷史">實作點二：初始值——broadcast 不補送歷史&lt;/h2>
&lt;p>broadcast stream 對「訂閱之前發生的事件」直接丟棄。衍生視圖訂閱 &lt;code>watchBooks()&lt;/code> 的當下，上一次 emit 早就過去了——不處理初始值，畫面會停在空清單直到下一次寫入才有資料。&lt;/p>
&lt;p>修法放在組裝層：&lt;code>StreamProvider&lt;/code> 用 &lt;code>async*&lt;/code> 先給當前值、再轉接後續變更。&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">watchBooksProvider&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">StreamProvider&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="p">((&lt;/span>&lt;span class="n">ref&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&lt;/span>&lt;span class="o">*&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">repository&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ref&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">watch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">bookRepositoryProvider&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="kd">yield&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">repository&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">getAllBooks&lt;/span>&lt;span class="p">();&lt;/span> &lt;span class="c1">// 訂閱當下：先 emit 當前完整書單
&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">yield&lt;/span>&lt;span class="o">*&lt;/span> &lt;span class="n">repository&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">watchBooks&lt;/span>&lt;span class="p">();&lt;/span> &lt;span class="c1">// 之後：轉發 repository 的變更通知
&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="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個「當前值 + 後續變更」的組合就是 RxDart &lt;code>BehaviorSubject&lt;/code> 內建的行為；純 &lt;code>dart:async&lt;/code> 用兩行 &lt;code>yield&lt;/code> 補上，不必為此引依賴。把初始值放組裝層而非機制層也有語意理由：repository 的 stream 誠實地只代表「變更」，「訂閱時要不要先看到當下」是消費端的呈現需求。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：書庫管理 App 的 repository 原本是純 <code>Future</code> pull 介面，衍生視圖靠補償刷新（背景在 <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>）。決策定向後要落地：repository 補 <code>watchBooks()</code> Stream 出口、用 <code>StreamProvider</code> 接進 Riverpod。
<strong>本篇範圍</strong>：落地時要答對的三個實作點，每一個的預設答案都會靜默失效。</p></blockquote>
<hr>
<h2 id="分層落點">分層落點</h2>
<p>實作橫跨三層、每層只說自己那層的語言（歸屬判準的推導見 <a href="/blog/ddd/observation-outlet-responsibility-split/" data-link-title="觀測出口的職責三分" data-link-desc="repository 要補「資料變了」的推送能力、卻不確定 Stream 介面放 domain 算不算洩漏時使用。歸屬判準是介面用什麼語言表達、不是需求來自誰：契約歸 domain、變更偵測歸 infrastructure、框架訂閱歸組裝層。">觀測出口的職責三分</a>）：</p>
<table>
  <thead>
      <tr>
          <th>層</th>
          <th>產出</th>
          <th>允許出現的型別</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>domain 契約</td>
          <td>介面方法 <code>Stream&lt;List&lt;Book&gt;&gt; watchBooks()</code></td>
          <td><code>dart:async</code> + domain entity</td>
      </tr>
      <tr>
          <td>infrastructure</td>
          <td><code>StreamController.broadcast()</code> + 寫入點 emit</td>
          <td>SQLite、controller 細節</td>
      </tr>
      <tr>
          <td>DI／presentation</td>
          <td><code>watchBooksProvider</code>（<code>StreamProvider</code>）</td>
          <td>Riverpod 型別</td>
      </tr>
  </tbody>
</table>
<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="c1">/// 提供書單變更的 Stream 出口，取代衍生視圖各自補償刷新。
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">/// 約束：僅純 dart:async + domain entity，禁止框架型別進入此介面。
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><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 class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">throw</span> <span class="n">UnimplementedError</span><span class="p">(</span><span class="s1">&#39;watchBooks 未在此 repository 實作&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h2 id="實作點一訂閱模型選-broadcast">實作點一：訂閱模型選 broadcast</h2>
<p>因為書庫清單、統計頁、待補完列表都要同時觀察同一份資料，這個觀測出口有多個訂閱者。<code>StreamController()</code> 預設建構子是單訂閱、第二個訂閱者出現時直接 throw <code>Bad state</code>；這個選型的完整分析（含單訂閱在只有一個訂閱者期間完全沉默的潛伏機制）在 <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>





<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">SQLiteBookRepository</span> <span class="kd">implements</span> <span class="n">BookRepository</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="c1">/// 全部寫入方法完成後透過此 controller emit 最新完整書單。
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">StreamController</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">_booksController</span> <span class="o">=</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">      <span class="n">StreamController</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">broadcast</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="err">@</span><span class="n">override</span>
</span></span><span class="line"><span class="ln">7</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 class="o">=&gt;</span> <span class="n">_booksController</span><span class="p">.</span><span class="n">stream</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>emit 集中在一個私有方法、掛在每個寫入方法尾端：</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">Future</span><span class="o">&lt;</span><span class="kt">void</span><span class="o">&gt;</span> <span class="n">_emitCurrentBooks</span><span class="p">()</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="n">_booksController</span><span class="p">.</span><span class="n">isClosed</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">return</span><span class="p">;</span> <span class="c1">// repository 已 close：靜默略過，不讓通知失敗中斷寫入流程
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>  <span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="kd">final</span> <span class="n">books</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">getAllBooks</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">_booksController</span><span class="p">.</span><span class="n">isClosed</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="n">_booksController</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="n">books</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>兩個容易漏的細節：</p>
<ol>
<li><strong>委派方法不重複 emit</strong>。介面上的相容性方法（<code>saveBook</code> 內部委派 <code>addBook</code>、<code>deleteBookById</code> 委派 <code>deleteBook</code>）走到底層寫入方法時已經 emit 過；在委派層再掛一次會讓一次寫入發兩次通知。emit 的掛載點是「實際執行寫入的方法集合」、不是「介面上所有看起來會寫入的方法」。</li>
<li><strong><code>isClosed</code> 要查兩次</strong>。<code>getAllBooks()</code> 是 async——查詢期間 repository 可能被 close，<code>add</code> 前不再確認就會對已關閉的 controller 拋例外，而且是從寫入方法的尾端拋出來、污染寫入本身的成功語意。</li>
</ol>
<h2 id="實作點二初始值broadcast-不補送歷史">實作點二：初始值——broadcast 不補送歷史</h2>
<p>broadcast stream 對「訂閱之前發生的事件」直接丟棄。衍生視圖訂閱 <code>watchBooks()</code> 的當下，上一次 emit 早就過去了——不處理初始值，畫面會停在空清單直到下一次寫入才有資料。</p>
<p>修法放在組裝層：<code>StreamProvider</code> 用 <code>async*</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">// 訂閱當下：先 emit 當前完整書單
</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">// 之後：轉發 repository 的變更通知
</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>這個「當前值 + 後續變更」的組合就是 RxDart <code>BehaviorSubject</code> 內建的行為；純 <code>dart:async</code> 用兩行 <code>yield</code> 補上，不必為此引依賴。把初始值放組裝層而非機制層也有語意理由：repository 的 stream 誠實地只代表「變更」，「訂閱時要不要先看到當下」是消費端的呈現需求。</p>
<h2 id="實作點三dispose關閉責任跟著-controller-的持有者">實作點三：dispose——關閉責任跟著 controller 的持有者</h2>
<p>controller 的持有者是 repository，關閉責任就在 repository 的生命週期方法裡：</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">Future</span><span class="o">&lt;</span><span class="kt">void</span><span class="o">&gt;</span> <span class="n">close</span><span class="p">()</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">await</span> <span class="n">_booksController</span><span class="p">.</span><span class="n">close</span><span class="p">();</span> <span class="c1">// 與資料庫連線一起釋放，避免 controller 洩漏
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="c1">// ...既有的連線清理
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="p">}</span></span></span></code></pre></div><p>配合實作點一的 <code>isClosed</code> 防護，close 之後殘留的寫入呼叫會靜默略過通知、不會炸在使用者的操作路徑上。驗收面用三個測試釘住這組行為：寫入後 stream 收到最新書單、多訂閱者同時收到、close 後不再送出事件。</p>
<h2 id="測試替身要同步這份契約">測試替身要同步這份契約</h2>
<p>repository 有介面就有替身；替身漏掉 <code>watchBooks()</code> 會出現「production 正常、測試環境炸 <code>UnimplementedError</code>」或反過來的錯位。這次落地同步了三類替身、依「既有測試依不依賴多次 emit」給不同深度：</p>
<table>
  <thead>
      <tr>
          <th>替身</th>
          <th>實作深度</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>記憶體版 repository（行為替身）</td>
          <td>等價的 broadcast controller + 寫入點 emit + dispose</td>
          <td>widget 測試要驗「寫入後畫面更新」</td>
      </tr>
      <tr>
          <td>手寫 mock</td>
          <td><code>Stream.value(當前快照)</code> 簡化實作</td>
          <td>既有用法只讀一次、不依賴推送</td>
      </tr>
      <tr>
          <td>codegen mock（Mockito）</td>
          <td>重新產生、stub 回空 stream</td>
          <td><code>implements</code> 不繼承介面預設實作</td>
      </tr>
  </tbody>
</table>
<p>第三列是 Dart 特有的陷阱：mock 類別 <code>implements</code> 介面時<strong>不會</strong>繼承介面上的預設實作，介面加了新方法、所有 codegen mock 都要重新產生，否則消費新方法的測試在執行期才爆。mock 與真實實作的 stream 契約不對齊的後果（測試綠、production throw）在 <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>把 repository stream 接給任何 reactive 框架前，三個問題各給一個明確答案：</p>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>本案答案</th>
          <th>不答的預設後果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>幾個訂閱者？</td>
          <td>多個 → <code>broadcast()</code></td>
          <td>單訂閱：第二個訂閱者執行期 throw</td>
      </tr>
      <tr>
          <td>訂閱當下要有值嗎？</td>
          <td>要 → 組裝層先 <code>yield</code> 當前值</td>
          <td>broadcast 不補歷史：畫面空到下次寫入</td>
      </tr>
      <tr>
          <td>controller 誰關？</td>
          <td>repository 持有、<code>close()</code> 一起關 + <code>isClosed</code> 防護</td>
          <td>洩漏、或 close 後寫入路徑拋例外</td>
      </tr>
  </tbody>
</table>
<p>本文範圍只涵蓋成功路徑。第四個問題——查詢失敗時 stream 該 <code>addError</code> 傳播還是吞掉——這裡沒有處理：<code>_emitCurrentBooks</code> 裡 <code>getAllBooks()</code> 拋例外時目前走 <code>isClosed</code> 防護的外圍、不會 <code>addError</code>，消費端收不到錯誤通知。失敗傳播的設計（要不要讓 <code>StreamProvider</code> 進 <code>AsyncError</code> 狀態、重試策略）是獨立主題；設計時要把 Riverpod 3 的預設行為算進去——失敗的 provider 會被自動重試（<code>ProviderScope</code> 的 <code>retry</code> 參數可關閉或調整間隔），<code>addError</code> 進到 provider 層的後果跟 2.x 不同。</p>
<p>介面歸屬的提醒收在最後：<code>watchBooks()</code> 的需求來自 Riverpod 消費端，但介面簽名只用 <code>dart:async Stream</code> 加 domain entity，所以它屬於 domain repository 介面——歸屬由介面用什麼語言表達決定，需求來自誰只決定介面該不該存在。Riverpod 型別止步於 <code>watchBooksProvider</code>、SQLite 型別止步於實作類，這條線就是三層各自的邊界。</p>
<h2 id="下一步">下一步</h2>
<p>補了觀測出口但還沒搞懂為什麼之前的導航補償和 EventBus 橋接不夠，先讀 <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>。單訂閱 vs broadcast 的完整選型分析在 <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>。觀測出口跟 domain event 各管什麼，見 <a href="/blog/ddd/domain-event-vs-state-stream/" data-link-title="domain event 與狀態流" data-link-desc="為了讓某個畫面刷新而補發事件、或監聽端掛著全事件過濾器時使用。事件記錄離散事實、狀態流發布連續觀測——判準是消費者問「發生了什麼」還是「現在是什麼」；載體借用的代價是涵蓋面靠枚舉維持。">domain event 與狀態流</a>。</p>
]]></content:encoded></item><item><title>1000 本書、1001 次 SQL — N+1 查詢藏在 async mapper 裡</title><link>https://tarrragon.github.io/blog/work-log/flutter_sqlite_n_plus_one_query/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_sqlite_n_plus_one_query/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter 書籍管理 App 進效能優化階段（UC-08）的現況盤點，抓到一個 P0：&lt;code>getAllBooks()&lt;/code> 對 1000 本書發出 1001 次 SQL 查詢、耗時約 2 秒；推算 10000 本書要 20 秒、UI 完全卡死
&lt;strong>疑問來源&lt;/strong>：沒有人寫過「對每本書查一次資料庫」這種程式碼——這個 N+1 是怎麼長出來的？
&lt;strong>整理目的&lt;/strong>：記下 N+1 在 ORM 之外的手寫形態（async mapper）、修法的結構、以及為什麼它在開發期永遠感覺不到
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.19.0 的 Phase 0 評估記錄；耗時數字是該記錄的影響評估、量級可信&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="c1">// 單筆轉換：把一列 DB map 轉成 Book——順便把它的 tags 查出來
&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="n">Future&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">Book&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">_mapToBook&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Map&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="kt">String&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">dynamic&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">map&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&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="kd">final&lt;/span> &lt;span class="n">db&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">_database&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="kd">final&lt;/span> &lt;span class="n">tagResult&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">db&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;book_tags&amp;#39;&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="p">...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="c1">// 列表查詢：撈全部、逐筆轉換
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n">Future&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">getAllBooks&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="p">...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">Future&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">wait&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">result&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">map&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">map&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">_mapToBook&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">map&lt;/span>&lt;span class="p">)));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>_mapToBook&lt;/code> 單看合理——Book 有 tags、轉換時補齊關聯是「完整的轉換」；&lt;code>getAllBooks&lt;/code> 單看也合理——每筆都用同一個轉換函式、&lt;code>Future.wait&lt;/code> 還做了並行。&lt;strong>N+1 不存在於任何一行、它存在於組合&lt;/strong>：一個做 IO 的轉換函式、被一個列表方法乘以 N。&lt;/p>
&lt;p>結構性的病灶是&lt;strong>mapper 做了 IO&lt;/strong>。轉換函式的職責是「資料形狀的映射」，把查詢塞進去的那一刻，它的成本從 O(1) 記憶體操作變成一次網路 / 磁碟往返——而呼叫端從簽名上看不出來（回傳本來就是 Future、多一次 await 沒有任何警訊）。&lt;/p>
&lt;h2 id="為什麼開發期永遠感覺不到">為什麼開發期永遠感覺不到&lt;/h2>
&lt;p>影響評估的三個數字說明了它的隱身機制：100 本約 200ms、1000 本約 2 秒、10000 本約 20 秒。開發跟測試用的資料集是幾十本——200ms 以下、混在正常的載入時間裡無法察覺。&lt;strong>N+1 的惡化是使用者資料量的線性函數&lt;/strong>，寫下它的人永遠不會遇到它爆炸的那天，遇到的是半年後書最多的那批使用者。&lt;/p>
&lt;p>這也解釋了為什麼它由效能盤點抓到、而不是被任何測試抓到：功能測試的資料量跟開發期一樣小，而「查詢次數」不在任何斷言的守備範圍——除非專門寫「操作 X 的 SQL 次數 ≤ K」這種預算型測試。&lt;/p>
&lt;h2 id="修法io-上移mapper-變純">修法：IO 上移、mapper 變純&lt;/h2>
&lt;p>修法的設計把查詢次數從 N+1 收斂到 2：&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">Future&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">getAllBooks&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="p">{&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">// 1. 一次撈所有書
&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">final&lt;/span> &lt;span class="n">books&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">db&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;books&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">orderBy:&lt;/span> &lt;span class="s1">&amp;#39;added_date DESC&amp;#39;&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 2. 一次撈所有標籤（IN 批次）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">final&lt;/span> &lt;span class="n">bookIds&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">books&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">map&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">b&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">b&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;id&amp;#39;&lt;/span>&lt;span class="p">]).&lt;/span>&lt;span class="n">toList&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">allTags&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">db&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;book_tags&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nl">where:&lt;/span> &lt;span class="s1">&amp;#39;book_id IN (&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="n">bookIds&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">map&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">_&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="s1">&amp;#39;?&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="n">join&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;,&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s1">)&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nl">whereArgs:&lt;/span> &lt;span class="n">bookIds&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 3. 記憶體組裝
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">final&lt;/span> &lt;span class="n">tagsByBookId&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">_groupTagsByBookId&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">allTags&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">books&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">map&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">b&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">_mapToBookWithTags&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">b&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">tagsByBookId&lt;/span>&lt;span class="p">)).&lt;/span>&lt;span class="n">toList&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>結構上的重點勝過次數本身：&lt;strong>IO 全部上移到列表方法、轉換函式變純&lt;/strong>——&lt;code>_mapToBookWithTags&lt;/code> 收「這本書的列」跟「查好的 tags 索引」、不碰資料庫。純轉換函式拿回了三個性質：成本可預期（呼叫 N 次就是 N 次記憶體操作）、可單獨測試（餵 map 斷言 Book）、且&lt;strong>結構上不可能再退化成 N+1&lt;/strong>——它沒有 db 可查。這跟 &lt;a href="https://tarrragon.github.io/blog/work-log/dart_unsettled_cart_pure_function/" data-link-title="「該收多少錢」抽成 pure function — IO 在邊界、領域計算在核心" data-link-desc="多個畫面都要顯示「未結帳的份數與金額」時，把計算抽成無 IO 的 pure function：資料由 caller 從 repository 拿好傳入、函式只做合併 / 扣減 / 折扣運算。含合併鍵要跟同一性定義同維度的陷阱、兩層折扣各自 clamp 的邊界、以及用註解預留擴充點讓未來規則接入不動本體。">pure function 領域計算&lt;/a>是同一個藥方在資料層的應用：IO 在邊界、計算（轉換）在核心。&lt;/p>
&lt;h2 id="伴生發現能力早就存在預設路徑不經過它">伴生發現：能力早就存在、預設路徑不經過它&lt;/h2>
&lt;p>同一次盤點還抓到第二個 P0——&lt;code>allBooksProvider&lt;/code> 直呼 &lt;code>getAllBooks()&lt;/code> 全量載入、記憶體隨書量膨脹。反直覺的是修這個問題&lt;strong>不用寫新能力&lt;/strong>：repository 早就有 &lt;code>getBooks(limit, offset)&lt;/code> 分頁方法、批次新增也有、快取系統也完整。能力都在、只是 UI 的預設路徑（&lt;code>allBooksProvider&lt;/code>）不經過它們。&lt;/p>
&lt;p>「有能力」跟「預設路徑用它」是兩回事——provider 是所有畫面拿書的入口、它選全載、分頁能力就是死碼。這是&lt;a href="https://tarrragon.github.io/blog/work-log/tool_default_behavior_shapes_user_habit/" data-link-title="工具的預設行為決定使用者習慣 — 從版本錯置看工具設計的 opinion 責任" data-link-desc="規範與工具預設不一致時工具會贏。預設路徑就是團隊的實際流程，接受自由輸入的介面設計時要負起 opinion 責任。">工具的預設行為決定使用者習慣&lt;/a>的資料層版本：要讓分頁被用、要嘛預設 provider 就是分頁的、要嘛全載入口加上明確的成本標記，靠「大家記得用分頁版」跟靠任何慣例一樣不可靠。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter 書籍管理 App 進效能優化階段（UC-08）的現況盤點，抓到一個 P0：<code>getAllBooks()</code> 對 1000 本書發出 1001 次 SQL 查詢、耗時約 2 秒；推算 10000 本書要 20 秒、UI 完全卡死
<strong>疑問來源</strong>：沒有人寫過「對每本書查一次資料庫」這種程式碼——這個 N+1 是怎麼長出來的？
<strong>整理目的</strong>：記下 N+1 在 ORM 之外的手寫形態（async mapper）、修法的結構、以及為什麼它在開發期永遠感覺不到
<strong>本文邊界</strong>：素材是該專案 v0.19.0 的 Phase 0 評估記錄；耗時數字是該記錄的影響評估、量級可信</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="c1">// 單筆轉換：把一列 DB map 轉成 Book——順便把它的 tags 查出來
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="n">Future</span><span class="o">&lt;</span><span class="n">Book</span><span class="o">&gt;</span> <span class="n">_mapToBook</span><span class="p">(</span><span class="n">Map</span><span class="o">&lt;</span><span class="kt">String</span><span class="p">,</span> <span class="kt">dynamic</span><span class="o">&gt;</span> <span class="n">map</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kd">final</span> <span class="n">db</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">_database</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="kd">final</span> <span class="n">tagResult</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">db</span><span class="p">.</span><span class="n">query</span><span class="p">(</span><span class="s1">&#39;book_tags&#39;</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="p">...</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1">// 列表查詢：撈全部、逐筆轉換
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="n">Future</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">getAllBooks</span><span class="p">()</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="p">...</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="k">return</span> <span class="n">Future</span><span class="p">.</span><span class="n">wait</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">map</span><span class="p">((</span><span class="n">map</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">_mapToBook</span><span class="p">(</span><span class="n">map</span><span class="p">)));</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>_mapToBook</code> 單看合理——Book 有 tags、轉換時補齊關聯是「完整的轉換」；<code>getAllBooks</code> 單看也合理——每筆都用同一個轉換函式、<code>Future.wait</code> 還做了並行。<strong>N+1 不存在於任何一行、它存在於組合</strong>：一個做 IO 的轉換函式、被一個列表方法乘以 N。</p>
<p>結構性的病灶是<strong>mapper 做了 IO</strong>。轉換函式的職責是「資料形狀的映射」，把查詢塞進去的那一刻，它的成本從 O(1) 記憶體操作變成一次網路 / 磁碟往返——而呼叫端從簽名上看不出來（回傳本來就是 Future、多一次 await 沒有任何警訊）。</p>
<h2 id="為什麼開發期永遠感覺不到">為什麼開發期永遠感覺不到</h2>
<p>影響評估的三個數字說明了它的隱身機制：100 本約 200ms、1000 本約 2 秒、10000 本約 20 秒。開發跟測試用的資料集是幾十本——200ms 以下、混在正常的載入時間裡無法察覺。<strong>N+1 的惡化是使用者資料量的線性函數</strong>，寫下它的人永遠不會遇到它爆炸的那天，遇到的是半年後書最多的那批使用者。</p>
<p>這也解釋了為什麼它由效能盤點抓到、而不是被任何測試抓到：功能測試的資料量跟開發期一樣小，而「查詢次數」不在任何斷言的守備範圍——除非專門寫「操作 X 的 SQL 次數 ≤ K」這種預算型測試。</p>
<h2 id="修法io-上移mapper-變純">修法：IO 上移、mapper 變純</h2>
<p>修法的設計把查詢次數從 N+1 收斂到 2：</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">Future</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">getAllBooks</span><span class="p">()</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="c1">// 1. 一次撈所有書
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">books</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">db</span><span class="p">.</span><span class="n">query</span><span class="p">(</span><span class="s1">&#39;books&#39;</span><span class="p">,</span> <span class="nl">orderBy:</span> <span class="s1">&#39;added_date DESC&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="c1">// 2. 一次撈所有標籤（IN 批次）
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">bookIds</span> <span class="o">=</span> <span class="n">books</span><span class="p">.</span><span class="n">map</span><span class="p">((</span><span class="n">b</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">b</span><span class="p">[</span><span class="s1">&#39;id&#39;</span><span class="p">]).</span><span class="n">toList</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="kd">final</span> <span class="n">allTags</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">db</span><span class="p">.</span><span class="n">query</span><span class="p">(</span><span class="s1">&#39;book_tags&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nl">where:</span> <span class="s1">&#39;book_id IN (</span><span class="si">${</span><span class="n">bookIds</span><span class="p">.</span><span class="n">map</span><span class="p">((</span><span class="n">_</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="s1">&#39;?&#39;</span><span class="p">).</span><span class="n">join</span><span class="p">(</span><span class="s1">&#39;,&#39;</span><span class="p">)</span><span class="si">}</span><span class="s1">)&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nl">whereArgs:</span> <span class="n">bookIds</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="c1">// 3. 記憶體組裝
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">tagsByBookId</span> <span class="o">=</span> <span class="n">_groupTagsByBookId</span><span class="p">(</span><span class="n">allTags</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="k">return</span> <span class="n">books</span><span class="p">.</span><span class="n">map</span><span class="p">((</span><span class="n">b</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">_mapToBookWithTags</span><span class="p">(</span><span class="n">b</span><span class="p">,</span> <span class="n">tagsByBookId</span><span class="p">)).</span><span class="n">toList</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>結構上的重點勝過次數本身：<strong>IO 全部上移到列表方法、轉換函式變純</strong>——<code>_mapToBookWithTags</code> 收「這本書的列」跟「查好的 tags 索引」、不碰資料庫。純轉換函式拿回了三個性質：成本可預期（呼叫 N 次就是 N 次記憶體操作）、可單獨測試（餵 map 斷言 Book）、且<strong>結構上不可能再退化成 N+1</strong>——它沒有 db 可查。這跟 <a href="/blog/work-log/dart_unsettled_cart_pure_function/" data-link-title="「該收多少錢」抽成 pure function — IO 在邊界、領域計算在核心" data-link-desc="多個畫面都要顯示「未結帳的份數與金額」時，把計算抽成無 IO 的 pure function：資料由 caller 從 repository 拿好傳入、函式只做合併 / 扣減 / 折扣運算。含合併鍵要跟同一性定義同維度的陷阱、兩層折扣各自 clamp 的邊界、以及用註解預留擴充點讓未來規則接入不動本體。">pure function 領域計算</a>是同一個藥方在資料層的應用：IO 在邊界、計算（轉換）在核心。</p>
<h2 id="伴生發現能力早就存在預設路徑不經過它">伴生發現：能力早就存在、預設路徑不經過它</h2>
<p>同一次盤點還抓到第二個 P0——<code>allBooksProvider</code> 直呼 <code>getAllBooks()</code> 全量載入、記憶體隨書量膨脹。反直覺的是修這個問題<strong>不用寫新能力</strong>：repository 早就有 <code>getBooks(limit, offset)</code> 分頁方法、批次新增也有、快取系統也完整。能力都在、只是 UI 的預設路徑（<code>allBooksProvider</code>）不經過它們。</p>
<p>「有能力」跟「預設路徑用它」是兩回事——provider 是所有畫面拿書的入口、它選全載、分頁能力就是死碼。這是<a href="/blog/work-log/tool_default_behavior_shapes_user_habit/" data-link-title="工具的預設行為決定使用者習慣 — 從版本錯置看工具設計的 opinion 責任" data-link-desc="規範與工具預設不一致時工具會贏。預設路徑就是團隊的實際流程，接受自由輸入的介面設計時要負起 opinion 責任。">工具的預設行為決定使用者習慣</a>的資料層版本：要讓分頁被用、要嘛預設 provider 就是分頁的、要嘛全載入口加上明確的成本標記，靠「大家記得用分頁版」跟靠任何慣例一樣不可靠。</p>
<h2 id="判讀徵兆">判讀徵兆</h2>
<ul>
<li><strong>mapper / 轉換函式的簽名是 <code>async</code></strong>——最便宜的掃描：<code>rg &quot;Future&lt;\w+&gt; _mapTo&quot; lib/</code>，每個命中都問它裡面的 await 在等什麼</li>
<li>迴圈或 <code>Future.wait</code> 裡呼叫「單筆處理」函式、而該函式含查詢——N+1 的標準組合形</li>
<li>某操作的耗時隨資料量線性惡化、但單筆操作很快——次數問題、不是單次效能問題</li>
<li>repository 有分頁 / 批次 API、但 provider / service 層的預設入口全量載入——能力與預設路徑脫節</li>
</ul>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>同藥方的領域計算版：<a href="/blog/work-log/dart_unsettled_cart_pure_function/" data-link-title="「該收多少錢」抽成 pure function — IO 在邊界、領域計算在核心" data-link-desc="多個畫面都要顯示「未結帳的份數與金額」時，把計算抽成無 IO 的 pure function：資料由 caller 從 repository 拿好傳入、函式只做合併 / 扣減 / 折扣運算。含合併鍵要跟同一性定義同維度的陷阱、兩層折扣各自 clamp 的邊界、以及用註解預留擴充點讓未來規則接入不動本體。">「該收多少錢」抽成 pure function</a>——IO 在邊界、核心保持純，資料層與領域層各一個現場</li>
<li>預設路徑的原則：<a href="/blog/work-log/tool_default_behavior_shapes_user_habit/" data-link-title="工具的預設行為決定使用者習慣 — 從版本錯置看工具設計的 opinion 責任" data-link-desc="規範與工具預設不一致時工具會贏。預設路徑就是團隊的實際流程，接受自由輸入的介面設計時要負起 opinion 責任。">工具的預設行為決定使用者習慣</a>——分頁能力閒置的機制跟工具預設值是同一件事</li>
<li>查詢預算的守法：<a href="/blog/work-log/flutter_test_signal_credibility_three_layers/" data-link-title="紅燈在量什麼 — 測試訊號的三層失真：斷言、量測、環境" data-link-desc="「全套件降至 0」有意義的前置條件是紅燈只反映程式缺陷。三層各自會失真：絕對計時斷言量的是機器負載、compact reporter 高並行下行覆寫產生假陰性、fresh checkout 缺 gitignored 生成產物讓整包結果不可信。含 flaky 判定的取樣門檻與對照實驗定歸因的做法。">紅燈在量什麼</a>——效能特性要用專門的量測管道守、不是塞計時斷言進單元測試</li>
</ul>
]]></content:encoded></item><item><title>mock 要配置 55 個方法、實際只用 5 個 — 測試痛是介面設計痛的探針</title><link>https://tarrragon.github.io/blog/work-log/flutter_port_interface_mock_hell_isp/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_port_interface_mock_hell_isp/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter 書籍管理 App 要幫 &lt;code>SyncReadinessService&lt;/code> 寫測試，mock 設置一路撞牆：四個依賴共 55 個方法要配置、漏配就 MissingStubError、mockito 的嵌套 when 又有語法限制——而這個 service 實際呼叫的方法只有 5 個
&lt;strong>疑問來源&lt;/strong>：測試這麼難寫，是測試工具的問題、還是被測物的問題？
&lt;strong>整理目的&lt;/strong>：記下「mock 負擔」作為介面設計探針的判讀方式、以及 Port 介面（ISP）的落地步驟
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.18.6 的重構計畫（含依賴盤點數字與 wave 拆分）；Port 是 hexagonal architecture 的用語、這裡取其「消費端定義的窄介面」語意&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="依賴盤點91-的-mock-配置是浪費">依賴盤點：91% 的 mock 配置是浪費&lt;/h2>
&lt;p>寫不動測試的第一步是把依賴攤開來數。&lt;code>SyncReadinessService&lt;/code> 的建構子收四個依賴，逐一盤點方法數與實際使用：&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>SyncRepository&lt;/code>&lt;/td>
 &lt;td>23&lt;/td>
 &lt;td>2（待同步變更、待解衝突）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>BookRepository&lt;/code>&lt;/td>
 &lt;td>16&lt;/td>
 &lt;td>1（getAllBooks）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>ChangeTracker&lt;/code>&lt;/td>
 &lt;td>10&lt;/td>
 &lt;td>&lt;strong>0&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>EventBus&lt;/code>&lt;/td>
 &lt;td>6&lt;/td>
 &lt;td>2&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>合計 55 個方法、用 5 個。mock 這個 service 的依賴，等於為 50 個永遠不會被呼叫的方法做配置決策——每個都可能漏（MissingStubError）、每個都是測試檔的噪音。&lt;code>ChangeTracker&lt;/code> 更直接：10 個方法、零使用，這個依賴純粹是建構子沿著前例複製來的。&lt;/p>
&lt;p>數字本身就是診斷：&lt;strong>mock 負擔正比於介面寬度、不正比於被測物的複雜度&lt;/strong>。測試難寫的根因不在 mockito、在被測物宣告的依賴遠寬於它需要的能力。&lt;/p>
&lt;h2 id="往上追一個-repository五種職責">往上追：一個 Repository、五種職責&lt;/h2>
&lt;p>&lt;code>SyncRepository&lt;/code> 的 23 個方法拆開看是五種職責：變更記錄管理（6）、同步任務管理（6、預留給 UC-08）、衝突解決（5）、離線佇列（4、預留 UC-09）、同步統計（2、預留 UC-10）。五分之三是&lt;strong>為未來 use case 預留的投機式方法&lt;/strong>——現在沒有人呼叫、但每個消費者都被迫認識它們。&lt;/p>
&lt;p>這是介面隔離原則（ISP）教科書式的違反現場，而它的第一個受害者是測試：生產程式碼呼叫方法時不在乎介面還有幾個方法、mock 卻要面對整個介面。&lt;strong>測試是第一個被迫「完整消費」介面的客戶&lt;/strong>，所以介面過寬的痛總是先在測試爆。&lt;/p>
&lt;h2 id="修法消費端定義的窄介面">修法：消費端定義的窄介面&lt;/h2>
&lt;p>重構的核心動作是抽 Port——從消費者的實際需求出發定義介面、而不是從資料來源的能力出發：&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">/// 從 SyncRepository 的 23 個方法中抽取實際需要的 2 個
&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">SyncQueryPort&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">List&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">ChangeRecord&lt;/span>&lt;span class="o">&amp;gt;&amp;gt;&lt;/span> &lt;span class="n">getPendingChanges&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="kt">int&lt;/span>&lt;span class="o">?&lt;/span> &lt;span class="n">limit&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="n">Future&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">ConflictResolution&lt;/span>&lt;span class="o">&amp;gt;&amp;gt;&lt;/span> &lt;span class="n">getPendingConflicts&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="kt">int&lt;/span>&lt;span class="o">?&lt;/span> &lt;span class="n">limit&lt;/span>&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="c1">/// 從 BookRepository 的 16 個方法中抽取實際需要的 1 個
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&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">BookQueryPort&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&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">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">getAllBooks&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>三個配套讓改動保持小：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Repository 實作 Port、原介面不動&lt;/strong>：&lt;code>abstract class SyncRepository implements SyncQueryPort&lt;/code>——既有實作自動滿足新介面、其他消費者不受影響&lt;/li>
&lt;li>&lt;strong>Service 建構子改收 Port&lt;/strong>：依賴從 55 個方法縮到 5 個、&lt;code>ChangeTracker&lt;/code> 直接移除；測試 mock 的對象變成 2 方法與 1 方法的小介面&lt;/li>
&lt;li>&lt;strong>預留方法標記啟用時機&lt;/strong>：&lt;code>// TODO(UC-08): 實際同步功能時啟用&lt;/code>——投機式方法不刪（設計已評估過）、但每個都有名字跟啟用條件，下次盤點時「這是預留還是死碼」有據可查&lt;/li>
&lt;/ul>
&lt;p>值得注意方向性：Port 放在&lt;strong>消費端的 domain 目錄&lt;/strong>（&lt;code>synchronization/ports/&lt;/code>、&lt;code>library/ports/&lt;/code>），因為它表達的是「這個 domain 需要什麼能力」、不是「Repository 提供什麼」。同一個 Repository 未來可以實作多個不同消費者的 Port，各自窄、互不牽連。&lt;/p>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;ul>
&lt;li>mock 設置的行數超過測試本體——先數被測物依賴的介面方法數 vs 實際呼叫數&lt;/li>
&lt;li>MissingStubError 反覆出現——每一次都是「介面要求你認識的方法」跟「你實際關心的方法」的差距&lt;/li>
&lt;li>建構子的某個依賴在整個 class 內零呼叫——複製前例的沉積、直接刪&lt;/li>
&lt;li>介面裡一半以上的方法標著「未來會用」——預留可以，但要有 TODO 加啟用條件，否則每個消費者與 mock 永遠陪葬&lt;/li>
&lt;/ul>
&lt;p>「測試很難寫」在這個 case 裡是禮物：它比任何架構審查都早、都具體地量化了介面設計的問題（91% 這個數字就是證據）。把測試痛當成噪音硬吞（寫更肥的 mock helper）、跟把它當探針回頭修介面，是兩條分岔路——同專案的 &lt;a href="https://tarrragon.github.io/blog/work-log/flutter_mock_infrastructure_overengineering_deleted/" data-link-title="1101 行自建測試基礎設施、重構刪掉 82.5% — 過度工程的三種形態" data-link-desc="自建測試基礎設施前先問框架的標準做法是什麼：mock 純資料物件、helper 帶並發鎖與記憶體洩漏防護、mock 放進 lib/ 進生產依賴圖，三種形態都在重新發明 Riverpod overrideWith 一行就有的東西。精緻的設計文件不是價值證明——它可以精心規劃一個不需要存在的系統。">mock 基礎設施過度工程&lt;/a>正是前一條路的下場。&lt;/p>
&lt;h2 id="相關閱讀">相關閱讀&lt;/h2>
&lt;ul>
&lt;li>概念地基：&lt;a href="https://tarrragon.github.io/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南&lt;/a>——service 與 repository 的邊界、以及分工表裡「domain 持有的是能力需求」&lt;/li>
&lt;li>硬吞測試痛的反面教材：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_mock_infrastructure_overengineering_deleted/" data-link-title="1101 行自建測試基礎設施、重構刪掉 82.5% — 過度工程的三種形態" data-link-desc="自建測試基礎設施前先問框架的標準做法是什麼：mock 純資料物件、helper 帶並發鎖與記憶體洩漏防護、mock 放進 lib/ 進生產依賴圖，三種形態都在重新發明 Riverpod overrideWith 一行就有的東西。精緻的設計文件不是價值證明——它可以精心規劃一個不需要存在的系統。">1101 行自建測試基礎設施&lt;/a>——mock 難寫的兩種回應：修介面（本文）vs 蓋更大的 mock 系統（該篇）&lt;/li>
&lt;li>投機式預留的另一形態：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_async_query_overdesign_oscillation/" data-link-title="同一個子系統膨脹兩次：異步查詢系統的過度設計震盪" data-link-desc="過度設計會復發、且兩輪的機制不同：設計期的膨脹來自想像的需求（別層已處理的重試、用不到的優先級佇列），迭代期的膨脹來自不刪的舊版本（三個實作並存、狀態多處追蹤）。偽需求的檢驗法是問「這個能力已經有別層在做嗎」。">過度設計震盪&lt;/a>——那篇的迭代期沉積跟本文的預留方法同源，差別是 Port 案例的預留有 TODO 加啟用條件、不是無主地放著&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter 書籍管理 App 要幫 <code>SyncReadinessService</code> 寫測試，mock 設置一路撞牆：四個依賴共 55 個方法要配置、漏配就 MissingStubError、mockito 的嵌套 when 又有語法限制——而這個 service 實際呼叫的方法只有 5 個
<strong>疑問來源</strong>：測試這麼難寫，是測試工具的問題、還是被測物的問題？
<strong>整理目的</strong>：記下「mock 負擔」作為介面設計探針的判讀方式、以及 Port 介面（ISP）的落地步驟
<strong>本文邊界</strong>：素材是該專案 v0.18.6 的重構計畫（含依賴盤點數字與 wave 拆分）；Port 是 hexagonal architecture 的用語、這裡取其「消費端定義的窄介面」語意</p></blockquote>
<hr>
<h2 id="依賴盤點91-的-mock-配置是浪費">依賴盤點：91% 的 mock 配置是浪費</h2>
<p>寫不動測試的第一步是把依賴攤開來數。<code>SyncReadinessService</code> 的建構子收四個依賴，逐一盤點方法數與實際使用：</p>
<table>
  <thead>
      <tr>
          <th>依賴</th>
          <th>介面方法數</th>
          <th>實際呼叫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>SyncRepository</code></td>
          <td>23</td>
          <td>2（待同步變更、待解衝突）</td>
      </tr>
      <tr>
          <td><code>BookRepository</code></td>
          <td>16</td>
          <td>1（getAllBooks）</td>
      </tr>
      <tr>
          <td><code>ChangeTracker</code></td>
          <td>10</td>
          <td><strong>0</strong></td>
      </tr>
      <tr>
          <td><code>EventBus</code></td>
          <td>6</td>
          <td>2</td>
      </tr>
  </tbody>
</table>
<p>合計 55 個方法、用 5 個。mock 這個 service 的依賴，等於為 50 個永遠不會被呼叫的方法做配置決策——每個都可能漏（MissingStubError）、每個都是測試檔的噪音。<code>ChangeTracker</code> 更直接：10 個方法、零使用，這個依賴純粹是建構子沿著前例複製來的。</p>
<p>數字本身就是診斷：<strong>mock 負擔正比於介面寬度、不正比於被測物的複雜度</strong>。測試難寫的根因不在 mockito、在被測物宣告的依賴遠寬於它需要的能力。</p>
<h2 id="往上追一個-repository五種職責">往上追：一個 Repository、五種職責</h2>
<p><code>SyncRepository</code> 的 23 個方法拆開看是五種職責：變更記錄管理（6）、同步任務管理（6、預留給 UC-08）、衝突解決（5）、離線佇列（4、預留 UC-09）、同步統計（2、預留 UC-10）。五分之三是<strong>為未來 use case 預留的投機式方法</strong>——現在沒有人呼叫、但每個消費者都被迫認識它們。</p>
<p>這是介面隔離原則（ISP）教科書式的違反現場，而它的第一個受害者是測試：生產程式碼呼叫方法時不在乎介面還有幾個方法、mock 卻要面對整個介面。<strong>測試是第一個被迫「完整消費」介面的客戶</strong>，所以介面過寬的痛總是先在測試爆。</p>
<h2 id="修法消費端定義的窄介面">修法：消費端定義的窄介面</h2>
<p>重構的核心動作是抽 Port——從消費者的實際需求出發定義介面、而不是從資料來源的能力出發：</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">/// 從 SyncRepository 的 23 個方法中抽取實際需要的 2 個
</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">SyncQueryPort</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">List</span><span class="o">&lt;</span><span class="n">ChangeRecord</span><span class="o">&gt;&gt;</span> <span class="n">getPendingChanges</span><span class="p">({</span><span class="kt">int</span><span class="o">?</span> <span class="n">limit</span><span class="p">});</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="n">Future</span><span class="o">&lt;</span><span class="n">List</span><span class="o">&lt;</span><span class="n">ConflictResolution</span><span class="o">&gt;&gt;</span> <span class="n">getPendingConflicts</span><span class="p">({</span><span class="kt">int</span><span class="o">?</span> <span class="n">limit</span><span class="p">});</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1">/// 從 BookRepository 的 16 個方法中抽取實際需要的 1 個
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span><span class="kd">abstract</span> <span class="kd">class</span> <span class="nc">BookQueryPort</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="n">Future</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">getAllBooks</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>三個配套讓改動保持小：</p>
<ul>
<li><strong>Repository 實作 Port、原介面不動</strong>：<code>abstract class SyncRepository implements SyncQueryPort</code>——既有實作自動滿足新介面、其他消費者不受影響</li>
<li><strong>Service 建構子改收 Port</strong>：依賴從 55 個方法縮到 5 個、<code>ChangeTracker</code> 直接移除；測試 mock 的對象變成 2 方法與 1 方法的小介面</li>
<li><strong>預留方法標記啟用時機</strong>：<code>// TODO(UC-08): 實際同步功能時啟用</code>——投機式方法不刪（設計已評估過）、但每個都有名字跟啟用條件，下次盤點時「這是預留還是死碼」有據可查</li>
</ul>
<p>值得注意方向性：Port 放在<strong>消費端的 domain 目錄</strong>（<code>synchronization/ports/</code>、<code>library/ports/</code>），因為它表達的是「這個 domain 需要什麼能力」、不是「Repository 提供什麼」。同一個 Repository 未來可以實作多個不同消費者的 Port，各自窄、互不牽連。</p>
<h2 id="判讀徵兆">判讀徵兆</h2>
<ul>
<li>mock 設置的行數超過測試本體——先數被測物依賴的介面方法數 vs 實際呼叫數</li>
<li>MissingStubError 反覆出現——每一次都是「介面要求你認識的方法」跟「你實際關心的方法」的差距</li>
<li>建構子的某個依賴在整個 class 內零呼叫——複製前例的沉積、直接刪</li>
<li>介面裡一半以上的方法標著「未來會用」——預留可以，但要有 TODO 加啟用條件，否則每個消費者與 mock 永遠陪葬</li>
</ul>
<p>「測試很難寫」在這個 case 裡是禮物：它比任何架構審查都早、都具體地量化了介面設計的問題（91% 這個數字就是證據）。把測試痛當成噪音硬吞（寫更肥的 mock helper）、跟把它當探針回頭修介面，是兩條分岔路——同專案的 <a href="/blog/work-log/flutter_mock_infrastructure_overengineering_deleted/" data-link-title="1101 行自建測試基礎設施、重構刪掉 82.5% — 過度工程的三種形態" data-link-desc="自建測試基礎設施前先問框架的標準做法是什麼：mock 純資料物件、helper 帶並發鎖與記憶體洩漏防護、mock 放進 lib/ 進生產依賴圖，三種形態都在重新發明 Riverpod overrideWith 一行就有的東西。精緻的設計文件不是價值證明——它可以精心規劃一個不需要存在的系統。">mock 基礎設施過度工程</a>正是前一條路的下場。</p>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>概念地基：<a href="/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南</a>——service 與 repository 的邊界、以及分工表裡「domain 持有的是能力需求」</li>
<li>硬吞測試痛的反面教材：<a href="/blog/work-log/flutter_mock_infrastructure_overengineering_deleted/" data-link-title="1101 行自建測試基礎設施、重構刪掉 82.5% — 過度工程的三種形態" data-link-desc="自建測試基礎設施前先問框架的標準做法是什麼：mock 純資料物件、helper 帶並發鎖與記憶體洩漏防護、mock 放進 lib/ 進生產依賴圖，三種形態都在重新發明 Riverpod overrideWith 一行就有的東西。精緻的設計文件不是價值證明——它可以精心規劃一個不需要存在的系統。">1101 行自建測試基礎設施</a>——mock 難寫的兩種回應：修介面（本文）vs 蓋更大的 mock 系統（該篇）</li>
<li>投機式預留的另一形態：<a href="/blog/work-log/flutter_async_query_overdesign_oscillation/" data-link-title="同一個子系統膨脹兩次：異步查詢系統的過度設計震盪" data-link-desc="過度設計會復發、且兩輪的機制不同：設計期的膨脹來自想像的需求（別層已處理的重試、用不到的優先級佇列），迭代期的膨脹來自不刪的舊版本（三個實作並存、狀態多處追蹤）。偽需求的檢驗法是問「這個能力已經有別層在做嗎」。">過度設計震盪</a>——那篇的迭代期沉積跟本文的預留方法同源，差別是 Port 案例的預留有 TODO 加啟用條件、不是無主地放著</li>
</ul>
]]></content:encoded></item><item><title>SQLite 只吃三種型別 — value object 在持久化邊界的序列化契約</title><link>https://tarrragon.github.io/blog/work-log/flutter_sqlite_value_object_serialization_boundary/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_sqlite_value_object_serialization_boundary/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter 書籍管理 App 的資料庫整合測試全面失敗，錯誤訊息：&lt;code>Invalid argument 整合測試作者 with type BookAuthor. Only num, String and Uint8List are supported&lt;/code>——所有涉及 SQLite 的 CRUD 操作都掛
&lt;strong>疑問來源&lt;/strong>：同一個 map 裡 &lt;code>id&lt;/code> 跟 &lt;code>title&lt;/code> 都存得進去，為什麼 &lt;code>author&lt;/code> 炸了？
&lt;strong>整理目的&lt;/strong>：記下 value object 跨持久化邊界的轉換責任、以及 toString/fromString 這條隱性契約的風險
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.10.6 的修復規劃記錄；sqflite 的型別限制是 SQLite 本身的特性、不是套件的設計選擇&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="錯誤現場三個欄位兩種寫法">錯誤現場：三個欄位、兩種寫法&lt;/h2>
&lt;p>炸點在 repository 把 entity 轉成資料庫 map 的方法：&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">Map&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="kt">String&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">dynamic&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">_bookToMap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Book&lt;/span> &lt;span class="n">book&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="k">return&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="s1">&amp;#39;id&amp;#39;&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="n">book&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">toString&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="c1">// BookId → String，存得進去
&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="s1">&amp;#39;title&amp;#39;&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="n">book&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">title&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">toString&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="c1">// BookTitle → String，存得進去
&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="s1">&amp;#39;author&amp;#39;&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="n">book&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">author&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// BookAuthor 物件直接塞 → 炸
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="p">};&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>sqflite 底下的 SQLite 只接受 &lt;code>num&lt;/code>、&lt;code>String&lt;/code>、&lt;code>Uint8List&lt;/code> 三種型別。&lt;code>BookAuthor&lt;/code> 是帶內部狀態的 value object（作者清單、譯者），直接放進 map 就是把一個 Dart 物件遞給不認識它的儲存引擎。錯誤訊息其實說得很清楚——難的不是修，是這個錯誤揭露的責任問題：&lt;strong>誰負責把領域型別拆成儲存型別？&lt;/strong>&lt;/p>
&lt;h2 id="責任歸位轉換發生在-repository-邊界">責任歸位：轉換發生在 repository 邊界&lt;/h2>
&lt;p>修法本身一行：&lt;code>'author': book.author.toString()&lt;/code>；讀回的方向 &lt;code>_mapToBook&lt;/code> 已經在用 &lt;code>BookAuthor.fromString(map['author'])&lt;/code> 重建。架構上這是 adapter 的職責放在 repository 層——domain 的 value object 不知道 SQLite 存在、SQLite 不知道 value object 存在，兩個世界的轉換集中在 I/O 邊界的 &lt;code>_bookToMap&lt;/code> / &lt;code>_mapToBook&lt;/code> 一對方法裡。&lt;/p>
&lt;p>這個歸位讓錯誤的形態變得可預測：&lt;strong>每個新的 VO 欄位都要在這對方法裡出現一次&lt;/strong>，漏掉序列化端會炸 Invalid argument（吵、好抓）、漏掉反序列化端會在讀取時炸型別轉換（也吵）。真正安靜的坑在第三種情況——兩端都寫了、但不對稱。&lt;/p>
&lt;h2 id="隱性契約tostring-與-fromstring-的對稱性沒人強制">隱性契約：toString 與 fromString 的對稱性沒人強制&lt;/h2>
&lt;p>用 &lt;code>toString()&lt;/code> / &lt;code>fromString()&lt;/code> 當序列化通道，工作的前提是 &lt;code>fromString(x.toString()) == x&lt;/code>——而這條契約沒有任何機制在守。修復記錄自己就把風險寫進了已知限制：複雜物件轉字串可能遺失部分內部狀態、未來需要更精細的序列化機制。&lt;/p>
&lt;p>具體的斷裂點兩種。其一，&lt;code>toString()&lt;/code> 的本業是除錯表示，哪天有人為了 log 可讀性把格式改成 &lt;code>BookAuthor(name: ...)&lt;/code>，資料庫裡從此存進去的是新格式、舊資料用新 &lt;code>fromString&lt;/code> 讀不回來——&lt;strong>兩個消費者（除錯與持久化）寄生在同一個方法上、變更理由不同步&lt;/strong>。其二，格式本身有損：這個專案的 &lt;code>BookAuthor&lt;/code> 把多作者序列化成「作者1, 作者2」、譯者成「作者 (譯者 譯)」——作者名字裡出現逗號或括號時，roundtrip 就不再對稱。&lt;/p>
&lt;p>正解方向修復記錄也留了：語意明確的序列化介面（&lt;code>toDbValue()&lt;/code> / 專用 &lt;code>Serializable&lt;/code>、或 JSON 結構化），讓「持久化格式」成為一個有自己名字、自己測試、自己變更理由的東西。過渡期至少要補上對稱性測試——對每個 VO 斷言 &lt;code>fromString(v.toString()) == v&lt;/code>、含邊界值（空作者、多作者、含譯者），把隱性契約變成會紅的測試。&lt;/p>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;ul>
&lt;li>&lt;code>Invalid argument ... with type X. Only num, String and Uint8List are supported&lt;/code>——X 就是漏轉換的 VO、去 &lt;code>_toMap&lt;/code> 系方法找它&lt;/li>
&lt;li>repository 的 map 轉換裡混用「物件直接放」跟「&lt;code>.toString()&lt;/code>」兩種寫法——前者是還沒炸的候選&lt;/li>
&lt;li>&lt;code>toString()&lt;/code> 同時服務除錯輸出跟持久化 / 快取 key——語意寄生，兩個消費者遲早有一個要改格式&lt;/li>
&lt;li>VO 有 &lt;code>fromString&lt;/code> 但測試裡沒有任何 roundtrip 斷言——對稱契約處於未驗證狀態&lt;/li>
&lt;/ul>
&lt;h2 id="相關閱讀">相關閱讀&lt;/h2>
&lt;ul>
&lt;li>出口語意的原則版：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_value_object_encapsulation_oscillation/" data-link-title="Value Object 的封裝擺盪：從全移除、完全封裝、到加回 .value getter" data-link-desc="VO 的封裝邊界在兩個極端之間來回——純字串（零封裝）跟完全封裝（禁止取原始值）各有成立的理由、也各自撞牆。穩態是給原始值一個有語意的官方出口，而不是把「取原始值」本身當違規。含 176 個編譯錯誤的工作量低估、以及「相容性介面」作為理想撤退訊號的判讀。">VO 封裝擺盪&lt;/a>——那篇論證「原始值要有語意明確的官方出口」，本文是 &lt;code>toString()&lt;/code> 被當出口用的實際風險清單&lt;/li>
&lt;li>持久化邊界的另一面：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_feature_complete_never_persisted/" data-link-title="功能「完成」、測試全過、資料從未落地 — 持久化迴圈是驗收的盲區" data-link-desc="domain 功能的測試可以全綠、而它的資料從未被序列化、資料庫沒有對應的表——單元測試都在記憶體內驗證行為、沒有一條測試走「存進去、重建、讀出來」的迴圈。驗收定義要含 roundtrip；entity 欄位與 schema 欄位的差集是靜默資料失真的清單。">功能完成卻從未持久化&lt;/a>——那篇是欄位沒進出邊界、本文是進了邊界但轉換錯誤，roundtrip 測試同時守住兩者&lt;/li>
&lt;li>概念地基：&lt;a href="https://tarrragon.github.io/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南&lt;/a> 的 entity 持久化邊界章節&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter 書籍管理 App 的資料庫整合測試全面失敗，錯誤訊息：<code>Invalid argument 整合測試作者 with type BookAuthor. Only num, String and Uint8List are supported</code>——所有涉及 SQLite 的 CRUD 操作都掛
<strong>疑問來源</strong>：同一個 map 裡 <code>id</code> 跟 <code>title</code> 都存得進去，為什麼 <code>author</code> 炸了？
<strong>整理目的</strong>：記下 value object 跨持久化邊界的轉換責任、以及 toString/fromString 這條隱性契約的風險
<strong>本文邊界</strong>：素材是該專案 v0.10.6 的修復規劃記錄；sqflite 的型別限制是 SQLite 本身的特性、不是套件的設計選擇</p></blockquote>
<hr>
<h2 id="錯誤現場三個欄位兩種寫法">錯誤現場：三個欄位、兩種寫法</h2>
<p>炸點在 repository 把 entity 轉成資料庫 map 的方法：</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">Map</span><span class="o">&lt;</span><span class="kt">String</span><span class="p">,</span> <span class="kt">dynamic</span><span class="o">&gt;</span> <span class="n">_bookToMap</span><span class="p">(</span><span class="n">Book</span> <span class="n">book</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">return</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s1">&#39;id&#39;</span><span class="o">:</span> <span class="n">book</span><span class="p">.</span><span class="n">id</span><span class="p">.</span><span class="n">toString</span><span class="p">(),</span>        <span class="c1">// BookId → String，存得進去
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>    <span class="s1">&#39;title&#39;</span><span class="o">:</span> <span class="n">book</span><span class="p">.</span><span class="n">title</span><span class="p">.</span><span class="n">toString</span><span class="p">(),</span>  <span class="c1">// BookTitle → String，存得進去
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span>    <span class="s1">&#39;author&#39;</span><span class="o">:</span> <span class="n">book</span><span class="p">.</span><span class="n">author</span><span class="p">,</span>           <span class="c1">// BookAuthor 物件直接塞 → 炸
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span>    <span class="p">...</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="p">};</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>sqflite 底下的 SQLite 只接受 <code>num</code>、<code>String</code>、<code>Uint8List</code> 三種型別。<code>BookAuthor</code> 是帶內部狀態的 value object（作者清單、譯者），直接放進 map 就是把一個 Dart 物件遞給不認識它的儲存引擎。錯誤訊息其實說得很清楚——難的不是修，是這個錯誤揭露的責任問題：<strong>誰負責把領域型別拆成儲存型別？</strong></p>
<h2 id="責任歸位轉換發生在-repository-邊界">責任歸位：轉換發生在 repository 邊界</h2>
<p>修法本身一行：<code>'author': book.author.toString()</code>；讀回的方向 <code>_mapToBook</code> 已經在用 <code>BookAuthor.fromString(map['author'])</code> 重建。架構上這是 adapter 的職責放在 repository 層——domain 的 value object 不知道 SQLite 存在、SQLite 不知道 value object 存在，兩個世界的轉換集中在 I/O 邊界的 <code>_bookToMap</code> / <code>_mapToBook</code> 一對方法裡。</p>
<p>這個歸位讓錯誤的形態變得可預測：<strong>每個新的 VO 欄位都要在這對方法裡出現一次</strong>，漏掉序列化端會炸 Invalid argument（吵、好抓）、漏掉反序列化端會在讀取時炸型別轉換（也吵）。真正安靜的坑在第三種情況——兩端都寫了、但不對稱。</p>
<h2 id="隱性契約tostring-與-fromstring-的對稱性沒人強制">隱性契約：toString 與 fromString 的對稱性沒人強制</h2>
<p>用 <code>toString()</code> / <code>fromString()</code> 當序列化通道，工作的前提是 <code>fromString(x.toString()) == x</code>——而這條契約沒有任何機制在守。修復記錄自己就把風險寫進了已知限制：複雜物件轉字串可能遺失部分內部狀態、未來需要更精細的序列化機制。</p>
<p>具體的斷裂點兩種。其一，<code>toString()</code> 的本業是除錯表示，哪天有人為了 log 可讀性把格式改成 <code>BookAuthor(name: ...)</code>，資料庫裡從此存進去的是新格式、舊資料用新 <code>fromString</code> 讀不回來——<strong>兩個消費者（除錯與持久化）寄生在同一個方法上、變更理由不同步</strong>。其二，格式本身有損：這個專案的 <code>BookAuthor</code> 把多作者序列化成「作者1, 作者2」、譯者成「作者 (譯者 譯)」——作者名字裡出現逗號或括號時，roundtrip 就不再對稱。</p>
<p>正解方向修復記錄也留了：語意明確的序列化介面（<code>toDbValue()</code> / 專用 <code>Serializable</code>、或 JSON 結構化），讓「持久化格式」成為一個有自己名字、自己測試、自己變更理由的東西。過渡期至少要補上對稱性測試——對每個 VO 斷言 <code>fromString(v.toString()) == v</code>、含邊界值（空作者、多作者、含譯者），把隱性契約變成會紅的測試。</p>
<h2 id="判讀徵兆">判讀徵兆</h2>
<ul>
<li><code>Invalid argument ... with type X. Only num, String and Uint8List are supported</code>——X 就是漏轉換的 VO、去 <code>_toMap</code> 系方法找它</li>
<li>repository 的 map 轉換裡混用「物件直接放」跟「<code>.toString()</code>」兩種寫法——前者是還沒炸的候選</li>
<li><code>toString()</code> 同時服務除錯輸出跟持久化 / 快取 key——語意寄生，兩個消費者遲早有一個要改格式</li>
<li>VO 有 <code>fromString</code> 但測試裡沒有任何 roundtrip 斷言——對稱契約處於未驗證狀態</li>
</ul>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>出口語意的原則版：<a href="/blog/work-log/flutter_value_object_encapsulation_oscillation/" data-link-title="Value Object 的封裝擺盪：從全移除、完全封裝、到加回 .value getter" data-link-desc="VO 的封裝邊界在兩個極端之間來回——純字串（零封裝）跟完全封裝（禁止取原始值）各有成立的理由、也各自撞牆。穩態是給原始值一個有語意的官方出口，而不是把「取原始值」本身當違規。含 176 個編譯錯誤的工作量低估、以及「相容性介面」作為理想撤退訊號的判讀。">VO 封裝擺盪</a>——那篇論證「原始值要有語意明確的官方出口」，本文是 <code>toString()</code> 被當出口用的實際風險清單</li>
<li>持久化邊界的另一面：<a href="/blog/work-log/flutter_feature_complete_never_persisted/" data-link-title="功能「完成」、測試全過、資料從未落地 — 持久化迴圈是驗收的盲區" data-link-desc="domain 功能的測試可以全綠、而它的資料從未被序列化、資料庫沒有對應的表——單元測試都在記憶體內驗證行為、沒有一條測試走「存進去、重建、讀出來」的迴圈。驗收定義要含 roundtrip；entity 欄位與 schema 欄位的差集是靜默資料失真的清單。">功能完成卻從未持久化</a>——那篇是欄位沒進出邊界、本文是進了邊界但轉換錯誤，roundtrip 測試同時守住兩者</li>
<li>概念地基：<a href="/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南</a> 的 entity 持久化邊界章節</li>
</ul>
]]></content:encoded></item><item><title>遷移計畫有寫入、有消費、缺讀出 — read-path 缺口與 fixture 假綠</title><link>https://tarrragon.github.io/blog/work-log/flutter_migration_read_path_gap_fake_green/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_migration_read_path_gap_fake_green/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Book entity 換成 tag-based 結構後（Deprecated Getter Facade 那次），接下來要派發消費端遷移的 ticket——把 &lt;code>book.author&lt;/code> 改成 &lt;code>book.getPrimaryTag(&amp;quot;author&amp;quot;)&lt;/code>。派發前的設計一致性檢查攔下了它：這個改動上線後，enrichment、export、CSV 全部會靜默失效
&lt;strong>疑問來源&lt;/strong>：facade 遷移做完了、資料 backfill 的 ticket 也排了，消費端遷移為什麼還是危險的？缺了什麼？
&lt;strong>整理目的&lt;/strong>：記下資料遷移的「三段通路」檢查、fixture 假綠的機制、以及依賴圖作為推導訊號的失效方式
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.32 的分析 ticket（含獨立重驗的證據鏈）；被攔下的事故沒有真的發生——這是一次派發前攔截的記錄&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="缺口新-api-的資料從哪來">缺口：新 API 的資料從哪來？&lt;/h2>
&lt;p>檢查的起點是一個樸素的問題：消費端改用 &lt;code>getPrimaryTag(&amp;quot;author&amp;quot;)&lt;/code> 之後，這個方法讀的 &lt;code>bookTags&lt;/code> 集合、內容從哪來？grep 的答案是&lt;strong>不從任何地方來&lt;/strong>——repository 的 &lt;code>_mapToBook&lt;/code> 建構 Book 時完全沒提 &lt;code>bookTags&lt;/code>，欄位恆為預設 &lt;code>const []&lt;/code>。從 SQLite 載入的每一本書，tag API 都回空。&lt;/p>
&lt;p>而計畫裡確實有兩張看起來相關的 ticket，逐一確認 scope 後都不覆蓋這個缺口：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Ticket&lt;/th>
 &lt;th>Scope&lt;/th>
 &lt;th>覆蓋 bookTags 注入？&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>W1-003&lt;/td>
 &lt;td>DB migration：把舊欄位資料寫入 tag 表&lt;/td>
 &lt;td>否——只有寫入方向&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>W3-001&lt;/td>
 &lt;td>重寫 search 的 SQL、JOIN tag 表&lt;/td>
 &lt;td>部分——只有搜尋查詢路徑&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>缺口&lt;/strong>&lt;/td>
 &lt;td>重寫 &lt;code>_mapToBook&lt;/code>、一般讀取路徑填充 tags&lt;/td>
 &lt;td>&lt;strong>無人認領&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>資料模型遷移的通路有三段：&lt;strong>寫入&lt;/strong>（backfill 讓新結構有資料）、&lt;strong>讀出&lt;/strong>（讀取路徑把新結構載進物件）、&lt;strong>消費&lt;/strong>（呼叫端改用新 API）。這個計畫排了第一段跟第三段、第二段整段缺席——不是排錯順序、是&lt;strong>沒有任何 ticket 認領它&lt;/strong>。缺口安靜的原因跟&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_feature_complete_never_persisted/" data-link-title="功能「完成」、測試全過、資料從未落地 — 持久化迴圈是驗收的盲區" data-link-desc="domain 功能的測試可以全綠、而它的資料從未被序列化、資料庫沒有對應的表——單元測試都在記憶體內驗證行為、沒有一條測試走「存進去、重建、讀出來」的迴圈。驗收定義要含 roundtrip；entity 欄位與 schema 欄位的差集是靜默資料失真的清單。">持久層靜默丟欄位&lt;/a>同構：每張存在的 ticket 都會被檢視、不存在的 ticket 沒有形狀可供檢視。&lt;/p>
&lt;h2 id="假綠機制fixture-不走真實資料通路">假綠機制：fixture 不走真實資料通路&lt;/h2>
&lt;p>更危險的是這個缺口&lt;strong>測試抓不到&lt;/strong>。消費端的 unit test fixture 自己 &lt;code>Book(...)&lt;/code> 建物件——遷移後 fixture 跟著改、建的時候直接帶上 &lt;code>bookTags&lt;/code>，於是 &lt;code>getPrimaryTag&lt;/code> 在測試裡有資料、斷言全過。真實環境的 Book 從 SQLite 經 &lt;code>_mapToBook&lt;/code> 載入、&lt;code>bookTags&lt;/code> 恆空——&lt;strong>測試世界跟真實世界走不同的資料通路&lt;/strong>，測試通過證明的只是「如果資料有進來、邏輯是對的」，而資料沒進來。&lt;/p>
&lt;p>這是假綠家族裡最結構性的一種：不是 mock 遮蔽、不是斷言過時，是 fixture 的建構方式繞過了真實系統的組裝路徑。守住它的測試必須走完整通路——寫進 SQLite、經 repository 讀出、斷言 tags 存在——也就是整合層的 roundtrip，unit fixture 結構上無能為力。&lt;/p>
&lt;h2 id="依賴圖是推導ready-是推導的推導">依賴圖是推導、ready 是推導的推導&lt;/h2>
&lt;p>第二個結構性缺陷在計畫層。盤點八張消費端遷移 ticket 的 &lt;code>blockedBy&lt;/code>：五張只列了 W1-002（entity 重寫）——它們的「前提」只記錄了&lt;strong>時序直覺&lt;/strong>（core entity 要先改），沒記錄&lt;strong>語意前提&lt;/strong>（我改用的 API 要真的有資料）。而 dashboard 判定 W2-008「ready 可派發」，依據就是這張不完整的 blockedBy。&lt;/p>
&lt;p>訊號鏈是這樣失真的：語意前提沒被列進 blockedBy → blockedBy 齊了 → ready 亮綠燈 → 派發。每一步推導都正確、第一步的輸入就缺了。這跟 &lt;a href="https://tarrragon.github.io/blog/report/lint-scope-must-be-explicit-fact/" data-link-title="檢查規則的作用域要顯式列舉：零 error 可能是沒被檢查" data-link-desc="新增與既有受檢目錄同類的內容目錄時、或工具鏈長期零 error 卻累積出違規時使用。規則的作用域由路徑常數決定、該常數常同時被多個檢查共用，擴作用域會連帶擴語意；作用域是獨立於規則內容的 fact，驗收方式是先確認新規則對已知違規報錯。">#221 規則存在 vs 規則涵蓋&lt;/a>同構：ready 訊號的可信度上限是依賴圖的完整性，圖不完整時 ready 的綠跟「沒檢查」的綠長一樣。修正也對準這裡：補建 read-path ticket、七張 ticket 的 blockedBy 補上資料通路前提、wave 重排序（讓 tag API 回真資料的工作先於所有消費端遷移）。&lt;/p>
&lt;p>流程上還有一筆值得記：這次分析被要求&lt;strong>獨立重驗&lt;/strong>——「勿盲信 PM 行號、重跑 grep / 讀檔」，五項證據全部重新驗證。攔截的品質靠的不是第一個發現者的正確、是第二雙眼睛用自己的指令重跑一遍。&lt;/p>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;ul>
&lt;li>遷移計畫的 ticket 清單裡，寫入（backfill / migration script）跟消費（API 呼叫端改寫）都有、讀取路徑（mapper / repository 載入）沒有獨立條目——逐段問「新結構的資料怎麼進物件」&lt;/li>
&lt;li>新 API 在測試全綠、實機回空值 / 預設值——查 fixture 的建構方式是否繞過真實組裝路徑&lt;/li>
&lt;li>&lt;code>blockedBy&lt;/code> 全是「結構上游」（entity、schema）而沒有「資料上游」（backfill、注入）——依賴圖記了時序、沒記語意&lt;/li>
&lt;li>grep 新欄位名在 repository / mapper 檔案零命中——讀取路徑還不知道這個欄位存在，消費端遷移是空中樓閣&lt;/li>
&lt;/ul>
&lt;h2 id="相關閱讀">相關閱讀&lt;/h2>
&lt;ul>
&lt;li>上一章：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_deprecated_getter_facade_entity_migration/" data-link-title="核心 entity 重寫、140&amp;#43; 檔消費端不動 — Deprecated Getter Facade 的過渡設計" data-link-desc="重寫被百餘檔引用的核心 entity 時，直接改會同時打爆全部消費端、長期分支的 merge 成本隨時間暴漲。第三條路是 facade：舊欄位保留為 deprecated getter、內部從新結構回讀，消費端零修改編譯通過、@Deprecated 讓編譯器自動列出遷移清單、再逐波清償。facade 要配退場計畫、否則就是永久相容層。">Deprecated Getter Facade&lt;/a>——facade 讓編譯過了，本文是「編譯過了之後、資料通路要自己驗證」的實錄&lt;/li>
&lt;li>同構的持久化盲區：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_feature_complete_never_persisted/" data-link-title="功能「完成」、測試全過、資料從未落地 — 持久化迴圈是驗收的盲區" data-link-desc="domain 功能的測試可以全綠、而它的資料從未被序列化、資料庫沒有對應的表——單元測試都在記憶體內驗證行為、沒有一條測試走「存進去、重建、讀出來」的迴圈。驗收定義要含 roundtrip；entity 欄位與 schema 欄位的差集是靜默資料失真的清單。">功能完成卻從未持久化&lt;/a>——那篇缺寫入段、本文缺讀出段，三段通路各有各的靜默缺法&lt;/li>
&lt;li>原則層：&lt;a href="https://tarrragon.github.io/blog/report/pipeline-artifact-field-contract/" data-link-title="多階段流程的 artifact 欄位契約：下游宣稱的輸入要能從上游產出推導、推導規則要明文" data-link-desc="多階段流程裡、下游階段宣稱「以上游的 X 為輸入」時、X 需要的每個欄位要嘛直接存在於上游的產出格式、要嘛有明文的推導規則。上游表七欄、下游要求的「失敗語意」欄不在其中也沒有推導說明 — 執行者只能自由心證、每次推得不一樣、漏標的欄位剛好是下游分支的開關。檢查方法是逐欄走查：把下游輸入格式的每一欄、對到上游產出格式的欄或一條明文推導。">#163 多階段流程的 artifact 欄位契約&lt;/a>——「下游宣稱以上游為輸入」要欄位層級可推導，blockedBy 的語意前提就是 ticket 系統的欄位契約&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Book entity 換成 tag-based 結構後（Deprecated Getter Facade 那次），接下來要派發消費端遷移的 ticket——把 <code>book.author</code> 改成 <code>book.getPrimaryTag(&quot;author&quot;)</code>。派發前的設計一致性檢查攔下了它：這個改動上線後，enrichment、export、CSV 全部會靜默失效
<strong>疑問來源</strong>：facade 遷移做完了、資料 backfill 的 ticket 也排了，消費端遷移為什麼還是危險的？缺了什麼？
<strong>整理目的</strong>：記下資料遷移的「三段通路」檢查、fixture 假綠的機制、以及依賴圖作為推導訊號的失效方式
<strong>本文邊界</strong>：素材是該專案 v0.32 的分析 ticket（含獨立重驗的證據鏈）；被攔下的事故沒有真的發生——這是一次派發前攔截的記錄</p></blockquote>
<hr>
<h2 id="缺口新-api-的資料從哪來">缺口：新 API 的資料從哪來？</h2>
<p>檢查的起點是一個樸素的問題：消費端改用 <code>getPrimaryTag(&quot;author&quot;)</code> 之後，這個方法讀的 <code>bookTags</code> 集合、內容從哪來？grep 的答案是<strong>不從任何地方來</strong>——repository 的 <code>_mapToBook</code> 建構 Book 時完全沒提 <code>bookTags</code>，欄位恆為預設 <code>const []</code>。從 SQLite 載入的每一本書，tag API 都回空。</p>
<p>而計畫裡確實有兩張看起來相關的 ticket，逐一確認 scope 後都不覆蓋這個缺口：</p>
<table>
  <thead>
      <tr>
          <th>Ticket</th>
          <th>Scope</th>
          <th>覆蓋 bookTags 注入？</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>W1-003</td>
          <td>DB migration：把舊欄位資料寫入 tag 表</td>
          <td>否——只有寫入方向</td>
      </tr>
      <tr>
          <td>W3-001</td>
          <td>重寫 search 的 SQL、JOIN tag 表</td>
          <td>部分——只有搜尋查詢路徑</td>
      </tr>
      <tr>
          <td><strong>缺口</strong></td>
          <td>重寫 <code>_mapToBook</code>、一般讀取路徑填充 tags</td>
          <td><strong>無人認領</strong></td>
      </tr>
  </tbody>
</table>
<p>資料模型遷移的通路有三段：<strong>寫入</strong>（backfill 讓新結構有資料）、<strong>讀出</strong>（讀取路徑把新結構載進物件）、<strong>消費</strong>（呼叫端改用新 API）。這個計畫排了第一段跟第三段、第二段整段缺席——不是排錯順序、是<strong>沒有任何 ticket 認領它</strong>。缺口安靜的原因跟<a href="/blog/work-log/flutter_feature_complete_never_persisted/" data-link-title="功能「完成」、測試全過、資料從未落地 — 持久化迴圈是驗收的盲區" data-link-desc="domain 功能的測試可以全綠、而它的資料從未被序列化、資料庫沒有對應的表——單元測試都在記憶體內驗證行為、沒有一條測試走「存進去、重建、讀出來」的迴圈。驗收定義要含 roundtrip；entity 欄位與 schema 欄位的差集是靜默資料失真的清單。">持久層靜默丟欄位</a>同構：每張存在的 ticket 都會被檢視、不存在的 ticket 沒有形狀可供檢視。</p>
<h2 id="假綠機制fixture-不走真實資料通路">假綠機制：fixture 不走真實資料通路</h2>
<p>更危險的是這個缺口<strong>測試抓不到</strong>。消費端的 unit test fixture 自己 <code>Book(...)</code> 建物件——遷移後 fixture 跟著改、建的時候直接帶上 <code>bookTags</code>，於是 <code>getPrimaryTag</code> 在測試裡有資料、斷言全過。真實環境的 Book 從 SQLite 經 <code>_mapToBook</code> 載入、<code>bookTags</code> 恆空——<strong>測試世界跟真實世界走不同的資料通路</strong>，測試通過證明的只是「如果資料有進來、邏輯是對的」，而資料沒進來。</p>
<p>這是假綠家族裡最結構性的一種：不是 mock 遮蔽、不是斷言過時，是 fixture 的建構方式繞過了真實系統的組裝路徑。守住它的測試必須走完整通路——寫進 SQLite、經 repository 讀出、斷言 tags 存在——也就是整合層的 roundtrip，unit fixture 結構上無能為力。</p>
<h2 id="依賴圖是推導ready-是推導的推導">依賴圖是推導、ready 是推導的推導</h2>
<p>第二個結構性缺陷在計畫層。盤點八張消費端遷移 ticket 的 <code>blockedBy</code>：五張只列了 W1-002（entity 重寫）——它們的「前提」只記錄了<strong>時序直覺</strong>（core entity 要先改），沒記錄<strong>語意前提</strong>（我改用的 API 要真的有資料）。而 dashboard 判定 W2-008「ready 可派發」，依據就是這張不完整的 blockedBy。</p>
<p>訊號鏈是這樣失真的：語意前提沒被列進 blockedBy → blockedBy 齊了 → ready 亮綠燈 → 派發。每一步推導都正確、第一步的輸入就缺了。這跟 <a href="/blog/report/lint-scope-must-be-explicit-fact/" data-link-title="檢查規則的作用域要顯式列舉：零 error 可能是沒被檢查" data-link-desc="新增與既有受檢目錄同類的內容目錄時、或工具鏈長期零 error 卻累積出違規時使用。規則的作用域由路徑常數決定、該常數常同時被多個檢查共用，擴作用域會連帶擴語意；作用域是獨立於規則內容的 fact，驗收方式是先確認新規則對已知違規報錯。">#221 規則存在 vs 規則涵蓋</a>同構：ready 訊號的可信度上限是依賴圖的完整性，圖不完整時 ready 的綠跟「沒檢查」的綠長一樣。修正也對準這裡：補建 read-path ticket、七張 ticket 的 blockedBy 補上資料通路前提、wave 重排序（讓 tag API 回真資料的工作先於所有消費端遷移）。</p>
<p>流程上還有一筆值得記：這次分析被要求<strong>獨立重驗</strong>——「勿盲信 PM 行號、重跑 grep / 讀檔」，五項證據全部重新驗證。攔截的品質靠的不是第一個發現者的正確、是第二雙眼睛用自己的指令重跑一遍。</p>
<h2 id="判讀徵兆">判讀徵兆</h2>
<ul>
<li>遷移計畫的 ticket 清單裡，寫入（backfill / migration script）跟消費（API 呼叫端改寫）都有、讀取路徑（mapper / repository 載入）沒有獨立條目——逐段問「新結構的資料怎麼進物件」</li>
<li>新 API 在測試全綠、實機回空值 / 預設值——查 fixture 的建構方式是否繞過真實組裝路徑</li>
<li><code>blockedBy</code> 全是「結構上游」（entity、schema）而沒有「資料上游」（backfill、注入）——依賴圖記了時序、沒記語意</li>
<li>grep 新欄位名在 repository / mapper 檔案零命中——讀取路徑還不知道這個欄位存在，消費端遷移是空中樓閣</li>
</ul>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>上一章：<a href="/blog/work-log/flutter_deprecated_getter_facade_entity_migration/" data-link-title="核心 entity 重寫、140&#43; 檔消費端不動 — Deprecated Getter Facade 的過渡設計" data-link-desc="重寫被百餘檔引用的核心 entity 時，直接改會同時打爆全部消費端、長期分支的 merge 成本隨時間暴漲。第三條路是 facade：舊欄位保留為 deprecated getter、內部從新結構回讀，消費端零修改編譯通過、@Deprecated 讓編譯器自動列出遷移清單、再逐波清償。facade 要配退場計畫、否則就是永久相容層。">Deprecated Getter Facade</a>——facade 讓編譯過了，本文是「編譯過了之後、資料通路要自己驗證」的實錄</li>
<li>同構的持久化盲區：<a href="/blog/work-log/flutter_feature_complete_never_persisted/" data-link-title="功能「完成」、測試全過、資料從未落地 — 持久化迴圈是驗收的盲區" data-link-desc="domain 功能的測試可以全綠、而它的資料從未被序列化、資料庫沒有對應的表——單元測試都在記憶體內驗證行為、沒有一條測試走「存進去、重建、讀出來」的迴圈。驗收定義要含 roundtrip；entity 欄位與 schema 欄位的差集是靜默資料失真的清單。">功能完成卻從未持久化</a>——那篇缺寫入段、本文缺讀出段，三段通路各有各的靜默缺法</li>
<li>原則層：<a href="/blog/report/pipeline-artifact-field-contract/" data-link-title="多階段流程的 artifact 欄位契約：下游宣稱的輸入要能從上游產出推導、推導規則要明文" data-link-desc="多階段流程裡、下游階段宣稱「以上游的 X 為輸入」時、X 需要的每個欄位要嘛直接存在於上游的產出格式、要嘛有明文的推導規則。上游表七欄、下游要求的「失敗語意」欄不在其中也沒有推導說明 — 執行者只能自由心證、每次推得不一樣、漏標的欄位剛好是下游分支的開關。檢查方法是逐欄走查：把下游輸入格式的每一欄、對到上游產出格式的欄或一條明文推導。">#163 多階段流程的 artifact 欄位契約</a>——「下游宣稱以上游為輸入」要欄位層級可推導，blockedBy 的語意前提就是 ticket 系統的欄位契約</li>
</ul>
]]></content:encoded></item></channel></rss>