<?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>Port on Tarragon</title><link>https://tarrragon.github.io/blog/tags/port/</link><description>Recent content in Port 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/port/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>Port</title><link>https://tarrragon.github.io/blog/ddd/knowledge-cards/port/</link><pubDate>Mon, 13 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/knowledge-cards/port/</guid><description>&lt;p>port 是 domain 對外宣告的介面：領域層用領域語言把需求說完——「我需要一個能存書、能查書的地方」——不提資料庫、不提網路。依賴方向因此朝內：實作端依賴介面、介面屬於領域，技術選型換掉時領域碼不動。port 的具體實作是 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/adapter/" data-link-title="Adapter" data-link-desc="把領域需求翻譯成具體技術操作的實作該放哪、跟領域的邊界在哪時使用。adapter 是 port 的具體實作——技術細節被擋在六角形之外的位置。">adapter&lt;/a>、兩者插上的位置是 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/composition-root/" data-link-title="Composition Root" data-link-desc="依賴組裝、路由註冊該集中在哪、組裝斷裂去哪檢查時使用。composition root 是應用程式唯一的組裝起點——DI、路由、事件接線的集中處。">composition root&lt;/a>。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>port 跟一般 interface 的差別在歸屬與語言：宣告在領域層、以領域概念命名（BookRepository 而非 SqliteClient）、方法簽名只用領域型別。介面本身是型別層的約束載體——它強制了「呼叫方看不見技術細節」，與 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/invariant/" data-link-title="Invariant" data-link-desc="領域模型的約束規則落在哪一層時使用。不變式是在物件整個生命週期都必須為真的業務規則——狀態只能沿流程轉換、被同一條規則綁住的欄位必須一起換。">invariant&lt;/a> 的型別層強制同一個機制。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>介面檔案位於 domain 目錄、簽名沒有框架型別，是 port 健康的訊號。介面裡出現 DatabaseConnection、HttpClient 這類技術型別時，port 已被實作細節滲透——呼叫方被迫認識它不該認識的層。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>port 定義「領域需要什麼」，不保證「有人供給它」——宣告了介面、沒有實作被插上，在 mock 測試裡不會有任何紅燈。供給的驗證屬組裝層，教學層展開見 &lt;a href="https://tarrragon.github.io/blog/ddd/composition-root-reachability/" data-link-title="組裝層的可達性" data-link-desc="行為測試全綠、功能在實機上沒有入口的失效形態出現時使用。mock 換掉的正是組裝，組裝完成與否在行為測試裡沒有證言；把可達性當成組裝層的不變式，在測試、發版與設計文件各給一個強制點。">組裝層的可達性&lt;/a>。port 的歸屬判準（逐型別問「這是誰的詞彙」）在 reactive 場景的完整推導見 &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>port 是 domain 對外宣告的介面：領域層用領域語言把需求說完——「我需要一個能存書、能查書的地方」——不提資料庫、不提網路。依賴方向因此朝內：實作端依賴介面、介面屬於領域，技術選型換掉時領域碼不動。port 的具體實作是 <a href="/blog/ddd/knowledge-cards/adapter/" data-link-title="Adapter" data-link-desc="把領域需求翻譯成具體技術操作的實作該放哪、跟領域的邊界在哪時使用。adapter 是 port 的具體實作——技術細節被擋在六角形之外的位置。">adapter</a>、兩者插上的位置是 <a href="/blog/ddd/knowledge-cards/composition-root/" data-link-title="Composition Root" data-link-desc="依賴組裝、路由註冊該集中在哪、組裝斷裂去哪檢查時使用。composition root 是應用程式唯一的組裝起點——DI、路由、事件接線的集中處。">composition root</a>。</p>
<h2 id="概念位置">概念位置</h2>
<p>port 跟一般 interface 的差別在歸屬與語言：宣告在領域層、以領域概念命名（BookRepository 而非 SqliteClient）、方法簽名只用領域型別。介面本身是型別層的約束載體——它強制了「呼叫方看不見技術細節」，與 <a href="/blog/ddd/knowledge-cards/invariant/" data-link-title="Invariant" data-link-desc="領域模型的約束規則落在哪一層時使用。不變式是在物件整個生命週期都必須為真的業務規則——狀態只能沿流程轉換、被同一條規則綁住的欄位必須一起換。">invariant</a> 的型別層強制同一個機制。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>介面檔案位於 domain 目錄、簽名沒有框架型別，是 port 健康的訊號。介面裡出現 DatabaseConnection、HttpClient 這類技術型別時，port 已被實作細節滲透——呼叫方被迫認識它不該認識的層。</p>
<h2 id="設計責任">設計責任</h2>
<p>port 定義「領域需要什麼」，不保證「有人供給它」——宣告了介面、沒有實作被插上，在 mock 測試裡不會有任何紅燈。供給的驗證屬組裝層，教學層展開見 <a href="/blog/ddd/composition-root-reachability/" data-link-title="組裝層的可達性" data-link-desc="行為測試全綠、功能在實機上沒有入口的失效形態出現時使用。mock 換掉的正是組裝，組裝完成與否在行為測試裡沒有證言；把可達性當成組裝層的不變式，在測試、發版與設計文件各給一個強制點。">組裝層的可達性</a>。port 的歸屬判準（逐型別問「這是誰的詞彙」）在 reactive 場景的完整推導見 <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>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>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></channel></rss>