<?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>Composition-Root on Tarragon</title><link>https://tarrragon.github.io/blog/tags/composition-root/</link><description>Recent content in Composition-Root on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 13 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/composition-root/index.xml" rel="self" type="application/rss+xml"/><item><title>組裝層的可達性</title><link>https://tarrragon.github.io/blog/ddd/composition-root-reachability/</link><pubDate>Mon, 13 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/composition-root-reachability/</guid><description>&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> 插上 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/adapter/" data-link-title="Adapter" data-link-desc="把領域需求翻譯成具體技術操作的實作該放哪、跟領域的邊界在哪時使用。adapter 是 port 的具體實作——技術細節被擋在六角形之外的位置。">adapter&lt;/a> 的地方——port 是 domain 對外宣告的介面、adapter 是介面的具體實作、&lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/dependency-injection/" data-link-title="Dependency Injection" data-link-desc="物件的依賴該由誰提供、測試怎麼換掉真實依賴時使用。依賴注入把「建構依賴」跟「使用依賴」分成兩個責任——使用方宣告需要什麼、提供方在組裝時決定給什麼。">依賴注入&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>（組裝根、依賴注入唯一的組裝起點），路由表以單點註冊靠攏它，UI 事件的接線散佈在各畫面。位置不同、同屬組裝層的責任：把功能單元接到使用者按得到的入口上。使用者入口之外、機器觸發的組裝位置（事件訂閱、排程任務）有同一種證言缺口、判準同樣適用；本章聚焦使用者入口。&lt;/p>
&lt;p>本模組（&lt;a href="https://tarrragon.github.io/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南&lt;/a>）的源頭句「讓違反規則的路徑走不通」守的是領域內部的規則；本章守它的對偶面——讓正確的路徑確實走得通。domain 模型全對、系統不可用，是分層架構特有的失效形態：每一層各自正確、層與層之間沒接上。&lt;/p>
&lt;h2 id="一個專案的五個問題">一個專案的五個問題&lt;/h2>
&lt;p>這個失效形態先用一個完整案例展開——本章後面的每個判準與工具都從它歸納而來。一個專案的單元測試全數通過、實機測試找出五個問題（完整記錄與 Flutter / Riverpod 的實作細節見 &lt;a href="https://tarrragon.github.io/blog/work-log/flutter_composition_root_wiring_gap/" data-link-title="測試全綠、功能失聯：五個 runtime 問題與組裝層的接線缺口" data-link-desc="113 張票收尾全綠的版本，實機測試找出五個問題：路由指向佔位頁、provider 佔位 throw、按鈕空 callback，加上兩個平台語意差異。單元測試的 mock override 是正當的測試 seam、同時是遮蔽接線斷裂的來源——記下三層共振的機制、反向追溯設計文件的結果、以及修補時規格層／測試層／發版層的分層落點。">測試全綠、功能失聯&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>注入項佔位&lt;/td>
 &lt;td>注入項停在拋「requires override」的佔位、production 一解析就連鎖報錯&lt;/td>
 &lt;td>DI 組裝&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>路由佔位&lt;/td>
 &lt;td>入口按鈕彈出「功能開發中」、路由表把入口指向佔位頁——功能本體已全部完成&lt;/td>
 &lt;td>路由表&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>空 callback&lt;/td>
 &lt;td>按鈕的事件處理是空實作、點擊沒有任何反應&lt;/td>
 &lt;td>UI 事件接線&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>前三個是同一種形狀：功能單元全部存在、對應的測試全部通過，斷的是「把功能接到入口」的那一段——依賴注入沒接上真實實作、路由表沒指向真實頁面、按鈕沒接上導航。後兩個是另一種形狀：程式碼在開發機的測試環境（host 環境）行為正確、在目標平台的語意下失效。兩種形狀的對策不同，平台差異的處置收在「邊界」一節；本章的主線是前三個。&lt;/p>
&lt;h2 id="分層的承諾與它的影子">分層的承諾與它的影子&lt;/h2>
&lt;p>分層架構（以及 ports &amp;amp; adapters、即六角形架構）的核心承諾是 domain 不依賴框架、可以脫離 infrastructure 單獨測試。兌現承諾的機制是 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/test-seam/" data-link-title="Test Seam" data-link-desc="測試想把真實依賴換成替身、該從哪裡換時使用。seam 是不修改程式本體就能替換其中一段行為的位置——介面加注入點是物件導向最常見的形式。">測試 seam&lt;/a>：seam 是不修改程式本體就能替換其中一段行為的位置——port 加上注入點、測試時把真實依賴換成 mock。seam 工作得越好、domain 的測試越乾淨，這是 DDD 教材反覆強調的部分。&lt;/p>
&lt;p>承諾有一道影子、而且跟承諾出自同一個機制：mock 換掉的正是組裝。本章用「證言」指一個測試通過時能證明的事——證言的範圍由測試的執行環境決定、mock 環境的綠燈證明的是 mock 環境下的行為。於是「組裝有沒有完成」這件事、在以 seam 為基礎的測試套件裡無從證明。兩類測試各自的證言範圍：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>測試&lt;/th>
 &lt;th>執行環境&lt;/th>
 &lt;th>證明什麼&lt;/th>
 &lt;th>對組裝的證言&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>行為測試&lt;/td>
 &lt;td>mock／override 注入&lt;/td>
 &lt;td>功能邏輯正確&lt;/td>
 &lt;td>零&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>接線測試&lt;/td>
 &lt;td>組裝路徑零 override、真實路由表與依賴&lt;/td>
 &lt;td>port 插上了 adapter&lt;/td>
 &lt;td>測試層唯一來源&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>行為測試維持 mock 注入、驗的是功能邏輯。上述案例的測試全落在這一類、全綠是誠實的：功能邏輯確實正確、綠燈在它的證言範圍內沒有失真——失真發生在把這個綠燈讀成「功能可用」的那一步。&lt;/p>
&lt;p>接線測試補的正是缺掉的那份證言、下一節展開。&lt;/p>
&lt;h2 id="接線測試組裝證言的來源">接線測試：組裝證言的來源&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/wiring-test/" data-link-title="Wiring Test" data-link-desc="行為測試全綠、還想知道組裝有沒有完成時使用。接線測試在組裝路徑零 override 的環境解析每個注入項、走真實路由表，只驗「port 插上了 adapter」。">接線測試&lt;/a>（wiring test）在組裝路徑零 override 的環境驗證組裝完成：逐一解析每個注入項、用真實路由表逐條導航、對每顆按鈕實際觸發並斷言導航發生。它只驗「插上了沒」、功能邏輯留給行為測試——兩類測試各守自己的證言範圍、彼此替代不了。&lt;/p>
&lt;p>它在既有測試光譜上的位置，可以從兩個熟悉的參照點定出來。第一個參照點是端對端測試：E2E 在目標平台同時驗行為與接線、代價是裝置需求與執行不穩定；接線測試把驗證範圍收窄到「接上了沒」、換取在 host 環境快速且確定地執行。第二個參照點是 DI 容器的自驗證：部分容器提供「啟動時逐一解析全部註冊項」的機制、專門攔沒被提供實作的註冊項；接線測試是這個機制的推廣——解析之外、把路由與 UI callback 的接線納進同一種證言。&lt;/p>
&lt;p>零 override 有明確的邊界：它指的是組裝路徑——domain 到入口之間不替換任何一段。最外圈的 infrastructure（遠端服務這類）可以在邊界替換、不影響組裝的證言。單機的 local-first 應用做得到全量零 override；有遠端依賴的專案、零 override 的範圍就是組裝路徑本身。&lt;/p>
&lt;p>該專案的修復批次先補了這批接線測試、對目標行為斷言、修復前確定性紅燈，修復以測試變綠為驗收點。日常的判讀分界在專案層級：override 出現在個別測試裡是正當用法；整個專案找不到任何一個「無 override 環境解析注入項」的測試、組裝層才算沒有證言。&lt;/p>
&lt;h2 id="佔位合法的中間態最靜默的失效">佔位：合法的中間態、最靜默的失效&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/placeholder/" data-link-title="Placeholder" data-link-desc="「開發中」頁、空 callback、拋出例外的注入項該怎麼管理時使用。佔位是先立介面後補實作的合法中間態——它的失效讓測試綠燈、比文件層約束更靜默。">佔位&lt;/a>是「先立介面、實作後補」的合法開發中間態：指向「開發中」頁的路由、空的事件 callback、拋「requires override」的注入項。先把介面立起來、實作後補，這個節奏本身沒有問題；問題在佔位的失效形態。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次&lt;/a> 記過文件層約束的失效是靜默的；佔位的失效比文件層再隱蔽一級——它讓測試綠燈。型別層看佔位是合法構件、行為測試的 override 讓它永遠沒被觸發、只有零 override 的解析會撞上它。文件層約束至少留下「規則寫在那裡、沒人遵守」的對照證據；佔位連對照證據都沒有、驗收讀到的只有通過。&lt;/p>
&lt;p>佔位因此需要一個獨立的攔截點。這個案例的處置是把攔截放進發版前置檢查：出現過的三種形態（佔位路由、佔位注入項、空 callback）都是靜態可掃描的——佔位頁的型別名、「requires override」的拋出語句、空函式體都 grep 得到——掃到即警告、由人判斷是刻意中間態還是漏網。&lt;/p>
&lt;p>回傳假值的佔位是掃描的死角：hardcoded 假資料、回空集合的 stub，在掃描與接線測試眼中都是正常構件——注入項解析得了、導航也會發生。這一類要靠行為斷言（對回傳內容驗證、假資料過不了斷言）或發版前的實機冒煙走查攔截；走查的三類敏感點在發版層一段收束、完整清單的住址在 work-log 記錄。同族的另一個死角是「插錯」：注入項插上的是真實作、只是不該是它（例如測試用實作被註冊進 production 組態）——解析成功、接線測試綠燈。接線測試的保證天花板在這裡很清楚：它證明「有插、插得起來」；插上的實作對不對、屬行為與整合測試的證言。&lt;/p>
&lt;h2 id="可達性作為組裝層的不變式">可達性作為組裝層的不變式&lt;/h2>
&lt;p>「use case 描述的每個入口在 production 環境可達」是一條 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/invariant/" data-link-title="Invariant" data-link-desc="領域模型的約束規則落在哪一層時使用。不變式是在物件整個生命週期都必須為真的業務規則——狀態只能沿流程轉換、被同一條規則綁住的欄位必須一起換。">不變式&lt;/a>。不變式的落點判準（完整推導見 &lt;a href="https://tarrragon.github.io/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次&lt;/a>）把約束分成文件、型別、執行三層：寫在文件裡靠人記得並自律、做進型別讓錯誤的寫法在編譯期被擋下、放在執行層讓違反在 runtime 當場被拒絕。把判準套在可達性上、答案是多層都有位置、單獨任何一層都不夠：&lt;/p></description><content:encoded><![CDATA[<p>組裝層是應用程式把 <a href="/blog/ddd/knowledge-cards/port/" data-link-title="Port" data-link-desc="判斷介面該宣告在哪一層、依賴方向該朝哪時使用。port 是 domain 對外宣告的介面——需求用領域語言說完、技術細節留在實作端。">port</a> 插上 <a href="/blog/ddd/knowledge-cards/adapter/" data-link-title="Adapter" data-link-desc="把領域需求翻譯成具體技術操作的實作該放哪、跟領域的邊界在哪時使用。adapter 是 port 的具體實作——技術細節被擋在六角形之外的位置。">adapter</a> 的地方——port 是 domain 對外宣告的介面、adapter 是介面的具體實作、<a href="/blog/ddd/knowledge-cards/dependency-injection/" data-link-title="Dependency Injection" data-link-desc="物件的依賴該由誰提供、測試怎麼換掉真實依賴時使用。依賴注入把「建構依賴」跟「使用依賴」分成兩個責任——使用方宣告需要什麼、提供方在組裝時決定給什麼。">依賴注入</a> 是把兩者接起來的機制。組裝發生在三個位置：依賴注入的組裝集中在 <a href="/blog/ddd/knowledge-cards/composition-root/" data-link-title="Composition Root" data-link-desc="依賴組裝、路由註冊該集中在哪、組裝斷裂去哪檢查時使用。composition root 是應用程式唯一的組裝起點——DI、路由、事件接線的集中處。">composition root</a>（組裝根、依賴注入唯一的組裝起點），路由表以單點註冊靠攏它，UI 事件的接線散佈在各畫面。位置不同、同屬組裝層的責任：把功能單元接到使用者按得到的入口上。使用者入口之外、機器觸發的組裝位置（事件訂閱、排程任務）有同一種證言缺口、判準同樣適用；本章聚焦使用者入口。</p>
<p>本模組（<a href="/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南</a>）的源頭句「讓違反規則的路徑走不通」守的是領域內部的規則；本章守它的對偶面——讓正確的路徑確實走得通。domain 模型全對、系統不可用，是分層架構特有的失效形態：每一層各自正確、層與層之間沒接上。</p>
<h2 id="一個專案的五個問題">一個專案的五個問題</h2>
<p>這個失效形態先用一個完整案例展開——本章後面的每個判準與工具都從它歸納而來。一個專案的單元測試全數通過、實機測試找出五個問題（完整記錄與 Flutter / Riverpod 的實作細節見 <a href="/blog/work-log/flutter_composition_root_wiring_gap/" data-link-title="測試全綠、功能失聯：五個 runtime 問題與組裝層的接線缺口" data-link-desc="113 張票收尾全綠的版本，實機測試找出五個問題：路由指向佔位頁、provider 佔位 throw、按鈕空 callback，加上兩個平台語意差異。單元測試的 mock override 是正當的測試 seam、同時是遮蔽接線斷裂的來源——記下三層共振的機制、反向追溯設計文件的結果、以及修補時規格層／測試層／發版層的分層落點。">測試全綠、功能失聯</a>）：</p>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>現象</th>
          <th>斷裂點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>注入項佔位</td>
          <td>注入項停在拋「requires override」的佔位、production 一解析就連鎖報錯</td>
          <td>DI 組裝</td>
      </tr>
      <tr>
          <td>路由佔位</td>
          <td>入口按鈕彈出「功能開發中」、路由表把入口指向佔位頁——功能本體已全部完成</td>
          <td>路由表</td>
      </tr>
      <tr>
          <td>空 callback</td>
          <td>按鈕的事件處理是空實作、點擊沒有任何反應</td>
          <td>UI 事件接線</td>
      </tr>
      <tr>
          <td>初始化順序</td>
          <td>框架初始化與例外攔截散在不同範圍、非同步例外可能逃出攔截</td>
          <td>平台語意</td>
      </tr>
      <tr>
          <td>資料庫開啟失敗</td>
          <td>一條資料庫設定指令在目標平台的執行語意不同、開啟直接失敗、持久化全面不可用</td>
          <td>平台語意</td>
      </tr>
  </tbody>
</table>
<p>前三個是同一種形狀：功能單元全部存在、對應的測試全部通過，斷的是「把功能接到入口」的那一段——依賴注入沒接上真實實作、路由表沒指向真實頁面、按鈕沒接上導航。後兩個是另一種形狀：程式碼在開發機的測試環境（host 環境）行為正確、在目標平台的語意下失效。兩種形狀的對策不同，平台差異的處置收在「邊界」一節；本章的主線是前三個。</p>
<h2 id="分層的承諾與它的影子">分層的承諾與它的影子</h2>
<p>分層架構（以及 ports &amp; adapters、即六角形架構）的核心承諾是 domain 不依賴框架、可以脫離 infrastructure 單獨測試。兌現承諾的機制是 <a href="/blog/ddd/knowledge-cards/test-seam/" data-link-title="Test Seam" data-link-desc="測試想把真實依賴換成替身、該從哪裡換時使用。seam 是不修改程式本體就能替換其中一段行為的位置——介面加注入點是物件導向最常見的形式。">測試 seam</a>：seam 是不修改程式本體就能替換其中一段行為的位置——port 加上注入點、測試時把真實依賴換成 mock。seam 工作得越好、domain 的測試越乾淨，這是 DDD 教材反覆強調的部分。</p>
<p>承諾有一道影子、而且跟承諾出自同一個機制：mock 換掉的正是組裝。本章用「證言」指一個測試通過時能證明的事——證言的範圍由測試的執行環境決定、mock 環境的綠燈證明的是 mock 環境下的行為。於是「組裝有沒有完成」這件事、在以 seam 為基礎的測試套件裡無從證明。兩類測試各自的證言範圍：</p>
<table>
  <thead>
      <tr>
          <th>測試</th>
          <th>執行環境</th>
          <th>證明什麼</th>
          <th>對組裝的證言</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>行為測試</td>
          <td>mock／override 注入</td>
          <td>功能邏輯正確</td>
          <td>零</td>
      </tr>
      <tr>
          <td>接線測試</td>
          <td>組裝路徑零 override、真實路由表與依賴</td>
          <td>port 插上了 adapter</td>
          <td>測試層唯一來源</td>
      </tr>
  </tbody>
</table>
<p>行為測試維持 mock 注入、驗的是功能邏輯。上述案例的測試全落在這一類、全綠是誠實的：功能邏輯確實正確、綠燈在它的證言範圍內沒有失真——失真發生在把這個綠燈讀成「功能可用」的那一步。</p>
<p>接線測試補的正是缺掉的那份證言、下一節展開。</p>
<h2 id="接線測試組裝證言的來源">接線測試：組裝證言的來源</h2>
<p><a href="/blog/ddd/knowledge-cards/wiring-test/" data-link-title="Wiring Test" data-link-desc="行為測試全綠、還想知道組裝有沒有完成時使用。接線測試在組裝路徑零 override 的環境解析每個注入項、走真實路由表，只驗「port 插上了 adapter」。">接線測試</a>（wiring test）在組裝路徑零 override 的環境驗證組裝完成：逐一解析每個注入項、用真實路由表逐條導航、對每顆按鈕實際觸發並斷言導航發生。它只驗「插上了沒」、功能邏輯留給行為測試——兩類測試各守自己的證言範圍、彼此替代不了。</p>
<p>它在既有測試光譜上的位置，可以從兩個熟悉的參照點定出來。第一個參照點是端對端測試：E2E 在目標平台同時驗行為與接線、代價是裝置需求與執行不穩定；接線測試把驗證範圍收窄到「接上了沒」、換取在 host 環境快速且確定地執行。第二個參照點是 DI 容器的自驗證：部分容器提供「啟動時逐一解析全部註冊項」的機制、專門攔沒被提供實作的註冊項；接線測試是這個機制的推廣——解析之外、把路由與 UI callback 的接線納進同一種證言。</p>
<p>零 override 有明確的邊界：它指的是組裝路徑——domain 到入口之間不替換任何一段。最外圈的 infrastructure（遠端服務這類）可以在邊界替換、不影響組裝的證言。單機的 local-first 應用做得到全量零 override；有遠端依賴的專案、零 override 的範圍就是組裝路徑本身。</p>
<p>該專案的修復批次先補了這批接線測試、對目標行為斷言、修復前確定性紅燈，修復以測試變綠為驗收點。日常的判讀分界在專案層級：override 出現在個別測試裡是正當用法；整個專案找不到任何一個「無 override 環境解析注入項」的測試、組裝層才算沒有證言。</p>
<h2 id="佔位合法的中間態最靜默的失效">佔位：合法的中間態、最靜默的失效</h2>
<p><a href="/blog/ddd/knowledge-cards/placeholder/" data-link-title="Placeholder" data-link-desc="「開發中」頁、空 callback、拋出例外的注入項該怎麼管理時使用。佔位是先立介面後補實作的合法中間態——它的失效讓測試綠燈、比文件層約束更靜默。">佔位</a>是「先立介面、實作後補」的合法開發中間態：指向「開發中」頁的路由、空的事件 callback、拋「requires override」的注入項。先把介面立起來、實作後補，這個節奏本身沒有問題；問題在佔位的失效形態。</p>
<p><a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a> 記過文件層約束的失效是靜默的；佔位的失效比文件層再隱蔽一級——它讓測試綠燈。型別層看佔位是合法構件、行為測試的 override 讓它永遠沒被觸發、只有零 override 的解析會撞上它。文件層約束至少留下「規則寫在那裡、沒人遵守」的對照證據；佔位連對照證據都沒有、驗收讀到的只有通過。</p>
<p>佔位因此需要一個獨立的攔截點。這個案例的處置是把攔截放進發版前置檢查：出現過的三種形態（佔位路由、佔位注入項、空 callback）都是靜態可掃描的——佔位頁的型別名、「requires override」的拋出語句、空函式體都 grep 得到——掃到即警告、由人判斷是刻意中間態還是漏網。</p>
<p>回傳假值的佔位是掃描的死角：hardcoded 假資料、回空集合的 stub，在掃描與接線測試眼中都是正常構件——注入項解析得了、導航也會發生。這一類要靠行為斷言（對回傳內容驗證、假資料過不了斷言）或發版前的實機冒煙走查攔截；走查的三類敏感點在發版層一段收束、完整清單的住址在 work-log 記錄。同族的另一個死角是「插錯」：注入項插上的是真實作、只是不該是它（例如測試用實作被註冊進 production 組態）——解析成功、接線測試綠燈。接線測試的保證天花板在這裡很清楚：它證明「有插、插得起來」；插上的實作對不對、屬行為與整合測試的證言。</p>
<h2 id="可達性作為組裝層的不變式">可達性作為組裝層的不變式</h2>
<p>「use case 描述的每個入口在 production 環境可達」是一條 <a href="/blog/ddd/knowledge-cards/invariant/" data-link-title="Invariant" data-link-desc="領域模型的約束規則落在哪一層時使用。不變式是在物件整個生命週期都必須為真的業務規則——狀態只能沿流程轉換、被同一條規則綁住的欄位必須一起換。">不變式</a>。不變式的落點判準（完整推導見 <a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a>）把約束分成文件、型別、執行三層：寫在文件裡靠人記得並自律、做進型別讓錯誤的寫法在編譯期被擋下、放在執行層讓違反在 runtime 當場被拒絕。把判準套在可達性上、答案是多層都有位置、單獨任何一層都不夠：</p>
<p><strong>文件層</strong>：use case 的成功保證補上可達性條款——路由指向真實頁面、callback 已接線、注入項在無 override 環境可解析。這層的作用是把組裝責任寫進驗收範圍：它定義「什麼叫完成」。文件層失效靜默、單獨存在時只是願望；怎麼把這層寫得可檢核、「use case 的完成定義」一節展開。</p>
<p><strong>型別層</strong>：組裝完成性用型別表達的空間有限、而且空間大小由生態決定。在編譯期生成組裝碼的 DI 生態、缺實作是編譯錯誤、型別層接得住這條不變式；runtime 解析的容器把「這個注入項沒人提供實作」推遲到執行期、編譯器看不見。路由表同理：以字串鍵查表的路由生態、斷鏈也是執行期才暴露。用 runtime 容器與字串路由的專案、型別層在這條不變式上是輔助而非主力。</p>
<p><strong>執行層</strong>：接線測試——違反當場紅燈、失效點集中。刻意不可達的入口（feature flag 關閉、分階段發布）不算違反：以旗標開啟的組態跑接線測試、或明列豁免清單——豁免要有記錄、讓「刻意」與「遺漏」在清單上分得開。</p>
<p><strong>發版層</strong>（對應強制層次一章補充的 CI 檢查層）：佔位掃描加實機冒煙走查。前者警示靜態可掃的佔位、由人判讀；後者是發版前在目標平台人工走一輪的清單——啟動健康、use case 主路徑走查、平台敏感點——攔 host 測試構不到的平台語意差異。</p>
<h2 id="use-case-的完成定義">use case 的完成定義</h2>
<p>組裝斷裂的上游在設計文件——這一節是文件層那一列的展開：可達性條款要寫得進 use case、use case 本身先要能回答裝配問題。</p>
<p>修復批次拿五個問題反向追溯提案與 use case 文件、確認每個問題在文件裡的對應條目長什麼樣。結果分成兩型：</p>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>設計文件對應</th>
          <th>缺口型態</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>注入項佔位</td>
          <td>提案定義了完整的功能方法與 UI 形態</td>
          <td>有能力定義、驗收不含可達性</td>
      </tr>
      <tr>
          <td>路由佔位</td>
          <td>use case 寫了「使用者點擊按鈕」</td>
          <td>有行為描述、接線無人認領</td>
      </tr>
      <tr>
          <td>空 callback</td>
          <td>use case 寫了操作步驟</td>
          <td>callback 實作不在驗收內</td>
      </tr>
      <tr>
          <td>兩個平台問題</td>
          <td>全文無對應</td>
          <td>平台層細節、設計文件構不到</td>
      </tr>
  </tbody>
</table>
<p>追溯出一個一致的形狀：提案思考「能力」（系統要有哪些功能）、use case 思考「行為」（使用者做什麼、系統回應什麼），兩者之間的「裝配」——把能力組起來、給行為一個入口的責任——沒有歸屬。三個組裝斷裂全部落在這條縫裡。</p>
<p>把縫補起來的做法是讓 use case 撰寫承擔對提案的壓力測試：寫 use case 時逐一回答四個檢核問句、填不出來的問句直接指出對應的設計缺口。問句從五個問題歸納而來、是起點集、新的斷裂形態出現時往上加。</p>
<p><strong>名詞可定位</strong>——文中每個畫面、按鈕、服務，能填出「位於哪個畫面、經哪條路由、由誰供給」。填不出來代表提案只設計了能力、沒設計裝配：案例裡注入項佔位對應的功能、提案寫滿了功能方法與 UI 形態、按鈕跟頁面由誰放上去沒有一個字。</p>
<p><strong>路徑連通</strong>——步驟一的入口能從應用程式啟動畫面一路追到。追不出這條路、入口接線就無人認領：路由佔位問題的 use case 描述了使用者「點擊按鈕」、按鈕到路由到頁面這條鏈的歸屬、文件裡翻不到。直達入口（web 的 URL、深連結、外部喚起）另立一類：它們繞過啟動鏈、每個直達入口各自追一條可達性。</p>
<p><strong>狀態完備</strong>——涉及的每個畫面、狀態機的每個狀態（空、正常、錯誤、權限與登入態）下入口可見性都有答案。這一問附帶一個操作型式：前置條件反向展開。use case 的前置條件每一條都隱含一個「不滿足時會怎樣」的狀態分支。案例裡一條前置條件寫「存在至少一筆資料」——實機上使用者的資料是空的、對應按鈕只存在於正常狀態分支、使用者的回報是「功能不見了」。把前置條件當成狀態枚舉的線索用、空狀態的畫面在設計期就被逼著給出答案；它與 <a href="/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡</a> 談的領域狀態機互為投影——領域的前置條件投影到畫面層、就是「這個狀態下入口在哪」的答案。</p>
<p><strong>環境差異</strong>——涉及平台 API 的步驟、「測試環境與目標平台行為一致嗎」有答案。答案為否或不確定的步驟、標記「需實機驗證」、餵給發版前的冒煙走查清單：兩個平台問題在設計文件裡無處可防、能做的是在設計期把「這一步只有實機能驗」標出來、讓漏網的位置從使用者手上移到發版前的清單上。</p>
<p>任何一問填不出來、代表對應的設計缺口還停在提案層、退回補齊再進實作。</p>
<h2 id="邊界組裝可達之外的失效">邊界：組裝可達之外的失效</h2>
<p>缺口分類先於對策。五個問題的兩種形狀對應兩組修法：組裝斷裂修規格與測試（可達性條款、接線測試）、平台差異修驗證流程（設計期標記、冒煙走查）。混用的代價是把「補測試」當成萬靈丹——平台語意差異是 host 測試補不到的那一類、案例裡那條資料庫設定指令的語意差異、開發機的測試環境重現不出來、要目標平台實際執行才暴露。</p>
<p>本章的作用域同樣要誠實標明：可達性守的是「入口接上了」；接上之後的行為正確性屬行為測試、資料在層間流動的正確性屬整合測試的其他主題。組裝層的證言與六角形內部的證言互補、彼此替代不了：層內的設計判準（<a href="/blog/ddd/data-bag-vs-domain-model/" data-link-title="資料袋與領域模型" data-link-desc="判斷一個型別該是一袋欄位還是有行為的領域模型：判準是「有沒有不允許任意組合的欄位」。含判準用錯時規則退化成建議的機制、以及資料袋起步後升級的演化訊號。">資料袋與領域模型</a>、<a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a>）決定六角形內部的品質、<a href="/blog/ddd/construction-path-design/" data-link-title="建構路徑設計" data-link-desc="工廠表達力不足時缺陷如何被逃生口吸收——逃生口讓正確的修法變不必要、以語意錯誤在下游復發。含原始值官方出口的穩態邊界、封裝擺盪的判讀。">建構路徑設計</a> 收物件怎麼被建出來、本章把同一個問題抬到應用程式層。層內的模型設計得再好、組裝層沒有證言、使用者拿到的仍然可能是一片接不通的綠燈。</p>
<h2 id="下一步">下一步</h2>
<ul>
<li>層內規則的落點選擇（文件／型別／執行三層判準的源頭）：<a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a></li>
<li>前置條件投影回領域層的狀態機設計：<a href="/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡</a></li>
<li>Flutter / Riverpod 的實作細節（override 判讀分界、接線測試與修復的執行記錄、佔位掃描與冒煙清單）：<a href="/blog/work-log/flutter_composition_root_wiring_gap/" data-link-title="測試全綠、功能失聯：五個 runtime 問題與組裝層的接線缺口" data-link-desc="113 張票收尾全綠的版本，實機測試找出五個問題：路由指向佔位頁、provider 佔位 throw、按鈕空 callback，加上兩個平台語意差異。單元測試的 mock override 是正當的測試 seam、同時是遮蔽接線斷裂的來源——記下三層共振的機制、反向追溯設計文件的結果、以及修補時規格層／測試層／發版層的分層落點。">測試全綠、功能失聯</a></li>
<li>同一失效形態的畫面層檢查法（路由存在但不可達）：<a href="/blog/ux-design/01-screen-state-machine/route-reachability/" data-link-title="路由可達性檢查" data-link-desc="Router 定義的路由 vs UI 實際可達的路由 — 路由存在但 UI 不可達等於死程式碼的 UX 版本">路由可達性檢查</a></li>
</ul>
]]></content:encoded></item><item><title>Composition Root</title><link>https://tarrragon.github.io/blog/ddd/knowledge-cards/composition-root/</link><pubDate>Mon, 13 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/knowledge-cards/composition-root/</guid><description>&lt;p>composition root（組裝根）是應用程式唯一的組裝起點：所有 &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;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/adapter/" data-link-title="Adapter" data-link-desc="把領域需求翻譯成具體技術操作的實作該放哪、跟領域的邊界在哪時使用。adapter 是 port 的具體實作——技術細節被擋在六角形之外的位置。">adapter&lt;/a>——DI 容器的註冊、啟動流程的接線集中於此。它通常是 main 函式旁的一小塊：知道全部具體型別的地方、也是唯一該知道的地方。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>composition root 是組裝層的核心、不是組裝層的全部：路由表以單點註冊靠攏它，UI 事件的接線散佈在各畫面——位置不同、同屬組裝層的責任。組裝層守「正確的路徑走得通」，與 &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>具體型別的建構與註冊集中在一處，是組裝責任歸位的訊號。new 語句散落各層、或 provider 停在佔位 throw「requires override」，是組裝責任洩漏或組裝未完成——後者在 mock 測試下全綠、只有無 override 的解析會暴露。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>組裝有沒有完成，在以 mock 為基礎的測試套件裡沒有證言；補證言的接線測試、可達性作為不變式的強制層選擇，教學層展開見 &lt;a href="https://tarrragon.github.io/blog/ddd/composition-root-reachability/" data-link-title="組裝層的可達性" data-link-desc="行為測試全綠、功能在實機上沒有入口的失效形態出現時使用。mock 換掉的正是組裝，組裝完成與否在行為測試裡沒有證言；把可達性當成組裝層的不變式，在測試、發版與設計文件各給一個強制點。">組裝層的可達性&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>composition root（組裝根）是應用程式唯一的組裝起點：所有 <a href="/blog/ddd/knowledge-cards/port/" data-link-title="Port" data-link-desc="判斷介面該宣告在哪一層、依賴方向該朝哪時使用。port 是 domain 對外宣告的介面——需求用領域語言說完、技術細節留在實作端。">port</a> 在這裡被插上 <a href="/blog/ddd/knowledge-cards/adapter/" data-link-title="Adapter" data-link-desc="把領域需求翻譯成具體技術操作的實作該放哪、跟領域的邊界在哪時使用。adapter 是 port 的具體實作——技術細節被擋在六角形之外的位置。">adapter</a>——DI 容器的註冊、啟動流程的接線集中於此。它通常是 main 函式旁的一小塊：知道全部具體型別的地方、也是唯一該知道的地方。</p>
<h2 id="概念位置">概念位置</h2>
<p>composition root 是組裝層的核心、不是組裝層的全部：路由表以單點註冊靠攏它，UI 事件的接線散佈在各畫面——位置不同、同屬組裝層的責任。組裝層守「正確的路徑走得通」，與 <a href="/blog/ddd/knowledge-cards/invariant/" data-link-title="Invariant" data-link-desc="領域模型的約束規則落在哪一層時使用。不變式是在物件整個生命週期都必須為真的業務規則——狀態只能沿流程轉換、被同一條規則綁住的欄位必須一起換。">invariant</a> 守「違反的路徑走不通」互為對偶。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>具體型別的建構與註冊集中在一處，是組裝責任歸位的訊號。new 語句散落各層、或 provider 停在佔位 throw「requires override」，是組裝責任洩漏或組裝未完成——後者在 mock 測試下全綠、只有無 override 的解析會暴露。</p>
<h2 id="設計責任">設計責任</h2>
<p>組裝有沒有完成，在以 mock 為基礎的測試套件裡沒有證言；補證言的接線測試、可達性作為不變式的強制層選擇，教學層展開見 <a href="/blog/ddd/composition-root-reachability/" data-link-title="組裝層的可達性" data-link-desc="行為測試全綠、功能在實機上沒有入口的失效形態出現時使用。mock 換掉的正是組裝，組裝完成與否在行為測試裡沒有證言；把可達性當成組裝層的不變式，在測試、發版與設計文件各給一個強制點。">組裝層的可達性</a>。</p>
]]></content:encoded></item><item><title>測試全綠、功能失聯：五個 runtime 問題與組裝層的接線缺口</title><link>https://tarrragon.github.io/blog/work-log/flutter_composition_root_wiring_gap/</link><pubDate>Mon, 13 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_composition_root_wiring_gap/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter 書籍管理 App 的一個版本完成 113 張票、單元測試 100% 通過、收尾驗收通過；實機測試（Android 實體機）找出五個問題——其中三個是「功能做完了、使用者到不了」
&lt;strong>疑問來源&lt;/strong>：規格審查、測試、版本收尾三道防線都在運作，為什麼五個問題全數漏網？
&lt;strong>整理目的&lt;/strong>：記下佔位實作讓測試綠燈的機制、反向追溯提案與 use case 文件的結果、以及修補時「規格層／測試層／發版層」的分層落點
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.38.1 修復批次的分析記錄；DDD 觀念層的判準另見 &lt;a href="https://tarrragon.github.io/blog/ddd/composition-root-reachability/" data-link-title="組裝層的可達性" data-link-desc="行為測試全綠、功能在實機上沒有入口的失效形態出現時使用。mock 換掉的正是組裝，組裝完成與否在行為測試裡沒有證言；把可達性當成組裝層的不變式，在測試、發版與設計文件各給一個強制點。">組裝層的可達性&lt;/a>&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="五個問題三種佔位兩種平台差異">五個問題、三種佔位、兩種平台差異&lt;/h2>
&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>Tag 管理崩壞&lt;/td>
 &lt;td>provider 佔位 throw「requires override」在 production 被觸發，畫面連鎖報錯&lt;/td>
 &lt;td>DI 組裝&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>掃描／匯入失聯&lt;/td>
 &lt;td>首頁按鈕顯示「功能開發中」提示，路由表把 &lt;code>/scan&lt;/code>、&lt;code>/import&lt;/code> 指向 ComingSoon 佔位頁——掃描與匯入的 MVVM 全套均已完成&lt;/td>
 &lt;td>路由表 + UI callback&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>資料管理頁按鈕沒反應&lt;/td>
 &lt;td>四顆按鈕的 onPressed 全是空實作&lt;/td>
 &lt;td>UI callback&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>啟動框架警告&lt;/td>
 &lt;td>binding 在 root zone 初始化、runApp 在 runZonedGuarded 子 zone，非同步例外可能逃出攔截&lt;/td>
 &lt;td>框架初始化順序（平台層）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>開庫失敗&lt;/td>
 &lt;td>Android 上 &lt;code>PRAGMA journal_mode = WAL&lt;/code> 以 execSQL 執行被拒、資料庫開啟直接失敗、全部持久化功能不可用&lt;/td>
 &lt;td>SQLite Android 語意（平台層）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>前三個是同一種形狀：功能單元全部存在、對應測試全部通過，斷的是「把功能接到入口」的那一段——DI 容器沒接上真實依賴、路由表沒指向真實頁面、按鈕沒接上導航。後兩個是另一種形狀：程式碼在 host 測試環境行為正確，在目標平台的語意下失效。&lt;/p>
&lt;h2 id="佔位讓測試綠燈的三層共振">佔位讓測試綠燈的三層共振&lt;/h2>
&lt;p>單一防線失效不足以讓五個問題全數漏網，三層機制疊在一起才做到：&lt;/p>
&lt;p>第一層在規格。use case 的成功保證寫的是功能行為——「成功匯入 X 本書籍」「實體書籍立即新增到書庫」——入口是否接上不在任何驗收條款裡。文件描述了使用者「點擊匯入按鈕」，但按鈕、路由、頁面這條鏈由誰負責接、接完長什麼樣，設計文件裡沒有一個字。&lt;/p>
&lt;p>第二層在測試設計。從 use case 推導出的測試落在 unit 與 widget 層，用 &lt;code>ProviderScope(overrides: [...])&lt;/code> 注入 mock。override 是 Riverpod 給的正當測試 seam——它讓 domain 與 ViewModel 可以脫離 infrastructure 單獨驗證，這是分層架構承諾的兌現。代價在 seam 的另一面：override 換掉的正是 production 的組裝——組裝完沒完成，這套測試從頭到尾無人作證。&lt;/p>
&lt;p>第三層在佔位本身。ComingSoon 頁是合法 widget、空 onPressed 是合法函式、throw 佔位的 provider 在 override 之下永遠不會被解析——佔位不觸發任何紅燈，測試斷言的是 mock 環境下的行為，佔位在測試的視野之外。三層疊加的結果：佔位通過了全部以 mock 為基礎的驗收，一路走到使用者手上。&lt;/p>
&lt;h3 id="override-的雙面性">override 的雙面性&lt;/h3>
&lt;p>override 同時是解藥跟盲點，而且是同一個機制。判讀訊號有一條可操作的分界——override 出現在個別測試裡是正當用法；整個專案找不到任何一個「無 override 環境解析 provider」的測試，才是組裝層裸奔的訊號。這個專案屬於後者：&lt;code>grep&lt;/code> 全部測試，production 等效環境（真實路由表、零 override 的 ProviderScope）的案例數是零。mock 遮蔽的另一種病因——替身的協定語意與真實體不符、而非組裝缺席——見 &lt;a href="https://tarrragon.github.io/blog/work-log/testing_three_layer_strategy/" data-link-title="192 個測試全過、實機全壞：Mock 遮蔽真實行為的三層測試策略" data-link-desc="unit test 全綠、實機部署後功能整片壞掉。mock-only 策略的結構盲區（text vs binary frame、缺 auth handshake、ANSI 多樣性被 FakeWebSocketChannel 遮蔽），以及分層測試各抓什麼、各遮蔽什麼。">192 個測試全過、實機全壞&lt;/a>。&lt;/p>
&lt;h2 id="反向追溯設計文件裡找不到入口">反向追溯：設計文件裡找不到「入口」&lt;/h2>
&lt;p>修復批次先做了一件事：拿五個問題反向追溯提案（PROP）、use case（UC）、規格（SPEC），確認每個問題在設計文件裡的對應條目長什麼樣。結果分成兩型：&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>Tag provider 佔位&lt;/td>
 &lt;td>提案定義了七個 CRUD 方法與 UI 形態&lt;/td>
 &lt;td>有功能定義、驗收不含可達性&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>路由佔位&lt;/td>
 &lt;td>UC 寫了「使用者點擊按鈕」&lt;/td>
 &lt;td>有行為描述、接線無人認領&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>空 onPressed&lt;/td>
 &lt;td>UC 寫了「進入資料管理頁面、點擊匯出」&lt;/td>
 &lt;td>有操作描述、callback 實作不在驗收內&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Zone 警告&lt;/td>
 &lt;td>全文無對應&lt;/td>
 &lt;td>平台層細節，設計文件構不到&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>PRAGMA 失敗&lt;/td>
 &lt;td>全文無對應&lt;/td>
 &lt;td>平台層細節，設計文件構不到&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>追溯給出的結論：提案端寫滿了能力（tag 管理要有七個 CRUD）、use case 端寫滿了行為（使用者點擊匯入按鈕），追到「按鈕由誰放上畫面、路由由誰指向真頁面」時，兩類文件都翻不到答案——三個佔位問題全部落在這條縫裡。&lt;/p>
&lt;h3 id="前置條件被當成免責條款">前置條件被當成免責條款&lt;/h3>
&lt;p>追溯裡有一個細節要單獨展開。匯出功能的 use case 前置條件寫著「書庫中存在至少一本書」——這條前置條件已經標出了一個狀態分支：書庫是空的時候會怎樣？匯出按鈕在哪？沒有任何文件回答。實機上使用者的書庫是空的（開庫失敗的下游效應），畫面走了空狀態分支、匯出按鈕只存在於正常狀態分支，使用者的回報是「匯出功能不見了」。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter 書籍管理 App 的一個版本完成 113 張票、單元測試 100% 通過、收尾驗收通過；實機測試（Android 實體機）找出五個問題——其中三個是「功能做完了、使用者到不了」
<strong>疑問來源</strong>：規格審查、測試、版本收尾三道防線都在運作，為什麼五個問題全數漏網？
<strong>整理目的</strong>：記下佔位實作讓測試綠燈的機制、反向追溯提案與 use case 文件的結果、以及修補時「規格層／測試層／發版層」的分層落點
<strong>本文邊界</strong>：素材是該專案 v0.38.1 修復批次的分析記錄；DDD 觀念層的判準另見 <a href="/blog/ddd/composition-root-reachability/" data-link-title="組裝層的可達性" data-link-desc="行為測試全綠、功能在實機上沒有入口的失效形態出現時使用。mock 換掉的正是組裝，組裝完成與否在行為測試裡沒有證言；把可達性當成組裝層的不變式，在測試、發版與設計文件各給一個強制點。">組裝層的可達性</a></p></blockquote>
<hr>
<h2 id="五個問題三種佔位兩種平台差異">五個問題、三種佔位、兩種平台差異</h2>
<p>實機日誌與使用者操作對出五個問題，斷裂點分成兩組：</p>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>現象</th>
          <th>斷裂點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Tag 管理崩壞</td>
          <td>provider 佔位 throw「requires override」在 production 被觸發，畫面連鎖報錯</td>
          <td>DI 組裝</td>
      </tr>
      <tr>
          <td>掃描／匯入失聯</td>
          <td>首頁按鈕顯示「功能開發中」提示，路由表把 <code>/scan</code>、<code>/import</code> 指向 ComingSoon 佔位頁——掃描與匯入的 MVVM 全套均已完成</td>
          <td>路由表 + UI callback</td>
      </tr>
      <tr>
          <td>資料管理頁按鈕沒反應</td>
          <td>四顆按鈕的 onPressed 全是空實作</td>
          <td>UI callback</td>
      </tr>
      <tr>
          <td>啟動框架警告</td>
          <td>binding 在 root zone 初始化、runApp 在 runZonedGuarded 子 zone，非同步例外可能逃出攔截</td>
          <td>框架初始化順序（平台層）</td>
      </tr>
      <tr>
          <td>開庫失敗</td>
          <td>Android 上 <code>PRAGMA journal_mode = WAL</code> 以 execSQL 執行被拒、資料庫開啟直接失敗、全部持久化功能不可用</td>
          <td>SQLite Android 語意（平台層）</td>
      </tr>
  </tbody>
</table>
<p>前三個是同一種形狀：功能單元全部存在、對應測試全部通過，斷的是「把功能接到入口」的那一段——DI 容器沒接上真實依賴、路由表沒指向真實頁面、按鈕沒接上導航。後兩個是另一種形狀：程式碼在 host 測試環境行為正確，在目標平台的語意下失效。</p>
<h2 id="佔位讓測試綠燈的三層共振">佔位讓測試綠燈的三層共振</h2>
<p>單一防線失效不足以讓五個問題全數漏網，三層機制疊在一起才做到：</p>
<p>第一層在規格。use case 的成功保證寫的是功能行為——「成功匯入 X 本書籍」「實體書籍立即新增到書庫」——入口是否接上不在任何驗收條款裡。文件描述了使用者「點擊匯入按鈕」，但按鈕、路由、頁面這條鏈由誰負責接、接完長什麼樣，設計文件裡沒有一個字。</p>
<p>第二層在測試設計。從 use case 推導出的測試落在 unit 與 widget 層，用 <code>ProviderScope(overrides: [...])</code> 注入 mock。override 是 Riverpod 給的正當測試 seam——它讓 domain 與 ViewModel 可以脫離 infrastructure 單獨驗證，這是分層架構承諾的兌現。代價在 seam 的另一面：override 換掉的正是 production 的組裝——組裝完沒完成，這套測試從頭到尾無人作證。</p>
<p>第三層在佔位本身。ComingSoon 頁是合法 widget、空 onPressed 是合法函式、throw 佔位的 provider 在 override 之下永遠不會被解析——佔位不觸發任何紅燈，測試斷言的是 mock 環境下的行為，佔位在測試的視野之外。三層疊加的結果：佔位通過了全部以 mock 為基礎的驗收，一路走到使用者手上。</p>
<h3 id="override-的雙面性">override 的雙面性</h3>
<p>override 同時是解藥跟盲點，而且是同一個機制。判讀訊號有一條可操作的分界——override 出現在個別測試裡是正當用法；整個專案找不到任何一個「無 override 環境解析 provider」的測試，才是組裝層裸奔的訊號。這個專案屬於後者：<code>grep</code> 全部測試，production 等效環境（真實路由表、零 override 的 ProviderScope）的案例數是零。mock 遮蔽的另一種病因——替身的協定語意與真實體不符、而非組裝缺席——見 <a href="/blog/work-log/testing_three_layer_strategy/" data-link-title="192 個測試全過、實機全壞：Mock 遮蔽真實行為的三層測試策略" data-link-desc="unit test 全綠、實機部署後功能整片壞掉。mock-only 策略的結構盲區（text vs binary frame、缺 auth handshake、ANSI 多樣性被 FakeWebSocketChannel 遮蔽），以及分層測試各抓什麼、各遮蔽什麼。">192 個測試全過、實機全壞</a>。</p>
<h2 id="反向追溯設計文件裡找不到入口">反向追溯：設計文件裡找不到「入口」</h2>
<p>修復批次先做了一件事：拿五個問題反向追溯提案（PROP）、use case（UC）、規格（SPEC），確認每個問題在設計文件裡的對應條目長什麼樣。結果分成兩型：</p>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>設計文件對應</th>
          <th>缺口型態</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Tag provider 佔位</td>
          <td>提案定義了七個 CRUD 方法與 UI 形態</td>
          <td>有功能定義、驗收不含可達性</td>
      </tr>
      <tr>
          <td>路由佔位</td>
          <td>UC 寫了「使用者點擊按鈕」</td>
          <td>有行為描述、接線無人認領</td>
      </tr>
      <tr>
          <td>空 onPressed</td>
          <td>UC 寫了「進入資料管理頁面、點擊匯出」</td>
          <td>有操作描述、callback 實作不在驗收內</td>
      </tr>
      <tr>
          <td>Zone 警告</td>
          <td>全文無對應</td>
          <td>平台層細節，設計文件構不到</td>
      </tr>
      <tr>
          <td>PRAGMA 失敗</td>
          <td>全文無對應</td>
          <td>平台層細節，設計文件構不到</td>
      </tr>
  </tbody>
</table>
<p>追溯給出的結論：提案端寫滿了能力（tag 管理要有七個 CRUD）、use case 端寫滿了行為（使用者點擊匯入按鈕），追到「按鈕由誰放上畫面、路由由誰指向真頁面」時，兩類文件都翻不到答案——三個佔位問題全部落在這條縫裡。</p>
<h3 id="前置條件被當成免責條款">前置條件被當成免責條款</h3>
<p>追溯裡有一個細節要單獨展開。匯出功能的 use case 前置條件寫著「書庫中存在至少一本書」——這條前置條件已經標出了一個狀態分支：書庫是空的時候會怎樣？匯出按鈕在哪？沒有任何文件回答。實機上使用者的書庫是空的（開庫失敗的下游效應），畫面走了空狀態分支、匯出按鈕只存在於正常狀態分支，使用者的回報是「匯出功能不見了」。</p>
<p>前置條件的每一條都隱含一個「不滿足時會怎樣」的分支。把前置條件當成限定範圍的免責條款用，分支就無人設計；把它當成狀態枚舉的線索用，空狀態的畫面就會在設計期被逼著給出答案。</p>
<h2 id="修補的分層落點">修補的分層落點</h2>
<p>五個問題各自的修法：三個接線問題把真實依賴、真實頁面、導航 callback 接回入口；zone 警告把 binding 初始化移進 runZonedGuarded、與 runApp 收在同一個 zone；PRAGMA 失敗把 journal mode 設定改走 rawQuery（回傳結果列的查詢路徑）。修掉之外，修補按層放了三組防線，涵蓋範圍超出「把五個問題修掉」本身：</p>
<p><strong>規格層</strong>：use case 文件補上「端到端可達性」成功保證條款（路由指向真實頁面、callback 已接線、provider 在無 override 環境可解析），並新增 use case 撰寫檢核：名詞可定位、路徑連通、狀態完備、環境差異——問句全文與判定方式見 <a href="/blog/ddd/composition-root-reachability/" data-link-title="組裝層的可達性" data-link-desc="行為測試全綠、功能在實機上沒有入口的失效形態出現時使用。mock 換掉的正是組裝，組裝完成與否在行為測試裡沒有證言；把可達性當成組裝層的不變式，在測試、發版與設計文件各給一個強制點。">組裝層的可達性</a>。</p>
<p><strong>測試層</strong>：區分行為測試與接線測試。行為測試維持 mock override（驗功能邏輯）；接線測試零 override、用真實路由表與真實依賴，只驗「port 有沒有插上 adapter」——本案是 local-first 單機 app、零 override 做得到全量；有遠端依賴的專案，零 override 的範圍是組裝路徑、最外圈 infrastructure 在邊界替換。修復前先補了十三個案例，其中十一個對目標行為斷言、修復前確定性紅燈——修復票以這批測試變綠為驗收點。</p>
<p><strong>發版層</strong>：兩道互補的防線。佔位掃描進發版前置檢查——ComingSoon、UnimplementedError、空 onPressed 都是靜態可 grep 的，掃到就警告；實機冒煙清單補掃描構不到的部分——平台語意差異只有在目標平台執行才會暴露，清單的三層（啟動健康、use case happy path 走查、平台敏感點）在打 tag 前人工走一輪。</p>
<p>平台層的兩個問題（zone、PRAGMA）在設計文件與 host 測試都無處可防：Android execSQL 拒絕有回傳列的 SQL 這件事，sqflite 的 ffi 測試環境重現不出來。能做的是把「這步只有實機能驗」在設計期標記出來，讓冒煙清單有明確來源——漏網的位置從使用者手上移到發版前的清單上。</p>
<h2 id="判準收束">判準收束</h2>
<ul>
<li><strong>綠燈的證言範圍</strong>：一個測試證明的是它執行環境下的行為。override 環境的綠燈對 production 組裝零證言——證言缺口要由接線測試補、而非把行為測試的 mock 拆掉。</li>
<li><strong>功能完成的定義</strong>：從入口可達才算完成。「MVVM 全套完成」與「使用者用得到」之間隔著組裝層，這段距離在票務上要有自己的驗收條款。</li>
<li><strong>佔位的紀律</strong>：佔位是合法的開發中間態，但它需要一個攔截點（發版掃描）。缺攔截點的佔位會通過所有以 mock 為基礎的驗收，終點站是使用者的回報。</li>
<li><strong>缺口分類先於對策</strong>：這批修復裡，三個接線問題走規格條款加接線測試，zone 與 PRAGMA 走冒煙清單。對五個問題一律用「補測試」回應，補到的只是 host 環境的綠燈——zone 與 PRAGMA 要目標平台實際執行才暴露。</li>
</ul>
]]></content:encoded></item></channel></rss>