<?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>Hexagonal-Architecture on Tarragon</title><link>https://tarrragon.github.io/blog/tags/hexagonal-architecture/</link><description>Recent content in Hexagonal-Architecture on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Thu, 16 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/hexagonal-architecture/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>觀測出口的職責三分</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>Adapter</title><link>https://tarrragon.github.io/blog/ddd/knowledge-cards/adapter/</link><pubDate>Mon, 13 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/knowledge-cards/adapter/</guid><description>&lt;p>adapter 是 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/port/" data-link-title="Port" data-link-desc="判斷介面該宣告在哪一層、依賴方向該朝哪時使用。port 是 domain 對外宣告的介面——需求用領域語言說完、技術細節留在實作端。">port&lt;/a> 的具體實作：把領域宣告的需求翻譯成某項技術的操作——SQLite 的查詢、HTTP 的請求、檔案系統的讀寫。技術細節被擋在六角形之外的位置就是這裡：領域只看得見 port、看不見 adapter。adapter 在 &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> 被插上 port；測試裡的 mock 是 adapter 的替身。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>adapter 有兩側。driven adapter 被領域呼叫（資料庫、外部服務的實作）；driving adapter 呼叫領域（UI 事件處理、API handler）。兩側的共同責任是翻譯：領域語言與技術語言在這裡互換、不讓任何一邊的詞彙滲到對面。adapter 對外接的介面是 &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/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>實作類 import 框架套件、以技術命名（SqliteBookRepository），是 adapter 各安其位的訊號。領域規則出現在 adapter 裡（折扣計算寫在 repository、狀態判斷寫在 handler）是反向滲透——規則離開了領域模型、&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>adapter 寫完、測試通過，只證明它自己行為正確；它有沒有被插上 port、插的是不是它，是組裝層的問題。接線的證言與強制層選擇見 &lt;a href="https://tarrragon.github.io/blog/ddd/composition-root-reachability/" data-link-title="組裝層的可達性" data-link-desc="行為測試全綠、功能在實機上沒有入口的失效形態出現時使用。mock 換掉的正是組裝，組裝完成與否在行為測試裡沒有證言；把可達性當成組裝層的不變式，在測試、發版與設計文件各給一個強制點。">組裝層的可達性&lt;/a>。adapter 作為變更偵測機制的具體歸屬案例（寫入點 emit vs 儲存層 hook 的選型）見 &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>adapter 是 <a href="/blog/ddd/knowledge-cards/port/" data-link-title="Port" data-link-desc="判斷介面該宣告在哪一層、依賴方向該朝哪時使用。port 是 domain 對外宣告的介面——需求用領域語言說完、技術細節留在實作端。">port</a> 的具體實作：把領域宣告的需求翻譯成某項技術的操作——SQLite 的查詢、HTTP 的請求、檔案系統的讀寫。技術細節被擋在六角形之外的位置就是這裡：領域只看得見 port、看不見 adapter。adapter 在 <a href="/blog/ddd/knowledge-cards/composition-root/" data-link-title="Composition Root" data-link-desc="依賴組裝、路由註冊該集中在哪、組裝斷裂去哪檢查時使用。composition root 是應用程式唯一的組裝起點——DI、路由、事件接線的集中處。">composition root</a> 被插上 port；測試裡的 mock 是 adapter 的替身。</p>
<h2 id="概念位置">概念位置</h2>
<p>adapter 有兩側。driven adapter 被領域呼叫（資料庫、外部服務的實作）；driving adapter 呼叫領域（UI 事件處理、API handler）。兩側的共同責任是翻譯：領域語言與技術語言在這裡互換、不讓任何一邊的詞彙滲到對面。adapter 對外接的介面是 <a href="/blog/ddd/knowledge-cards/port/" data-link-title="Port" data-link-desc="判斷介面該宣告在哪一層、依賴方向該朝哪時使用。port 是 domain 對外宣告的介面——需求用領域語言說完、技術細節留在實作端。">port</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>實作類 import 框架套件、以技術命名（SqliteBookRepository），是 adapter 各安其位的訊號。領域規則出現在 adapter 裡（折扣計算寫在 repository、狀態判斷寫在 handler）是反向滲透——規則離開了領域模型、<a href="/blog/ddd/knowledge-cards/invariant/" data-link-title="Invariant" data-link-desc="領域模型的約束規則落在哪一層時使用。不變式是在物件整個生命週期都必須為真的業務規則——狀態只能沿流程轉換、被同一條規則綁住的欄位必須一起換。">invariant</a> 的強制位置跟著失守。</p>
<h2 id="設計責任">設計責任</h2>
<p>adapter 寫完、測試通過，只證明它自己行為正確；它有沒有被插上 port、插的是不是它，是組裝層的問題。接線的證言與強制層選擇見 <a href="/blog/ddd/composition-root-reachability/" data-link-title="組裝層的可達性" data-link-desc="行為測試全綠、功能在實機上沒有入口的失效形態出現時使用。mock 換掉的正是組裝，組裝完成與否在行為測試裡沒有證言；把可達性當成組裝層的不變式，在測試、發版與設計文件各給一個強制點。">組裝層的可達性</a>。adapter 作為變更偵測機制的具體歸屬案例（寫入點 emit vs 儲存層 hook 的選型）見 <a href="/blog/ddd/observation-outlet-responsibility-split/" data-link-title="觀測出口的職責三分" data-link-desc="repository 要補「資料變了」的推送能力、卻不確定 Stream 介面放 domain 算不算洩漏時使用。歸屬判準是介面用什麼語言表達、不是需求來自誰：契約歸 domain、變更偵測歸 infrastructure、框架訂閱歸組裝層。">觀測出口的職責三分</a>。</p>
]]></content:encoded></item></channel></rss>