<?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>Dip on Tarragon</title><link>https://tarrragon.github.io/blog/tags/dip/</link><description>Recent content in Dip on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 10 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/dip/index.xml" rel="self" type="application/rss+xml"/><item><title>用事件做同步查詢、等於手工重建 RPC — 跨 domain 解耦的完整帳單</title><link>https://tarrragon.github.io/blog/work-log/cross_domain_event_request_response_cost/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/cross_domain_event_request_response_cost/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter 書籍管理 App 的借閱功能（Loan domain）建立借閱記錄前要查書籍資訊，流程圖直白地畫著 &lt;code>LoanService --&amp;gt; BookRepository&lt;/code>——Loan domain 直接摸進 Library domain 的 repository
&lt;strong>疑問來源&lt;/strong>：架構修正把它改成事件驅動的 request/response（&lt;code>BookInfoRequested&lt;/code> / &lt;code>BookInfoProvided&lt;/code>）。解耦成立了，但設計規格裡多出 requestId、waitFor、timeout、三條錯誤路徑——這些複雜度是必要代價、還是選錯工具的訊號？
&lt;strong>整理目的&lt;/strong>：把「事件驅動解耦」的帳單攤開、跟替代選項（消費端 Port）比價、收斂出選擇判準
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.12-C.2 的設計規格（流程圖與事件介面層級、未及實作）——本文是架構決策推導、不是實測事故&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="問題依賴方向錯了">問題：依賴方向錯了&lt;/h2>
&lt;p>&lt;code>LoanService&lt;/code> 直接呼叫 &lt;code>BookRepository&lt;/code>（Library domain 的資產）的問題有三層：Library 的變更會波及 Loan、Loan 的測試被迫載入 Library 的實作、兩個 domain 的邊界在這條呼叫上糊掉。這是依賴倒置（DIP）的標準違反現場——高層的業務流程直接抓住了別人家的低層實作。&lt;/p>
&lt;p>要修的東西很明確。值得慢下來的是&lt;strong>修法的選項空間&lt;/strong>，因為這次選的路線把帳單放大了。&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-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">LoanService --&amp;gt; EventBus: 發布 BookInfoRequested(bookId, requestId)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">EventBus --&amp;gt; LibraryService: 訂閱並處理
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">LibraryService --&amp;gt; EventBus: 發布 BookInfoProvided(bookInfo, requestId)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">EventBus --&amp;gt; LoanService: waitFor 收到回應（5 秒 timeout）&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>耦合確實消失了——Library 完全不知道 Loan 存在。但設計規格裡跟著出現的機制、逐項對照同步呼叫：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>事件版要自己做的&lt;/th>
 &lt;th>同步呼叫的對應物&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>requestId&lt;/code>（UUID）配對&lt;/td>
 &lt;td>呼叫堆疊天然對應請求與回傳&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>waitFor&lt;/code> + 5 秒 timeout&lt;/td>
 &lt;td>函式 return（要等多久是呼叫語意）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>書籍不存在的錯誤事件&lt;/td>
 &lt;td>回傳 null / 拋例外&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>超時路徑&lt;/td>
 &lt;td>不存在（同步呼叫不會「沒人接」）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>EventBus 故障路徑&lt;/td>
 &lt;td>不存在&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表的形狀有個名字：&lt;strong>用事件通道手工重建 RPC&lt;/strong>。事件的天然語意是通知——「事情發生了」、fire and forget、任意多訂閱者、無所謂回應；request/response 的語意是對話——一問一答、有超時、有失敗。把對話硬放上通知通道，correlation、timeout、錯誤回傳這些 RPC 基礎設施就得自己蓋一遍，而且每個跨 domain 查詢點都要再蓋一遍。&lt;/p>
&lt;h2 id="便宜的替代消費端-port">便宜的替代：消費端 Port&lt;/h2>
&lt;p>解「依賴方向」有更便宜的工具、而且同專案後來自己用過——&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_port_interface_mock_hell_isp/" data-link-title="mock 要配置 55 個方法、實際只用 5 個 — 測試痛是介面設計痛的探針" data-link-desc="service 測試的 mock 負擔正比於它依賴的介面寬度：依賴四個大介面共 55 個方法、實際呼叫 5 個，91% 的 mock 配置是純浪費、還會炸 MissingStubError。修法是介面隔離——抽出只含實際使用方法的 Port，讓 mock 縮到跟真實依賴一樣窄；為未來預留的方法用 TODO 標記啟用時機。">Port 介面&lt;/a>：Loan domain 宣告自己需要的能力、Library 提供實作、組裝層接線：&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">// Loan domain 內：宣告需求（依賴方向：Library 實作向 Loan 的介面靠）
&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">BookInfoPort&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">BookInfo&lt;/span>&lt;span class="o">?&amp;gt;&lt;/span> &lt;span class="n">getBookInfo&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">String&lt;/span> &lt;span class="n">bookId&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>DIP 同樣成立（Loan 不認識 Library、只認識自己定義的 Port）、測試同樣乾淨（mock 一個單方法介面）、而呼叫還是一次普通的 await——requestId、timeout、事件錯誤路徑全部不需要。&lt;/p>
&lt;p>那事件版買到的是什麼？兩樣 Port 給不了的：&lt;strong>時間解耦&lt;/strong>（發布者不等回應、雙方可以不同時在線——分散式或背景處理的前提）跟&lt;strong>基數解耦&lt;/strong>（一個事件任意多訂閱者）。判準因此收得很乾淨：&lt;/p>
&lt;ul>
&lt;li>要解的是&lt;strong>依賴方向&lt;/strong>（單機、同進程、一問一答）→ Port，付介面一張的價&lt;/li>
&lt;li>要解的是&lt;strong>時間或部署&lt;/strong>（跨進程、離線佇列、多方反應）→ 事件，correlation 與 timeout 是這個量級問題的合理成本&lt;/li>
&lt;/ul>
&lt;p>這個 case 的查詢是同進程、一對一、呼叫端必須等到答案才能往下走——三個特徵全部指向 Port。事件版不是錯（它也真的解了耦）、是&lt;strong>用了下一個量級的工具付了下一個量級的帳&lt;/strong>。&lt;/p>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;ul>
&lt;li>事件名成對出現 &lt;code>XxxRequested&lt;/code> / &lt;code>XxxProvided&lt;/code> 且帶 correlation id——你在事件通道上做 RPC，先問「需要時間解耦嗎」&lt;/li>
&lt;li>發布事件之後 &lt;code>waitFor&lt;/code> / await 回應才能繼續——呼叫端語意上是同步的，事件只是繞路&lt;/li>
&lt;li>跨 domain 呼叫的替代方案清單裡沒有「消費端 Port」——選項空間漏了最便宜的一格&lt;/li>
&lt;li>反向確認：訂閱者不只一個、或處理可以離線 / 延後——這時事件才是本命，別退回 Port&lt;/li>
&lt;/ul>
&lt;h2 id="相關閱讀">相關閱讀&lt;/h2>
&lt;ul>
&lt;li>便宜選項的實戰：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_port_interface_mock_hell_isp/" data-link-title="mock 要配置 55 個方法、實際只用 5 個 — 測試痛是介面設計痛的探針" data-link-desc="service 測試的 mock 負擔正比於它依賴的介面寬度：依賴四個大介面共 55 個方法、實際呼叫 5 個，91% 的 mock 配置是純浪費、還會炸 MissingStubError。修法是介面隔離——抽出只含實際使用方法的 Port，讓 mock 縮到跟真實依賴一樣窄；為未來預留的方法用 TODO 標記啟用時機。">mock 55 個方法只用 5 個：Port 介面&lt;/a>——同專案後期用 Port 解依賴的完整記錄&lt;/li>
&lt;li>事件的正確場合：&lt;a href="https://tarrragon.github.io/blog/work-log/domain_event_naming_past_tense/" data-link-title="BookImported 不能寫成 ImportBook — 事件命名的過去式是語意類別、不是風格" data-link-desc="domain event 用過去式命名（BookImported）因為事件是已發生的事實；動詞開頭（ImportBook）是命令的形狀、訂閱者會誤讀成指令。字尾清單的自動檢查抓不住不規則動詞——偵測可機械化、判定要看語意。附一次版本終止：工作日誌宣稱的命名問題實際早已不存在、執行前驗前提省下整輪流程。">Domain Event 命名的過去式&lt;/a>——事件是已發生的事實、&lt;code>BookInfoRequested&lt;/code> 這個名字本身就洩漏了它其實是請求不是事實&lt;/li>
&lt;li>概念地基：&lt;a href="https://tarrragon.github.io/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南&lt;/a>——跨領域邊界與事件建模；工具量級的選擇也是「從操作推導」的一部分&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter 書籍管理 App 的借閱功能（Loan domain）建立借閱記錄前要查書籍資訊，流程圖直白地畫著 <code>LoanService --&gt; BookRepository</code>——Loan domain 直接摸進 Library domain 的 repository
<strong>疑問來源</strong>：架構修正把它改成事件驅動的 request/response（<code>BookInfoRequested</code> / <code>BookInfoProvided</code>）。解耦成立了，但設計規格裡多出 requestId、waitFor、timeout、三條錯誤路徑——這些複雜度是必要代價、還是選錯工具的訊號？
<strong>整理目的</strong>：把「事件驅動解耦」的帳單攤開、跟替代選項（消費端 Port）比價、收斂出選擇判準
<strong>本文邊界</strong>：素材是該專案 v0.12-C.2 的設計規格（流程圖與事件介面層級、未及實作）——本文是架構決策推導、不是實測事故</p></blockquote>
<hr>
<h2 id="問題依賴方向錯了">問題：依賴方向錯了</h2>
<p><code>LoanService</code> 直接呼叫 <code>BookRepository</code>（Library domain 的資產）的問題有三層：Library 的變更會波及 Loan、Loan 的測試被迫載入 Library 的實作、兩個 domain 的邊界在這條呼叫上糊掉。這是依賴倒置（DIP）的標準違反現場——高層的業務流程直接抓住了別人家的低層實作。</p>
<p>要修的東西很明確。值得慢下來的是<strong>修法的選項空間</strong>，因為這次選的路線把帳單放大了。</p>
<h2 id="事件驅動版每一項都是同步呼叫免費附贈的">事件驅動版：每一項都是同步呼叫免費附贈的</h2>
<p>修正設計把一次查詢改成一趟事件往返：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">LoanService  --&gt; EventBus: 發布 BookInfoRequested(bookId, requestId)
</span></span><span class="line"><span class="ln">2</span><span class="cl">EventBus     --&gt; LibraryService: 訂閱並處理
</span></span><span class="line"><span class="ln">3</span><span class="cl">LibraryService --&gt; EventBus: 發布 BookInfoProvided(bookInfo, requestId)
</span></span><span class="line"><span class="ln">4</span><span class="cl">EventBus     --&gt; LoanService: waitFor 收到回應（5 秒 timeout）</span></span></code></pre></div><p>耦合確實消失了——Library 完全不知道 Loan 存在。但設計規格裡跟著出現的機制、逐項對照同步呼叫：</p>
<table>
  <thead>
      <tr>
          <th>事件版要自己做的</th>
          <th>同步呼叫的對應物</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>requestId</code>（UUID）配對</td>
          <td>呼叫堆疊天然對應請求與回傳</td>
      </tr>
      <tr>
          <td><code>waitFor</code> + 5 秒 timeout</td>
          <td>函式 return（要等多久是呼叫語意）</td>
      </tr>
      <tr>
          <td>書籍不存在的錯誤事件</td>
          <td>回傳 null / 拋例外</td>
      </tr>
      <tr>
          <td>超時路徑</td>
          <td>不存在（同步呼叫不會「沒人接」）</td>
      </tr>
      <tr>
          <td>EventBus 故障路徑</td>
          <td>不存在</td>
      </tr>
  </tbody>
</table>
<p>這張表的形狀有個名字：<strong>用事件通道手工重建 RPC</strong>。事件的天然語意是通知——「事情發生了」、fire and forget、任意多訂閱者、無所謂回應；request/response 的語意是對話——一問一答、有超時、有失敗。把對話硬放上通知通道，correlation、timeout、錯誤回傳這些 RPC 基礎設施就得自己蓋一遍，而且每個跨 domain 查詢點都要再蓋一遍。</p>
<h2 id="便宜的替代消費端-port">便宜的替代：消費端 Port</h2>
<p>解「依賴方向」有更便宜的工具、而且同專案後來自己用過——<a href="/blog/work-log/flutter_port_interface_mock_hell_isp/" data-link-title="mock 要配置 55 個方法、實際只用 5 個 — 測試痛是介面設計痛的探針" data-link-desc="service 測試的 mock 負擔正比於它依賴的介面寬度：依賴四個大介面共 55 個方法、實際呼叫 5 個，91% 的 mock 配置是純浪費、還會炸 MissingStubError。修法是介面隔離——抽出只含實際使用方法的 Port，讓 mock 縮到跟真實依賴一樣窄；為未來預留的方法用 TODO 標記啟用時機。">Port 介面</a>：Loan domain 宣告自己需要的能力、Library 提供實作、組裝層接線：</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">// Loan domain 內：宣告需求（依賴方向：Library 實作向 Loan 的介面靠）
</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">BookInfoPort</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">BookInfo</span><span class="o">?&gt;</span> <span class="n">getBookInfo</span><span class="p">(</span><span class="kt">String</span> <span class="n">bookId</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>DIP 同樣成立（Loan 不認識 Library、只認識自己定義的 Port）、測試同樣乾淨（mock 一個單方法介面）、而呼叫還是一次普通的 await——requestId、timeout、事件錯誤路徑全部不需要。</p>
<p>那事件版買到的是什麼？兩樣 Port 給不了的：<strong>時間解耦</strong>（發布者不等回應、雙方可以不同時在線——分散式或背景處理的前提）跟<strong>基數解耦</strong>（一個事件任意多訂閱者）。判準因此收得很乾淨：</p>
<ul>
<li>要解的是<strong>依賴方向</strong>（單機、同進程、一問一答）→ Port，付介面一張的價</li>
<li>要解的是<strong>時間或部署</strong>（跨進程、離線佇列、多方反應）→ 事件，correlation 與 timeout 是這個量級問題的合理成本</li>
</ul>
<p>這個 case 的查詢是同進程、一對一、呼叫端必須等到答案才能往下走——三個特徵全部指向 Port。事件版不是錯（它也真的解了耦）、是<strong>用了下一個量級的工具付了下一個量級的帳</strong>。</p>
<h2 id="判讀徵兆">判讀徵兆</h2>
<ul>
<li>事件名成對出現 <code>XxxRequested</code> / <code>XxxProvided</code> 且帶 correlation id——你在事件通道上做 RPC，先問「需要時間解耦嗎」</li>
<li>發布事件之後 <code>waitFor</code> / await 回應才能繼續——呼叫端語意上是同步的，事件只是繞路</li>
<li>跨 domain 呼叫的替代方案清單裡沒有「消費端 Port」——選項空間漏了最便宜的一格</li>
<li>反向確認：訂閱者不只一個、或處理可以離線 / 延後——這時事件才是本命，別退回 Port</li>
</ul>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>便宜選項的實戰：<a href="/blog/work-log/flutter_port_interface_mock_hell_isp/" data-link-title="mock 要配置 55 個方法、實際只用 5 個 — 測試痛是介面設計痛的探針" data-link-desc="service 測試的 mock 負擔正比於它依賴的介面寬度：依賴四個大介面共 55 個方法、實際呼叫 5 個，91% 的 mock 配置是純浪費、還會炸 MissingStubError。修法是介面隔離——抽出只含實際使用方法的 Port，讓 mock 縮到跟真實依賴一樣窄；為未來預留的方法用 TODO 標記啟用時機。">mock 55 個方法只用 5 個：Port 介面</a>——同專案後期用 Port 解依賴的完整記錄</li>
<li>事件的正確場合：<a href="/blog/work-log/domain_event_naming_past_tense/" data-link-title="BookImported 不能寫成 ImportBook — 事件命名的過去式是語意類別、不是風格" data-link-desc="domain event 用過去式命名（BookImported）因為事件是已發生的事實；動詞開頭（ImportBook）是命令的形狀、訂閱者會誤讀成指令。字尾清單的自動檢查抓不住不規則動詞——偵測可機械化、判定要看語意。附一次版本終止：工作日誌宣稱的命名問題實際早已不存在、執行前驗前提省下整輪流程。">Domain Event 命名的過去式</a>——事件是已發生的事實、<code>BookInfoRequested</code> 這個名字本身就洩漏了它其實是請求不是事實</li>
<li>概念地基：<a href="/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南</a>——跨領域邊界與事件建模；工具量級的選擇也是「從操作推導」的一部分</li>
</ul>
]]></content:encoded></item></channel></rss>