<?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>Architecture on Tarragon</title><link>https://tarrragon.github.io/blog/tags/architecture/</link><description>Recent content in Architecture on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 22 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/architecture/index.xml" rel="self" type="application/rss+xml"/><item><title>Collector 架構</title><link>https://tarrragon.github.io/blog/monitoring/04-collector/architecture/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/04-collector/architecture/</guid><description>&lt;p>Collector 是監控資料的接收與處理中心，職責是把 SDK 送來的事件資料轉換成可查詢、可觸發動作的持久化記錄。整條鏈路由五段組成，每段有明確的輸入和輸出，段與段之間用結構化資料傳遞。&lt;/p>
&lt;h2 id="五段處理鏈路">五段處理鏈路&lt;/h2>
&lt;h3 id="第一段http-endpoint-接收">第一段：HTTP endpoint 接收&lt;/h3>
&lt;p>Collector 對外提供一個 HTTP POST endpoint（例如 &lt;code>/v1/events&lt;/code>），接收 SDK 送來的 JSON body。每個 request 可以是單一事件或批次事件陣列。&lt;/p>
&lt;p>Endpoint 的職責只有兩件事：驗證 HTTP 層面的基本條件（Content-Type、body size limit、認證 token），然後把 body 傳給下一段。HTTP 層面的錯誤（413 body too large、401 unauthorized）在這裡回應，不進入後續處理。&lt;/p>
&lt;p>自用工具場景下，Go 的 &lt;code>net/http&lt;/code> 標準庫提供的 HTTP server 已足夠。一個 &lt;code>http.HandleFunc(&amp;quot;/v1/events&amp;quot;, handler)&lt;/code> 加上 &lt;code>json.NewDecoder(r.Body).Decode(&amp;amp;events)&lt;/code> 就完成接收。不需要 framework。&lt;/p>
&lt;h3 id="第二段json-schema-驗證">第二段：JSON Schema 驗證&lt;/h3>
&lt;p>收到的 JSON body 用 JSON Schema 驗證結構正確性 — 必要欄位是否存在、型別是否正確、值是否在合法範圍內。驗證失敗的事件被拒絕並記錄原因，通過的事件進入下一段。&lt;/p>
&lt;p>Schema 驗證是 collector 的品質閘門。沒有驗證的 collector 會累積格式不一致的資料，查詢時需要處理各種邊界條件。驗證在寫入前攔截問題，比寫入後清理成本低。&lt;/p>
&lt;p>驗證的粒度是事件級 — 批次中的一個事件驗證失敗不影響其他事件。回應中標明哪些事件被接受、哪些被拒絕及原因。&lt;/p>
&lt;h3 id="ingestion-回應格式">Ingestion 回應格式&lt;/h3>
&lt;p>回應格式把「接受了幾筆、拒絕了幾筆、拒絕原因」三件事用一套一致的結構表達。SDK 端只需要判斷 status code 就知道怎麼處理 buffer。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 200 OK — 單筆成功或批次全部成功
&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="p">{&lt;/span> &lt;span class="nt">&amp;#34;accepted&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c1">// 207 Multi-Status — 批次部分失敗
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;accepted&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;rejected&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;errors&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;index&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;message&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;missing required field: type&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;fields&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="c1">// 400 Bad Request — 單筆失敗或批次全部失敗
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;error&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;schema validation failed&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;details&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;field&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;message&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;missing required field&amp;#34;&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">&lt;span class="c1">// 503 Service Unavailable — 寫入端暫時不可用
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;error&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;service temporarily unavailable&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;retry_after&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">5&lt;/span> &lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>設計選擇：207 的 &lt;code>errors&lt;/code> 陣列用 &lt;code>index&lt;/code> 標明失敗事件在原始 batch 中的位置（0-based），SDK 可以用 index 對照原始事件做 debug log。合法事件不因部分失敗而被丟棄 — 部分成功是 batch 收集的核心價值。400 和 207 的差異是「全軍覆沒 vs 部分存活」，SDK 端的處理策略不同：400 直接清 buffer（schema 問題重試也不會過），207 只清成功的部分。&lt;/p>
&lt;h3 id="health-endpoint-回應">Health endpoint 回應&lt;/h3>
&lt;p>Health endpoint 回傳 collector 自身的運行狀態，不包含事件內容。用途是 SDK 端確認 collector 可達、監控腳本定期探測。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// GET /health → 200 OK
&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="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="nt">&amp;#34;status&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;ok&amp;#34;&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="nt">&amp;#34;uptime_seconds&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">3600&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="nt">&amp;#34;total_events&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1234&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;storage_bytes&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">5242880&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;version&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;0.1.0&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>total_events&lt;/code> 和 &lt;code>storage_bytes&lt;/code> 讓監控腳本判斷 collector 的負載趨勢。&lt;code>version&lt;/code> 讓 SDK 確認 collector 版本（schema 不匹配時的第一個 debug 線索）。&lt;/p>
&lt;h3 id="第三段儲存">第三段：儲存&lt;/h3>
&lt;p>通過驗證的事件寫入 Storage Backend。Collector 使用可插拔的 Storage interface — day-one 預設用 SQLite（零依賴、嵌入式），分析需求觸發時切換到 PostgreSQL。具體的 backend 選擇和功能分層見 &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇&lt;/a>，可插拔架構見 &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Collector 是監控資料的接收與處理中心，職責是把 SDK 送來的事件資料轉換成可查詢、可觸發動作的持久化記錄。整條鏈路由五段組成，每段有明確的輸入和輸出，段與段之間用結構化資料傳遞。</p>
<h2 id="五段處理鏈路">五段處理鏈路</h2>
<h3 id="第一段http-endpoint-接收">第一段：HTTP endpoint 接收</h3>
<p>Collector 對外提供一個 HTTP POST endpoint（例如 <code>/v1/events</code>），接收 SDK 送來的 JSON body。每個 request 可以是單一事件或批次事件陣列。</p>
<p>Endpoint 的職責只有兩件事：驗證 HTTP 層面的基本條件（Content-Type、body size limit、認證 token），然後把 body 傳給下一段。HTTP 層面的錯誤（413 body too large、401 unauthorized）在這裡回應，不進入後續處理。</p>
<p>自用工具場景下，Go 的 <code>net/http</code> 標準庫提供的 HTTP server 已足夠。一個 <code>http.HandleFunc(&quot;/v1/events&quot;, handler)</code> 加上 <code>json.NewDecoder(r.Body).Decode(&amp;events)</code> 就完成接收。不需要 framework。</p>
<h3 id="第二段json-schema-驗證">第二段：JSON Schema 驗證</h3>
<p>收到的 JSON body 用 JSON Schema 驗證結構正確性 — 必要欄位是否存在、型別是否正確、值是否在合法範圍內。驗證失敗的事件被拒絕並記錄原因，通過的事件進入下一段。</p>
<p>Schema 驗證是 collector 的品質閘門。沒有驗證的 collector 會累積格式不一致的資料，查詢時需要處理各種邊界條件。驗證在寫入前攔截問題，比寫入後清理成本低。</p>
<p>驗證的粒度是事件級 — 批次中的一個事件驗證失敗不影響其他事件。回應中標明哪些事件被接受、哪些被拒絕及原因。</p>
<h3 id="ingestion-回應格式">Ingestion 回應格式</h3>
<p>回應格式把「接受了幾筆、拒絕了幾筆、拒絕原因」三件事用一套一致的結構表達。SDK 端只需要判斷 status code 就知道怎麼處理 buffer。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 200 OK — 單筆成功或批次全部成功
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="p">{</span> <span class="nt">&#34;accepted&#34;</span><span class="p">:</span> <span class="mi">1</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1">// 207 Multi-Status — 批次部分失敗
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="nt">&#34;accepted&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="nt">&#34;rejected&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="nt">&#34;errors&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">{</span> <span class="nt">&#34;index&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nt">&#34;message&#34;</span><span class="p">:</span> <span class="s2">&#34;missing required field: type&#34;</span><span class="p">,</span> <span class="nt">&#34;fields&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;type&#34;</span><span class="p">]</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="p">]</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1">// 400 Bad Request — 單筆失敗或批次全部失敗
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span><span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="nt">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;schema validation failed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">  <span class="nt">&#34;details&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">{</span> <span class="nt">&#34;field&#34;</span><span class="p">:</span> <span class="s2">&#34;type&#34;</span><span class="p">,</span> <span class="nt">&#34;message&#34;</span><span class="p">:</span> <span class="s2">&#34;missing required field&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">  <span class="p">]</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="c1">// 503 Service Unavailable — 寫入端暫時不可用
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="c1"></span><span class="p">{</span> <span class="nt">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;service temporarily unavailable&#34;</span><span class="p">,</span> <span class="nt">&#34;retry_after&#34;</span><span class="p">:</span> <span class="mi">5</span> <span class="p">}</span></span></span></code></pre></div><p>設計選擇：207 的 <code>errors</code> 陣列用 <code>index</code> 標明失敗事件在原始 batch 中的位置（0-based），SDK 可以用 index 對照原始事件做 debug log。合法事件不因部分失敗而被丟棄 — 部分成功是 batch 收集的核心價值。400 和 207 的差異是「全軍覆沒 vs 部分存活」，SDK 端的處理策略不同：400 直接清 buffer（schema 問題重試也不會過），207 只清成功的部分。</p>
<h3 id="health-endpoint-回應">Health endpoint 回應</h3>
<p>Health endpoint 回傳 collector 自身的運行狀態，不包含事件內容。用途是 SDK 端確認 collector 可達、監控腳本定期探測。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// GET /health → 200 OK
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;status&#34;</span><span class="p">:</span> <span class="s2">&#34;ok&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nt">&#34;uptime_seconds&#34;</span><span class="p">:</span> <span class="mi">3600</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nt">&#34;total_events&#34;</span><span class="p">:</span> <span class="mi">1234</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nt">&#34;storage_bytes&#34;</span><span class="p">:</span> <span class="mi">5242880</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="nt">&#34;version&#34;</span><span class="p">:</span> <span class="s2">&#34;0.1.0&#34;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>total_events</code> 和 <code>storage_bytes</code> 讓監控腳本判斷 collector 的負載趨勢。<code>version</code> 讓 SDK 確認 collector 版本（schema 不匹配時的第一個 debug 線索）。</p>
<h3 id="第三段儲存">第三段：儲存</h3>
<p>通過驗證的事件寫入 Storage Backend。Collector 使用可插拔的 Storage interface — day-one 預設用 SQLite（零依賴、嵌入式），分析需求觸發時切換到 PostgreSQL。具體的 backend 選擇和功能分層見 <a href="/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇</a>，可插拔架構見 <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a>。</p>
<h3 id="第四段查詢">第四段：查詢</h3>
<p>儲存的事件透過 CLI 指令或 HTTP 查詢 endpoint 存取。SQLite backend 下用 SQL 查詢；匯出為 JSONL 格式後也可用 <code>grep</code> + <code>jq</code> 做臨時分析。</p>
<p>查詢設計見 <a href="/blog/monitoring/04-collector/query-api/" data-link-title="查詢 API 設計" data-link-desc="CLI grep 友好的 JSONL 結構 &#43; HTTP 查詢 endpoint — 兩種查詢介面各自的適用場景和設計要點">查詢 API 設計</a>。</p>
<h3 id="第五段rule-engine">第五段：Rule engine</h3>
<p>Rule engine 在事件寫入後觸發，檢查事件是否匹配預定義的規則。匹配時執行對應的動作（發通知、寫 summary、觸發 webhook）。</p>
<p>Rule engine 設計見 <a href="/blog/monitoring/04-collector/rule-engine/" data-link-title="Rule engine 設計" data-link-desc="條件 → 動作 → 模板的三段式規則結構 — 讓 collector 從被動儲存變成主動回應">Rule engine 設計</a>。</p>
<h2 id="多獨立-client-併發寫入">多獨立 client 併發寫入</h2>
<p>上述五段鏈路描述的是單一 request 的路徑。實際運行時，多個 SDK 會同時送事件——以下先描述場景，下方<a href="#%e4%b8%a6%e7%99%bc%e5%af%ab%e5%85%a5%e7%ad%96%e7%95%a5">並發寫入策略</a>再詳述 collector 如何處理。</p>
<p>常見部署場景中，多個完全獨立的 SDK 實例同時送事件到同一個 collector——不同 process、不同 app、甚至不同語言的 SDK。這和「一個 app 內的多 thread 併發」不同：每個 SDK 有自己的 buffer 和 HTTP 連線，不共享任何狀態。</p>
<p>SDK 端不需要知道其他 SDK 的存在。每個 SDK 獨立 init、獨立 buffer、獨立 flush、獨立 close。SDK 端的唯一接觸點是 collector 的 HTTP endpoint——併發安全由 storage backend 的併發策略保證（見下方<a href="#%e4%b8%a6%e7%99%bc%e5%af%ab%e5%85%a5%e7%ad%96%e7%95%a5">並發寫入策略</a>），不需要 SDK 端協調。多 client 同時 flush 時的背壓機制見 <a href="/blog/monitoring/04-collector/ingestion-scaling/" data-link-title="Ingestion Scaling" data-link-desc="四層防線應對 ingestion 端的流量擴展 — SDK 取樣、Collector 背壓、水平擴展、Queue 解耦">Ingestion 背壓與流量管控</a>。</p>
<p>例如 CI pipeline 的多個 job 同時送 build 事件，或微服務架構中多個 service 各自送事件到同一個 collector。另一個具體案例是 Claude Code 的 Hook 系統——多個 Hook 同時觸發時，每個 Hook 是獨立的 Python process，各自初始化 SDK、產生事件、flush 到同一個 collector。</p>
<h2 id="並發寫入策略">並發寫入策略</h2>
<p>Go 的 HTTP server 為每個 request 分配一個 goroutine。多個 SDK 同時 flush 時，collector 同時收到多個寫入請求。Storage Backend 的並發能力決定了這些 goroutine 怎麼協調。</p>
<h3 id="sqlite-backend單寫者模型">SQLite Backend：單寫者模型</h3>
<p>SQLite 的 WAL mode 允許一個 writer 和多個 concurrent reader — 讀寫不互相阻塞，但多個 writer 之間是序列化的。Go 端有兩種處理 pattern：</p>
<p><strong>Single-writer goroutine + channel</strong>：所有 <code>Store()</code> 呼叫把事件送進一個 Go channel，由一個專屬的 goroutine 從 channel 讀取並序列寫入 SQLite。HTTP handler 送完 channel 後等待確認（或用 buffered channel 異步）。優點是背壓控制清晰 — channel 滿時 HTTP handler 自然阻塞，可以回 503。缺點是多一層間接。</p>
<p><strong>Busy timeout fallback</strong>：不在 Go 層管序列化，讓 SQLite driver 自己處理。設定 <code>_pragma=busy_timeout(5000)</code>，多個 goroutine 同時呼叫 <code>Store()</code> 時，SQLite 讓等待的 goroutine block 直到寫入鎖釋放（最多 5 秒）。優點是實作簡單（不需要 channel 和額外 goroutine）。缺點是背壓不可控 — goroutine 數量可能累積。</p>
<p>自用工具場景推薦 busy timeout（簡單）、寫入量增長到出現超時錯誤時切換到 channel pattern。</p>
<h3 id="postgresql-backend連線池">PostgreSQL Backend：連線池</h3>
<p>PostgreSQL 透過連線池（<code>database/sql</code> 的 <code>SetMaxOpenConns</code>）支援並行寫入。多個 goroutine 可以同時寫入不同的連線，不需要額外的序列化機制。</p>
<h2 id="go-單一-binary-的設計選擇">Go 單一 binary 的設計選擇</h2>
<p>Collector 用 Go 編譯成單一 binary，不依賴外部 runtime（JVM、Python interpreter、Node.js）。部署是複製一個檔案，啟動是執行一個指令。</p>
<p>這個選擇在自用工具場景下有特定優勢：server 和 collector 在同一台機器上，部署流程是 <code>scp collector user@host:</code> + <code>ssh user@host ./collector</code>。不需要 package manager、不需要 container registry、不需要 orchestration。</p>
<p>Go 的 <code>net/http</code> 標準庫提供 production-ready 的 HTTP server，JSON 處理用標準庫的 <code>encoding/json</code>，SQLite 用 <code>modernc.org/sqlite</code>（pure Go、無 CGO 依賴）。整個 collector 的核心邏輯可以在 500 行以內完成。</p>
<p>具體的部署步驟（systemd service 檔案、啟動參數、設定檔格式）和 Quick Start（從零到第一筆事件出現在 collector）見 monitor repo 的 deployment guide。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>功能分層與 Backend 選擇 → <a href="/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇</a></li>
<li>可插拔 Storage Backend 架構 → <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a></li>
<li>JSONL 匯出與備份格式 → <a href="/blog/monitoring/04-collector/jsonl-storage/" data-link-title="JSONL 匯出與備份格式" data-link-desc="JSONL 作為匯出和備份格式的設計 — 人類可讀、grep 友好、SQLite 損壞時的重建來源">JSONL 儲存設計</a></li>
<li>查詢 API 的設計 → <a href="/blog/monitoring/04-collector/query-api/" data-link-title="查詢 API 設計" data-link-desc="CLI grep 友好的 JSONL 結構 &#43; HTTP 查詢 endpoint — 兩種查詢介面各自的適用場景和設計要點">查詢 API 設計</a></li>
<li>Rule engine → <a href="/blog/monitoring/04-collector/rule-engine/" data-link-title="Rule engine 設計" data-link-desc="條件 → 動作 → 模板的三段式規則結構 — 讓 collector 從被動儲存變成主動回應">Rule engine 設計</a></li>
<li>背壓與流量管控的基礎概念 → <a href="/blog/devops/03-traffic-management/" data-link-title="模組三：流量管控" data-link-desc="收到的流量超過處理能力時怎麼辦 — 背壓、rate limit、熔斷、bulkhead 四種防護機制">DevOps 流量管控</a></li>
<li>端到端資料完整性 → <a href="/blog/monitoring/04-collector/data-integrity/" data-link-title="端到端資料完整性" data-link-desc="從 SDK 到 storage 的資料損失地圖 — 每個環節的損失類型、控制策略、完整性指標、被自己 SDK DDoS 的防護">端到端資料完整性</a></li>
<li>Error fingerprint 與去重分群 → <a href="/blog/monitoring/04-collector/error-fingerprint/" data-link-title="Error Fingerprint 與去重分群" data-link-desc="把大量 error 事件歸組成可管理的 issue 列表 — fingerprint 演算法、message normalization、error_groups 表設計、自架方案的務實邊界">Error Fingerprint 與去重分群</a></li>
</ul>
]]></content:encoded></item><item><title>10.1 服務拆分與邊界判讀</title><link>https://tarrragon.github.io/blog/backend/10-system-evolution/service-decomposition-boundaries/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/10-system-evolution/service-decomposition-boundaries/</guid><description>&lt;p>Monolith 與 microservice 是兩種耦合策略、各自承擔代價：monolith 用單一程式碼庫換低協作成本、microservice 用獨立邊界換團隊與部署彈性。本章處理「演進速度跟組織能力對齊」這個決策邊界 — 起點是辨識當下壓力來源、再選擇拆分軸、流行度與堅持習慣都是次要訊號。&lt;/p>
&lt;h2 id="monolith-與-microservice-的責任差異">Monolith 與 Microservice 的責任差異&lt;/h2>
&lt;p>Monolith 用「同一個程式碼庫、同一個部署單位、同一個資料庫」換取低協作成本與簡單事務語意。Microservice 用「獨立程式碼庫、獨立部署、獨立資料邊界」換取團隊獨立性、技術選型彈性與局部故障隔離。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>Monolith&lt;/th>
 &lt;th>Microservice&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>變更速度&lt;/td>
 &lt;td>單庫改完直接上線&lt;/td>
 &lt;td>跨服務協調，需要契約對齊&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>事務一致性&lt;/td>
 &lt;td>本地 transaction 就解決&lt;/td>
 &lt;td>跨服務需要 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/saga/" data-link-title="Saga" data-link-desc="處理跨服務分散事務的補償型 transaction 序列、用最終一致換 ACID atomic">saga&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/outbox-pattern/" data-link-title="Outbox Pattern" data-link-desc="說明資料庫狀態變更與事件發布如何透過 outbox 維持一致">outbox&lt;/a> 或最終一致性&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>故障隔離&lt;/td>
 &lt;td>單點失敗會整個服務掛掉&lt;/td>
 &lt;td>一個服務掛了，其他可能還能服務&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>部署單位&lt;/td>
 &lt;td>整個應用一次部署&lt;/td>
 &lt;td>各服務獨立部署，發布節奏不互相阻擋&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>運維複雜度&lt;/td>
 &lt;td>一組基礎設施&lt;/td>
 &lt;td>N 組基礎設施 + 服務間通訊監控&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Debug 路徑&lt;/td>
 &lt;td>同一個 stack trace 看到底&lt;/td>
 &lt;td>跨服務 trace context、log 聚合不可省&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>讀者要從這張表反推自己的真實壓力來源。如果痛點是「部署互相卡住、發布頻率被別人拖慢」，拆分能解決；如果痛點是「程式碼太亂、新人看不懂」，拆服務只會把亂的範圍擴大成跨服務契約混亂。&lt;/p>
&lt;p>這張表是兩端對比、實際系統常落在中間。常見折衷形態：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/modular-monolith/" data-link-title="Modular Monolith" data-link-desc="單一部署單位 &amp;#43; 模組化內部邊界的架構、是 monolith 跟 microservice 之間的折衷形態">Modular monolith&lt;/a>&lt;/strong>（單一部署 + 模組化邊界）：保留 monolith 的部署簡單、用模組邊界防止程式碼互相穿透。Shopify、Basecamp、Stack Overflow 是大規模長期維持的代表 — monolith 不是進化中段、是 valid endgame。&lt;/li>
&lt;li>&lt;strong>Macro-services&lt;/strong>（少量大服務、5-15 個）：避免 microservice 的極端碎片化、保留拆分帶來的部署獨立性。是多數中型團隊的實際終點、不是過渡形態。&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cell-based-architecture/" data-link-title="Cell-Based Architecture" data-link-desc="把系統拆成多個 isolated cell、控制 blast radius、跨 cell 共用標準介面">Cell-based architecture&lt;/a>&lt;/strong>（多 cell 各自獨立、跨 cell 共用標準介面）：AWS、Slack、DoorDash 用來控制 blast radius — 把整個系統複製成多個 isolated cell、每個 cell 內可以是 monolith 或 microservice。&lt;/li>
&lt;/ul>
&lt;p>拆分不是進化方向、是壓力應對工具。維持 monolith 在某些情境（極小團隊、PMF 前期、無 DevOps 能力）是更負責任的選擇。&lt;/p>
&lt;h2 id="拆分軸的判讀">拆分軸的判讀&lt;/h2>
&lt;p>服務邊界不只一條軸。常見的四條軸對應不同的壓力來源，正確的拆法是「壓力在哪裡、就沿那條軸拆」，不是同時動四條軸。&lt;/p>
&lt;h3 id="資料邊界">資料邊界&lt;/h3>
&lt;p>當兩塊業務的資料&lt;strong>生命週期不同、一致性需求不同、查詢模式不同&lt;/strong>時，資料邊界已經形成。例如訂單資料需要強一致性與長期保留，瀏覽紀錄可以最終一致性、定期清理。把這兩類資料放同一個 schema 會讓 backup、migration、index 策略互相干擾。&lt;/p>
&lt;p>判讀訊號：同一張表上不同欄位的 read/write QPS 差三個數量級、同一個 transaction 同時寫入多種業務概念、schema migration 一動就要鎖住整個業務的寫入。&lt;/p>
&lt;h3 id="團隊邊界">團隊邊界&lt;/h3>
&lt;p>當兩塊業務由不同團隊維護、發布節奏不同、技術棧偏好不同時，團隊邊界已經形成。Conway&amp;rsquo;s Law 反過來操作：用服務邊界保護團隊邊界，避免一隊改動觸發另一隊重 review。&lt;/p>
&lt;p>判讀訊號：PR review 跨團隊比例過半、發版需要協調多個團隊、技術升級（語言版本、framework 升級）因為其他團隊未準備好而被擋住。&lt;/p>
&lt;h3 id="部署邊界">部署邊界&lt;/h3>
&lt;p>當部分功能需要&lt;strong>獨立的部署節奏、獨立的擴展策略、獨立的可用性等級&lt;/strong>時，部署邊界已經形成。背景批次工作要按小時排程、API 服務要 7×24 線上、報表服務只在工作日運行，三者放同一個部署單位會讓最嚴格的可用性要求拖累其他。&lt;/p>
&lt;p>判讀訊號：高峰時某個功能擴展速度跟不上、低峰時某個功能浪費資源、單一發版策略覆蓋不了所有功能的風險等級。&lt;/p>
&lt;h3 id="流量邊界">流量邊界&lt;/h3>
&lt;p>當不同功能的&lt;strong>流量形狀、失敗代價、SLO 等級不同&lt;/strong>時，流量邊界已經形成。付款 API 一秒 100 個請求、商品搜尋一秒 10000 個請求、後台報表一天 100 個請求，三者放同一個服務會讓彼此爭資源，付款被搜尋擠掉是業務災難。&lt;/p>
&lt;p>判讀訊號：高頻 endpoint 壓爆低頻 endpoint 共用的連線池、不同 endpoint 的 latency 分布同時惡化、無法針對核心交易設定獨立的 SLO 跟 alert。&lt;/p>
&lt;h3 id="其他常見拆分軸">其他常見拆分軸&lt;/h3>
&lt;p>上面四條是技術驅動的主要拆分軸。實務上還有其他軸常成為真實驅動力、要一併納入判讀：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>失敗代價 / blast radius 軸&lt;/strong>：核心交易（掛了會有業務災難）跟邊緣推薦（掛了沒人在意）的可用性等級差距大、適合拆開降低 blast radius。跟 SLO 軸高相關但不同 — 重點在「失敗時誰受影響」的範圍隔離。&lt;/li>
&lt;li>&lt;strong>變更頻率 / 風險軸&lt;/strong>：high-velocity 實驗功能跟 stable 核心應拆開、降低實驗對核心穩定性的牽連。跟團隊軸高相關但獨立 — 同一團隊也可能維持兩種變更頻率的程式碼。&lt;/li>
&lt;li>&lt;strong>資料敏感度 / 合規邊界&lt;/strong>：PCI / PII / 醫療資料的隔離常是合規硬要求（GDPR data residency 強制資料拆境），不是技術選擇。這類軸跟資料邊界相關但服從不同壓力。&lt;/li>
&lt;li>&lt;strong>組織非技術約束&lt;/strong>：併購整合、外部合規節奏、團隊 reorg、預算切分都會強制拆分 — 比 metric 訊號更早觸發、技術上不一定最佳但無法繞過。&lt;/li>
&lt;/ul>
&lt;p>這些軸跟前四條可以同時生效、也可能彼此衝突（合規逼資料拆境、但流量軸建議聚合）。處理衝突時優先順序通常是「合規 &amp;gt; 失敗代價 &amp;gt; 部署 / 流量 &amp;gt; 團隊 &amp;gt; 資料 &amp;gt; 變更頻率」、但每個組織會有自己的權重。&lt;/p></description><content:encoded><![CDATA[<p>Monolith 與 microservice 是兩種耦合策略、各自承擔代價：monolith 用單一程式碼庫換低協作成本、microservice 用獨立邊界換團隊與部署彈性。本章處理「演進速度跟組織能力對齊」這個決策邊界 — 起點是辨識當下壓力來源、再選擇拆分軸、流行度與堅持習慣都是次要訊號。</p>
<h2 id="monolith-與-microservice-的責任差異">Monolith 與 Microservice 的責任差異</h2>
<p>Monolith 用「同一個程式碼庫、同一個部署單位、同一個資料庫」換取低協作成本與簡單事務語意。Microservice 用「獨立程式碼庫、獨立部署、獨立資料邊界」換取團隊獨立性、技術選型彈性與局部故障隔離。</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Monolith</th>
          <th>Microservice</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>變更速度</td>
          <td>單庫改完直接上線</td>
          <td>跨服務協調，需要契約對齊</td>
      </tr>
      <tr>
          <td>事務一致性</td>
          <td>本地 transaction 就解決</td>
          <td>跨服務需要 <a href="/blog/backend/knowledge-cards/saga/" data-link-title="Saga" data-link-desc="處理跨服務分散事務的補償型 transaction 序列、用最終一致換 ACID atomic">saga</a>、<a href="/blog/backend/knowledge-cards/outbox-pattern/" data-link-title="Outbox Pattern" data-link-desc="說明資料庫狀態變更與事件發布如何透過 outbox 維持一致">outbox</a> 或最終一致性</td>
      </tr>
      <tr>
          <td>故障隔離</td>
          <td>單點失敗會整個服務掛掉</td>
          <td>一個服務掛了，其他可能還能服務</td>
      </tr>
      <tr>
          <td>部署單位</td>
          <td>整個應用一次部署</td>
          <td>各服務獨立部署，發布節奏不互相阻擋</td>
      </tr>
      <tr>
          <td>運維複雜度</td>
          <td>一組基礎設施</td>
          <td>N 組基礎設施 + 服務間通訊監控</td>
      </tr>
      <tr>
          <td>Debug 路徑</td>
          <td>同一個 stack trace 看到底</td>
          <td>跨服務 trace context、log 聚合不可省</td>
      </tr>
      <tr>
          <td>適合規模</td>
          <td>早期、單一團隊、業務尚未分化</td>
          <td>多團隊、業務已分化、可獨立演進</td>
      </tr>
  </tbody>
</table>
<p>讀者要從這張表反推自己的真實壓力來源。如果痛點是「部署互相卡住、發布頻率被別人拖慢」，拆分能解決；如果痛點是「程式碼太亂、新人看不懂」，拆服務只會把亂的範圍擴大成跨服務契約混亂。</p>
<p>這張表是兩端對比、實際系統常落在中間。常見折衷形態：</p>
<ul>
<li><strong><a href="/blog/backend/knowledge-cards/modular-monolith/" data-link-title="Modular Monolith" data-link-desc="單一部署單位 &#43; 模組化內部邊界的架構、是 monolith 跟 microservice 之間的折衷形態">Modular monolith</a></strong>（單一部署 + 模組化邊界）：保留 monolith 的部署簡單、用模組邊界防止程式碼互相穿透。Shopify、Basecamp、Stack Overflow 是大規模長期維持的代表 — monolith 不是進化中段、是 valid endgame。</li>
<li><strong>Macro-services</strong>（少量大服務、5-15 個）：避免 microservice 的極端碎片化、保留拆分帶來的部署獨立性。是多數中型團隊的實際終點、不是過渡形態。</li>
<li><strong><a href="/blog/backend/knowledge-cards/cell-based-architecture/" data-link-title="Cell-Based Architecture" data-link-desc="把系統拆成多個 isolated cell、控制 blast radius、跨 cell 共用標準介面">Cell-based architecture</a></strong>（多 cell 各自獨立、跨 cell 共用標準介面）：AWS、Slack、DoorDash 用來控制 blast radius — 把整個系統複製成多個 isolated cell、每個 cell 內可以是 monolith 或 microservice。</li>
</ul>
<p>拆分不是進化方向、是壓力應對工具。維持 monolith 在某些情境（極小團隊、PMF 前期、無 DevOps 能力）是更負責任的選擇。</p>
<h2 id="拆分軸的判讀">拆分軸的判讀</h2>
<p>服務邊界不只一條軸。常見的四條軸對應不同的壓力來源，正確的拆法是「壓力在哪裡、就沿那條軸拆」，不是同時動四條軸。</p>
<h3 id="資料邊界">資料邊界</h3>
<p>當兩塊業務的資料<strong>生命週期不同、一致性需求不同、查詢模式不同</strong>時，資料邊界已經形成。例如訂單資料需要強一致性與長期保留，瀏覽紀錄可以最終一致性、定期清理。把這兩類資料放同一個 schema 會讓 backup、migration、index 策略互相干擾。</p>
<p>判讀訊號：同一張表上不同欄位的 read/write QPS 差三個數量級、同一個 transaction 同時寫入多種業務概念、schema migration 一動就要鎖住整個業務的寫入。</p>
<h3 id="團隊邊界">團隊邊界</h3>
<p>當兩塊業務由不同團隊維護、發布節奏不同、技術棧偏好不同時，團隊邊界已經形成。Conway&rsquo;s Law 反過來操作：用服務邊界保護團隊邊界，避免一隊改動觸發另一隊重 review。</p>
<p>判讀訊號：PR review 跨團隊比例過半、發版需要協調多個團隊、技術升級（語言版本、framework 升級）因為其他團隊未準備好而被擋住。</p>
<h3 id="部署邊界">部署邊界</h3>
<p>當部分功能需要<strong>獨立的部署節奏、獨立的擴展策略、獨立的可用性等級</strong>時，部署邊界已經形成。背景批次工作要按小時排程、API 服務要 7×24 線上、報表服務只在工作日運行，三者放同一個部署單位會讓最嚴格的可用性要求拖累其他。</p>
<p>判讀訊號：高峰時某個功能擴展速度跟不上、低峰時某個功能浪費資源、單一發版策略覆蓋不了所有功能的風險等級。</p>
<h3 id="流量邊界">流量邊界</h3>
<p>當不同功能的<strong>流量形狀、失敗代價、SLO 等級不同</strong>時，流量邊界已經形成。付款 API 一秒 100 個請求、商品搜尋一秒 10000 個請求、後台報表一天 100 個請求，三者放同一個服務會讓彼此爭資源，付款被搜尋擠掉是業務災難。</p>
<p>判讀訊號：高頻 endpoint 壓爆低頻 endpoint 共用的連線池、不同 endpoint 的 latency 分布同時惡化、無法針對核心交易設定獨立的 SLO 跟 alert。</p>
<h3 id="其他常見拆分軸">其他常見拆分軸</h3>
<p>上面四條是技術驅動的主要拆分軸。實務上還有其他軸常成為真實驅動力、要一併納入判讀：</p>
<ul>
<li><strong>失敗代價 / blast radius 軸</strong>：核心交易（掛了會有業務災難）跟邊緣推薦（掛了沒人在意）的可用性等級差距大、適合拆開降低 blast radius。跟 SLO 軸高相關但不同 — 重點在「失敗時誰受影響」的範圍隔離。</li>
<li><strong>變更頻率 / 風險軸</strong>：high-velocity 實驗功能跟 stable 核心應拆開、降低實驗對核心穩定性的牽連。跟團隊軸高相關但獨立 — 同一團隊也可能維持兩種變更頻率的程式碼。</li>
<li><strong>資料敏感度 / 合規邊界</strong>：PCI / PII / 醫療資料的隔離常是合規硬要求（GDPR data residency 強制資料拆境），不是技術選擇。這類軸跟資料邊界相關但服從不同壓力。</li>
<li><strong>組織非技術約束</strong>：併購整合、外部合規節奏、團隊 reorg、預算切分都會強制拆分 — 比 metric 訊號更早觸發、技術上不一定最佳但無法繞過。</li>
</ul>
<p>這些軸跟前四條可以同時生效、也可能彼此衝突（合規逼資料拆境、但流量軸建議聚合）。處理衝突時優先順序通常是「合規 &gt; 失敗代價 &gt; 部署 / 流量 &gt; 團隊 &gt; 資料 &gt; 變更頻率」、但每個組織會有自己的權重。</p>
<h2 id="拆分時機的判讀">拆分時機的判讀</h2>
<p>拆分時機不能等到「已經痛到動不了」才開始，那時候拆分要付的代價最高。也不能在「還沒長出邊界」時提早拆，那會用 microservice 的協調成本懲罰一個還沒到規模的系統。</p>
<p>提早訊號（可以開始準備但不一定立刻動手）：</p>
<ul>
<li>程式碼裡同一份邏輯被三個 PR 同時修改、merge conflict 增加</li>
<li>同一個 service 的不同功能開始有不同的擴展需求</li>
<li>不同團隊對同一個發版視窗的需求開始衝突</li>
</ul>
<p>該動手訊號（再拖就要付高昂代價）：</p>
<ul>
<li>任何一個功能改動需要 freeze 整個服務發版</li>
<li>局部高峰擴展時整個服務一起擴展，成本翻倍</li>
<li>一個團隊的事故會直接影響另一個團隊的營運指標</li>
<li>跨團隊 deadlock：A 等 B 改完才能上、B 等 A 改完才能上</li>
</ul>
<p>過晚訊號（拆分要付遷移代價）：</p>
<ul>
<li>已經出現過跨團隊事故、且復盤結論是「無法分離責任」</li>
<li>DB 連線池在多個業務間爭搶、無法用 connection pool 隔離解決</li>
<li>部署平台跑不動：CI 太慢、build 太大、本地開發無法啟動完整環境</li>
</ul>
<h2 id="拆分代價與回退路徑">拆分代價與回退路徑</h2>
<p>拆分不是免費操作。每多一個服務，就多一份運維成本、跨服務 trace 成本、契約治理成本。讀者要在拆分前認知這些代價，而不是事後才發現。</p>
<table>
  <thead>
      <tr>
          <th>代價類型</th>
          <th>具體表現</th>
          <th>緩解方向</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>分散事務</td>
          <td>一筆業務動作跨多個服務、需要 saga 或最終一致性</td>
          <td><a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 message queue</a> 的 outbox、<a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a></td>
      </tr>
      <tr>
          <td>運維複雜度</td>
          <td>N 個服務 × M 個環境 × K 個版本，組合爆炸</td>
          <td>收斂部署平台、用 <a href="/blog/backend/05-deployment-platform/kubernetes-deployment/" data-link-title="5.2 Kubernetes 部署策略" data-link-desc="整理 deployment、probe 與 rolling update">5.2 K8s 部署策略</a> 統一管理</td>
      </tr>
      <tr>
          <td>跨服務 debug</td>
          <td>一個請求跨多個服務、不知道在哪一段失敗</td>
          <td><a href="/blog/backend/knowledge-cards/trace-context/" data-link-title="Trace Context" data-link-desc="說明跨服務 request 如何用 trace context 串起路徑與耗時">04 trace context</a>、結構化 log 聚合</td>
      </tr>
      <tr>
          <td>契約治理</td>
          <td>服務 A 的 API 改動會影響服務 B、C、D</td>
          <td><a href="/blog/backend/knowledge-cards/contract/" data-link-title="Boundary Contract" data-link-desc="說明跨邊界約定如何維持相容與可驗證">contract test</a>、版本化 API</td>
      </tr>
      <tr>
          <td>資料一致性</td>
          <td>各服務 DB 獨立，跨服務查詢需要 join 或 read model</td>
          <td>CQRS、event-driven projection、reconciliation</td>
      </tr>
  </tbody>
</table>
<p>拆分失敗的回退路徑要在拆分前設計好。常見回退策略：保留原 monolith 程式碼一段時間（雙寫期），新服務出問題可以切回；先拆<strong>讀路徑</strong>驗證流量，再拆寫路徑；用 feature flag 控制是否走新服務。沒有回退路徑的拆分一旦撞牆，會比不拆更難收拾。</p>
<h3 id="拆分後的通訊優先級事件--同步-rpc">拆分後的通訊優先級：事件 &gt; 同步 RPC</h3>
<p>拆完後跨服務通訊有兩條路：同步 RPC（gRPC、REST）跟異步事件（<a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">message queue</a>、event bus）。預設應該選事件、保留 RPC 給「真的需要同步回應的查詢」。</p>
<p>理由：</p>
<ul>
<li><strong>失敗代價隔離</strong>：服務 A 發事件給 B、B 掛了不影響 A — 事件留在 queue 等。同步 RPC 下、B 掛了 A 也跟著掛</li>
<li><strong>流量解耦</strong>：事件本身就是 buffer、能吸收 burst。同步 RPC 是 throughput 的硬上限、A 的尖峰 = B 的尖峰</li>
<li><strong>可重放</strong>：事件可以重放（replay）做資料修補、debug、新服務 backfill。同步 RPC 過了就過了</li>
<li><strong>服務獨立演進</strong>：事件 schema 可以加欄位向下相容、consumer 慢慢 adapt。RPC interface 改動是 breaking change</li>
</ul>
<p>該用同步 RPC 的少數場景：使用者請求路徑需要立即回應（「使用者按下查詢、顯示結果」）、且兩個服務都在同一個 latency budget 內。其他都優先事件。</p>
<p>詳見 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 模組訊息佇列</a> 跟 <a href="/blog/backend/00-service-selection/async-delivery-selection/" data-link-title="0.3 非同步與事件傳遞選型" data-link-desc="區分背景工作、durable queue、stream、pub/sub 與 outbox 的選型邊界">0.3 非同步與事件傳遞選型</a>。</p>
<h2 id="反例拆分過度的收回">反例：拆分過度的收回</h2>
<p>服務拆分的反向動作是合併。當拆分後發現「服務間呼叫太頻繁、近乎同步、跨服務事務太多」時，代表這條邊界拆錯了。處理方式是把這兩個服務合回去，繼續增加跨服務工具只會堆疊複雜度。</p>
<p>判讀「該合併」的訊號：服務 A 與 B 之間每秒幾百次同步呼叫且失敗會連鎖、A 改動必定觸發 B 改動且兩者由同一團隊維護、跨服務事務佔總業務動作比例過高、跨服務 latency 是 SLO 主要消耗者。</p>
<p>合併不是失敗。它代表團隊已經理解這條邊界不該存在，及時收回比硬撐更負責任。Modular monolith（單一部署、模組化邊界）是常見的折衷形態：保留模組邊界、避免分散事務代價、未來壓力出現時再分拆。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀重點</th>
          <th>對應動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>多團隊發版互相阻擋</td>
          <td>部署邊界已形成、但服務仍綁在一起</td>
          <td>從 CI/部署單位開始拆，先讓發布獨立</td>
      </tr>
      <tr>
          <td>同一服務不同功能擴展需求差距大</td>
          <td>流量邊界已形成</td>
          <td>沿流量軸拆，高頻 endpoint 獨立服務 + 獨立 auto scaling</td>
      </tr>
      <tr>
          <td>DB 寫入鎖跨業務互相影響</td>
          <td>資料邊界已形成</td>
          <td>沿資料軸拆，獨立 schema 與獨立 DB instance</td>
      </tr>
      <tr>
          <td>拆分後跨服務同步呼叫激增</td>
          <td>邊界拆錯、實際耦合並未被服務界線解開</td>
          <td>評估合併、或改用事件驅動把同步呼叫變成非同步交接</td>
      </tr>
      <tr>
          <td>拆分後事故 MTTR 拉長</td>
          <td>跨服務觀測能力跟不上</td>
          <td>補 <a href="/blog/backend/knowledge-cards/trace-context/" data-link-title="Trace Context" data-link-desc="說明跨服務 request 如何用 trace context 串起路徑與耗時">04 trace context</a> 與 service topology</td>
      </tr>
      <tr>
          <td>拆分後 dev velocity 反而下降</td>
          <td>契約治理跟跨服務協作成本超過拆分收益</td>
          <td>評估合併或建立 shared kernel</td>
      </tr>
  </tbody>
</table>
<h2 id="常見誤區">常見誤區</h2>
<p>把「technical debt」當成拆分理由。Monolith 程式碼髒亂的解法是重構，不是拆服務。拆服務只是把髒亂從單庫變成跨服務契約混亂，問題並沒有消失。</p>
<p>把「跟風 microservice」當成決策。沒有業務壓力、團隊規模不到位、運維能力不夠的情況下拆服務，新的協作成本會壓垮整個團隊，這比 monolith 的痛苦更大。</p>
<p>把拆分當成單向操作。沒有設計回退路徑、沒有保留合併選項，拆錯了就只能硬撐。成熟的服務演進策略要把「拆」跟「合」當成雙向可逆操作。</p>
<h2 id="定位邊界">定位邊界</h2>
<p>本章專注「該不該拆、沿哪條軸拆、拆完怎麼收尾」。當問題進入具體拆分後的部署、流量、觀測責任，分別交給以下模組：</p>
<ul>
<li>服務獨立部署 → <a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">05 deployment platform</a></li>
<li>跨服務交接與事件 → <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 message queue</a></li>
<li>跨服務觀測與 trace → <a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">04 observability</a></li>
<li>跨服務一致性與冪等性 → <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 idempotency-replay</a> + outbox pattern</li>
</ul>
<h2 id="案例回寫">案例回寫</h2>
<p>服務拆分判讀可用以下案例回寫：</p>
<ul>
<li><a href="/blog/backend/09-performance-capacity/cases/netflix-aurora-consolidation/" data-link-title="9.C23 Netflix：把關聯式 DB 統一到 Aurora、效能 &#43;75%、成本 -28%" data-link-desc="Netflix 把多套關聯式 DB 統一到 Aurora、效能提升 75%、成本下降 28%、串流數十億小時">9.C23 Netflix：把關聯式 DB 統一到 Aurora、效能 +75%、成本 -28%</a> — 反例方向：原本各 microservice 各自 DB 造成運維碎片化、最後做 consolidation；對照本章「拆分過度的收回」段。</li>
<li><a href="/blog/backend/05-deployment-platform/cases/conde-nast-platform-modernization-eks/" data-link-title="5.C2 Condé Nast：EKS 平台整併與標準化" data-link-desc="多地區異質 Kubernetes 平台整併為統一控制面的案例。">5.C2 Condé Nast：EKS 平台整併與標準化</a> — Condé Nast 把多 brand 各自的 K8s cluster 整併到統一 EKS 控制面、降低跨團隊運維分歧。對照本章「拆分代價 / 運維複雜度」段：拆出去快、合回來慢、設計時就要評估這種非對稱性。</li>
<li><a href="/blog/backend/09-performance-capacity/cases/riot-games-eks-multi-cluster/" data-link-title="9.C12 Riot Games：246 個 EKS cluster 的多遊戲多地區治理" data-link-desc="Riot Games 從 Mesos 遷移到 EKS、用 246 個 cluster 跨遊戲跨地區治理、年省 1000 萬美金">9.C12 Riot Games：246 個 EKS cluster 的多遊戲多地區治理</a> — Riot 的拆分軸是「遊戲 × 地區 × 環境」三維交集、246 個 cluster 是這三軸的笛卡兒積取一個 subset。對照本章「拆分軸 / 部署邊界」段：實務上的拆分常常是多軸交集、不是單軸推進。</li>
</ul>
<p>Netflix Aurora consolidation 是反例最有教學價值的一筆 — 它證明「拆 microservice 各自 DB → consolidation 回 Aurora」是 valid endgame、拆服務不是單向操作。Condé Nast 跟 Riot Games 補充另兩條維度：碎片化的運維代價、多軸交集的設計複雜度。把這三筆放回「拆分時機判讀」框架的不同節點上、能看出拆分決策的本質是「沿哪幾條軸 + 接受哪些代價」的組合。</p>
<h2 id="跨模組路由">跨模組路由</h2>
<ol>
<li>與 <a href="/blog/backend/00-service-selection/service-capability-map/" data-link-title="0.1 後端服務能力地圖" data-link-desc="用需求類型判斷應先評估資料庫、快取、訊息佇列、觀測平台或部署平台">0.1 後端服務能力地圖</a> 的交接：拆分前要先理解每塊責任屬於哪種能力分類，避免拆出語意混亂的服務。</li>
<li>與 <a href="/blog/backend/00-service-selection/traffic-data-scale/" data-link-title="0.5 流量與資料量評估" data-link-desc="用流量形狀、資料成長、hot key、保留期限與尖峰模式評估後端需求規模">0.5 流量與資料量評估</a> 的交接：流量軸拆分要先有流量基線。</li>
<li>與 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 message queue</a> 的交接：拆分後跨服務通訊優先用事件、不是同步 RPC。</li>
<li>與 <a href="/blog/backend/09-performance-capacity/scaling-axes/" data-link-title="9.13 擴展軸與 Stateless 前提" data-link-desc="整理垂直 / 水平擴展取捨、stateless vs stateful 前提、auto scaling 操作模型與兩種擴展的 hidden cost">9.13 擴展軸</a> 的交接：拆分常常是水平擴展的前提（無狀態服務拆分後才能獨立水平擴展）。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<p><strong>規模成長路線下一站 → <a href="/blog/backend/09-performance-capacity/scaling-axes/" data-link-title="9.13 擴展軸與 Stateless 前提" data-link-desc="整理垂直 / 水平擴展取捨、stateless vs stateful 前提、auto scaling 操作模型與兩種擴展的 hidden cost">9.13 擴展軸與 Stateless 前提</a></strong>：拆分後接著要為每個服務選擇擴展軸。</p>
<p>其他延伸方向：</p>
<ul>
<li>實作層：服務如何獨立部署 → <a href="/blog/backend/05-deployment-platform/kubernetes-deployment/" data-link-title="5.2 Kubernetes 部署策略" data-link-desc="整理 deployment、probe 與 rolling update">5.2 Kubernetes 部署策略</a></li>
<li>事件層：拆分後跨服務通訊設計 → <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">03 模組訊息佇列</a></li>
</ul>
]]></content:encoded></item><item><title>Deterministic vs Fuzzy engineering</title><link>https://tarrragon.github.io/blog/llm/knowledge-cards/deterministic-vs-fuzzy/</link><pubDate>Thu, 14 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/knowledge-cards/deterministic-vs-fuzzy/</guid><description>&lt;p>Deterministic vs Fuzzy engineering 的核心概念是「&lt;strong>LLM 軟體跟傳統軟體在設計典範上的根本差異&lt;/strong>」。Deterministic 軟體建立在「同 input → 同 output」假設、fuzzy 軟體建立在「同 input → 分佈」假設。兩者在資料、邏輯、行為一致性、實驗成本四維度都不同、設計直覺要分開。實務上一個 LLM 應用是兩者混合、guardrail 設計是把 fuzzy 邊界包進 deterministic 約束。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>四維對照：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>Deterministic 軟體&lt;/th>
 &lt;th>Fuzzy 軟體&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>資料形狀&lt;/td>
 &lt;td>結構化（JSON、DB row）&lt;/td>
 &lt;td>半結構化 / 非結構化&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>邏輯來源&lt;/td>
 &lt;td>人類寫死規則&lt;/td>
 &lt;td>模型推論、依 prompt + context 浮動&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>行為一致性&lt;/td>
 &lt;td>同 input → 同 output&lt;/td>
 &lt;td>同 input → 分佈&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>分解原則&lt;/td>
 &lt;td>按職責 / 模組&lt;/td>
 &lt;td>按角色 / agent&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>測試方式&lt;/td>
 &lt;td>unit test、覆蓋率&lt;/td>
 &lt;td>eval、judge、distribution metric&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>實驗成本&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>低（改 prompt 即可）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>典型 LLM 應用的混合：&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">User input
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> ↓ Fuzzy（LLM 理解意圖）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> ↓ Deterministic（DB / API / policy）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> ↓ Fuzzy（LLM 寫回應）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> ↓ Deterministic（發送 / 寫入）&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>讀 LLM 應用設計文章或開始設計 production AI 系統時、這個 framing 決定每個 step 的工具選擇。實作判讀：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>哪段該 deterministic / 哪段該 fuzzy&lt;/strong>：規則可窮舉、失敗代價高、需要解釋、需要 byte-exact 重現的 → deterministic；自由文字輸入、生成有風格的輸出、邊界模糊的 → fuzzy。&lt;/li>
&lt;li>&lt;strong>典範用錯的反模式&lt;/strong>：deterministic 需求硬用 fuzzy（用 LLM 算稅金）、fuzzy 需求硬用 deterministic（regex 解析自由文字）、邊界混（prompt 內塞算術 / code 內塞意圖分類）。&lt;/li>
&lt;li>&lt;strong>Fuzzy 邊界的四種 guardrail&lt;/strong>：schema validation、output validator、action gating、distribution monitoring。混用、不同 risk class 分擔不同層。&lt;/li>
&lt;li>&lt;strong>跟 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/human-in-the-loop/" data-link-title="Human-in-the-loop（HITL）" data-link-desc="人類介入 LLM 工作流的設計：三種觸發時機（pre-act / mid-stream / post-hoc）、避免橡皮圖章化的四條件">HITL&lt;/a> 的關係&lt;/strong>：HITL 是 deterministic guardrail 的一種——把人類判斷當 deterministic check 包 fuzzy LLM 行為。&lt;/li>
&lt;li>&lt;strong>失敗的歸因分層&lt;/strong>：壞掉時要問「是 prompt / model / context / tool / 還是 deterministic glue 的 bug」。deterministic 軟體歸因單一、fuzzy 軟體要分這幾層查。&lt;/li>
&lt;/ol>
&lt;p>完整典範討論見 &lt;a href="https://tarrragon.github.io/blog/llm/00-foundations/deterministic-vs-fuzzy-engineering/" data-link-title="0.8 Deterministic vs Fuzzy Engineering：軟體設計典範的位移" data-link-desc="傳統 deterministic 軟體跟 fuzzy LLM 軟體在資料、邏輯、分解、實驗成本四個維度的根本差異、以及哪段該 deterministic、哪段該 fuzzy 的決策框架">0.8 Deterministic vs Fuzzy Engineering&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Deterministic vs Fuzzy engineering 的核心概念是「<strong>LLM 軟體跟傳統軟體在設計典範上的根本差異</strong>」。Deterministic 軟體建立在「同 input → 同 output」假設、fuzzy 軟體建立在「同 input → 分佈」假設。兩者在資料、邏輯、行為一致性、實驗成本四維度都不同、設計直覺要分開。實務上一個 LLM 應用是兩者混合、guardrail 設計是把 fuzzy 邊界包進 deterministic 約束。</p>
<h2 id="概念位置">概念位置</h2>
<p>四維對照：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Deterministic 軟體</th>
          <th>Fuzzy 軟體</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>資料形狀</td>
          <td>結構化（JSON、DB row）</td>
          <td>半結構化 / 非結構化</td>
      </tr>
      <tr>
          <td>邏輯來源</td>
          <td>人類寫死規則</td>
          <td>模型推論、依 prompt + context 浮動</td>
      </tr>
      <tr>
          <td>行為一致性</td>
          <td>同 input → 同 output</td>
          <td>同 input → 分佈</td>
      </tr>
      <tr>
          <td>分解原則</td>
          <td>按職責 / 模組</td>
          <td>按角色 / agent</td>
      </tr>
      <tr>
          <td>測試方式</td>
          <td>unit test、覆蓋率</td>
          <td>eval、judge、distribution metric</td>
      </tr>
      <tr>
          <td>實驗成本</td>
          <td>高</td>
          <td>低（改 prompt 即可）</td>
      </tr>
  </tbody>
</table>
<p>典型 LLM 應用的混合：</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">User input
</span></span><span class="line"><span class="ln">2</span><span class="cl">   ↓ Fuzzy（LLM 理解意圖）
</span></span><span class="line"><span class="ln">3</span><span class="cl">   ↓ Deterministic（DB / API / policy）
</span></span><span class="line"><span class="ln">4</span><span class="cl">   ↓ Fuzzy（LLM 寫回應）
</span></span><span class="line"><span class="ln">5</span><span class="cl">   ↓ Deterministic（發送 / 寫入）</span></span></code></pre></div><h2 id="設計責任">設計責任</h2>
<p>讀 LLM 應用設計文章或開始設計 production AI 系統時、這個 framing 決定每個 step 的工具選擇。實作判讀：</p>
<ol>
<li><strong>哪段該 deterministic / 哪段該 fuzzy</strong>：規則可窮舉、失敗代價高、需要解釋、需要 byte-exact 重現的 → deterministic；自由文字輸入、生成有風格的輸出、邊界模糊的 → fuzzy。</li>
<li><strong>典範用錯的反模式</strong>：deterministic 需求硬用 fuzzy（用 LLM 算稅金）、fuzzy 需求硬用 deterministic（regex 解析自由文字）、邊界混（prompt 內塞算術 / code 內塞意圖分類）。</li>
<li><strong>Fuzzy 邊界的四種 guardrail</strong>：schema validation、output validator、action gating、distribution monitoring。混用、不同 risk class 分擔不同層。</li>
<li><strong>跟 <a href="/blog/llm/knowledge-cards/human-in-the-loop/" data-link-title="Human-in-the-loop（HITL）" data-link-desc="人類介入 LLM 工作流的設計：三種觸發時機（pre-act / mid-stream / post-hoc）、避免橡皮圖章化的四條件">HITL</a> 的關係</strong>：HITL 是 deterministic guardrail 的一種——把人類判斷當 deterministic check 包 fuzzy LLM 行為。</li>
<li><strong>失敗的歸因分層</strong>：壞掉時要問「是 prompt / model / context / tool / 還是 deterministic glue 的 bug」。deterministic 軟體歸因單一、fuzzy 軟體要分這幾層查。</li>
</ol>
<p>完整典範討論見 <a href="/blog/llm/00-foundations/deterministic-vs-fuzzy-engineering/" data-link-title="0.8 Deterministic vs Fuzzy Engineering：軟體設計典範的位移" data-link-desc="傳統 deterministic 軟體跟 fuzzy LLM 軟體在資料、邏輯、分解、實驗成本四個維度的根本差異、以及哪段該 deterministic、哪段該 fuzzy 的決策框架">0.8 Deterministic vs Fuzzy Engineering</a>。</p>
]]></content:encoded></item><item><title>Guardrail</title><link>https://tarrragon.github.io/blog/llm/knowledge-cards/guardrail/</link><pubDate>Thu, 14 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/knowledge-cards/guardrail/</guid><description>&lt;p>Guardrail 的核心概念是「&lt;strong>在 LLM 的 fuzzy 行為外層加上可驗證的控制邊界&lt;/strong>」。LLM 本身會生成機率性輸出，guardrail 用 deterministic 檢查、policy、&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/structured-output/" data-link-title="Structured Output" data-link-desc="讓 LLM 輸出可被 parser 穩定消費的推論階段設計：JSON mode、schema-guided decoding、grammar 約束都屬於這一層">structured output&lt;/a>、權限與人工審查，把錯誤後果限制在可承擔範圍內。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Guardrail 是一組控制層。常見形式包含 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/structured-output/" data-link-title="Structured Output" data-link-desc="讓 LLM 輸出可被 parser 穩定消費的推論階段設計：JSON mode、schema-guided decoding、grammar 約束都屬於這一層">structured output&lt;/a>、validator、allowlist、rate limit、sandbox、human approval、eval、monitoring 與 rollback。它通常包在模型輸出與下游副作用之間。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>客服分類可以用 enum schema 限制類別；tool use 可以用 allowlist 限制可呼叫工具；production 操作可以要求 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/human-in-the-loop/" data-link-title="Human-in-the-loop（HITL）" data-link-desc="人類介入 LLM 工作流的設計：三種觸發時機（pre-act / mid-stream / post-hoc）、避免橡皮圖章化的四條件">human-in-the-loop&lt;/a> approval；外部內容進 context 前可以標記為 untrusted，降低 prompt injection 後果。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>設計 guardrail 時先判斷失敗代價，再選控制強度。低風險任務用 schema 與 retry 即可；高副作用任務要加 permission boundary、sandbox、審查與 audit log。相關基礎見 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/deterministic-vs-fuzzy/" data-link-title="Deterministic vs Fuzzy engineering" data-link-desc="LLM 軟體 vs 傳統軟體在資料 / 邏輯 / 行為一致性 / 實驗成本四維度的典範差異、決定哪段該包 guardrail">Deterministic vs Fuzzy engineering&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Guardrail 的核心概念是「<strong>在 LLM 的 fuzzy 行為外層加上可驗證的控制邊界</strong>」。LLM 本身會生成機率性輸出，guardrail 用 deterministic 檢查、policy、<a href="/blog/llm/knowledge-cards/structured-output/" data-link-title="Structured Output" data-link-desc="讓 LLM 輸出可被 parser 穩定消費的推論階段設計：JSON mode、schema-guided decoding、grammar 約束都屬於這一層">structured output</a>、權限與人工審查，把錯誤後果限制在可承擔範圍內。</p>
<h2 id="概念位置">概念位置</h2>
<p>Guardrail 是一組控制層。常見形式包含 <a href="/blog/llm/knowledge-cards/structured-output/" data-link-title="Structured Output" data-link-desc="讓 LLM 輸出可被 parser 穩定消費的推論階段設計：JSON mode、schema-guided decoding、grammar 約束都屬於這一層">structured output</a>、validator、allowlist、rate limit、sandbox、human approval、eval、monitoring 與 rollback。它通常包在模型輸出與下游副作用之間。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>客服分類可以用 enum schema 限制類別；tool use 可以用 allowlist 限制可呼叫工具；production 操作可以要求 <a href="/blog/llm/knowledge-cards/human-in-the-loop/" data-link-title="Human-in-the-loop（HITL）" data-link-desc="人類介入 LLM 工作流的設計：三種觸發時機（pre-act / mid-stream / post-hoc）、避免橡皮圖章化的四條件">human-in-the-loop</a> approval；外部內容進 context 前可以標記為 untrusted，降低 prompt injection 後果。</p>
<h2 id="設計責任">設計責任</h2>
<p>設計 guardrail 時先判斷失敗代價，再選控制強度。低風險任務用 schema 與 retry 即可；高副作用任務要加 permission boundary、sandbox、審查與 audit log。相關基礎見 <a href="/blog/llm/knowledge-cards/deterministic-vs-fuzzy/" data-link-title="Deterministic vs Fuzzy engineering" data-link-desc="LLM 軟體 vs 傳統軟體在資料 / 邏輯 / 行為一致性 / 實驗成本四維度的典範差異、決定哪段該包 guardrail">Deterministic vs Fuzzy engineering</a>。</p>
]]></content:encoded></item><item><title>Local vs Cloud LLM</title><link>https://tarrragon.github.io/blog/llm/knowledge-cards/local-vs-cloud/</link><pubDate>Thu, 14 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/knowledge-cards/local-vs-cloud/</guid><description>&lt;p>Local vs cloud LLM 的核心概念是「&lt;strong>把模型執行位置視為工程取捨，而不是信仰選擇&lt;/strong>」。本地 LLM 把資料、權重與 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/inference-server/" data-link-title="Inference Server" data-link-desc="載入模型權重、處理 prompt、產生 token 的常駐 process">推論伺服器&lt;/a> 放在自己的機器上；雲端 LLM 把 serving 與模型能力交給 provider，換取更強模型與更低維運負擔。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>這個決策跨越 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/three-layer-architecture/" data-link-title="Three-Layer Architecture" data-link-desc="把本地 LLM 工具拆成介面層、推論伺服器層、模型權重層的基礎心智模型">three-layer architecture&lt;/a> 的所有層：介面可以相同，伺服器與模型位置不同。常見組合是同一個 IDE 介面同時接本地 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/openai-compatible-api/" data-link-title="OpenAI 相容 API" data-link-desc="本地推論伺服器跟雲端 OpenAI 共用的 API 形狀標準">OpenAI 相容 API&lt;/a> 與雲端 API，依任務切換。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>本地適合私有資料、離線、可控成本、低資料外流風險；雲端適合高難度 reasoning、大型 agent、多模態、需要最新旗艦能力的任務。混合策略常見於 coding：本地做補完、摘要、低風險查詢，雲端處理複雜修復與大型 agent loop。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>判斷時看五個訊號：資料敏感度、模型能力需求、延遲體感、每月成本、維運能力。當任務失敗代價高且能力要求高，雲端未必可替代人工審查；當資料敏感且任務簡單，本地模型通常更划算。完整框架見 &lt;a href="https://tarrragon.github.io/blog/llm/00-foundations/local-vs-cloud/" data-link-title="0.0 本地 vs 雲端 LLM" data-link-desc="從隱私、成本、速度、能力四個維度建立本地與雲端 LLM 的基本對照">0.6 本地 vs 雲端&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Local vs cloud LLM 的核心概念是「<strong>把模型執行位置視為工程取捨，而不是信仰選擇</strong>」。本地 LLM 把資料、權重與 <a href="/blog/llm/knowledge-cards/inference-server/" data-link-title="Inference Server" data-link-desc="載入模型權重、處理 prompt、產生 token 的常駐 process">推論伺服器</a> 放在自己的機器上；雲端 LLM 把 serving 與模型能力交給 provider，換取更強模型與更低維運負擔。</p>
<h2 id="概念位置">概念位置</h2>
<p>這個決策跨越 <a href="/blog/llm/knowledge-cards/three-layer-architecture/" data-link-title="Three-Layer Architecture" data-link-desc="把本地 LLM 工具拆成介面層、推論伺服器層、模型權重層的基礎心智模型">three-layer architecture</a> 的所有層：介面可以相同，伺服器與模型位置不同。常見組合是同一個 IDE 介面同時接本地 <a href="/blog/llm/knowledge-cards/openai-compatible-api/" data-link-title="OpenAI 相容 API" data-link-desc="本地推論伺服器跟雲端 OpenAI 共用的 API 形狀標準">OpenAI 相容 API</a> 與雲端 API，依任務切換。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>本地適合私有資料、離線、可控成本、低資料外流風險；雲端適合高難度 reasoning、大型 agent、多模態、需要最新旗艦能力的任務。混合策略常見於 coding：本地做補完、摘要、低風險查詢，雲端處理複雜修復與大型 agent loop。</p>
<h2 id="設計責任">設計責任</h2>
<p>判斷時看五個訊號：資料敏感度、模型能力需求、延遲體感、每月成本、維運能力。當任務失敗代價高且能力要求高，雲端未必可替代人工審查；當資料敏感且任務簡單，本地模型通常更划算。完整框架見 <a href="/blog/llm/00-foundations/local-vs-cloud/" data-link-title="0.0 本地 vs 雲端 LLM" data-link-desc="從隱私、成本、速度、能力四個維度建立本地與雲端 LLM 的基本對照">0.6 本地 vs 雲端</a>。</p>
]]></content:encoded></item><item><title>Multi-agent system</title><link>https://tarrragon.github.io/blog/llm/knowledge-cards/multi-agent-system/</link><pubDate>Thu, 14 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/knowledge-cards/multi-agent-system/</guid><description>&lt;p>Multi-agent system 的核心概念是「&lt;strong>多個 LLM &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/agent/" data-link-title="LLM Agent" data-link-desc="把控制流交給 LLM 的應用模式：自主決策、跨多步呼叫工具、人類角色從主導變監督">agent&lt;/a> 協作完成任務&lt;/strong>」。跟 multi-call workflow 的差異&lt;strong>不在 agent 數量多寡、在控制流跟責任邊界&lt;/strong>——multi-call 是主程式編排每 step、multi-agent 是 agent 自決下一步並可呼叫其他 agent。屬於 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/agent/" data-link-title="LLM Agent" data-link-desc="把控制流交給 LLM 的應用模式：自主決策、跨多步呼叫工具、人類角色從主導變監督">agent&lt;/a> 概念的進一步擴展。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>跟 multi-call 對照：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>Multi-call workflow&lt;/th>
 &lt;th>Multi-agent system&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>控制流&lt;/td>
 &lt;td>主程式編排&lt;/td>
 &lt;td>Agent 自決&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>角色&lt;/td>
 &lt;td>Step 是函數、無「身份」&lt;/td>
 &lt;td>每個 agent 有 role / 工具集&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Context&lt;/td>
 &lt;td>主程式傳 context&lt;/td>
 &lt;td>Agent 自帶 memory&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>重用&lt;/td>
 &lt;td>Step 是函數、容易 import&lt;/td>
 &lt;td>Agent 跨系統重用透過協議&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>失敗歸屬&lt;/td>
 &lt;td>Step 失敗、主程式接&lt;/td>
 &lt;td>Agent 失敗可能 cascading&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&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>Flat&lt;/td>
 &lt;td>All-to-all、無 orchestrator&lt;/td>
 &lt;td>2-4 個 agent、動態協商&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Hierarchical&lt;/td>
 &lt;td>Orchestrator + specialists&lt;/td>
 &lt;td>多專業 agent、單一對外介面&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Agent-as-tool&lt;/td>
 &lt;td>Agent 互通像 tool call（如 MCP）&lt;/td>
 &lt;td>跨組織重用、標準協議&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>讀 agent framework / paper 看到「multi-agent」「orchestrator」「agent-as-tool」就是這層設計。實作判讀：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>「先 multi-call、不夠再 multi-agent」&lt;/strong>：multi-agent 是「特定問題的解法」、不是「更高級的設計」。判讀訊號：role 顯著差異 / 跨產品重用 / 真正平行 / 動態協作 / 團隊熟悉度——四條件全滿足才走 multi-agent。&lt;/li>
&lt;li>&lt;strong>Specialization gain vs orchestration overhead&lt;/strong>：拆細帶來單一責任、獨立優化、重用、平行；代價是 context 重複傳遞、latency 累積、debug 困難、責任歸屬模糊。&lt;/li>
&lt;li>&lt;strong>特有失敗模式&lt;/strong>：循環依賴、責任歸屬模糊、context 重複傳遞、orchestrator 單點瓶頸、agent 互相 hallucinate。每類有對應 guardrail（call stack 監測、trace 全紀錄、shared context、deterministic dispatch rule、schema validation）。&lt;/li>
&lt;li>&lt;strong>跟 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/mcp/" data-link-title="MCP（Model Context Protocol）" data-link-desc="LLM application ↔ 外部 tool server 之間的標準化協議、複用 OpenAI 相容 API 的成功模式">MCP&lt;/a> 的關係&lt;/strong>：MCP 的 tool primitive 視角下、agent-as-tool 可包成 MCP server 暴露、跨組織重用走這條路。&lt;/li>
&lt;/ol>
&lt;p>完整 multi-agent 拓樸設計見 &lt;a href="https://tarrragon.github.io/blog/llm/04-applications/multi-agent-topology/" data-link-title="4.8 Multi-Agent 拓樸：flat / hierarchical / agent-as-tool" data-link-desc="從 multi-call workflow 走到 multi-agent system 的判讀、flat vs hierarchical 拓樸、agent-as-tool 的 MCP 視角、specialization 跟 orchestration overhead 的取捨">4.8 Multi-Agent 拓樸&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Multi-agent system 的核心概念是「<strong>多個 LLM <a href="/blog/llm/knowledge-cards/agent/" data-link-title="LLM Agent" data-link-desc="把控制流交給 LLM 的應用模式：自主決策、跨多步呼叫工具、人類角色從主導變監督">agent</a> 協作完成任務</strong>」。跟 multi-call workflow 的差異<strong>不在 agent 數量多寡、在控制流跟責任邊界</strong>——multi-call 是主程式編排每 step、multi-agent 是 agent 自決下一步並可呼叫其他 agent。屬於 <a href="/blog/llm/knowledge-cards/agent/" data-link-title="LLM Agent" data-link-desc="把控制流交給 LLM 的應用模式：自主決策、跨多步呼叫工具、人類角色從主導變監督">agent</a> 概念的進一步擴展。</p>
<h2 id="概念位置">概念位置</h2>
<p>跟 multi-call 對照：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Multi-call workflow</th>
          <th>Multi-agent system</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>控制流</td>
          <td>主程式編排</td>
          <td>Agent 自決</td>
      </tr>
      <tr>
          <td>角色</td>
          <td>Step 是函數、無「身份」</td>
          <td>每個 agent 有 role / 工具集</td>
      </tr>
      <tr>
          <td>Context</td>
          <td>主程式傳 context</td>
          <td>Agent 自帶 memory</td>
      </tr>
      <tr>
          <td>重用</td>
          <td>Step 是函數、容易 import</td>
          <td>Agent 跨系統重用透過協議</td>
      </tr>
      <tr>
          <td>失敗歸屬</td>
          <td>Step 失敗、主程式接</td>
          <td>Agent 失敗可能 cascading</td>
      </tr>
  </tbody>
</table>
<p>三種主流拓樸：</p>
<table>
  <thead>
      <tr>
          <th>拓樸</th>
          <th>結構</th>
          <th>適用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Flat</td>
          <td>All-to-all、無 orchestrator</td>
          <td>2-4 個 agent、動態協商</td>
      </tr>
      <tr>
          <td>Hierarchical</td>
          <td>Orchestrator + specialists</td>
          <td>多專業 agent、單一對外介面</td>
      </tr>
      <tr>
          <td>Agent-as-tool</td>
          <td>Agent 互通像 tool call（如 MCP）</td>
          <td>跨組織重用、標準協議</td>
      </tr>
  </tbody>
</table>
<h2 id="設計責任">設計責任</h2>
<p>讀 agent framework / paper 看到「multi-agent」「orchestrator」「agent-as-tool」就是這層設計。實作判讀：</p>
<ol>
<li><strong>「先 multi-call、不夠再 multi-agent」</strong>：multi-agent 是「特定問題的解法」、不是「更高級的設計」。判讀訊號：role 顯著差異 / 跨產品重用 / 真正平行 / 動態協作 / 團隊熟悉度——四條件全滿足才走 multi-agent。</li>
<li><strong>Specialization gain vs orchestration overhead</strong>：拆細帶來單一責任、獨立優化、重用、平行；代價是 context 重複傳遞、latency 累積、debug 困難、責任歸屬模糊。</li>
<li><strong>特有失敗模式</strong>：循環依賴、責任歸屬模糊、context 重複傳遞、orchestrator 單點瓶頸、agent 互相 hallucinate。每類有對應 guardrail（call stack 監測、trace 全紀錄、shared context、deterministic dispatch rule、schema validation）。</li>
<li><strong>跟 <a href="/blog/llm/knowledge-cards/mcp/" data-link-title="MCP（Model Context Protocol）" data-link-desc="LLM application ↔ 外部 tool server 之間的標準化協議、複用 OpenAI 相容 API 的成功模式">MCP</a> 的關係</strong>：MCP 的 tool primitive 視角下、agent-as-tool 可包成 MCP server 暴露、跨組織重用走這條路。</li>
</ol>
<p>完整 multi-agent 拓樸設計見 <a href="/blog/llm/04-applications/multi-agent-topology/" data-link-title="4.8 Multi-Agent 拓樸：flat / hierarchical / agent-as-tool" data-link-desc="從 multi-call workflow 走到 multi-agent system 的判讀、flat vs hierarchical 拓樸、agent-as-tool 的 MCP 視角、specialization 跟 orchestration overhead 的取捨">4.8 Multi-Agent 拓樸</a>。</p>
]]></content:encoded></item><item><title>Three-Layer Architecture</title><link>https://tarrragon.github.io/blog/llm/knowledge-cards/three-layer-architecture/</link><pubDate>Thu, 14 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/knowledge-cards/three-layer-architecture/</guid><description>&lt;p>Three-layer architecture（三層架構）的核心概念是「&lt;strong>把本地 LLM 系統拆成介面層、&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/inference-server/" data-link-title="Inference Server" data-link-desc="載入模型權重、處理 prompt、產生 token 的常駐 process">inference server&lt;/a> 層、模型層&lt;/strong>」。這個分層讓讀者能判斷一個工具是在處理使用者互動、模型 serving，還是權重本身。&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">介面層：CLI / IDE plugin / Web UI，負責接收任務與顯示結果
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">伺服器層：inference server，負責載入模型、提供 API、跑推論
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">模型層：權重檔與 tokenizer，負責提供可被執行的神經網路參數&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>它跟 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/openai-compatible-api/" data-link-title="OpenAI 相容 API" data-link-desc="本地推論伺服器跟雲端 OpenAI 共用的 API 形狀標準">OpenAI 相容 API&lt;/a> 的關係是：API 是介面層跟伺服器層之間的標準接縫；跟 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/inference-server/" data-link-title="Inference Server" data-link-desc="載入模型權重、處理 prompt、產生 token 的常駐 process">inference server&lt;/a> 的關係是：伺服器層是三層中的中介。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>看到 Continue.dev、Open WebUI、aider，通常是在介面層；看到 Ollama、LM Studio server、llama.cpp server、vLLM，通常是在伺服器層；看到 GGUF、Safetensors、MLX 權重，通常是在模型層。LM Studio 這類 all-in-one 工具會跨層，但仍可拆成 UI 與 server 兩種責任。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>排錯或換工具時，先問「問題出在哪一層」。連不上 &lt;code>localhost&lt;/code> 是伺服器或網路問題；回答品質差多半是模型或 prompt 問題；IDE 操作不順是介面層問題。完整推導見 &lt;a href="https://tarrragon.github.io/blog/llm/00-foundations/three-layer-architecture/" data-link-title="0.2 介面 / 伺服器 / 模型三層架構" data-link-desc="把任何本地 LLM 工具放回正確的層級，用三層心智模型看懂工具關係">0.2 三層架構&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Three-layer architecture（三層架構）的核心概念是「<strong>把本地 LLM 系統拆成介面層、<a href="/blog/llm/knowledge-cards/inference-server/" data-link-title="Inference Server" data-link-desc="載入模型權重、處理 prompt、產生 token 的常駐 process">inference server</a> 層、模型層</strong>」。這個分層讓讀者能判斷一個工具是在處理使用者互動、模型 serving，還是權重本身。</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">介面層：CLI / IDE plugin / Web UI，負責接收任務與顯示結果
</span></span><span class="line"><span class="ln">2</span><span class="cl">伺服器層：inference server，負責載入模型、提供 API、跑推論
</span></span><span class="line"><span class="ln">3</span><span class="cl">模型層：權重檔與 tokenizer，負責提供可被執行的神經網路參數</span></span></code></pre></div><p>它跟 <a href="/blog/llm/knowledge-cards/openai-compatible-api/" data-link-title="OpenAI 相容 API" data-link-desc="本地推論伺服器跟雲端 OpenAI 共用的 API 形狀標準">OpenAI 相容 API</a> 的關係是：API 是介面層跟伺服器層之間的標準接縫；跟 <a href="/blog/llm/knowledge-cards/inference-server/" data-link-title="Inference Server" data-link-desc="載入模型權重、處理 prompt、產生 token 的常駐 process">inference server</a> 的關係是：伺服器層是三層中的中介。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>看到 Continue.dev、Open WebUI、aider，通常是在介面層；看到 Ollama、LM Studio server、llama.cpp server、vLLM，通常是在伺服器層；看到 GGUF、Safetensors、MLX 權重，通常是在模型層。LM Studio 這類 all-in-one 工具會跨層，但仍可拆成 UI 與 server 兩種責任。</p>
<h2 id="設計責任">設計責任</h2>
<p>排錯或換工具時，先問「問題出在哪一層」。連不上 <code>localhost</code> 是伺服器或網路問題；回答品質差多半是模型或 prompt 問題；IDE 操作不順是介面層問題。完整推導見 <a href="/blog/llm/00-foundations/three-layer-architecture/" data-link-title="0.2 介面 / 伺服器 / 模型三層架構" data-link-desc="把任何本地 LLM 工具放回正確的層級，用三層心智模型看懂工具關係">0.2 三層架構</a>。</p>
]]></content:encoded></item><item><title>Activation Function</title><link>https://tarrragon.github.io/blog/llm/knowledge-cards/activation-function/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/knowledge-cards/activation-function/</guid><description>&lt;p>Activation function（激活函數）的核心概念是「在 linear layer（矩陣乘法）之間插入的非線性函數」。沒有 activation function、整個多層神經網路會塌縮成單一個線性變換、表達能力跟單層 linear 一樣弱。activation function 讓深度網路真的「深」起來。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>LLM 中 activation function 主要出現在 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/ffn/" data-link-title="FFN（Feed-Forward Network）" data-link-desc="Transformer block 內部的兩層 linear &amp;#43; activation、佔模型參數量的多數">FFN&lt;/a> 內、夾在兩個矩陣乘法之間：&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">FFN: input → W_up (linear) → activation → W_down (linear) → output
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> ↑
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> 這裡是 activation function&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>主流 LLM 用的 activation function 演化：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Activation&lt;/th>
 &lt;th>公式（簡化）&lt;/th>
 &lt;th>出現在&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>ReLU&lt;/td>
 &lt;td>&lt;code>max(0, x)&lt;/code>&lt;/td>
 &lt;td>早期 Transformer（如 BERT）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>GELU&lt;/td>
 &lt;td>&lt;code>x · Φ(x)&lt;/code>（Φ 是 Gaussian CDF）&lt;/td>
 &lt;td>GPT-2 / 3、BERT 後期&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>SwiGLU&lt;/td>
 &lt;td>&lt;code>Swish(xW) ⊙ (xV)&lt;/code>&lt;/td>
 &lt;td>Llama、Gemma、Qwen 等主流&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>GeGLU&lt;/td>
 &lt;td>&lt;code>GELU(xW) ⊙ (xV)&lt;/code>&lt;/td>
 &lt;td>部分 Google 系列模型&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>SwiGLU / GeGLU 是「gated」變體、用兩條線性投影相乘、表達能力比單一 activation 強、是現代 LLM 主流。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>讀 paper / model card 看到 SwiGLU、ReLU、GELU 等詞、知道它們是 FFN 內部的選擇、影響模型表達能力跟訓練穩定性、不影響「模型怎麼用 / 怎麼 inference」這類使用者面議題。寫 code 場景的判讀：模型用什麼 activation 由模型作者決定、使用者通常不用調；但若要 fine-tune 或自己訓模型、activation 選擇是設計決策之一。&lt;/p></description><content:encoded><![CDATA[<p>Activation function（激活函數）的核心概念是「在 linear layer（矩陣乘法）之間插入的非線性函數」。沒有 activation function、整個多層神經網路會塌縮成單一個線性變換、表達能力跟單層 linear 一樣弱。activation function 讓深度網路真的「深」起來。</p>
<h2 id="概念位置">概念位置</h2>
<p>LLM 中 activation function 主要出現在 <a href="/blog/llm/knowledge-cards/ffn/" data-link-title="FFN（Feed-Forward Network）" data-link-desc="Transformer block 內部的兩層 linear &#43; activation、佔模型參數量的多數">FFN</a> 內、夾在兩個矩陣乘法之間：</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">FFN: input → W_up (linear) → activation → W_down (linear) → output
</span></span><span class="line"><span class="ln">2</span><span class="cl">                              ↑
</span></span><span class="line"><span class="ln">3</span><span class="cl">                       這裡是 activation function</span></span></code></pre></div><p>主流 LLM 用的 activation function 演化：</p>
<table>
  <thead>
      <tr>
          <th>Activation</th>
          <th>公式（簡化）</th>
          <th>出現在</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>ReLU</td>
          <td><code>max(0, x)</code></td>
          <td>早期 Transformer（如 BERT）</td>
      </tr>
      <tr>
          <td>GELU</td>
          <td><code>x · Φ(x)</code>（Φ 是 Gaussian CDF）</td>
          <td>GPT-2 / 3、BERT 後期</td>
      </tr>
      <tr>
          <td>SwiGLU</td>
          <td><code>Swish(xW) ⊙ (xV)</code></td>
          <td>Llama、Gemma、Qwen 等主流</td>
      </tr>
      <tr>
          <td>GeGLU</td>
          <td><code>GELU(xW) ⊙ (xV)</code></td>
          <td>部分 Google 系列模型</td>
      </tr>
  </tbody>
</table>
<p>SwiGLU / GeGLU 是「gated」變體、用兩條線性投影相乘、表達能力比單一 activation 強、是現代 LLM 主流。</p>
<h2 id="設計責任">設計責任</h2>
<p>讀 paper / model card 看到 SwiGLU、ReLU、GELU 等詞、知道它們是 FFN 內部的選擇、影響模型表達能力跟訓練穩定性、不影響「模型怎麼用 / 怎麼 inference」這類使用者面議題。寫 code 場景的判讀：模型用什麼 activation 由模型作者決定、使用者通常不用調；但若要 fine-tune 或自己訓模型、activation 選擇是設計決策之一。</p>
]]></content:encoded></item><item><title>Attention</title><link>https://tarrragon.github.io/blog/llm/knowledge-cards/attention/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/knowledge-cards/attention/</guid><description>&lt;p>Attention 的核心概念是「Transformer 中讓每個 token 對其他 token 加權平均、產生 context-aware 表示」的計算機制。具體運作是用 Query（Q）、Key（K）、Value（V）三組向量算 attention score、再用 softmax 把 score 變成權重、最後加權平均 V。這個機制是 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/kv-cache/" data-link-title="KV Cache" data-link-desc="已處理 token 的 attention 中間結果暫存：避免重算、加速後續生成">KV cache&lt;/a> 概念的源頭、也是 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/context-window/" data-link-title="Context Window" data-link-desc="模型一次能處理的最大 token 數量：prompt 加生成的總和上限">context window&lt;/a> 上限的計算瓶頸。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Attention 在 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/transformer/" data-link-title="Transformer" data-link-desc="寫 code 用的 LLM 神經網路架構：基於 attention 機制、自回歸生成 token">Transformer&lt;/a> block 中的位置：&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">Transformer block：
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> ├── Layer Norm
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> ├── Attention（本卡聚焦）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> │ ├── Q · K^T → attention score
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> │ ├── softmax → weight
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> │ └── weight · V → output
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> ├── Layer Norm
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> └── FFN 層（或 MoE）&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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">attention(Q, K, V) = softmax(Q · K^T / √d) · V&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Attention 的常見變體（影響 KV cache 體積跟推論性能）：&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>MHA（Multi-Head Attention）&lt;/td>
 &lt;td>原始 Transformer 設計、每 head 獨立 Q / K / V&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>GQA（Grouped-Query Attention）&lt;/td>
 &lt;td>head group 共用 K / V、KV cache 體積減小、推論較快&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>MLA（Multi-head Latent Attention）&lt;/td>
 &lt;td>DeepSeek 提出、KV cache 壓縮更激進&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Flash Attention&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/flash-attention/" data-link-title="Flash Attention" data-link-desc="Attention 計算的記憶體友善實作、減少 GPU memory 讀寫、提升長 context 推論吞吐">演算法層的優化實作&lt;/a>、跟變體獨立&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>理解 attention 後可以解釋三個現象：為什麼 LLM 推論的記憶體用量隨 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/context-window/" data-link-title="Context Window" data-link-desc="模型一次能處理的最大 token 數量：prompt 加生成的總和上限">context&lt;/a> 長度線性增加（KV cache 是 attention 暫存）、為什麼 &lt;a href="https://tarrragon.github.io/blog/llm/05-discrete-gpu/kv-cache-quantization-strategy/" data-link-title="5.2 KV cache 量化策略" data-link-desc="PC 場景用 K=Q8 / V=Q4 等量化把 KV cache 壓縮、騰出 VRAM 開大 context window 或加併發數的判讀">KV cache 量化&lt;/a> 對品質影響有不對稱性（K 用於 score 比較、V 用於加權平均、誤差累積方式不同）、為什麼不同 attention 變體在同等模型大小下推論速度差異明顯（KV cache 體積跟卡間頻寬需求不同）。&lt;/p>
&lt;p>工程實務上、Attention 是 LLM 推論性能跟記憶體需求的最大來源、量化策略、context 上限、併發數設計都圍繞 attention 跟 KV cache 展開。&lt;/p></description><content:encoded><![CDATA[<p>Attention 的核心概念是「Transformer 中讓每個 token 對其他 token 加權平均、產生 context-aware 表示」的計算機制。具體運作是用 Query（Q）、Key（K）、Value（V）三組向量算 attention score、再用 softmax 把 score 變成權重、最後加權平均 V。這個機制是 <a href="/blog/llm/knowledge-cards/kv-cache/" data-link-title="KV Cache" data-link-desc="已處理 token 的 attention 中間結果暫存：避免重算、加速後續生成">KV cache</a> 概念的源頭、也是 <a href="/blog/llm/knowledge-cards/context-window/" data-link-title="Context Window" data-link-desc="模型一次能處理的最大 token 數量：prompt 加生成的總和上限">context window</a> 上限的計算瓶頸。</p>
<h2 id="概念位置">概念位置</h2>
<p>Attention 在 <a href="/blog/llm/knowledge-cards/transformer/" data-link-title="Transformer" data-link-desc="寫 code 用的 LLM 神經網路架構：基於 attention 機制、自回歸生成 token">Transformer</a> block 中的位置：</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">Transformer block：
</span></span><span class="line"><span class="ln">2</span><span class="cl">  ├── Layer Norm
</span></span><span class="line"><span class="ln">3</span><span class="cl">  ├── Attention（本卡聚焦）
</span></span><span class="line"><span class="ln">4</span><span class="cl">  │     ├── Q · K^T → attention score
</span></span><span class="line"><span class="ln">5</span><span class="cl">  │     ├── softmax → weight
</span></span><span class="line"><span class="ln">6</span><span class="cl">  │     └── weight · V → output
</span></span><span class="line"><span class="ln">7</span><span class="cl">  ├── Layer Norm
</span></span><span class="line"><span class="ln">8</span><span class="cl">  └── FFN 層（或 MoE）</span></span></code></pre></div><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">attention(Q, K, V) = softmax(Q · K^T / √d) · V</span></span></code></pre></div><p>Attention 的常見變體（影響 KV cache 體積跟推論性能）：</p>
<table>
  <thead>
      <tr>
          <th>變體</th>
          <th>描述</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>MHA（Multi-Head Attention）</td>
          <td>原始 Transformer 設計、每 head 獨立 Q / K / V</td>
      </tr>
      <tr>
          <td>GQA（Grouped-Query Attention）</td>
          <td>head group 共用 K / V、KV cache 體積減小、推論較快</td>
      </tr>
      <tr>
          <td>MLA（Multi-head Latent Attention）</td>
          <td>DeepSeek 提出、KV cache 壓縮更激進</td>
      </tr>
      <tr>
          <td>Flash Attention</td>
          <td><a href="/blog/llm/knowledge-cards/flash-attention/" data-link-title="Flash Attention" data-link-desc="Attention 計算的記憶體友善實作、減少 GPU memory 讀寫、提升長 context 推論吞吐">演算法層的優化實作</a>、跟變體獨立</td>
      </tr>
  </tbody>
</table>
<h2 id="設計責任">設計責任</h2>
<p>理解 attention 後可以解釋三個現象：為什麼 LLM 推論的記憶體用量隨 <a href="/blog/llm/knowledge-cards/context-window/" data-link-title="Context Window" data-link-desc="模型一次能處理的最大 token 數量：prompt 加生成的總和上限">context</a> 長度線性增加（KV cache 是 attention 暫存）、為什麼 <a href="/blog/llm/05-discrete-gpu/kv-cache-quantization-strategy/" data-link-title="5.2 KV cache 量化策略" data-link-desc="PC 場景用 K=Q8 / V=Q4 等量化把 KV cache 壓縮、騰出 VRAM 開大 context window 或加併發數的判讀">KV cache 量化</a> 對品質影響有不對稱性（K 用於 score 比較、V 用於加權平均、誤差累積方式不同）、為什麼不同 attention 變體在同等模型大小下推論速度差異明顯（KV cache 體積跟卡間頻寬需求不同）。</p>
<p>工程實務上、Attention 是 LLM 推論性能跟記憶體需求的最大來源、量化策略、context 上限、併發數設計都圍繞 attention 跟 KV cache 展開。</p>
]]></content:encoded></item><item><title>Causal Mask</title><link>https://tarrragon.github.io/blog/llm/knowledge-cards/causal-mask/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/knowledge-cards/causal-mask/</guid><description>&lt;p>Causal mask（因果遮罩）的核心概念是「在 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/self-attention/" data-link-title="Self-Attention" data-link-desc="Q / K / V 都從同一個 sequence 投影出來的 attention、Transformer 的標誌性設計">self-attention&lt;/a> 計算時、把 token i 看 token j (j &amp;gt; i) 的 attention 分數設成 -∞、&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/softmax/" data-link-title="Softmax" data-link-desc="把任意實數向量正規化成「總和為 1、每個分量 ∈ [0,1]」的機率分佈">softmax&lt;/a> 後機率為 0」。直覺：LLM 是 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/autoregressive/" data-link-title="Autoregressive" data-link-desc="LLM 一次生成一個 token、把已生成內容作為下一次輸入的架構">autoregressive&lt;/a> 的、生成 token N 時不能看到 N+1 以後（後面還沒生）、causal mask 強制這個約束、是 decoder-only Transformer 的標誌。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Causal mask 在 attention 計算中的位置：&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">score = Q @ K^T / sqrt(d) ← shape (seq_len, seq_len)、每對 token 一個分數
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">score = score + causal_mask ← 加上 mask
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">attention = softmax(score) @ V
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">causal_mask 長這樣（lower triangular、上三角全是 -∞）：
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> K_0 K_1 K_2 K_3
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">Q_0 [ 0 -∞ -∞ -∞ ] ← token 0 只能看自己
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">Q_1 [ 0 0 -∞ -∞ ] ← token 1 能看 0~1
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">Q_2 [ 0 0 0 -∞ ]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">Q_3 [ 0 0 0 0 ]&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>關鍵特性：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>訓練時並行有效&lt;/strong>：所有 token 同時跑 forward pass、causal mask 確保每個 token 只看到該看的範圍。沒 mask 就會「偷看未來」、訓出 cheating 模型。&lt;/li>
&lt;li>&lt;strong>推論時自動成立&lt;/strong>：自回歸生成本來就是一個一個生、後面不存在、mask 是隱式的。&lt;/li>
&lt;li>&lt;strong>跟 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/kv-cache/" data-link-title="KV Cache" data-link-desc="已處理 token 的 attention 中間結果暫存：避免重算、加速後續生成">KV cache&lt;/a> 結合&lt;/strong>：推論時 cache 只存「過去」的 K/V、causal mask 自然滿足。&lt;/li>
&lt;/ol>
&lt;p>跟其他 attention 變體的關係：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>架構&lt;/th>
 &lt;th>是否用 causal mask&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Decoder-only LLM（GPT / Llama / Gemma）&lt;/td>
 &lt;td>用、是標配&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Encoder-only（BERT）&lt;/td>
 &lt;td>不用、可以看雙向 context&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Encoder-decoder（T5）&lt;/td>
 &lt;td>Decoder 部分用、Encoder 部分不用&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>讀 paper / model card 看到「causal」「decoder-only」「auto-regressive」這幾組詞、就是這個機制。實務上、寫 code 場景的所有主流 LLM 都用 causal mask、所以這個概念是隱式 default、不會主動暴露給使用者；但理解它能解釋為什麼 LLM 是「接龍」、為什麼 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/context-window/" data-link-title="Context Window" data-link-desc="模型一次能處理的最大 token 數量：prompt 加生成的總和上限">bidirectional context&lt;/a> 在 LLM 裡不存在（要 bidirectional 要用 encoder 架構）。&lt;/p></description><content:encoded><![CDATA[<p>Causal mask（因果遮罩）的核心概念是「在 <a href="/blog/llm/knowledge-cards/self-attention/" data-link-title="Self-Attention" data-link-desc="Q / K / V 都從同一個 sequence 投影出來的 attention、Transformer 的標誌性設計">self-attention</a> 計算時、把 token i 看 token j (j &gt; i) 的 attention 分數設成 -∞、<a href="/blog/llm/knowledge-cards/softmax/" data-link-title="Softmax" data-link-desc="把任意實數向量正規化成「總和為 1、每個分量 ∈ [0,1]」的機率分佈">softmax</a> 後機率為 0」。直覺：LLM 是 <a href="/blog/llm/knowledge-cards/autoregressive/" data-link-title="Autoregressive" data-link-desc="LLM 一次生成一個 token、把已生成內容作為下一次輸入的架構">autoregressive</a> 的、生成 token N 時不能看到 N+1 以後（後面還沒生）、causal mask 強制這個約束、是 decoder-only Transformer 的標誌。</p>
<h2 id="概念位置">概念位置</h2>
<p>Causal mask 在 attention 計算中的位置：</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">score = Q @ K^T / sqrt(d)     ← shape (seq_len, seq_len)、每對 token 一個分數
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">score = score + causal_mask   ← 加上 mask
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">attention = softmax(score) @ V
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">causal_mask 長這樣（lower triangular、上三角全是 -∞）：
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        K_0    K_1    K_2    K_3
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">Q_0   [  0    -∞     -∞     -∞ ]   ← token 0 只能看自己
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">Q_1   [  0     0     -∞     -∞ ]   ← token 1 能看 0~1
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">Q_2   [  0     0      0     -∞ ]
</span></span><span class="line"><span class="ln">10</span><span class="cl">Q_3   [  0     0      0      0 ]</span></span></code></pre></div><p>關鍵特性：</p>
<ol>
<li><strong>訓練時並行有效</strong>：所有 token 同時跑 forward pass、causal mask 確保每個 token 只看到該看的範圍。沒 mask 就會「偷看未來」、訓出 cheating 模型。</li>
<li><strong>推論時自動成立</strong>：自回歸生成本來就是一個一個生、後面不存在、mask 是隱式的。</li>
<li><strong>跟 <a href="/blog/llm/knowledge-cards/kv-cache/" data-link-title="KV Cache" data-link-desc="已處理 token 的 attention 中間結果暫存：避免重算、加速後續生成">KV cache</a> 結合</strong>：推論時 cache 只存「過去」的 K/V、causal mask 自然滿足。</li>
</ol>
<p>跟其他 attention 變體的關係：</p>
<table>
  <thead>
      <tr>
          <th>架構</th>
          <th>是否用 causal mask</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Decoder-only LLM（GPT / Llama / Gemma）</td>
          <td>用、是標配</td>
      </tr>
      <tr>
          <td>Encoder-only（BERT）</td>
          <td>不用、可以看雙向 context</td>
      </tr>
      <tr>
          <td>Encoder-decoder（T5）</td>
          <td>Decoder 部分用、Encoder 部分不用</td>
      </tr>
  </tbody>
</table>
<h2 id="設計責任">設計責任</h2>
<p>讀 paper / model card 看到「causal」「decoder-only」「auto-regressive」這幾組詞、就是這個機制。實務上、寫 code 場景的所有主流 LLM 都用 causal mask、所以這個概念是隱式 default、不會主動暴露給使用者；但理解它能解釋為什麼 LLM 是「接龍」、為什麼 <a href="/blog/llm/knowledge-cards/context-window/" data-link-title="Context Window" data-link-desc="模型一次能處理的最大 token 數量：prompt 加生成的總和上限">bidirectional context</a> 在 LLM 裡不存在（要 bidirectional 要用 encoder 架構）。</p>
]]></content:encoded></item><item><title>Embedding Layer</title><link>https://tarrragon.github.io/blog/llm/knowledge-cards/embedding-layer/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/knowledge-cards/embedding-layer/</guid><description>&lt;p>Embedding layer（嵌入層）的核心概念是「Transformer 第一層的查表結構：把整數 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/token/" data-link-title="Token" data-link-desc="LLM 處理文字時的最小單位：介於字元與單字之間">token&lt;/a> ID 對應到一個可訓練向量（embedding）」。本質上是 &lt;code>vocab_size × hidden_dim&lt;/code> 的權重矩陣、每個 token ID 取對應 row 當該 token 的向量表示。後續所有 Transformer block 都對這些向量做運算。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Embedding layer 在 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/forward-pass/" data-link-title="Forward Pass" data-link-desc="input 經過所有 layer 的計算、得到 output 的單向流程；推論跟訓練都會跑、訓練多一個反向階段">forward pass&lt;/a> 的位置：&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">input：&amp;#34;Hello world&amp;#34;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> ↓ tokenizer
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">token IDs: [9906, 1917] ← 整數序列
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> ↓ embedding layer（vocab × hidden 查表）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">embeddings: [[0.1, -0.3, ...], [0.5, 0.2, ...]] ← 向量序列、(seq_len, hidden_dim)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> ↓ Transformer block × N
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> ↓ output projection
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">logits&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>跟 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/embedding-model/" data-link-title="Embedding Model" data-link-desc="把文字轉成向量的模型：用於 codebase 索引與語意搜尋">embedding model&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>Embedding layer（本卡）&lt;/td>
 &lt;td>LLM 內部第一層、把 token ID 轉向量&lt;/td>
 &lt;td>否、是 LLM 的一部分&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/embedding-model/" data-link-title="Embedding Model" data-link-desc="把文字轉成向量的模型：用於 codebase 索引與語意搜尋">Embedding model&lt;/a>&lt;/td>
 &lt;td>獨立模型、把整段文字轉向量、用於 RAG / 相似度&lt;/td>
 &lt;td>是、獨立模型&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>兩者「都產出向量」、但層級跟用途完全不同：embedding layer 是 LLM 內部結構（per-token、給模型 forward pass 用）、embedding model 是外部工具（per-text、給檢索系統用）。&lt;/p>
&lt;p>Embedding layer 的大小：&lt;/p>
&lt;ul>
&lt;li>Gemma 4 31B：vocab=256K、hidden=5120、embedding matrix ≈ 256K × 5120 = 1.3B 參數&lt;/li>
&lt;li>Llama 3 8B：vocab=128K、hidden=4096、embedding matrix ≈ 0.5B 參數&lt;/li>
&lt;/ul>
&lt;p>通常跟 output projection（hidden → vocab）相同大小、有些模型 tied（共用權重）、有些 untied。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>讀模型架構圖看到「token embedding」「embed_tokens」就是這一層。實務意涵：模型大小有非小比例來自 embedding（vocab 越大、embedding 越大）；換 tokenizer 等於整個 embedding 重訓、是 fine-tune 時通常不動的部分。&lt;/p></description><content:encoded><![CDATA[<p>Embedding layer（嵌入層）的核心概念是「Transformer 第一層的查表結構：把整數 <a href="/blog/llm/knowledge-cards/token/" data-link-title="Token" data-link-desc="LLM 處理文字時的最小單位：介於字元與單字之間">token</a> ID 對應到一個可訓練向量（embedding）」。本質上是 <code>vocab_size × hidden_dim</code> 的權重矩陣、每個 token ID 取對應 row 當該 token 的向量表示。後續所有 Transformer block 都對這些向量做運算。</p>
<h2 id="概念位置">概念位置</h2>
<p>Embedding layer 在 <a href="/blog/llm/knowledge-cards/forward-pass/" data-link-title="Forward Pass" data-link-desc="input 經過所有 layer 的計算、得到 output 的單向流程；推論跟訓練都會跑、訓練多一個反向階段">forward pass</a> 的位置：</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">input：&#34;Hello world&#34;
</span></span><span class="line"><span class="ln">2</span><span class="cl">   ↓ tokenizer
</span></span><span class="line"><span class="ln">3</span><span class="cl">token IDs: [9906, 1917]            ← 整數序列
</span></span><span class="line"><span class="ln">4</span><span class="cl">   ↓ embedding layer（vocab × hidden 查表）
</span></span><span class="line"><span class="ln">5</span><span class="cl">embeddings: [[0.1, -0.3, ...], [0.5, 0.2, ...]]   ← 向量序列、(seq_len, hidden_dim)
</span></span><span class="line"><span class="ln">6</span><span class="cl">   ↓ Transformer block × N
</span></span><span class="line"><span class="ln">7</span><span class="cl">   ↓ output projection
</span></span><span class="line"><span class="ln">8</span><span class="cl">logits</span></span></code></pre></div><p>跟 <a href="/blog/llm/knowledge-cards/embedding-model/" data-link-title="Embedding Model" data-link-desc="把文字轉成向量的模型：用於 codebase 索引與語意搜尋">embedding model</a> 的差別：</p>
<table>
  <thead>
      <tr>
          <th>概念</th>
          <th>用途</th>
          <th>是否獨立訓練 / 部署</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Embedding layer（本卡）</td>
          <td>LLM 內部第一層、把 token ID 轉向量</td>
          <td>否、是 LLM 的一部分</td>
      </tr>
      <tr>
          <td><a href="/blog/llm/knowledge-cards/embedding-model/" data-link-title="Embedding Model" data-link-desc="把文字轉成向量的模型：用於 codebase 索引與語意搜尋">Embedding model</a></td>
          <td>獨立模型、把整段文字轉向量、用於 RAG / 相似度</td>
          <td>是、獨立模型</td>
      </tr>
  </tbody>
</table>
<p>兩者「都產出向量」、但層級跟用途完全不同：embedding layer 是 LLM 內部結構（per-token、給模型 forward pass 用）、embedding model 是外部工具（per-text、給檢索系統用）。</p>
<p>Embedding layer 的大小：</p>
<ul>
<li>Gemma 4 31B：vocab=256K、hidden=5120、embedding matrix ≈ 256K × 5120 = 1.3B 參數</li>
<li>Llama 3 8B：vocab=128K、hidden=4096、embedding matrix ≈ 0.5B 參數</li>
</ul>
<p>通常跟 output projection（hidden → vocab）相同大小、有些模型 tied（共用權重）、有些 untied。</p>
<h2 id="設計責任">設計責任</h2>
<p>讀模型架構圖看到「token embedding」「embed_tokens」就是這一層。實務意涵：模型大小有非小比例來自 embedding（vocab 越大、embedding 越大）；換 tokenizer 等於整個 embedding 重訓、是 fine-tune 時通常不動的部分。</p>
]]></content:encoded></item><item><title>FFN（Feed-Forward Network）</title><link>https://tarrragon.github.io/blog/llm/knowledge-cards/ffn/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/knowledge-cards/ffn/</guid><description>&lt;p>FFN（Feed-Forward Network、前饋網路）的核心概念是「Transformer block 中 attention 後面的兩層 linear + &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/activation-function/" data-link-title="Activation Function" data-link-desc="在 linear layer 之間插入的非線性函數、讓神經網路能表達非線性關係">activation function&lt;/a> 結構」。FFN 是 LLM 中&lt;strong>參數量最大&lt;/strong>的元件、典型 Transformer block 裡 FFN 約佔 2/3 參數、attention 約佔 1/3。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>標準 FFN 的計算：&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">input（hidden_dim）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> ↓ W_up（linear、hidden_dim → intermediate_dim、通常放大 4x）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">intermediate vector
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> ↓ activation function（ReLU / GELU / SwiGLU）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> ↓ W_down（linear、intermediate_dim → hidden_dim）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">output（hidden_dim）&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Intermediate dim 通常是 hidden dim 的 4 倍（例如 hidden=4096、intermediate=16384）、所以 FFN 的參數量是 &lt;code>hidden × intermediate × 2 ≈ 8 × hidden²&lt;/code>、遠大於 attention 的 &lt;code>4 × hidden²&lt;/code>（Q/K/V/O 四個 hidden × hidden 矩陣）。&lt;/p>
&lt;p>FFN 變體：&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>標準 FFN&lt;/td>
 &lt;td>兩個 linear + 一個 activation&lt;/td>
 &lt;td>早期 Transformer、BERT、GPT-2&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>SwiGLU FFN&lt;/td>
 &lt;td>三個 linear（gate + up + down）+ Swish&lt;/td>
 &lt;td>Llama、Gemma、Qwen 主流&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>MoE FFN&lt;/td>
 &lt;td>多個「expert」FFN、每個 token 只啟用幾個&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/moe/" data-link-title="Mixture of Experts (MoE)" data-link-desc="把 transformer 的 FFN 層拆成多個專家、每 token 只啟用少數、總參數大但每 token 計算量小的架構">MoE&lt;/a> 模型&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>理解 FFN 是參數大頭、能解釋幾件事：&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/moe/" data-link-title="Mixture of Experts (MoE)" data-link-desc="把 transformer 的 FFN 層拆成多個專家、每 token 只啟用少數、總參數大但每 token 計算量小的架構">MoE&lt;/a> 為什麼是「把 FFN 換成多個專家、只啟用部分」（因為 FFN 是最值得稀疏化的部分）、&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/moe-cpu-offload/" data-link-title="MoE CPU 卸載" data-link-desc="把 Mixture-of-Experts 模型不活躍的專家層權重放在系統 RAM、用到再走 PCIe 拉回 GPU、讓有限 VRAM 跑得了更大模型">MoE CPU offload&lt;/a> 為什麼是「把 expert FFN 卸到 RAM」（FFN 大、卸下來省 VRAM）、為什麼模型大小用「參數量」算（FFN 主導）。LoRA fine-tuning 時、通常選擇對 attention 的 Q/V 投影做 LoRA、不對 FFN 動、因為 FFN 太大、LoRA 收益相對小。&lt;/p></description><content:encoded><![CDATA[<p>FFN（Feed-Forward Network、前饋網路）的核心概念是「Transformer block 中 attention 後面的兩層 linear + <a href="/blog/llm/knowledge-cards/activation-function/" data-link-title="Activation Function" data-link-desc="在 linear layer 之間插入的非線性函數、讓神經網路能表達非線性關係">activation function</a> 結構」。FFN 是 LLM 中<strong>參數量最大</strong>的元件、典型 Transformer block 裡 FFN 約佔 2/3 參數、attention 約佔 1/3。</p>
<h2 id="概念位置">概念位置</h2>
<p>標準 FFN 的計算：</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">input（hidden_dim）
</span></span><span class="line"><span class="ln">2</span><span class="cl">  ↓ W_up（linear、hidden_dim → intermediate_dim、通常放大 4x）
</span></span><span class="line"><span class="ln">3</span><span class="cl">intermediate vector
</span></span><span class="line"><span class="ln">4</span><span class="cl">  ↓ activation function（ReLU / GELU / SwiGLU）
</span></span><span class="line"><span class="ln">5</span><span class="cl">  ↓ W_down（linear、intermediate_dim → hidden_dim）
</span></span><span class="line"><span class="ln">6</span><span class="cl">output（hidden_dim）</span></span></code></pre></div><p>Intermediate dim 通常是 hidden dim 的 4 倍（例如 hidden=4096、intermediate=16384）、所以 FFN 的參數量是 <code>hidden × intermediate × 2 ≈ 8 × hidden²</code>、遠大於 attention 的 <code>4 × hidden²</code>（Q/K/V/O 四個 hidden × hidden 矩陣）。</p>
<p>FFN 變體：</p>
<table>
  <thead>
      <tr>
          <th>變體</th>
          <th>結構特性</th>
          <th>出現在</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>標準 FFN</td>
          <td>兩個 linear + 一個 activation</td>
          <td>早期 Transformer、BERT、GPT-2</td>
      </tr>
      <tr>
          <td>SwiGLU FFN</td>
          <td>三個 linear（gate + up + down）+ Swish</td>
          <td>Llama、Gemma、Qwen 主流</td>
      </tr>
      <tr>
          <td>MoE FFN</td>
          <td>多個「expert」FFN、每個 token 只啟用幾個</td>
          <td><a href="/blog/llm/knowledge-cards/moe/" data-link-title="Mixture of Experts (MoE)" data-link-desc="把 transformer 的 FFN 層拆成多個專家、每 token 只啟用少數、總參數大但每 token 計算量小的架構">MoE</a> 模型</td>
      </tr>
  </tbody>
</table>
<h2 id="設計責任">設計責任</h2>
<p>理解 FFN 是參數大頭、能解釋幾件事：<a href="/blog/llm/knowledge-cards/moe/" data-link-title="Mixture of Experts (MoE)" data-link-desc="把 transformer 的 FFN 層拆成多個專家、每 token 只啟用少數、總參數大但每 token 計算量小的架構">MoE</a> 為什麼是「把 FFN 換成多個專家、只啟用部分」（因為 FFN 是最值得稀疏化的部分）、<a href="/blog/llm/knowledge-cards/moe-cpu-offload/" data-link-title="MoE CPU 卸載" data-link-desc="把 Mixture-of-Experts 模型不活躍的專家層權重放在系統 RAM、用到再走 PCIe 拉回 GPU、讓有限 VRAM 跑得了更大模型">MoE CPU offload</a> 為什麼是「把 expert FFN 卸到 RAM」（FFN 大、卸下來省 VRAM）、為什麼模型大小用「參數量」算（FFN 主導）。LoRA fine-tuning 時、通常選擇對 attention 的 Q/V 投影做 LoRA、不對 FFN 動、因為 FFN 太大、LoRA 收益相對小。</p>
]]></content:encoded></item><item><title>Layer Normalization</title><link>https://tarrragon.github.io/blog/llm/knowledge-cards/layer-normalization/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/knowledge-cards/layer-normalization/</guid><description>&lt;p>Layer normalization（LayerNorm）的核心概念是「對單一 token 的 hidden state 向量做正規化」——把該向量的 mean 移到 0、std 縮到 1、再用兩個可學參數做仿射變換。它是 Transformer 穩定深層訓練的關鍵元件、跟 batch normalization 的差別是「正規化軸不同」、LayerNorm 對單個 sample 內部做、不依賴 batch 統計。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>LayerNorm 在 Transformer block 內的位置（現代主流是 pre-norm）：&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">Transformer block（pre-norm 配置）：
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> x
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> ↓ LayerNorm
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> ↓ Self-Attention
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> ↓ + 跟 x 做 residual connection
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> ↓ LayerNorm
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> ↓ FFN
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> ↓ + 跟前一步輸出做 residual connection&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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>LayerNorm&lt;/td>
 &lt;td>&lt;code>(x - mean) / std × γ + β&lt;/code>&lt;/td>
 &lt;td>早期 Transformer（GPT-2、BERT）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RMSNorm&lt;/td>
 &lt;td>&lt;code>x / rms(x) × γ&lt;/code>（不減 mean、不加 β）&lt;/td>
 &lt;td>Llama、Gemma、Qwen 等主流&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>RMSNorm 比 LayerNorm 簡單、實測訓練穩定性接近、推論更快（少算 mean 跟加 β）、所以現代 LLM 多用 RMSNorm。讀 paper 看到「RMSNorm」就是 LayerNorm 的這個簡化變體。&lt;/p>
&lt;p>Pre-norm vs post-norm：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Pre-norm&lt;/strong>（LayerNorm 在 attention / FFN 之前）：深度模型訓練較穩、現代主流。&lt;/li>
&lt;li>&lt;strong>Post-norm&lt;/strong>（LayerNorm 在 residual add 之後）：原始 Transformer paper 的設計、深層訓練不穩定。&lt;/li>
&lt;/ul>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>理解 LayerNorm 後可以判讀「深層 LLM 為什麼訓得起來」的部分答案：&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/residual-connection/" data-link-title="Residual Connection" data-link-desc="把 layer 的輸入直接加到輸出上的「跳接」、讓深層網路的梯度能穩定回流">residual connection&lt;/a> + LayerNorm 是讓梯度能穩定流過幾十層 Transformer 的兩根支柱。讀 model card 看到「RMSNorm」「pre-norm」等詞、知道對應的設計選擇跟訓練穩定性意涵。&lt;/p></description><content:encoded><![CDATA[<p>Layer normalization（LayerNorm）的核心概念是「對單一 token 的 hidden state 向量做正規化」——把該向量的 mean 移到 0、std 縮到 1、再用兩個可學參數做仿射變換。它是 Transformer 穩定深層訓練的關鍵元件、跟 batch normalization 的差別是「正規化軸不同」、LayerNorm 對單個 sample 內部做、不依賴 batch 統計。</p>
<h2 id="概念位置">概念位置</h2>
<p>LayerNorm 在 Transformer block 內的位置（現代主流是 pre-norm）：</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">Transformer block（pre-norm 配置）：
</span></span><span class="line"><span class="ln">2</span><span class="cl">  x
</span></span><span class="line"><span class="ln">3</span><span class="cl">  ↓ LayerNorm
</span></span><span class="line"><span class="ln">4</span><span class="cl">  ↓ Self-Attention
</span></span><span class="line"><span class="ln">5</span><span class="cl">  ↓ + 跟 x 做 residual connection
</span></span><span class="line"><span class="ln">6</span><span class="cl">  ↓ LayerNorm
</span></span><span class="line"><span class="ln">7</span><span class="cl">  ↓ FFN
</span></span><span class="line"><span class="ln">8</span><span class="cl">  ↓ + 跟前一步輸出做 residual connection</span></span></code></pre></div><p>主流變體比較：</p>
<table>
  <thead>
      <tr>
          <th>變體</th>
          <th>計算</th>
          <th>出現在</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>LayerNorm</td>
          <td><code>(x - mean) / std × γ + β</code></td>
          <td>早期 Transformer（GPT-2、BERT）</td>
      </tr>
      <tr>
          <td>RMSNorm</td>
          <td><code>x / rms(x) × γ</code>（不減 mean、不加 β）</td>
          <td>Llama、Gemma、Qwen 等主流</td>
      </tr>
  </tbody>
</table>
<p>RMSNorm 比 LayerNorm 簡單、實測訓練穩定性接近、推論更快（少算 mean 跟加 β）、所以現代 LLM 多用 RMSNorm。讀 paper 看到「RMSNorm」就是 LayerNorm 的這個簡化變體。</p>
<p>Pre-norm vs post-norm：</p>
<ul>
<li><strong>Pre-norm</strong>（LayerNorm 在 attention / FFN 之前）：深度模型訓練較穩、現代主流。</li>
<li><strong>Post-norm</strong>（LayerNorm 在 residual add 之後）：原始 Transformer paper 的設計、深層訓練不穩定。</li>
</ul>
<h2 id="設計責任">設計責任</h2>
<p>理解 LayerNorm 後可以判讀「深層 LLM 為什麼訓得起來」的部分答案：<a href="/blog/llm/knowledge-cards/residual-connection/" data-link-title="Residual Connection" data-link-desc="把 layer 的輸入直接加到輸出上的「跳接」、讓深層網路的梯度能穩定回流">residual connection</a> + LayerNorm 是讓梯度能穩定流過幾十層 Transformer 的兩根支柱。讀 model card 看到「RMSNorm」「pre-norm」等詞、知道對應的設計選擇跟訓練穩定性意涵。</p>
]]></content:encoded></item><item><title>Multi-Head Attention</title><link>https://tarrragon.github.io/blog/llm/knowledge-cards/multi-head-attention/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/knowledge-cards/multi-head-attention/</guid><description>&lt;p>Multi-Head Attention（MHA、多頭注意力）的核心概念是「把 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/self-attention/" data-link-title="Self-Attention" data-link-desc="Q / K / V 都從同一個 sequence 投影出來的 attention、Transformer 的標誌性設計">self-attention&lt;/a> 的 Q/K/V 投影切成多個獨立的 &lt;strong>head&lt;/strong>、各自算 attention、最後再 concat 起來」。直覺：每個 head 可以學會關注不同類型的關係（語法 / 語意 / 位置 / 共指 etc.）、比單一 attention 表達能力強。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>MHA 的計算結構：&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">輸入 hidden state（dim = 4096）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> ↓ 投影成 Q/K/V、每個切成 h 個 head（如 h=32、每個 head 128 維）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">Head 1：Q_1、K_1、V_1 → attention_1（128 維）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">Head 2：Q_2、K_2、V_2 → attention_2
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">...
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">Head h：Q_h、K_h、V_h → attention_h
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> ↓ concat 所有 head 輸出（h × 128 = 4096）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> ↓ output projection（4096 → 4096）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">最終輸出&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>多頭變體：MHA → GQA → MLA 是 KV cache 體積壓縮的演化方向。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>變體&lt;/th>
 &lt;th>Q head 數&lt;/th>
 &lt;th>K/V head 數&lt;/th>
 &lt;th>KV cache 體積&lt;/th>
 &lt;th>出現在&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>MHA（Multi-Head Attention）&lt;/td>
 &lt;td>h&lt;/td>
 &lt;td>h&lt;/td>
 &lt;td>100%（基準）&lt;/td>
 &lt;td>原始 Transformer、GPT-3、Llama 1&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>MQA（Multi-Query Attention）&lt;/td>
 &lt;td>h&lt;/td>
 &lt;td>1（所有 head 共用）&lt;/td>
 &lt;td>1/h&lt;/td>
 &lt;td>PaLM、Falcon&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>GQA（Grouped-Query Attention）&lt;/td>
 &lt;td>h&lt;/td>
 &lt;td>h/g（每 g 個 Q head 共用一組 K/V）&lt;/td>
 &lt;td>1/g&lt;/td>
 &lt;td>Llama 2 / 3、Mistral、Gemma&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>MLA（Multi-head Latent Attention）&lt;/td>
 &lt;td>h&lt;/td>
 &lt;td>用 latent 壓縮再展開&lt;/td>
 &lt;td>更激進壓縮&lt;/td>
 &lt;td>DeepSeek-V2 / V3&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>讀 model card 看到 &lt;code>num_attention_heads: 32&lt;/code>、&lt;code>num_key_value_heads: 8&lt;/code> 等就是 MHA / GQA 設定（Q=32、K/V=8 表示 GQA、g=4）。寫 code 場景的意涵：GQA / MLA 的 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/kv-cache/" data-link-title="KV Cache" data-link-desc="已處理 token 的 attention 中間結果暫存：避免重算、加速後續生成">KV cache&lt;/a> 體積小、長 context / 高併發場景更友善、是現代 LLM 大量採用的設計。&lt;/p></description><content:encoded><![CDATA[<p>Multi-Head Attention（MHA、多頭注意力）的核心概念是「把 <a href="/blog/llm/knowledge-cards/self-attention/" data-link-title="Self-Attention" data-link-desc="Q / K / V 都從同一個 sequence 投影出來的 attention、Transformer 的標誌性設計">self-attention</a> 的 Q/K/V 投影切成多個獨立的 <strong>head</strong>、各自算 attention、最後再 concat 起來」。直覺：每個 head 可以學會關注不同類型的關係（語法 / 語意 / 位置 / 共指 etc.）、比單一 attention 表達能力強。</p>
<h2 id="概念位置">概念位置</h2>
<p>MHA 的計算結構：</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">輸入 hidden state（dim = 4096）
</span></span><span class="line"><span class="ln">2</span><span class="cl">   ↓ 投影成 Q/K/V、每個切成 h 個 head（如 h=32、每個 head 128 維）
</span></span><span class="line"><span class="ln">3</span><span class="cl">Head 1：Q_1、K_1、V_1 → attention_1（128 維）
</span></span><span class="line"><span class="ln">4</span><span class="cl">Head 2：Q_2、K_2、V_2 → attention_2
</span></span><span class="line"><span class="ln">5</span><span class="cl">...
</span></span><span class="line"><span class="ln">6</span><span class="cl">Head h：Q_h、K_h、V_h → attention_h
</span></span><span class="line"><span class="ln">7</span><span class="cl">   ↓ concat 所有 head 輸出（h × 128 = 4096）
</span></span><span class="line"><span class="ln">8</span><span class="cl">   ↓ output projection（4096 → 4096）
</span></span><span class="line"><span class="ln">9</span><span class="cl">最終輸出</span></span></code></pre></div><p>多頭變體：MHA → GQA → MLA 是 KV cache 體積壓縮的演化方向。</p>
<table>
  <thead>
      <tr>
          <th>變體</th>
          <th>Q head 數</th>
          <th>K/V head 數</th>
          <th>KV cache 體積</th>
          <th>出現在</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>MHA（Multi-Head Attention）</td>
          <td>h</td>
          <td>h</td>
          <td>100%（基準）</td>
          <td>原始 Transformer、GPT-3、Llama 1</td>
      </tr>
      <tr>
          <td>MQA（Multi-Query Attention）</td>
          <td>h</td>
          <td>1（所有 head 共用）</td>
          <td>1/h</td>
          <td>PaLM、Falcon</td>
      </tr>
      <tr>
          <td>GQA（Grouped-Query Attention）</td>
          <td>h</td>
          <td>h/g（每 g 個 Q head 共用一組 K/V）</td>
          <td>1/g</td>
          <td>Llama 2 / 3、Mistral、Gemma</td>
      </tr>
      <tr>
          <td>MLA（Multi-head Latent Attention）</td>
          <td>h</td>
          <td>用 latent 壓縮再展開</td>
          <td>更激進壓縮</td>
          <td>DeepSeek-V2 / V3</td>
      </tr>
  </tbody>
</table>
<h2 id="設計責任">設計責任</h2>
<p>讀 model card 看到 <code>num_attention_heads: 32</code>、<code>num_key_value_heads: 8</code> 等就是 MHA / GQA 設定（Q=32、K/V=8 表示 GQA、g=4）。寫 code 場景的意涵：GQA / MLA 的 <a href="/blog/llm/knowledge-cards/kv-cache/" data-link-title="KV Cache" data-link-desc="已處理 token 的 attention 中間結果暫存：避免重算、加速後續生成">KV cache</a> 體積小、長 context / 高併發場景更友善、是現代 LLM 大量採用的設計。</p>
]]></content:encoded></item><item><title>Multimodal Fusion</title><link>https://tarrragon.github.io/blog/llm/knowledge-cards/multimodal-fusion/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/knowledge-cards/multimodal-fusion/</guid><description>&lt;p>Multimodal fusion（多模態融合）的核心概念是「&lt;strong>&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/vlm/" data-link-title="VLM（Vision-Language Model）" data-link-desc="同時吃圖片 &amp;#43; 文字輸入、產生文字輸出的 LLM 變體、coding 工作流中處理截圖 / 設計稿 / UI debug 的基底">VLM&lt;/a> 把 vision encoder 產出的 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/image-token/" data-link-title="Image Token" data-link-desc="VLM 把圖片轉成「對 Transformer 而言跟 text token 同質」的向量、計入 context window 預算">image token&lt;/a> 跟 text token 結合進 LLM 的設計方式&lt;/strong>」。三條主流路線：early fusion（image token 跟 text token 串成同 sequence）、cross-attention（separate stream、attention 跨流）、native multimodal（單一網路統一處理）。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>三種 fusion 方式的對比：&lt;/p>
&lt;h3 id="1-early-fusion最主流">1. Early Fusion（最主流）&lt;/h3>





&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">image → vision encoder → image tokens ─┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> ├→ concat 成單一 sequence → 同 LLM Transformer 處理
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">text → tokenizer → text tokens ────────┘&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;ul>
&lt;li>&lt;strong>特性&lt;/strong>：image token 跟 text token 在同一個 token sequence、共用 LLM 的 attention / FFN&lt;/li>
&lt;li>&lt;strong>代表&lt;/strong>：LLaVA、Qwen2-VL、Llama 3.2 Vision、Pixtral、GPT-4V 多數變體&lt;/li>
&lt;li>&lt;strong>優點&lt;/strong>：實作簡單、可重用 LLM 的 weight、訓練資料效率高&lt;/li>
&lt;li>&lt;strong>缺點&lt;/strong>：image token 佔 context、長對話 / 多圖時 context budget 吃緊&lt;/li>
&lt;/ul>
&lt;h3 id="2-cross-attentionflamingo-style">2. Cross-Attention（Flamingo-style）&lt;/h3>





&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">image → vision encoder → image features ─┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> │ Cross-attention 層
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">text → tokenizer → tokens → LLM Transformer ──┤ 插在每幾層 Transformer 之間
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> │ Image features 不進 LLM 主流
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">output ←─────────────────────────────────┘&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;ul>
&lt;li>&lt;strong>特性&lt;/strong>：image features 不變成 LLM 的 token、透過額外的 cross-attention 層注入&lt;/li>
&lt;li>&lt;strong>代表&lt;/strong>：Flamingo（DeepMind）、Idefics（Hugging Face）、部分 video LLM&lt;/li>
&lt;li>&lt;strong>優點&lt;/strong>：text token sequence 不會被 image 撐大、長文字 + 多圖比較友善&lt;/li>
&lt;li>&lt;strong>缺點&lt;/strong>：架構複雜、訓練難、推論伺服器支援度差&lt;/li>
&lt;/ul>
&lt;h3 id="3-native-multimodalunified-token-space">3. Native Multimodal（unified token space）&lt;/h3>





&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">image → patchify → discrete image tokens（如 VQ-VAE 編碼）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">text → tokenizer → text tokens
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">兩者共用 vocab、同一個 Transformer 從頭訓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">（沒有「分開的 vision encoder」、modality 在 vocab level 統一）&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;ul>
&lt;li>&lt;strong>特性&lt;/strong>：架構上「圖跟文字是同一種東西」、共用 vocab&lt;/li>
&lt;li>&lt;strong>代表&lt;/strong>：Chameleon（Meta 研究）、未來 trend&lt;/li>
&lt;li>&lt;strong>優點&lt;/strong>：理論最 clean、跨模態 generation 自然（生圖 + 生文都同個模型）&lt;/li>
&lt;li>&lt;strong>缺點&lt;/strong>：訓練極貴、目前研究階段為主、實用 VLM 仍以 early fusion 為主流&lt;/li>
&lt;/ul>
&lt;h2 id="主流選擇對比">主流選擇對比&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>路線&lt;/th>
 &lt;th>佔比（2026/5）&lt;/th>
 &lt;th>對 coding 場景的影響&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Early fusion&lt;/td>
 &lt;td>~85%&lt;/td>
 &lt;td>Image token 佔 context、要算清楚 context budget&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Cross-attention&lt;/td>
 &lt;td>~10%&lt;/td>
 &lt;td>推論伺服器支援度差、本地跑選項少&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Native multimodal&lt;/td>
 &lt;td>&amp;lt; 5%&lt;/td>
 &lt;td>研究階段、現在不適合 production / 本地工作流&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>讀 VLM paper / blog 看到「early fusion」「LLaVA-style」「Flamingo-style」「cross-attention adapter」就是這分類。寫 code 場景的判讀：&lt;/p></description><content:encoded><![CDATA[<p>Multimodal fusion（多模態融合）的核心概念是「<strong><a href="/blog/llm/knowledge-cards/vlm/" data-link-title="VLM（Vision-Language Model）" data-link-desc="同時吃圖片 &#43; 文字輸入、產生文字輸出的 LLM 變體、coding 工作流中處理截圖 / 設計稿 / UI debug 的基底">VLM</a> 把 vision encoder 產出的 <a href="/blog/llm/knowledge-cards/image-token/" data-link-title="Image Token" data-link-desc="VLM 把圖片轉成「對 Transformer 而言跟 text token 同質」的向量、計入 context window 預算">image token</a> 跟 text token 結合進 LLM 的設計方式</strong>」。三條主流路線：early fusion（image token 跟 text token 串成同 sequence）、cross-attention（separate stream、attention 跨流）、native multimodal（單一網路統一處理）。</p>
<h2 id="概念位置">概念位置</h2>
<p>三種 fusion 方式的對比：</p>
<h3 id="1-early-fusion最主流">1. Early Fusion（最主流）</h3>





<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">image → vision encoder → image tokens ─┐
</span></span><span class="line"><span class="ln">2</span><span class="cl">                                       ├→ concat 成單一 sequence → 同 LLM Transformer 處理
</span></span><span class="line"><span class="ln">3</span><span class="cl">text → tokenizer → text tokens ────────┘</span></span></code></pre></div><ul>
<li><strong>特性</strong>：image token 跟 text token 在同一個 token sequence、共用 LLM 的 attention / FFN</li>
<li><strong>代表</strong>：LLaVA、Qwen2-VL、Llama 3.2 Vision、Pixtral、GPT-4V 多數變體</li>
<li><strong>優點</strong>：實作簡單、可重用 LLM 的 weight、訓練資料效率高</li>
<li><strong>缺點</strong>：image token 佔 context、長對話 / 多圖時 context budget 吃緊</li>
</ul>
<h3 id="2-cross-attentionflamingo-style">2. Cross-Attention（Flamingo-style）</h3>





<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">image → vision encoder → image features ─┐
</span></span><span class="line"><span class="ln">2</span><span class="cl">                                          │ Cross-attention 層
</span></span><span class="line"><span class="ln">3</span><span class="cl">text → tokenizer → tokens → LLM Transformer ──┤  插在每幾層 Transformer 之間
</span></span><span class="line"><span class="ln">4</span><span class="cl">                                          │ Image features 不進 LLM 主流
</span></span><span class="line"><span class="ln">5</span><span class="cl">output ←─────────────────────────────────┘</span></span></code></pre></div><ul>
<li><strong>特性</strong>：image features 不變成 LLM 的 token、透過額外的 cross-attention 層注入</li>
<li><strong>代表</strong>：Flamingo（DeepMind）、Idefics（Hugging Face）、部分 video LLM</li>
<li><strong>優點</strong>：text token sequence 不會被 image 撐大、長文字 + 多圖比較友善</li>
<li><strong>缺點</strong>：架構複雜、訓練難、推論伺服器支援度差</li>
</ul>
<h3 id="3-native-multimodalunified-token-space">3. Native Multimodal（unified token space）</h3>





<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">image → patchify → discrete image tokens（如 VQ-VAE 編碼）
</span></span><span class="line"><span class="ln">2</span><span class="cl">text → tokenizer → text tokens
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl">兩者共用 vocab、同一個 Transformer 從頭訓
</span></span><span class="line"><span class="ln">5</span><span class="cl">（沒有「分開的 vision encoder」、modality 在 vocab level 統一）</span></span></code></pre></div><ul>
<li><strong>特性</strong>：架構上「圖跟文字是同一種東西」、共用 vocab</li>
<li><strong>代表</strong>：Chameleon（Meta 研究）、未來 trend</li>
<li><strong>優點</strong>：理論最 clean、跨模態 generation 自然（生圖 + 生文都同個模型）</li>
<li><strong>缺點</strong>：訓練極貴、目前研究階段為主、實用 VLM 仍以 early fusion 為主流</li>
</ul>
<h2 id="主流選擇對比">主流選擇對比</h2>
<table>
  <thead>
      <tr>
          <th>路線</th>
          <th>佔比（2026/5）</th>
          <th>對 coding 場景的影響</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Early fusion</td>
          <td>~85%</td>
          <td>Image token 佔 context、要算清楚 context budget</td>
      </tr>
      <tr>
          <td>Cross-attention</td>
          <td>~10%</td>
          <td>推論伺服器支援度差、本地跑選項少</td>
      </tr>
      <tr>
          <td>Native multimodal</td>
          <td>&lt; 5%</td>
          <td>研究階段、現在不適合 production / 本地工作流</td>
      </tr>
  </tbody>
</table>
<h2 id="設計責任">設計責任</h2>
<p>讀 VLM paper / blog 看到「early fusion」「LLaVA-style」「Flamingo-style」「cross-attention adapter」就是這分類。寫 code 場景的判讀：</p>
<ol>
<li><strong>本地跑 VLM 多半是 early fusion</strong>：選 Qwen2.5-VL / Llama 3.2 Vision / Gemma 3 Vision 都是這條路線、推論伺服器（llama.cpp、Ollama、LM Studio）都支援</li>
<li><strong>Cross-attention 模型本地跑可能撞牆</strong>：推論伺服器對 Idefics 等 cross-attention 模型支援度差、不一定能跑 GGUF</li>
<li><strong>理解 fusion 影響 token 估算</strong>：early fusion 下「image token = 真的進 context」、cross-attention 下不算進 context window 主流</li>
<li><strong>未來 trend 是 unified</strong>：但現在做 production / 本地工作流不必等、用 early fusion 主流模型即可</li>
</ol>
]]></content:encoded></item><item><title>Residual Connection</title><link>https://tarrragon.github.io/blog/llm/knowledge-cards/residual-connection/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/knowledge-cards/residual-connection/</guid><description>&lt;p>Residual connection（殘差連接、skip connection）的核心概念是「把 layer 的輸入直接加到輸出上」、形式是 &lt;code>output = layer(x) + x&lt;/code>。這個簡單加法解決了深層網路的訓練退化問題：沒有 residual、模型加深會反而變差（不是過擬合、是 gradient 在反向傳播中衰減太多）；有 residual、訓練幾十甚至上百層都穩。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Residual connection 在 Transformer block 中出現兩次：&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">Transformer block：
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> x
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> ├──────────────┐ ← skip connection（保留原始 x）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> ↓ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> LayerNorm │
&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"> Self-Attention │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> ↓ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> +←─────────────┘ ← residual add：attention output + x
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> ├──────────────┐ ← skip connection（保留 attention 後的值）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> ↓ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> LayerNorm │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> ↓ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> FFN │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> ↓ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> +←─────────────┘ ← residual add：FFN output + previous
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> 進入下一個 block&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>關鍵性質：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Gradient 可以走捷徑&lt;/strong>：&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/backpropagation/" data-link-title="Backpropagation" data-link-desc="從 output loss 反向遞推、用 chain rule 算出每個權重的 gradient 的演算法">Backpropagation&lt;/a> 時、gradient 能透過 skip connection 直接傳回淺層、避免 chain rule 累積衰減。&lt;/li>
&lt;li>&lt;strong>Layer 學「殘差」而不是「完整轉換」&lt;/strong>：每層學「該怎麼微調輸入」、不用學「從零生成輸出」、優化更容易。&lt;/li>
&lt;li>&lt;strong>跟 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/layer-normalization/" data-link-title="Layer Normalization" data-link-desc="在每個 token 的 hidden state 上做正規化（減 mean、除 std）、穩定深層網路訓練">LayerNorm&lt;/a> 配對&lt;/strong>：兩者一起是深層 Transformer 訓得起來的基礎。&lt;/li>
&lt;/ol>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>理解 residual connection 後可以判讀 Transformer 能堆幾十層的根本原因（不是因為 attention、是因為 residual + LayerNorm 讓深層仍可訓練）；也能看懂 ResNet、ViT 等其他用 residual 架構的設計。LLM 推論時 residual 不算 bottleneck、但在訓練 / fine-tune 時、residual 是 gradient flow 健康度的關鍵。&lt;/p></description><content:encoded><![CDATA[<p>Residual connection（殘差連接、skip connection）的核心概念是「把 layer 的輸入直接加到輸出上」、形式是 <code>output = layer(x) + x</code>。這個簡單加法解決了深層網路的訓練退化問題：沒有 residual、模型加深會反而變差（不是過擬合、是 gradient 在反向傳播中衰減太多）；有 residual、訓練幾十甚至上百層都穩。</p>
<h2 id="概念位置">概念位置</h2>
<p>Residual connection 在 Transformer block 中出現兩次：</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">Transformer block：
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  x
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  ├──────────────┐  ← skip connection（保留原始 x）
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  ↓              │
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  LayerNorm      │
</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">  Self-Attention │
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  ↓              │
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  +←─────────────┘  ← residual add：attention output + x
</span></span><span class="line"><span class="ln">10</span><span class="cl">  │
</span></span><span class="line"><span class="ln">11</span><span class="cl">  ├──────────────┐  ← skip connection（保留 attention 後的值）
</span></span><span class="line"><span class="ln">12</span><span class="cl">  ↓              │
</span></span><span class="line"><span class="ln">13</span><span class="cl">  LayerNorm      │
</span></span><span class="line"><span class="ln">14</span><span class="cl">  ↓              │
</span></span><span class="line"><span class="ln">15</span><span class="cl">  FFN            │
</span></span><span class="line"><span class="ln">16</span><span class="cl">  ↓              │
</span></span><span class="line"><span class="ln">17</span><span class="cl">  +←─────────────┘  ← residual add：FFN output + previous
</span></span><span class="line"><span class="ln">18</span><span class="cl">  ↓
</span></span><span class="line"><span class="ln">19</span><span class="cl">  進入下一個 block</span></span></code></pre></div><p>關鍵性質：</p>
<ol>
<li><strong>Gradient 可以走捷徑</strong>：<a href="/blog/llm/knowledge-cards/backpropagation/" data-link-title="Backpropagation" data-link-desc="從 output loss 反向遞推、用 chain rule 算出每個權重的 gradient 的演算法">Backpropagation</a> 時、gradient 能透過 skip connection 直接傳回淺層、避免 chain rule 累積衰減。</li>
<li><strong>Layer 學「殘差」而不是「完整轉換」</strong>：每層學「該怎麼微調輸入」、不用學「從零生成輸出」、優化更容易。</li>
<li><strong>跟 <a href="/blog/llm/knowledge-cards/layer-normalization/" data-link-title="Layer Normalization" data-link-desc="在每個 token 的 hidden state 上做正規化（減 mean、除 std）、穩定深層網路訓練">LayerNorm</a> 配對</strong>：兩者一起是深層 Transformer 訓得起來的基礎。</li>
</ol>
<h2 id="設計責任">設計責任</h2>
<p>理解 residual connection 後可以判讀 Transformer 能堆幾十層的根本原因（不是因為 attention、是因為 residual + LayerNorm 讓深層仍可訓練）；也能看懂 ResNet、ViT 等其他用 residual 架構的設計。LLM 推論時 residual 不算 bottleneck、但在訓練 / fine-tune 時、residual 是 gradient flow 健康度的關鍵。</p>
]]></content:encoded></item><item><title>RoPE（Rotary Position Embedding）</title><link>https://tarrragon.github.io/blog/llm/knowledge-cards/rope/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/knowledge-cards/rope/</guid><description>&lt;p>RoPE（Rotary Position Embedding、旋轉位置編碼、Su et al., 2021）的核心概念是「&lt;strong>把 token 在序列中的位置資訊用旋轉矩陣直接旋轉進 Q 跟 K 向量裡&lt;/strong>、不是用加法疊加另一個 embedding」。RoPE 是 Llama、Gemma、Qwen、Mistral 等現代 LLM 的標配、相對早期的 absolute / learned positional embedding 有更好的長 context 推廣性。&lt;/p>
&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>Absolute（原 Transformer）&lt;/td>
 &lt;td>用 sin/cos 函數產生固定 position embedding、加到 token embedding&lt;/td>
 &lt;td>訓練長度外推性差&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Learned absolute（GPT-2）&lt;/td>
 &lt;td>每個位置學一個可訓練向量、加到 token embedding&lt;/td>
 &lt;td>超過訓練長度完全沒對應 embedding&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Relative&lt;/td>
 &lt;td>attention 算分數時加上「相對位置」的 bias&lt;/td>
 &lt;td>實作複雜、跟 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/kv-cache/" data-link-title="KV Cache" data-link-desc="已處理 token 的 attention 中間結果暫存：避免重算、加速後續生成">KV cache&lt;/a> 兼容性差&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>RoPE&lt;/strong>&lt;/td>
 &lt;td>用旋轉矩陣把位置旋轉進 Q/K（不動 V）&lt;/td>
 &lt;td>主流、長 context 推廣性好（配 scaling）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>RoPE 的核心數學（簡化）：&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">傳統：token at position m 的 Q 是 Q_m = x_m @ W_Q
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">RoPE：Q_m = R(m) × (x_m @ W_Q) ← R(m) 是依位置 m 決定的旋轉矩陣
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">attention score = Q_m @ K_n^T
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> = R(m) × q × (R(n) × k)^T
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> = q × R(m - n) × k^T ← 只依賴相對位置 (m-n)！&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>關鍵性質：RoPE 算出的 attention score 只依賴&lt;strong>相對位置&lt;/strong>、所以推廣到比訓練長度更長的 context 時有自然的數學基礎、配合 RoPE scaling（YaRN、NTK-aware、Position Interpolation）就能把 8K 訓練的模型擴展到 128K / 1M context。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>讀 model card 看到 &lt;code>rope_theta: 10000&lt;/code>、&lt;code>rope_scaling: {type: yarn, factor: 8}&lt;/code> 等就是 RoPE 配置。寫 code 場景的意涵：long context 模型（如 Llama 3 128K）的推廣能力主要靠 RoPE + scaling、不是直接訓練 128K 全長；但聲稱 context 跟「實用 context」仍有差距、長 context 上模型表現會逐步衰減。&lt;/p></description><content:encoded><![CDATA[<p>RoPE（Rotary Position Embedding、旋轉位置編碼、Su et al., 2021）的核心概念是「<strong>把 token 在序列中的位置資訊用旋轉矩陣直接旋轉進 Q 跟 K 向量裡</strong>、不是用加法疊加另一個 embedding」。RoPE 是 Llama、Gemma、Qwen、Mistral 等現代 LLM 的標配、相對早期的 absolute / learned positional embedding 有更好的長 context 推廣性。</p>
<h2 id="概念位置">概念位置</h2>
<p>位置編碼的演化路線：</p>
<table>
  <thead>
      <tr>
          <th>方法</th>
          <th>機制</th>
          <th>主要問題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Absolute（原 Transformer）</td>
          <td>用 sin/cos 函數產生固定 position embedding、加到 token embedding</td>
          <td>訓練長度外推性差</td>
      </tr>
      <tr>
          <td>Learned absolute（GPT-2）</td>
          <td>每個位置學一個可訓練向量、加到 token embedding</td>
          <td>超過訓練長度完全沒對應 embedding</td>
      </tr>
      <tr>
          <td>Relative</td>
          <td>attention 算分數時加上「相對位置」的 bias</td>
          <td>實作複雜、跟 <a href="/blog/llm/knowledge-cards/kv-cache/" data-link-title="KV Cache" data-link-desc="已處理 token 的 attention 中間結果暫存：避免重算、加速後續生成">KV cache</a> 兼容性差</td>
      </tr>
      <tr>
          <td><strong>RoPE</strong></td>
          <td>用旋轉矩陣把位置旋轉進 Q/K（不動 V）</td>
          <td>主流、長 context 推廣性好（配 scaling）</td>
      </tr>
  </tbody>
</table>
<p>RoPE 的核心數學（簡化）：</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">傳統：token at position m 的 Q 是 Q_m = x_m @ W_Q
</span></span><span class="line"><span class="ln">2</span><span class="cl">RoPE：Q_m = R(m) × (x_m @ W_Q)  ← R(m) 是依位置 m 決定的旋轉矩陣
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl">attention score = Q_m @ K_n^T
</span></span><span class="line"><span class="ln">5</span><span class="cl">               = R(m) × q × (R(n) × k)^T
</span></span><span class="line"><span class="ln">6</span><span class="cl">               = q × R(m - n) × k^T  ← 只依賴相對位置 (m-n)！</span></span></code></pre></div><p>關鍵性質：RoPE 算出的 attention score 只依賴<strong>相對位置</strong>、所以推廣到比訓練長度更長的 context 時有自然的數學基礎、配合 RoPE scaling（YaRN、NTK-aware、Position Interpolation）就能把 8K 訓練的模型擴展到 128K / 1M context。</p>
<h2 id="設計責任">設計責任</h2>
<p>讀 model card 看到 <code>rope_theta: 10000</code>、<code>rope_scaling: {type: yarn, factor: 8}</code> 等就是 RoPE 配置。寫 code 場景的意涵：long context 模型（如 Llama 3 128K）的推廣能力主要靠 RoPE + scaling、不是直接訓練 128K 全長；但聲稱 context 跟「實用 context」仍有差距、長 context 上模型表現會逐步衰減。</p>
]]></content:encoded></item><item><title>Scaffold vs Harness</title><link>https://tarrragon.github.io/blog/llm/knowledge-cards/scaffold-vs-harness/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/knowledge-cards/scaffold-vs-harness/</guid><description>&lt;p>Scaffold 跟 harness 的核心概念是「&lt;strong>把 coding agent 拆成『建構時靜態結構』跟『runtime 動態邏輯』兩層&lt;/strong>」。Scaffold 是建構時就決定的：system prompt 模板、tool schema 註冊、subagent 拓樸；harness 是 runtime 動態運作：tool dispatch、context budget 管理、safety / 中斷、handoff。Claude Code、Cursor、Aider、Codex 這類 coding agent 的內部設計都遵循這個分層。&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">Scaffold（建構時、static）：
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> ├── System prompt 模板（角色、約束、輸出格式）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> ├── Tool schema 註冊（read_file / write_file / run_bash 等的 spec）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> ├── Subagent 拓樸（main agent + 子 agent 的調用關係）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> ├── Skill / playbook 註冊
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> └── 安全 policy（什麼可寫、什麼要 confirm）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> ↓ 編譯 / 載入
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">Harness（runtime、dynamic）：
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> ├── Tool dispatch（接 LLM tool call、執行、回 result）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> ├── Context budget 管理（剪裁歷史、塞新內容、不超 25% 規則）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> ├── Safety / 中斷（confirm UI、permission boundary、可逆性檢查）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> ├── Error recovery（tool failed → retry / fallback / escalate）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> └── Telemetry（trace / metrics / cost）&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>跟既有概念的關係：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>概念&lt;/th>
 &lt;th>跟 scaffold / harness 的關係&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/system-prompt/" data-link-title="System Prompt" data-link-desc="LLM application 中由開發者預設、不直接顯示給使用者的指令層、定義模型的角色、行為規範、輸出格式">System prompt&lt;/a>&lt;/td>
 &lt;td>Scaffold 的核心元件、定義 agent 角色&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/tool-use/" data-link-title="Tool Use" data-link-desc="LLM 透過結構化呼叫外部工具（讀檔、查資料庫、發 API request）來擴展能力的設計、function calling 跟 MCP 是常見實作">Tool use&lt;/a>&lt;/td>
 &lt;td>Scaffold 註冊 tool spec、Harness 在 runtime dispatch&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/agent-loop/" data-link-title="Agent Loop" data-link-desc="LLM agent 自我循環的工作流：LLM 規劃下一步、執行 tool、看結果、再規劃下一步、直到任務完成或停止條件觸發">Agent loop&lt;/a>&lt;/td>
 &lt;td>Harness 的核心 loop（perceive / reason / act / observe / terminate）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/function-calling/" data-link-title="Function Calling" data-link-desc="模型訓練階段建立的「呼叫工具」能力：知道何時該呼叫、傳什麼參數">Function calling&lt;/a>&lt;/td>
 &lt;td>Tool spec 的具體 protocol&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>讀 coding agent paper / blog 看到「scaffold」「harness」「context engineering」就是這 framing。寫 code 場景的判讀：&lt;/p></description><content:encoded><![CDATA[<p>Scaffold 跟 harness 的核心概念是「<strong>把 coding agent 拆成『建構時靜態結構』跟『runtime 動態邏輯』兩層</strong>」。Scaffold 是建構時就決定的：system prompt 模板、tool schema 註冊、subagent 拓樸；harness 是 runtime 動態運作：tool dispatch、context budget 管理、safety / 中斷、handoff。Claude Code、Cursor、Aider、Codex 這類 coding agent 的內部設計都遵循這個分層。</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">Scaffold（建構時、static）：
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  ├── System prompt 模板（角色、約束、輸出格式）
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  ├── Tool schema 註冊（read_file / write_file / run_bash 等的 spec）
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  ├── Subagent 拓樸（main agent + 子 agent 的調用關係）
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  ├── Skill / playbook 註冊
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  └── 安全 policy（什麼可寫、什麼要 confirm）
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">   ↓ 編譯 / 載入
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">Harness（runtime、dynamic）：
</span></span><span class="line"><span class="ln">11</span><span class="cl">  ├── Tool dispatch（接 LLM tool call、執行、回 result）
</span></span><span class="line"><span class="ln">12</span><span class="cl">  ├── Context budget 管理（剪裁歷史、塞新內容、不超 25% 規則）
</span></span><span class="line"><span class="ln">13</span><span class="cl">  ├── Safety / 中斷（confirm UI、permission boundary、可逆性檢查）
</span></span><span class="line"><span class="ln">14</span><span class="cl">  ├── Error recovery（tool failed → retry / fallback / escalate）
</span></span><span class="line"><span class="ln">15</span><span class="cl">  └── Telemetry（trace / metrics / cost）</span></span></code></pre></div><p>跟既有概念的關係：</p>
<table>
  <thead>
      <tr>
          <th>概念</th>
          <th>跟 scaffold / harness 的關係</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/llm/knowledge-cards/system-prompt/" data-link-title="System Prompt" data-link-desc="LLM application 中由開發者預設、不直接顯示給使用者的指令層、定義模型的角色、行為規範、輸出格式">System prompt</a></td>
          <td>Scaffold 的核心元件、定義 agent 角色</td>
      </tr>
      <tr>
          <td><a href="/blog/llm/knowledge-cards/tool-use/" data-link-title="Tool Use" data-link-desc="LLM 透過結構化呼叫外部工具（讀檔、查資料庫、發 API request）來擴展能力的設計、function calling 跟 MCP 是常見實作">Tool use</a></td>
          <td>Scaffold 註冊 tool spec、Harness 在 runtime dispatch</td>
      </tr>
      <tr>
          <td><a href="/blog/llm/knowledge-cards/agent-loop/" data-link-title="Agent Loop" data-link-desc="LLM agent 自我循環的工作流：LLM 規劃下一步、執行 tool、看結果、再規劃下一步、直到任務完成或停止條件觸發">Agent loop</a></td>
          <td>Harness 的核心 loop（perceive / reason / act / observe / terminate）</td>
      </tr>
      <tr>
          <td><a href="/blog/llm/knowledge-cards/function-calling/" data-link-title="Function Calling" data-link-desc="模型訓練階段建立的「呼叫工具」能力：知道何時該呼叫、傳什麼參數">Function calling</a></td>
          <td>Tool spec 的具體 protocol</td>
      </tr>
  </tbody>
</table>
<h2 id="設計責任">設計責任</h2>
<p>讀 coding agent paper / blog 看到「scaffold」「harness」「context engineering」就是這 framing。寫 code 場景的判讀：</p>
<ol>
<li><strong>看新 coding agent 時、分兩層拆解</strong>：scaffold（system prompt、tool list、subagent 結構）是「設計做了什麼」、harness（context 怎麼裁、tool 怎麼 dispatch、安全怎麼擋）是「runtime 怎麼跑」</li>
<li><strong>修改 / 客製 agent 時、看你動的是哪層</strong>：改 system prompt = 動 scaffold；改 tool 執行邏輯 = 動 harness</li>
<li><strong>跟 <a href="/blog/llm/04-applications/coding-agent-harness/" data-link-title="4.17 Coding agent harness：scaffold / context engineering / subagent" data-link-desc="Coding agent 的內部設計：scaffold vs harness 分層、context budget 25% 規則、subagent 拓樸、跟 Claude Code / Cursor / Aider 的 mapping">4.17 coding-agent harness</a> 的關係</strong>：本卡是定義、4.12 是 coding 場景的工程實務（context budget、scaffold 模式、harness pattern）</li>
</ol>
]]></content:encoded></item><item><title>Self-Attention</title><link>https://tarrragon.github.io/blog/llm/knowledge-cards/self-attention/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/knowledge-cards/self-attention/</guid><description>&lt;p>Self-attention 的核心概念是「Query / Key / Value 三組向量都從&lt;strong>同一個&lt;/strong> sequence 投影出來的 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/attention/" data-link-title="Attention" data-link-desc="Transformer 內部讓每個 token 對其他 token 加權平均的核心機制、形成 KV cache 跟 context window 的計算基礎">attention&lt;/a>」。對比下、cross-attention 的 Q 來自一個 sequence、K/V 來自另一個 sequence（如 encoder-decoder 的 decoder 看 encoder）。LLM（decoder-only）每層都是 self-attention、self-attention 是 Transformer 「讓每個 token 看到序列其他 token」的機制本身。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Self-attention 的計算步驟：&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">輸入 sequence: x_1, x_2, ..., x_n（每個是向量）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">對每個 token i：
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> Q_i = x_i × W_Q ← Query：「我要找什麼樣的資訊」
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> K_i = x_i × W_K ← Key：「我提供什麼樣的資訊」
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> V_i = x_i × W_V ← Value：「我的實際內容」
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">attention(Q_i, K, V) = softmax(Q_i · K^T / √d) · V
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> └─ Q 跟所有 K 算分數、決定權重 ─┘
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> └─ 加權平均所有 V ─┘&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>關鍵特性：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Q / K / V 來源相同&lt;/strong>：跟 cross-attention 區分；都從同一個輸入 sequence 投影。&lt;/li>
&lt;li>&lt;strong>每個 token 都跟所有 token 算一次&lt;/strong>：複雜度 O(n²)、是 long context 痛點根源。&lt;/li>
&lt;li>&lt;strong>Causal mask 在 self-attention 內生效&lt;/strong>：LLM 的 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/transformer/" data-link-title="Transformer" data-link-desc="寫 code 用的 LLM 神經網路架構：基於 attention 機制、自回歸生成 token">decoder-only&lt;/a> self-attention 加 causal mask、token i 只能看 1~i、不能看 i+1 以後（不能偷看未來）。&lt;/li>
&lt;/ol>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>理解 self-attention 後可以判讀幾件 LLM 設計事：&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/kv-cache/" data-link-title="KV Cache" data-link-desc="已處理 token 的 attention 中間結果暫存：避免重算、加速後續生成">KV cache&lt;/a> 為什麼有效（自回歸生成時、過去 token 的 K/V 不變、存下來下次直接用）；MHA / GQA / MLA 等變體在動什麼（共享 / 壓縮 K/V 投影、不動 Q）；為什麼長 context 推論慢（self-attention 的 O(n²) 計算）。&lt;/p></description><content:encoded><![CDATA[<p>Self-attention 的核心概念是「Query / Key / Value 三組向量都從<strong>同一個</strong> sequence 投影出來的 <a href="/blog/llm/knowledge-cards/attention/" data-link-title="Attention" data-link-desc="Transformer 內部讓每個 token 對其他 token 加權平均的核心機制、形成 KV cache 跟 context window 的計算基礎">attention</a>」。對比下、cross-attention 的 Q 來自一個 sequence、K/V 來自另一個 sequence（如 encoder-decoder 的 decoder 看 encoder）。LLM（decoder-only）每層都是 self-attention、self-attention 是 Transformer 「讓每個 token 看到序列其他 token」的機制本身。</p>
<h2 id="概念位置">概念位置</h2>
<p>Self-attention 的計算步驟：</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">輸入 sequence: x_1, x_2, ..., x_n（每個是向量）
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">對每個 token i：
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  Q_i = x_i × W_Q   ← Query：「我要找什麼樣的資訊」
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  K_i = x_i × W_K   ← Key：「我提供什麼樣的資訊」
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  V_i = x_i × W_V   ← Value：「我的實際內容」
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">attention(Q_i, K, V) = softmax(Q_i · K^T / √d) · V
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">                       └─ Q 跟所有 K 算分數、決定權重 ─┘
</span></span><span class="line"><span class="ln">10</span><span class="cl">                                                       └─ 加權平均所有 V ─┘</span></span></code></pre></div><p>關鍵特性：</p>
<ol>
<li><strong>Q / K / V 來源相同</strong>：跟 cross-attention 區分；都從同一個輸入 sequence 投影。</li>
<li><strong>每個 token 都跟所有 token 算一次</strong>：複雜度 O(n²)、是 long context 痛點根源。</li>
<li><strong>Causal mask 在 self-attention 內生效</strong>：LLM 的 <a href="/blog/llm/knowledge-cards/transformer/" data-link-title="Transformer" data-link-desc="寫 code 用的 LLM 神經網路架構：基於 attention 機制、自回歸生成 token">decoder-only</a> self-attention 加 causal mask、token i 只能看 1~i、不能看 i+1 以後（不能偷看未來）。</li>
</ol>
<h2 id="設計責任">設計責任</h2>
<p>理解 self-attention 後可以判讀幾件 LLM 設計事：<a href="/blog/llm/knowledge-cards/kv-cache/" data-link-title="KV Cache" data-link-desc="已處理 token 的 attention 中間結果暫存：避免重算、加速後續生成">KV cache</a> 為什麼有效（自回歸生成時、過去 token 的 K/V 不變、存下來下次直接用）；MHA / GQA / MLA 等變體在動什麼（共享 / 壓縮 K/V 投影、不動 Q）；為什麼長 context 推論慢（self-attention 的 O(n²) 計算）。</p>
]]></content:encoded></item><item><title>Vision Encoder</title><link>https://tarrragon.github.io/blog/llm/knowledge-cards/vision-encoder/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/knowledge-cards/vision-encoder/</guid><description>&lt;p>Vision encoder（視覺編碼器）的核心概念是「&lt;strong>&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/vlm/" data-link-title="VLM（Vision-Language Model）" data-link-desc="同時吃圖片 &amp;#43; 文字輸入、產生文字輸出的 LLM 變體、coding 工作流中處理截圖 / 設計稿 / UI debug 的基底">VLM&lt;/a> 內部把圖片轉成向量序列的模組&lt;/strong>」。主流做法是「把圖片切成 patch、每個 patch 過 ViT（Vision Transformer）變一個向量」、再進入 LLM 的 Transformer 層。Vision encoder 通常用 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/clip/" data-link-title="CLIP" data-link-desc="OpenAI 2021 提出的 contrastive image-text pretraining、現代 VLM 的 vision encoder 大多衍生自它">CLIP&lt;/a> 預訓練的權重起始、再跟 LLM 一起 fine-tune。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Vision encoder 在 VLM 中的位置：&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">Input image（如 1024×1024 RGB）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> ↓ 切 patch（如 14×14 patch、每張圖 ~5000 個 patch）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> ↓ Vision encoder（ViT 或 CLIP image encoder）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">Image feature vectors（每個 patch 對應一個 768/1024 維向量）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> ↓ Projection layer（vision dim → LLM hidden dim）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">[Image tokens](/llm/knowledge-cards/image-token/)（變成 LLM 可吃的「視覺 token」）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">跟 text token 混合 → Transformer → output token&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>主流 vision encoder 設計：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>設計&lt;/th>
 &lt;th>機制&lt;/th>
 &lt;th>代表 VLM&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>CLIP ViT-L/14（或變體）&lt;/td>
 &lt;td>OpenAI CLIP 的 image encoder 直接用&lt;/td>
 &lt;td>LLaVA-1.5、Qwen2-VL、Pixtral&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>SigLIP&lt;/td>
 &lt;td>Google 的 sigmoid-loss CLIP 變體、訓得更穩&lt;/td>
 &lt;td>Gemma 3 Vision、Idefics2&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>自訓 / 多解析度 ViT&lt;/td>
 &lt;td>從頭訓、支援動態解析度（不固定 224×224）&lt;/td>
 &lt;td>Qwen2.5-VL、GPT-4V&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Native multimodal（單一網路）&lt;/td>
 &lt;td>圖跟文字共用 Transformer、不分開 encoder&lt;/td>
 &lt;td>Chameleon（Meta 研究）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Vision encoder 的關鍵設計取捨：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>解析度&lt;/strong>：固定（224×224 / 336×336）vs 動態（依輸入圖大小）&lt;/li>
&lt;li>&lt;strong>參數量&lt;/strong>：vision encoder 0.3B-1B 是主流；太小辨識能力差、太大拖累整體推論速度&lt;/li>
&lt;li>&lt;strong>Pretrain 來源&lt;/strong>：用 CLIP / SigLIP 預訓練的權重起始、加上 multimodal fine-tune；少數從頭訓&lt;/li>
&lt;li>&lt;strong>跟 LLM 結合方式&lt;/strong>：見 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/multimodal-fusion/" data-link-title="Multimodal Fusion" data-link-desc="VLM 把 vision encoder 跟 LLM 結合的方式：early fusion / cross-attention / native multimodal 三條路線">multimodal fusion&lt;/a> 卡&lt;/li>
&lt;/ol>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>讀 VLM model card 看到「vision tower」「ViT backbone」「image encoder」就是這部分。寫 code 場景的判讀：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>解析度影響細節辨識&lt;/strong>：低解析度（224）對「截圖中的小字 / 細邊框」可能模糊、看不清；高解析度（1024+）能看清楚但 token 用量大&lt;/li>
&lt;li>&lt;strong>Token 用量估算&lt;/strong>：一張 1024×1024 圖經過 vision encoder 後、產出 ~500-2500 image tokens（依設計）、相當於一段中等長度的文字 prompt&lt;/li>
&lt;li>&lt;strong>動態解析度模型更實用&lt;/strong>：Qwen2.5-VL / GPT-4V 等支援動態解析度、不會把高清截圖縮成 224 失去細節&lt;/li>
&lt;li>&lt;strong>Vision encoder 不能單獨 fine-tune&lt;/strong>：通常跟 LLM 一起訓、單獨換 vision encoder 會破壞 alignment&lt;/li>
&lt;/ol></description><content:encoded><![CDATA[<p>Vision encoder（視覺編碼器）的核心概念是「<strong><a href="/blog/llm/knowledge-cards/vlm/" data-link-title="VLM（Vision-Language Model）" data-link-desc="同時吃圖片 &#43; 文字輸入、產生文字輸出的 LLM 變體、coding 工作流中處理截圖 / 設計稿 / UI debug 的基底">VLM</a> 內部把圖片轉成向量序列的模組</strong>」。主流做法是「把圖片切成 patch、每個 patch 過 ViT（Vision Transformer）變一個向量」、再進入 LLM 的 Transformer 層。Vision encoder 通常用 <a href="/blog/llm/knowledge-cards/clip/" data-link-title="CLIP" data-link-desc="OpenAI 2021 提出的 contrastive image-text pretraining、現代 VLM 的 vision encoder 大多衍生自它">CLIP</a> 預訓練的權重起始、再跟 LLM 一起 fine-tune。</p>
<h2 id="概念位置">概念位置</h2>
<p>Vision encoder 在 VLM 中的位置：</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">Input image（如 1024×1024 RGB）
</span></span><span class="line"><span class="ln">2</span><span class="cl">   ↓ 切 patch（如 14×14 patch、每張圖 ~5000 個 patch）
</span></span><span class="line"><span class="ln">3</span><span class="cl">   ↓ Vision encoder（ViT 或 CLIP image encoder）
</span></span><span class="line"><span class="ln">4</span><span class="cl">Image feature vectors（每個 patch 對應一個 768/1024 維向量）
</span></span><span class="line"><span class="ln">5</span><span class="cl">   ↓ Projection layer（vision dim → LLM hidden dim）
</span></span><span class="line"><span class="ln">6</span><span class="cl">[Image tokens](/llm/knowledge-cards/image-token/)（變成 LLM 可吃的「視覺 token」）
</span></span><span class="line"><span class="ln">7</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln">8</span><span class="cl">跟 text token 混合 → Transformer → output token</span></span></code></pre></div><p>主流 vision encoder 設計：</p>
<table>
  <thead>
      <tr>
          <th>設計</th>
          <th>機制</th>
          <th>代表 VLM</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CLIP ViT-L/14（或變體）</td>
          <td>OpenAI CLIP 的 image encoder 直接用</td>
          <td>LLaVA-1.5、Qwen2-VL、Pixtral</td>
      </tr>
      <tr>
          <td>SigLIP</td>
          <td>Google 的 sigmoid-loss CLIP 變體、訓得更穩</td>
          <td>Gemma 3 Vision、Idefics2</td>
      </tr>
      <tr>
          <td>自訓 / 多解析度 ViT</td>
          <td>從頭訓、支援動態解析度（不固定 224×224）</td>
          <td>Qwen2.5-VL、GPT-4V</td>
      </tr>
      <tr>
          <td>Native multimodal（單一網路）</td>
          <td>圖跟文字共用 Transformer、不分開 encoder</td>
          <td>Chameleon（Meta 研究）</td>
      </tr>
  </tbody>
</table>
<p>Vision encoder 的關鍵設計取捨：</p>
<ol>
<li><strong>解析度</strong>：固定（224×224 / 336×336）vs 動態（依輸入圖大小）</li>
<li><strong>參數量</strong>：vision encoder 0.3B-1B 是主流；太小辨識能力差、太大拖累整體推論速度</li>
<li><strong>Pretrain 來源</strong>：用 CLIP / SigLIP 預訓練的權重起始、加上 multimodal fine-tune；少數從頭訓</li>
<li><strong>跟 LLM 結合方式</strong>：見 <a href="/blog/llm/knowledge-cards/multimodal-fusion/" data-link-title="Multimodal Fusion" data-link-desc="VLM 把 vision encoder 跟 LLM 結合的方式：early fusion / cross-attention / native multimodal 三條路線">multimodal fusion</a> 卡</li>
</ol>
<h2 id="設計責任">設計責任</h2>
<p>讀 VLM model card 看到「vision tower」「ViT backbone」「image encoder」就是這部分。寫 code 場景的判讀：</p>
<ol>
<li><strong>解析度影響細節辨識</strong>：低解析度（224）對「截圖中的小字 / 細邊框」可能模糊、看不清；高解析度（1024+）能看清楚但 token 用量大</li>
<li><strong>Token 用量估算</strong>：一張 1024×1024 圖經過 vision encoder 後、產出 ~500-2500 image tokens（依設計）、相當於一段中等長度的文字 prompt</li>
<li><strong>動態解析度模型更實用</strong>：Qwen2.5-VL / GPT-4V 等支援動態解析度、不會把高清截圖縮成 224 失去細節</li>
<li><strong>Vision encoder 不能單獨 fine-tune</strong>：通常跟 LLM 一起訓、單獨換 vision encoder 會破壞 alignment</li>
</ol>
]]></content:encoded></item><item><title>4.1 事件來源、處理流程與狀態邊界</title><link>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/component-boundaries/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/component-boundaries/</guid><description>&lt;p>事件系統的核心邊界是把「收到訊號」、「轉成事件」、「套用規則」、「更新狀態」與「輸出結果」拆開。每個邊界都應該有自己的型別與測試，否則一個 handler 或 worker 很快就會同時負責協定、驗證、去重、狀態與推送。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨 event source、normalizer、processor、repository、publisher 的責任&lt;/li>
&lt;li>用 Go interface 表達元件能力，而不是表達資料夾模板&lt;/li>
&lt;li>把外部格式限制在 adapter 內&lt;/li>
&lt;li>讓狀態更新集中到 repository 或 state owner&lt;/li>
&lt;li>用測試驗證每個邊界是否可替換&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察事件流程容易被寫成一團">【觀察】事件流程容易被寫成一團&lt;/h2>
&lt;p>事件流程膨脹的常見原因是入口程式碼太方便。HTTP handler 可以 decode JSON、驗證欄位、查 map、送通知；worker 也可以讀 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a>、判斷重複、更新狀態、寫 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a>。短期看起來直接，長期會讓每個入口都複製一套規則。&lt;/p>
&lt;p>反模式示意：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">handleCallback&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">raw&lt;/span> &lt;span class="nx">CallbackPayload&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewDecoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Decode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">raw&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">raw&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;missing id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">seen&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">raw&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusNoContent&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nx">seen&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">raw&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="kc">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="nx">states&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">raw&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">AccountID&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;active&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="nx">hub&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Broadcast&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">raw&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">AccountID&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;active&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式的問題是責任混在一起。HTTP 協定、輸入格式、去重策略、狀態更新與推送規則都被綁在同一個函式，任何一項改變都會影響整個入口。&lt;/p>
&lt;h2 id="判讀事件邊界應該按照責任切開">【判讀】事件邊界應該按照責任切開&lt;/h2>
&lt;p>事件邊界的核心規則是每一層只知道自己必須知道的資訊。adapter 知道外部協定，normalizer 知道格式轉換，processor 知道事件規則，repository 知道狀態保存，publisher 知道輸出方式。&lt;/p>
&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">HTTP / queue / timer
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> adapter
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> │ raw input
&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"> normalizer
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> │ DomainEvent
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> processor
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> ├── deduper
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> ├── repository
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> └── publisher&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這是依賴方向的要求。外部來源依賴內部事件模型；內部處理流程不依賴外部 raw payload。&lt;/p>
&lt;h2 id="策略先定義內部事件模型">【策略】先定義內部事件模型&lt;/h2>
&lt;p>內部事件模型的核心責任是提供穩定語意。不同來源可以有不同欄位名稱與時間格式，但進入 processor 前都應轉成同一種事件。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">EventType&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="kd">const&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="nx">EventNotificationCreated&lt;/span> &lt;span class="nx">EventType&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;notification.created&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">EventAccountActivated&lt;/span> &lt;span class="nx">EventType&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;account.activated&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">EventJobFinished&lt;/span> &lt;span class="nx">EventType&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;job.finished&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">EventSource&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="kd">const&lt;/span> &lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">SourceHTTPCallback&lt;/span> &lt;span class="nx">EventSource&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;http_callback&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">SourceQueue&lt;/span> &lt;span class="nx">EventSource&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;queue&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">SourceTimer&lt;/span> &lt;span class="nx">EventSource&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;timer&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">DomainEvent&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="nx">ID&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="nx">Source&lt;/span> &lt;span class="nx">EventSource&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="nx">Type&lt;/span> &lt;span class="nx">EventType&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="nx">SubjectID&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="nx">OccurredAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl"> &lt;span class="nx">ReceivedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="nx">Payload&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">RawMessage&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>OccurredAt&lt;/code> 是事件發生時間，&lt;code>ReceivedAt&lt;/code> 是系統收到時間。這兩個欄位要分開，因為外部事件可能延遲送達；去重與排序通常看事件語意時間，操作監控通常看收到時間。&lt;/p>
&lt;h2 id="執行adapter-只負責外部格式">【執行】adapter 只負責外部格式&lt;/h2>
&lt;p>adapter 的核心責任是把外部輸入轉成內部事件或 command。它可以知道 JSON tag、HTTP status、queue &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack&lt;/a>、header，但不應直接修改狀態。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">CallbackPayload&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">EventID&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;event_id&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">AccountID&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;account_id&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">EventName&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;event_name&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">Timestamp&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;timestamp&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">CallbackHandler&lt;/span> &lt;span class="kd">struct&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="nx">processor&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">EventProcessor&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">now&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">h&lt;/span> &lt;span class="nx">CallbackHandler&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">ServeHTTP&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">payload&lt;/span> &lt;span class="nx">CallbackPayload&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewDecoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Decode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">payload&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;invalid_json&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="nx">event&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NormalizeCallback&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">payload&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">h&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">now&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;invalid_event&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">h&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">processor&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Process&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Context&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusInternalServerError&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;process_event_failed&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">31&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusAccepted&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">32&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>handler 的測試應該檢查 HTTP 行為與 normalize 錯誤對應。事件規則的測試不應透過 HTTP handler 才能執行，否則 processor 的變化會被協定細節干擾。&lt;/p></description><content:encoded><![CDATA[<p>事件系統的核心邊界是把「收到訊號」、「轉成事件」、「套用規則」、「更新狀態」與「輸出結果」拆開。每個邊界都應該有自己的型別與測試，否則一個 handler 或 worker 很快就會同時負責協定、驗證、去重、狀態與推送。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨 event source、normalizer、processor、repository、publisher 的責任</li>
<li>用 Go interface 表達元件能力，而不是表達資料夾模板</li>
<li>把外部格式限制在 adapter 內</li>
<li>讓狀態更新集中到 repository 或 state owner</li>
<li>用測試驗證每個邊界是否可替換</li>
</ol>
<hr>
<h2 id="觀察事件流程容易被寫成一團">【觀察】事件流程容易被寫成一團</h2>
<p>事件流程膨脹的常見原因是入口程式碼太方便。HTTP handler 可以 decode JSON、驗證欄位、查 map、送通知；worker 也可以讀 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a>、判斷重複、更新狀態、寫 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>。短期看起來直接，長期會讓每個入口都複製一套規則。</p>
<p>反模式示意：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">handleCallback</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="kd">var</span> <span class="nx">raw</span> <span class="nx">CallbackPayload</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">json</span><span class="p">.</span><span class="nf">NewDecoder</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">).</span><span class="nf">Decode</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">raw</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">if</span> <span class="nx">raw</span><span class="p">.</span><span class="nx">ID</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;missing id&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="nx">seen</span><span class="p">[</span><span class="nx">raw</span><span class="p">.</span><span class="nx">ID</span><span class="p">]</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusNoContent</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">seen</span><span class="p">[</span><span class="nx">raw</span><span class="p">.</span><span class="nx">ID</span><span class="p">]</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">states</span><span class="p">[</span><span class="nx">raw</span><span class="p">.</span><span class="nx">AccountID</span><span class="p">]</span> <span class="p">=</span> <span class="s">&#34;active&#34;</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="nx">hub</span><span class="p">.</span><span class="nf">Broadcast</span><span class="p">(</span><span class="nx">raw</span><span class="p">.</span><span class="nx">AccountID</span><span class="p">,</span> <span class="s">&#34;active&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式的問題是責任混在一起。HTTP 協定、輸入格式、去重策略、狀態更新與推送規則都被綁在同一個函式，任何一項改變都會影響整個入口。</p>
<h2 id="判讀事件邊界應該按照責任切開">【判讀】事件邊界應該按照責任切開</h2>
<p>事件邊界的核心規則是每一層只知道自己必須知道的資訊。adapter 知道外部協定，normalizer 知道格式轉換，processor 知道事件規則，repository 知道狀態保存，publisher 知道輸出方式。</p>
<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">HTTP / queue / timer
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">        │
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        ▼
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    adapter
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        │ raw input
</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">   normalizer
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        │ DomainEvent
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        ▼
</span></span><span class="line"><span class="ln">10</span><span class="cl">   processor
</span></span><span class="line"><span class="ln">11</span><span class="cl">        │
</span></span><span class="line"><span class="ln">12</span><span class="cl">        ├── deduper
</span></span><span class="line"><span class="ln">13</span><span class="cl">        ├── repository
</span></span><span class="line"><span class="ln">14</span><span class="cl">        └── publisher</span></span></code></pre></div><p>這是依賴方向的要求。外部來源依賴內部事件模型；內部處理流程不依賴外部 raw payload。</p>
<h2 id="策略先定義內部事件模型">【策略】先定義內部事件模型</h2>
<p>內部事件模型的核心責任是提供穩定語意。不同來源可以有不同欄位名稱與時間格式，但進入 processor 前都應轉成同一種事件。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">EventType</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kd">const</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">EventNotificationCreated</span> <span class="nx">EventType</span> <span class="p">=</span> <span class="s">&#34;notification.created&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">EventAccountActivated</span>    <span class="nx">EventType</span> <span class="p">=</span> <span class="s">&#34;account.activated&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">EventJobFinished</span>         <span class="nx">EventType</span> <span class="p">=</span> <span class="s">&#34;job.finished&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">type</span> <span class="nx">EventSource</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="kd">const</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">SourceHTTPCallback</span> <span class="nx">EventSource</span> <span class="p">=</span> <span class="s">&#34;http_callback&#34;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">SourceQueue</span>        <span class="nx">EventSource</span> <span class="p">=</span> <span class="s">&#34;queue&#34;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">SourceTimer</span>        <span class="nx">EventSource</span> <span class="p">=</span> <span class="s">&#34;timer&#34;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="kd">type</span> <span class="nx">DomainEvent</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="nx">ID</span>         <span class="kt">string</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="nx">Source</span>     <span class="nx">EventSource</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="nx">Type</span>       <span class="nx">EventType</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="nx">SubjectID</span>  <span class="kt">string</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="nx">OccurredAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="nx">ReceivedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="nx">Payload</span>    <span class="nx">json</span><span class="p">.</span><span class="nx">RawMessage</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>OccurredAt</code> 是事件發生時間，<code>ReceivedAt</code> 是系統收到時間。這兩個欄位要分開，因為外部事件可能延遲送達；去重與排序通常看事件語意時間，操作監控通常看收到時間。</p>
<h2 id="執行adapter-只負責外部格式">【執行】adapter 只負責外部格式</h2>
<p>adapter 的核心責任是把外部輸入轉成內部事件或 command。它可以知道 JSON tag、HTTP status、queue <a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack</a>、header，但不應直接修改狀態。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">CallbackPayload</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">EventID</span>   <span class="kt">string</span> <span class="s">`json:&#34;event_id&#34;`</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">AccountID</span> <span class="kt">string</span> <span class="s">`json:&#34;account_id&#34;`</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">EventName</span> <span class="kt">string</span> <span class="s">`json:&#34;event_name&#34;`</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">Timestamp</span> <span class="kt">string</span> <span class="s">`json:&#34;timestamp&#34;`</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="kd">type</span> <span class="nx">CallbackHandler</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">processor</span> <span class="o">*</span><span class="nx">EventProcessor</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">now</span>       <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="nx">CallbackHandler</span><span class="p">)</span> <span class="nf">ServeHTTP</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="kd">var</span> <span class="nx">payload</span> <span class="nx">CallbackPayload</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">NewDecoder</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">).</span><span class="nf">Decode</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">payload</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">,</span> <span class="s">&#34;invalid_json&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="nx">event</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">NormalizeCallback</span><span class="p">(</span><span class="nx">payload</span><span class="p">,</span> <span class="nx">h</span><span class="p">.</span><span class="nf">now</span><span class="p">())</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">,</span> <span class="s">&#34;invalid_event&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">h</span><span class="p">.</span><span class="nx">processor</span><span class="p">.</span><span class="nf">Process</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nf">Context</span><span class="p">(),</span> <span class="nx">event</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusInternalServerError</span><span class="p">,</span> <span class="s">&#34;process_event_failed&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">
</span></span><span class="line"><span class="ln">31</span><span class="cl">    <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusAccepted</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>handler 的測試應該檢查 HTTP 行為與 normalize 錯誤對應。事件規則的測試不應透過 HTTP handler 才能執行，否則 processor 的變化會被協定細節干擾。</p>
<h2 id="執行normalizer-負責轉換與基本合約">【執行】normalizer 負責轉換與基本合約</h2>
<p>normalizer 的核心責任是把 raw input 轉成 <code>DomainEvent</code>，並拒絕語意不完整的資料。它是外部世界與內部模型的邊界。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">NormalizeCallback</span><span class="p">(</span><span class="nx">raw</span> <span class="nx">CallbackPayload</span><span class="p">,</span> <span class="nx">receivedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">)</span> <span class="p">(</span><span class="nx">DomainEvent</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">occurredAt</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Parse</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">RFC3339</span><span class="p">,</span> <span class="nx">raw</span><span class="p">.</span><span class="nx">Timestamp</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">return</span> <span class="nx">DomainEvent</span><span class="p">{},</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;parse timestamp: %w&#34;</span><span class="p">,</span> <span class="nx">err</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="nx">event</span> <span class="o">:=</span> <span class="nx">DomainEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>         <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">raw</span><span class="p">.</span><span class="nx">EventID</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">Source</span><span class="p">:</span>     <span class="nx">SourceHTTPCallback</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>       <span class="nf">mapCallbackEventName</span><span class="p">(</span><span class="nx">raw</span><span class="p">.</span><span class="nx">EventName</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span>  <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">raw</span><span class="p">.</span><span class="nx">AccountID</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">occurredAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">ReceivedAt</span><span class="p">:</span> <span class="nx">receivedAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">event</span><span class="p">.</span><span class="nf">Validate</span><span class="p">();</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="k">return</span> <span class="nx">DomainEvent</span><span class="p">{},</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">return</span> <span class="nx">event</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">e</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="nf">Validate</span><span class="p">()</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="k">if</span> <span class="nx">e</span><span class="p">.</span><span class="nx">ID</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;event id is required&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="k">if</span> <span class="nx">e</span><span class="p">.</span><span class="nx">Type</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;event type is required&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="k">if</span> <span class="nx">e</span><span class="p">.</span><span class="nx">SubjectID</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;subject id is required&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">    <span class="k">if</span> <span class="nx">e</span><span class="p">.</span><span class="nx">OccurredAt</span><span class="p">.</span><span class="nf">IsZero</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;occurred at is required&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">    <span class="k">if</span> <span class="nx">e</span><span class="p">.</span><span class="nx">ReceivedAt</span><span class="p">.</span><span class="nf">IsZero</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;received at is required&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>validation 應該保護 envelope 的必要欄位。更細的 payload 規則可以放在特定事件的 normalizer 或 processor，避免 <code>Validate</code> 變成所有事件的巨大規則表。</p>
<h2 id="執行processor-負責事件規則">【執行】processor 負責事件規則</h2>
<p>processor 的核心責任是套用內部事件規則。它可以驗證、去重、更新狀態、寫入事件紀錄、呼叫 publisher，但不應知道 HTTP body 或 queue message 的原始格式。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">EventRepository</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nf">Apply</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kd">type</span> <span class="nx">Deduper</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nf">Seen</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="p">(</span><span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">type</span> <span class="nx">Publisher</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nf">Publish</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="kd">type</span> <span class="nx">EventProcessor</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">deduper</span>    <span class="nx">Deduper</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">repository</span> <span class="nx">EventRepository</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">publisher</span>  <span class="nx">Publisher</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">p</span> <span class="o">*</span><span class="nx">EventProcessor</span><span class="p">)</span> <span class="nf">Process</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">event</span><span class="p">.</span><span class="nf">Validate</span><span class="p">();</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="k">return</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="nx">duplicated</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">p</span><span class="p">.</span><span class="nx">deduper</span><span class="p">.</span><span class="nf">Seen</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;dedup event: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="k">if</span> <span class="nx">duplicated</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">
</span></span><span class="line"><span class="ln">32</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">p</span><span class="p">.</span><span class="nx">repository</span><span class="p">.</span><span class="nf">Apply</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;apply event: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">
</span></span><span class="line"><span class="ln">36</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">p</span><span class="p">.</span><span class="nx">publisher</span><span class="p">.</span><span class="nf">Publish</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;publish event: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">
</span></span><span class="line"><span class="ln">40</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 processor 依賴能力介面，不依賴具體實作。Go 的 implicit interface 讓 memory repository、<a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database</a> repository 或測試 fake 都可以自然接上。</p>
<h2 id="判讀publisher-失敗策略必須明確">【判讀】publisher 失敗策略必須明確</h2>
<p>publisher 的核心問題是「輸出失敗是否影響狀態成功」。即時通知、審計紀錄、外部 <a href="/blog/backend/knowledge-cards/webhook/" data-link-title="Webhook" data-link-desc="說明外部系統回呼事件的接收、驗證與處理邊界">webhook</a> 的可靠性要求不同，不能一律用同一個錯誤策略。</p>
<p>常見策略：</p>
<table>
  <thead>
      <tr>
          <th>輸出類型</th>
          <th>失敗策略</th>
          <th>適用情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>即時 UI 推送</td>
          <td>記錄錯誤，可允許狀態已更新</td>
          <td>客戶端可重新查詢最新狀態</td>
      </tr>
      <tr>
          <td>事件紀錄</td>
          <td>失敗時中止流程</td>
          <td>紀錄是不可遺失的資料</td>
      </tr>
      <tr>
          <td>外部 webhook</td>
          <td>寫入 outbox，稍後重試</td>
          <td>下游需要可靠接收</td>
      </tr>
  </tbody>
</table>
<p>若 <code>repository.Apply</code> 成功但 <code>publisher.Publish</code> 失敗，系統必須知道這是可接受的降級，還是需要重試與補償。這個決策應該寫在 processor 或 usecase 的設計裡，不應藏在 publisher implementation。</p>
<h2 id="測試每個邊界分開測">【測試】每個邊界分開測</h2>
<p>事件邊界的測試目標是讓錯誤定位清楚。normalizer 測 raw input 轉換，processor 測規則順序，repository 測狀態一致性，publisher 測輸出協定。</p>
<p>processor fake test 範例：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestProcessorSkipsDuplicateEvent</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">processor</span> <span class="o">:=</span> <span class="nx">EventProcessor</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">deduper</span><span class="p">:</span>    <span class="nx">fakeDeduper</span><span class="p">{</span><span class="nx">duplicated</span><span class="p">:</span> <span class="kc">true</span><span class="p">},</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">repository</span><span class="p">:</span> <span class="o">&amp;</span><span class="nx">fakeRepository</span><span class="p">{},</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">publisher</span><span class="p">:</span>  <span class="o">&amp;</span><span class="nx">fakePublisher</span><span class="p">{},</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">err</span> <span class="o">:=</span> <span class="nx">processor</span><span class="p">.</span><span class="nf">Process</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">(),</span> <span class="nx">DomainEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>         <span class="s">&#34;evt_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>       <span class="nx">EventAccountActivated</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span>  <span class="s">&#34;acct_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">ReceivedAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;process event: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">if</span> <span class="nx">processor</span><span class="p">.</span><span class="nx">repository</span><span class="p">.(</span><span class="o">*</span><span class="nx">fakeRepository</span><span class="p">).</span><span class="nx">applied</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;duplicate event should not update repository&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這種測試不需要 HTTP server。它直接驗證 processor 的規則：重複事件不應更新狀態，也不應送出推送。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一 Go 服務內的事件來源與處理邊界；分散式一致性與 event sourcing，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">Go 進階：資料庫 transaction 與 schema migration</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Go 進階：Durable queue、outbox 與 idempotency</a></li>
<li><a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">Backend：資料庫與持久化</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 action、event、repository 與 publisher 的邊界；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/new-websocket-action/" data-link-title="6.1 如何新增一個即時訊息 action" data-link-desc="修改 client message、路由與 handler">Go：如何新增一個即時訊息 action</a></li>
<li><a href="/blog/go/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">Go：如何新增一種 domain event</a></li>
<li><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
<li><a href="/blog/go/07-refactoring/hexagonal-migration/" data-link-title="7.6 逐步遷移到 ports/adapters 架構" data-link-desc="用 ports 與 adapters 控制 Go 服務的依賴方向">Go：逐步遷移到 ports/adapters 架構</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>事件系統的可維護性來自清楚邊界：adapter 處理外部格式，normalizer 建立內部事件，processor 套用規則，repository 擁有狀態，publisher 輸出結果。當每個元件只承擔一種責任時，新增來源、新增事件或替換儲存實作都會變成局部修改。</p>
]]></content:encoded></item><item><title>7.1 資料庫 transaction 與 schema migration</title><link>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/database-transactions/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/database-transactions/</guid><description>&lt;p>資料庫整合的核心責任是讓持久化行為符合 application 的狀態規則。Repository port 決定 usecase 需要哪些資料能力；&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/transaction-boundary/" data-link-title="Transaction Boundary" data-link-desc="說明哪些資料變更應在同一個交易中一起成功或一起回復">transaction boundary&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/schema-migration/" data-link-title="Schema Migration" data-link-desc="說明資料庫結構如何隨應用程式版本安全演進">schema migration&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/expand-contract/" data-link-title="Expand / Contract" data-link-desc="說明先擴充相容面、再收斂舊路徑的遷移做法">Expand / Contract&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/isolation-level/" data-link-title="Isolation Level" data-link-desc="說明資料庫交易隔離級別如何影響並發讀寫結果">isolation level&lt;/a> 則決定這些能力在資料庫中如何保持一致。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>判斷 [&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction&lt;/a> boundary](/go-advanced/backend/knowledge-cards/transaction-boundary) 應該放在 repository 還是 usecase&lt;/li>
&lt;li>理解 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">migration&lt;/a> 為什麼要維持向前相容&lt;/li>
&lt;li>分辨 application validation、constraint 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/isolation-level/" data-link-title="Isolation Level" data-link-desc="說明資料庫交易隔離級別如何影響並發讀寫結果">isolation level&lt;/a> 的責任&lt;/li>
&lt;li>用 contract test 保護 memory repository 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database&lt;/a> repository 的一致行為&lt;/li>
&lt;li>讓 SQL 細節留在 adapter，讓 domain 規則留在 application&lt;/li>
&lt;/ol>
&lt;h2 id="前置章節">前置章節&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go 入門：如何新增 repository port&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go 入門：狀態管理的安全邊界&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Go 進階：Source of Truth：狀態邊界&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Backend：Source of Truth&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">Backend：Connection Pool&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="後續撰寫方向">後續撰寫方向&lt;/h2>
&lt;ol>
&lt;li>Repository method 如何表達交易語意，讓 SQL 細節留在 adapter。&lt;/li>
&lt;li>一個 usecase 需要多筆寫入同時成功或失敗時，transaction boundary 應放在哪裡。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">Migration&lt;/a> 如何維持向前相容，避免新舊程式版本互相破壞資料。&lt;/li>
&lt;li>Isolation level、unique constraint 與 application-level validation 如何分工。&lt;/li>
&lt;li>Contract test 如何保護 memory repository 與 database repository 的一致行為。&lt;/li>
&lt;/ol>
&lt;h2 id="觀察transaction-是一致性邊界">【觀察】transaction 是一致性邊界&lt;/h2>
&lt;p>transaction 的核心用途是把一組資料庫操作綁成單一一致性單位。判斷重點是：這個 usecase 哪些狀態要一起成功或一起失敗。效能與寫入便利性都應放在一致性需求之後評估。&lt;/p>
&lt;p>例如建立訂單時，可能同時需要：&lt;/p>
&lt;ul>
&lt;li>寫入 order 主表&lt;/li>
&lt;li>寫入 order items&lt;/li>
&lt;li>更新 inventory&lt;/li>
&lt;li>寫入 outbox event&lt;/li>
&lt;/ul>
&lt;p>如果其中一個步驟失敗，整組操作就應回滾，避免 application 狀態和資料庫狀態分裂。&lt;/p>
&lt;h2 id="判讀transaction-boundary-應該跟-usecase-對齊">【判讀】transaction boundary 應該跟 usecase 對齊&lt;/h2>
&lt;p>交易邊界最常見的錯誤，是把 transaction 放得太低或太高。&lt;/p>
&lt;ul>
&lt;li>放太低：repository 各自開 transaction，usecase 層看起來成功，實際上無法保證整體一致。&lt;/li>
&lt;li>放太高：把不需要一致性的讀取、外部 API、長迴圈也包進 transaction，讓連線被占住太久。&lt;/li>
&lt;/ul>
&lt;p>一般原則是：&lt;/p>
&lt;ul>
&lt;li>要維持同一個 domain 不變式的寫入，應放在同一個 transaction。&lt;/li>
&lt;li>可以重試或可補償的外部互動，通常應放在 transaction 之外。&lt;/li>
&lt;/ul>
&lt;h2 id="策略migration-要讓舊版與新版可以共存">【策略】&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">Migration&lt;/a> 要讓舊版與新版可以共存&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/schema-migration/" data-link-title="Schema Migration" data-link-desc="說明資料庫結構如何隨應用程式版本安全演進">schema migration&lt;/a> 的核心是讓部署期間的新舊版本能同時活著。實務上常見的是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/expand-contract/" data-link-title="Expand / Contract" data-link-desc="說明先擴充相容面、再收斂舊路徑的遷移做法">Expand / Contract&lt;/a> 流程：&lt;/p></description><content:encoded><![CDATA[<p>資料庫整合的核心責任是讓持久化行為符合 application 的狀態規則。Repository port 決定 usecase 需要哪些資料能力；<a href="/blog/backend/knowledge-cards/transaction-boundary/" data-link-title="Transaction Boundary" data-link-desc="說明哪些資料變更應在同一個交易中一起成功或一起回復">transaction boundary</a>、<a href="/blog/backend/knowledge-cards/schema-migration/" data-link-title="Schema Migration" data-link-desc="說明資料庫結構如何隨應用程式版本安全演進">schema migration</a>、<a href="/blog/backend/knowledge-cards/expand-contract/" data-link-title="Expand / Contract" data-link-desc="說明先擴充相容面、再收斂舊路徑的遷移做法">Expand / Contract</a> 與 <a href="/blog/backend/knowledge-cards/isolation-level/" data-link-title="Isolation Level" data-link-desc="說明資料庫交易隔離級別如何影響並發讀寫結果">isolation level</a> 則決定這些能力在資料庫中如何保持一致。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>判斷 [<a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a> boundary](/go-advanced/backend/knowledge-cards/transaction-boundary) 應該放在 repository 還是 usecase</li>
<li>理解 <a href="/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">migration</a> 為什麼要維持向前相容</li>
<li>分辨 application validation、constraint 與 <a href="/blog/backend/knowledge-cards/isolation-level/" data-link-title="Isolation Level" data-link-desc="說明資料庫交易隔離級別如何影響並發讀寫結果">isolation level</a> 的責任</li>
<li>用 contract test 保護 memory repository 與 <a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database</a> repository 的一致行為</li>
<li>讓 SQL 細節留在 adapter，讓 domain 規則留在 application</li>
</ol>
<h2 id="前置章節">前置章節</h2>
<ul>
<li><a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go 入門：如何新增 repository port</a></li>
<li><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go 入門：狀態管理的安全邊界</a></li>
<li><a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Go 進階：Source of Truth：狀態邊界</a></li>
<li><a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Backend：Source of Truth</a></li>
<li><a href="/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">Backend：Connection Pool</a></li>
</ul>
<h2 id="後續撰寫方向">後續撰寫方向</h2>
<ol>
<li>Repository method 如何表達交易語意，讓 SQL 細節留在 adapter。</li>
<li>一個 usecase 需要多筆寫入同時成功或失敗時，transaction boundary 應放在哪裡。</li>
<li><a href="/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">Migration</a> 如何維持向前相容，避免新舊程式版本互相破壞資料。</li>
<li>Isolation level、unique constraint 與 application-level validation 如何分工。</li>
<li>Contract test 如何保護 memory repository 與 database repository 的一致行為。</li>
</ol>
<h2 id="觀察transaction-是一致性邊界">【觀察】transaction 是一致性邊界</h2>
<p>transaction 的核心用途是把一組資料庫操作綁成單一一致性單位。判斷重點是：這個 usecase 哪些狀態要一起成功或一起失敗。效能與寫入便利性都應放在一致性需求之後評估。</p>
<p>例如建立訂單時，可能同時需要：</p>
<ul>
<li>寫入 order 主表</li>
<li>寫入 order items</li>
<li>更新 inventory</li>
<li>寫入 outbox event</li>
</ul>
<p>如果其中一個步驟失敗，整組操作就應回滾，避免 application 狀態和資料庫狀態分裂。</p>
<h2 id="判讀transaction-boundary-應該跟-usecase-對齊">【判讀】transaction boundary 應該跟 usecase 對齊</h2>
<p>交易邊界最常見的錯誤，是把 transaction 放得太低或太高。</p>
<ul>
<li>放太低：repository 各自開 transaction，usecase 層看起來成功，實際上無法保證整體一致。</li>
<li>放太高：把不需要一致性的讀取、外部 API、長迴圈也包進 transaction，讓連線被占住太久。</li>
</ul>
<p>一般原則是：</p>
<ul>
<li>要維持同一個 domain 不變式的寫入，應放在同一個 transaction。</li>
<li>可以重試或可補償的外部互動，通常應放在 transaction 之外。</li>
</ul>
<h2 id="策略migration-要讓舊版與新版可以共存">【策略】<a href="/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">Migration</a> 要讓舊版與新版可以共存</h2>
<p><a href="/blog/backend/knowledge-cards/schema-migration/" data-link-title="Schema Migration" data-link-desc="說明資料庫結構如何隨應用程式版本安全演進">schema migration</a> 的核心是讓部署期間的新舊版本能同時活著。實務上常見的是 <a href="/blog/backend/knowledge-cards/expand-contract/" data-link-title="Expand / Contract" data-link-desc="說明先擴充相容面、再收斂舊路徑的遷移做法">Expand / Contract</a> 流程：</p>
<ol>
<li>先新增欄位、表或索引。</li>
<li>讓新舊程式都能讀寫。</li>
<li>確認流量已切到新版本。</li>
<li>再移除舊欄位或舊邏輯。</li>
</ol>
<p>這樣做的目的，是避免應用版本與資料庫版本在 rolling deploy 時互相踩到。</p>
<h2 id="判讀constraintvalidation-與-isolation-level-各管不同風險">【判讀】constraint、validation 與 isolation level 各管不同風險</h2>
<p>這三者的責任應清楚分工：</p>
<ul>
<li>application validation：在進資料庫前先檢查基本輸入是否合法。</li>
<li>unique / foreign key / check constraint：在資料庫層保底，防止不合法資料落地。</li>
<li>isolation level：處理多交易同時進行時的可見性與衝突問題。</li>
</ul>
<p>如果只靠 application validation，資料庫仍可能被其他路徑寫入不合法資料。如果只靠資料庫 constraint，錯誤回報可能太晚。兩者通常要一起用。</p>
<h2 id="執行contract-test-檢查-repository-語意一致">【執行】contract test 檢查 repository 語意一致</h2>
<p>當你同時有 memory repository 與 database repository 時，測試重點是它們對外暴露的語意是否一致。SQL 細節屬於 database adapter 的內部實作。</p>
<p>通常要測：</p>
<ul>
<li>找不到資料時怎麼回傳</li>
<li>重複寫入時怎麼回傳</li>
<li>transaction 失敗時是否維持一致狀態</li>
<li>欄位驗證與預設值是否相同</li>
</ul>
<p>這類測試可以讓 repository adapter 保持可替換，讓資料庫替換時 usecase 維持穩定。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章不會選定特定資料庫或 ORM。真正的重點是 Go application 如何定義資料一致性責任，讓 SQLite、PostgreSQL 或其他儲存技術都能成為可替換 adapter。</p>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 Go 的 repository port 與狀態邊界；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go：如何新增 repository port</a></li>
<li><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go：狀態管理的安全邊界</a></li>
<li><a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Go 進階：Source of Truth</a></li>
</ul>
]]></content:encoded></item><item><title>0.2 介面 / 伺服器 / 模型三層架構</title><link>https://tarrragon.github.io/blog/llm/00-foundations/three-layer-architecture/</link><pubDate>Mon, 11 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/00-foundations/three-layer-architecture/</guid><description>&lt;p>本地 LLM 生態的核心心智模型是**&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/three-layer-architecture/" data-link-title="Three-Layer Architecture" data-link-desc="把本地 LLM 工具拆成介面層、推論伺服器層、模型權重層的基礎心智模型">三層架構&lt;/a>**：介面層（CLI / UI / Plugin）→ 伺服器層（推論引擎與 API）→ 模型本身（權重檔）。三層之間有明確邊界，每層可以獨立替換；理解這個分層後，看到任何新工具都能立刻判斷它在解哪一層的問題。&lt;/p>
&lt;p>對應到你已經熟悉的雲端世界：ChatGPT 網頁是介面層，OpenAI 的後端服務是伺服器層，GPT-5 模型是模型層。Cursor 是另一個介面層，連到的也是同一批雲端伺服器。介面跟伺服器各自獨立演化，這就是為什麼換介面不用換模型、換模型不用換介面。&lt;/p>
&lt;p>本地 LLM 把這三層全部搬到你的 Mac 上，但分層關係不變。看懂這點，後面所有工具關係就清楚。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>讀完本章後，你應該能：&lt;/p>
&lt;ol>
&lt;li>看到任一個本地 LLM 工具，立刻判斷它屬於哪一層。&lt;/li>
&lt;li>理解為什麼可以「介面換、伺服器留」或「伺服器換、介面留」。&lt;/li>
&lt;li>看懂 &lt;code>localhost:11434&lt;/code> 這類本地 API endpoint 的意義。&lt;/li>
&lt;li>對應雲端世界的工具，建立熟悉感橋接。&lt;/li>
&lt;/ol>
&lt;h2 id="三層的責任邊界">三層的責任邊界&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>層級&lt;/th>
 &lt;th>責任&lt;/th>
 &lt;th>本地代表&lt;/th>
 &lt;th>雲端對應&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>介面層&lt;/td>
 &lt;td>接收使用者輸入、顯示輸出、整合 IDE / 終端機&lt;/td>
 &lt;td>Continue.dev、Open WebUI、aider、CLI&lt;/td>
 &lt;td>ChatGPT 網頁、Cursor、Claude Desktop&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>伺服器層&lt;/td>
 &lt;td>載入模型權重、處理 prompt、產生 token、提供 HTTP API&lt;/td>
 &lt;td>Ollama、LM Studio、llama.cpp &lt;code>server&lt;/code>、oMLX、vLLM&lt;/td>
 &lt;td>OpenAI 後端服務、Anthropic 後端服務&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>模型層&lt;/td>
 &lt;td>神經網路權重檔本身&lt;/td>
 &lt;td>Gemma 4、Qwen3、Llama 3.x、gpt-oss&lt;/td>
 &lt;td>GPT-5、Claude Sonnet、Gemini&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表是後續判讀新工具的基底。任何工具都可以放到這三層的某一格；少數工具同時跨多層（例如 LM Studio 內建介面跟伺服器），但它的功能仍可拆成三層去理解。&lt;/p>
&lt;h2 id="介面層你實際在用的東西">介面層：你實際在用的東西&lt;/h2>
&lt;p>介面層的責任是「人類能舒服地把任務送進去、把結果拿出來」。它本身不跑模型，只是把使用者輸入打包成 API 請求、把 API 回應顯示出來。&lt;/p>
&lt;p>接近真實的例子：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Continue.dev&lt;/strong>：VS Code 擴充套件，把 Cmd+L 開啟側邊對話框、Cmd+I 觸發 inline 編輯。背後送的是 OpenAI 相容 API 請求，target 可以是本地 Ollama 也可以是雲端 OpenAI。&lt;/li>
&lt;li>&lt;strong>aider&lt;/strong>：CLI 工具，把 git 倉庫狀態跟 prompt 一起打包送進 LLM，再把回應的 diff apply 到本機檔案。背後也是送 API 請求。&lt;/li>
&lt;li>&lt;strong>Open WebUI&lt;/strong>：類 ChatGPT 風格的網頁介面，跑在本機 Docker 裡，連到本地或遠端的 LLM API。&lt;/li>
&lt;li>&lt;strong>CLI 直接呼叫&lt;/strong>：&lt;code>ollama run gemma4:31b&lt;/code> 在終端機開一個對話 session，本身也是一個介面層。&lt;/li>
&lt;/ul>
&lt;p>介面層的選擇影響日常使用體驗，但完全不影響推論速度或品質。換介面不用換模型，這就是分層的好處。&lt;/p>
&lt;h2 id="伺服器層載入權重與跑推論">伺服器層：載入權重與跑推論&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/inference-server/" data-link-title="Inference Server" data-link-desc="載入模型權重、處理 prompt、產生 token 的常駐 process">伺服器層&lt;/a>負責把模型權重從磁碟載入記憶體、接收 HTTP API 請求、處理 prompt、跑推論、把生成的 token 流回客戶端。&lt;/p>
&lt;p>接近真實的例子：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;a href="https://tarrragon.github.io/blog/llm/01-local-llm-services/ollama/" data-link-title="1.0 Ollama：主流推論伺服器" data-link-desc="一行 brew 裝完、ollama run 一鍵跑 Gemma 4 MTP、OpenAI 相容 API on localhost:11434">Ollama&lt;/a>&lt;/strong>：最主流的本地推論伺服器、預設聽 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/port-and-localhost/" data-link-title="Port 與 Localhost" data-link-desc="TCP port 與 listen address 如何決定 API server 的對外暴露範圍">&lt;code>localhost:11434&lt;/code>&lt;/a>、提供 OpenAI 相容 API 與自己的原生 API。內建 model registry、&lt;code>ollama pull gemma4:31b&lt;/code> 會自動下載權重檔。&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://tarrragon.github.io/blog/llm/01-local-llm-services/lm-studio/" data-link-title="1.1 LM Studio：GUI 探索模型" data-link-desc="GUI 取向的本地推論伺服器：內建模型瀏覽器、speculative decoding 設定面板、適合探索新模型">LM Studio&lt;/a>&lt;/strong>：GUI 工具、內建模型瀏覽器與本地伺服器。可以在 UI 上開啟 server、預設聽 &lt;code>localhost:1234&lt;/code>。適合喜歡可視化操作、不熟悉終端機的使用者。&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://tarrragon.github.io/blog/llm/01-local-llm-services/llama-cpp/" data-link-title="1.2 llama.cpp：底層推論引擎" data-link-desc="GGUF 格式、量化、MTP 仍 beta；多數讀者不需要直接接觸，Ollama 已經包好">llama.cpp &lt;code>server&lt;/code>&lt;/a>&lt;/strong>：底層推論引擎附帶的 HTTP server、需要手動編譯與配置。Ollama 內部其實是用 llama.cpp 當推論引擎。&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/omlx/" data-link-title="oMLX" data-link-desc="以 MLX 為基礎、針對 Apple Silicon 長 context 與 SSD KV cache 優化的本地推論伺服器路線">oMLX&lt;/a>&lt;/strong>：建在 MLX 之上的特化伺服器、主打 paged SSD KV cache、針對 coding agent 長 context 場景的首字延遲優化。詳見 &lt;a href="https://tarrragon.github.io/blog/llm/00-foundations/mlx-mtp-omlx/" data-link-title="0.4 MLX / MTP / oMLX 的區別" data-link-desc="三個常被混為一談的術語：framework、加速技巧、特化 server，疊加而非互斥">0.4 MLX / MTP / oMLX&lt;/a>。&lt;/li>
&lt;/ul>
&lt;p>伺服器層的選擇影響：&lt;/p></description><content:encoded><![CDATA[<p>本地 LLM 生態的核心心智模型是**<a href="/blog/llm/knowledge-cards/three-layer-architecture/" data-link-title="Three-Layer Architecture" data-link-desc="把本地 LLM 工具拆成介面層、推論伺服器層、模型權重層的基礎心智模型">三層架構</a>**：介面層（CLI / UI / Plugin）→ 伺服器層（推論引擎與 API）→ 模型本身（權重檔）。三層之間有明確邊界，每層可以獨立替換；理解這個分層後，看到任何新工具都能立刻判斷它在解哪一層的問題。</p>
<p>對應到你已經熟悉的雲端世界：ChatGPT 網頁是介面層，OpenAI 的後端服務是伺服器層，GPT-5 模型是模型層。Cursor 是另一個介面層，連到的也是同一批雲端伺服器。介面跟伺服器各自獨立演化，這就是為什麼換介面不用換模型、換模型不用換介面。</p>
<p>本地 LLM 把這三層全部搬到你的 Mac 上，但分層關係不變。看懂這點，後面所有工具關係就清楚。</p>
<h2 id="本章目標">本章目標</h2>
<p>讀完本章後，你應該能：</p>
<ol>
<li>看到任一個本地 LLM 工具，立刻判斷它屬於哪一層。</li>
<li>理解為什麼可以「介面換、伺服器留」或「伺服器換、介面留」。</li>
<li>看懂 <code>localhost:11434</code> 這類本地 API endpoint 的意義。</li>
<li>對應雲端世界的工具，建立熟悉感橋接。</li>
</ol>
<h2 id="三層的責任邊界">三層的責任邊界</h2>
<table>
  <thead>
      <tr>
          <th>層級</th>
          <th>責任</th>
          <th>本地代表</th>
          <th>雲端對應</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>介面層</td>
          <td>接收使用者輸入、顯示輸出、整合 IDE / 終端機</td>
          <td>Continue.dev、Open WebUI、aider、CLI</td>
          <td>ChatGPT 網頁、Cursor、Claude Desktop</td>
      </tr>
      <tr>
          <td>伺服器層</td>
          <td>載入模型權重、處理 prompt、產生 token、提供 HTTP API</td>
          <td>Ollama、LM Studio、llama.cpp <code>server</code>、oMLX、vLLM</td>
          <td>OpenAI 後端服務、Anthropic 後端服務</td>
      </tr>
      <tr>
          <td>模型層</td>
          <td>神經網路權重檔本身</td>
          <td>Gemma 4、Qwen3、Llama 3.x、gpt-oss</td>
          <td>GPT-5、Claude Sonnet、Gemini</td>
      </tr>
  </tbody>
</table>
<p>這張表是後續判讀新工具的基底。任何工具都可以放到這三層的某一格；少數工具同時跨多層（例如 LM Studio 內建介面跟伺服器），但它的功能仍可拆成三層去理解。</p>
<h2 id="介面層你實際在用的東西">介面層：你實際在用的東西</h2>
<p>介面層的責任是「人類能舒服地把任務送進去、把結果拿出來」。它本身不跑模型，只是把使用者輸入打包成 API 請求、把 API 回應顯示出來。</p>
<p>接近真實的例子：</p>
<ul>
<li><strong>Continue.dev</strong>：VS Code 擴充套件，把 Cmd+L 開啟側邊對話框、Cmd+I 觸發 inline 編輯。背後送的是 OpenAI 相容 API 請求，target 可以是本地 Ollama 也可以是雲端 OpenAI。</li>
<li><strong>aider</strong>：CLI 工具，把 git 倉庫狀態跟 prompt 一起打包送進 LLM，再把回應的 diff apply 到本機檔案。背後也是送 API 請求。</li>
<li><strong>Open WebUI</strong>：類 ChatGPT 風格的網頁介面，跑在本機 Docker 裡，連到本地或遠端的 LLM API。</li>
<li><strong>CLI 直接呼叫</strong>：<code>ollama run gemma4:31b</code> 在終端機開一個對話 session，本身也是一個介面層。</li>
</ul>
<p>介面層的選擇影響日常使用體驗，但完全不影響推論速度或品質。換介面不用換模型，這就是分層的好處。</p>
<h2 id="伺服器層載入權重與跑推論">伺服器層：載入權重與跑推論</h2>
<p><a href="/blog/llm/knowledge-cards/inference-server/" data-link-title="Inference Server" data-link-desc="載入模型權重、處理 prompt、產生 token 的常駐 process">伺服器層</a>負責把模型權重從磁碟載入記憶體、接收 HTTP API 請求、處理 prompt、跑推論、把生成的 token 流回客戶端。</p>
<p>接近真實的例子：</p>
<ul>
<li><strong><a href="/blog/llm/01-local-llm-services/ollama/" data-link-title="1.0 Ollama：主流推論伺服器" data-link-desc="一行 brew 裝完、ollama run 一鍵跑 Gemma 4 MTP、OpenAI 相容 API on localhost:11434">Ollama</a></strong>：最主流的本地推論伺服器、預設聽 <a href="/blog/llm/knowledge-cards/port-and-localhost/" data-link-title="Port 與 Localhost" data-link-desc="TCP port 與 listen address 如何決定 API server 的對外暴露範圍"><code>localhost:11434</code></a>、提供 OpenAI 相容 API 與自己的原生 API。內建 model registry、<code>ollama pull gemma4:31b</code> 會自動下載權重檔。</li>
<li><strong><a href="/blog/llm/01-local-llm-services/lm-studio/" data-link-title="1.1 LM Studio：GUI 探索模型" data-link-desc="GUI 取向的本地推論伺服器：內建模型瀏覽器、speculative decoding 設定面板、適合探索新模型">LM Studio</a></strong>：GUI 工具、內建模型瀏覽器與本地伺服器。可以在 UI 上開啟 server、預設聽 <code>localhost:1234</code>。適合喜歡可視化操作、不熟悉終端機的使用者。</li>
<li><strong><a href="/blog/llm/01-local-llm-services/llama-cpp/" data-link-title="1.2 llama.cpp：底層推論引擎" data-link-desc="GGUF 格式、量化、MTP 仍 beta；多數讀者不需要直接接觸，Ollama 已經包好">llama.cpp <code>server</code></a></strong>：底層推論引擎附帶的 HTTP server、需要手動編譯與配置。Ollama 內部其實是用 llama.cpp 當推論引擎。</li>
<li><strong><a href="/blog/llm/knowledge-cards/omlx/" data-link-title="oMLX" data-link-desc="以 MLX 為基礎、針對 Apple Silicon 長 context 與 SSD KV cache 優化的本地推論伺服器路線">oMLX</a></strong>：建在 MLX 之上的特化伺服器、主打 paged SSD KV cache、針對 coding agent 長 context 場景的首字延遲優化。詳見 <a href="/blog/llm/00-foundations/mlx-mtp-omlx/" data-link-title="0.4 MLX / MTP / oMLX 的區別" data-link-desc="三個常被混為一談的術語：framework、加速技巧、特化 server，疊加而非互斥">0.4 MLX / MTP / oMLX</a>。</li>
</ul>
<p>伺服器層的選擇影響：</p>
<ol>
<li><strong>速度</strong>：不同伺服器對量化、KV cache、speculative decoding 的支援度不同。</li>
<li><strong>能跑哪些模型</strong>：每個伺服器支援的模型格式不同（GGUF、MLX、Safetensors 等）。</li>
<li><strong>API 形狀</strong>：多數本地伺服器同時提供「OpenAI 相容」跟「自家原生」兩套 API。詳見 <a href="/blog/llm/00-foundations/openai-compatible-api/" data-link-title="0.3 OpenAI 相容 API" data-link-desc="為什麼幾乎所有本地 LLM 工具不用改就能切到本地：背後是同一套 API 形狀">0.3 OpenAI 相容 API</a>。</li>
</ol>
<p>陷阱是把伺服器跟模型混為一談。「Ollama 跑得快不快」這句話離開模型與機器脈絡就難以判讀、要追問「Ollama 跑哪個模型、在哪台 Mac 上、tok/s 多少」才有意義。伺服器是執行引擎、模型是被執行的對象。</p>
<h2 id="模型層權重檔本身">模型層：權重檔本身</h2>
<p>模型層就是神經網路的權重檔。本身只是一堆數字，沒有伺服器就無法執行；但同一個模型可以被不同伺服器載入，前提是格式相容。</p>
<p>接近真實的例子：</p>
<ul>
<li><strong>Gemma 4 31B</strong>：Google 釋出的開源模型，31 billion 參數。權重檔可以是 <code>gemma-4-31b-it-Q4_K_M.gguf</code>（GGUF 格式、Q4 量化）或 <code>mlx-community/gemma-4-31b-it-4bit</code>（MLX 格式）。</li>
<li><strong>Qwen3-Coder 30B</strong>：Alibaba 釋出的 coding 專用模型、<a href="/blog/llm/knowledge-cards/swe-bench/" data-link-title="SWE-bench" data-link-desc="用真實 GitHub issue 量化 LLM coding 能力的 benchmark">SWE-bench</a> 等 coding benchmark 上表現強。</li>
<li><strong>Llama 3.x 系列</strong>：Meta 釋出的開源模型，是早期本地 LLM 生態的主力。</li>
<li><strong>gpt-oss 20B</strong>：OpenAI 釋出的開源版本，2025 年發布。</li>
</ul>
<p>模型層的關鍵屬性：</p>
<ol>
<li><strong>參數規模</strong>（B = billion）：7B、14B、31B、70B 等。規模越大能力越強，但記憶體佔用、推論速度成本也越高。</li>
<li><strong>量化等級</strong>：bf16、Q8、Q5_K、Q4_K 等。同模型不同量化，記憶體與品質的取捨不同。</li>
<li><strong>格式</strong>：GGUF（llama.cpp 與 Ollama 主流）、MLX（Apple 框架）、Safetensors（Hugging Face 通用）等。不同伺服器支援的格式不同。</li>
<li><strong>訓練目的</strong>：<a href="/blog/llm/knowledge-cards/base-model/" data-link-title="Base Model" data-link-desc="未經指令微調的原始模型：擅長文字接龍、適合下游微調用途">base model</a>、<a href="/blog/llm/knowledge-cards/instruction-tuned/" data-link-title="Instruction-Tuned Model" data-link-desc="經過指令微調的模型：會跟著 prompt 走、回答使用者問題">instruction-tuned</a>、coding-tuned 等。寫 code 場景下 instruction-tuned + coding 版本通常勝過 base model；base model 適合下游微調研究、直接拿來對話的場景較少。</li>
</ol>
<p>模型選擇影響能力與速度。同樣 32GB Mac 跑 Gemma 4 31B 跟 Qwen3-Coder 30B，兩個模型擅長的任務不同，速度也不同。詳見 <a href="/blog/llm/01-local-llm-services/model-selection-priority/" data-link-title="1.4 寫 code 場景的模型選型優先順序" data-link-desc="Gemma 4 31B MTP → Qwen3-Coder 30B → Qwen3 14B → gpt-oss 20B 的取捨與適用情境">模型選型章節</a>。</p>
<h2 id="拼裝組合三層的搭配範例">拼裝組合：三層的搭配範例</h2>
<p>理解三層後，本地 LLM 的所有「組合」都變得簡單。下表是幾個常見組合：</p>
<table>
  <thead>
      <tr>
          <th>介面層</th>
          <th>伺服器層</th>
          <th>模型層</th>
          <th>用途</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Continue.dev</td>
          <td>Ollama</td>
          <td>Gemma 4 31B MTP</td>
          <td>VS Code 寫 code 主力</td>
      </tr>
      <tr>
          <td>Continue.dev</td>
          <td>LM Studio</td>
          <td>Qwen3-Coder 30B</td>
          <td>LM Studio 派的 VS Code 整合</td>
      </tr>
      <tr>
          <td>aider</td>
          <td>Ollama</td>
          <td>Qwen3-Coder 30B</td>
          <td>CLI 寫 code、git-aware</td>
      </tr>
      <tr>
          <td>Open WebUI</td>
          <td>Ollama</td>
          <td>Gemma 4 31B</td>
          <td>類 ChatGPT 網頁、團隊共用</td>
      </tr>
      <tr>
          <td>Ollama CLI</td>
          <td>Ollama</td>
          <td>Llama 3.3 70B Q3</td>
          <td>終端機直接對話、極限模型壓榨</td>
      </tr>
      <tr>
          <td>LM Studio UI</td>
          <td>LM Studio</td>
          <td>任意</td>
          <td>純探索新模型、GUI 派</td>
      </tr>
  </tbody>
</table>
<p>表格中的規格欄位（量化等級、<code>gemma4:31b-coding-mtp-bf16</code> 這類 model tag、Q3 等）含義見 <a href="/blog/llm/00-foundations/hardware-memory-budget/" data-link-title="0.5 Apple Silicon 記憶體預算" data-link-desc="記憶體決定能跑什麼，Q4 量化下的可運作模型對照與系統保留">0.5 記憶體預算</a> 與 <a href="/blog/llm/01-local-llm-services/ollama/#model-tag-%e5%91%bd%e5%90%8d%e8%a6%8f%e5%89%87" data-link-title="1.0 Ollama：主流推論伺服器" data-link-desc="一行 brew 裝完、ollama run 一鍵跑 Gemma 4 MTP、OpenAI 相容 API on localhost:11434">Ollama model tag 命名規則</a>。</p>
<p>注意三件事：</p>
<ol>
<li>介面跟伺服器之間用 HTTP API 通訊，所以介面層可以同時連多個伺服器，或一個伺服器服務多個介面層。</li>
<li>同一個介面（如 Continue.dev）可以同時設定本地 Ollama 跟雲端 OpenAI，根據任務切換。</li>
<li>LM Studio 自己同時是介面 + 伺服器，所以表上有兩列；但它的伺服器部分也可以對外 expose，讓其他介面（如 Continue.dev）連進來。</li>
</ol>
<h2 id="雲端對應關係建立熟悉感橋接">雲端對應關係：建立熟悉感橋接</h2>
<p>下表把本地三層對應到雲端世界，幫助建立直覺：</p>
<table>
  <thead>
      <tr>
          <th>本地</th>
          <th>雲端對應</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Continue.dev</td>
          <td>Cursor</td>
      </tr>
      <tr>
          <td>Open WebUI</td>
          <td>ChatGPT 網頁</td>
      </tr>
      <tr>
          <td>Ollama / LM Studio (server 部分)</td>
          <td>OpenAI / Anthropic 後端服務</td>
      </tr>
      <tr>
          <td>Ollama API on localhost:11434</td>
          <td>api.openai.com</td>
      </tr>
      <tr>
          <td>Gemma 4 31B</td>
          <td>GPT-5、Claude Sonnet 4.6</td>
      </tr>
      <tr>
          <td><code>gemma4:31b-coding-mtp-bf16</code>（模型 tag）</td>
          <td><code>gpt-5</code>、<code>claude-sonnet-4-6</code>（API model name）</td>
      </tr>
  </tbody>
</table>
<p>這個對應的關鍵啟示是：<strong>Cursor 跟 Continue.dev 都是介面層</strong>、差別在於 Cursor 預設綁雲端、Continue.dev 預設綁本地、但兩者的責任邊界一樣。換句話說、要在 VS Code 裡接本地 LLM、不需要尋找專屬「本地版的 Cursor」、找一個能設定 OpenAI 相容 endpoint 的介面層就好。</p>
<h2 id="分層失效徵兆什麼時候三層心智模型會失準">分層失效徵兆：什麼時候三層心智模型會失準</h2>
<p>三層架構是教學用的乾淨模型、實務上有幾類工具會跨層或讓邊界模糊、判讀時要對應調整：</p>
<ol>
<li><strong>同層耦合（介面 + 伺服器綁死）</strong>：LM Studio 的 GUI 跟內建 server 同屬一個 app、關掉 LM Studio 視窗 server 就停。這類工具用起來方便、但失去「介面換、伺服器留」的彈性、想常駐 server 時建議改用 <a href="/blog/llm/01-local-llm-services/ollama/#%e8%83%8c%e6%99%af%e5%b8%b8%e9%a7%90launchd-service" data-link-title="1.0 Ollama：主流推論伺服器" data-link-desc="一行 brew 裝完、ollama run 一鍵跑 Gemma 4 MTP、OpenAI 相容 API on localhost:11434">Ollama 的 launchd service 模式</a>。</li>
<li><strong>伺服器內嵌引擎（責任邊界模糊）</strong>：Ollama 內部用 llama.cpp 當推論引擎、但對使用者展現的是 Ollama API 跟 model tag。看到「Ollama 不支援某個 llama.cpp 新功能」時、要回到 Ollama 的 release notes 看版本 cherry-pick 狀態、不是看 llama.cpp 上游。</li>
<li><strong>All-in-one 工具淡化分層</strong>：Open WebUI 把介面、user 管理、RAG pipeline 都包進一個 Docker container、看起來像「裝完就能用」、但底層仍要連到一個伺服器層（Ollama / OpenAI）。判讀此類工具時、先問「它的 server 是內建還是外接」、就能放回正確的分層。</li>
<li><strong>「Cursor 是本地工具嗎」常見誤判</strong>：Cursor 是介面層、它連的是雲端伺服器層、跑的是雲端模型 — 不是本地工具。對應到本地的是 Continue.dev + Ollama + 本地模型的組合。</li>
</ol>
<p>判讀新工具的反射動作：先把它拆成三層（這工具負責介面 / 伺服器 / 模型 的哪一段？）、再問「它做了多少跨層耦合、影響什麼彈性」。</p>
<h2 id="下一章">下一章</h2>
<p>下一章：<a href="/blog/llm/00-foundations/openai-compatible-api/" data-link-title="0.3 OpenAI 相容 API" data-link-desc="為什麼幾乎所有本地 LLM 工具不用改就能切到本地：背後是同一套 API 形狀">0.3 OpenAI 相容 API</a>，解釋為什麼三層之間能自由組合，背後是同一套 API 形狀。</p>
]]></content:encoded></item><item><title>0.2 組合優先：小介面與明確依賴</title><link>https://tarrragon.github.io/blog/go/00-philosophy/composition/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/00-philosophy/composition/</guid><description>&lt;p>Go 組合的核心原則是用小型型別與小介面拼出行為。程式需要什麼能力，就依賴那個能力；型別擁有哪些資料，就把資料明確放在 struct 裡。這種設計讓高併發服務更容易拆解責任，也更容易在選型成立時維持邊界清楚。&lt;/p>
&lt;h2 id="為什麼這章在第零章">為什麼這章在第零章&lt;/h2>
&lt;p>當你的工作負載本來就適合 Go 時，真正需要確認的就不只是語法，而是 Go 如何幫你把服務邊界維持清楚。組合優先讓 &lt;code>main()&lt;/code>、constructor、handler、worker 與 repository 的責任能被直接看見，這是 Go 在服務型專案中很重要的可維護性來源。&lt;/p>
&lt;h2 id="組合先描述擁有什麼">組合先描述擁有什麼&lt;/h2>
&lt;p>struct 的核心責任是把一組資料與依賴放在同一個明確邊界內。Go 不用 class inheritance 表達「某個型別繼承另一個型別」，而是用欄位組合出需要的結構。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Logger&lt;/span> &lt;span class="kd">interface&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nf">Info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">message&lt;/span> &lt;span class="kt">string&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Server&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="nx">addr&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="nx">logger&lt;/span> &lt;span class="nx">Logger&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>Server&lt;/code> 擁有一個地址，也依賴一個 logger。這些資訊都在欄位上直接呈現，讀者不需要追蹤隱式容器或父類別初始化順序。&lt;/p>
&lt;p>當依賴變多時，struct 仍然應該只保留這個型別真正需要的依賴。把整個 application container 塞進 struct，通常會讓依賴邊界變模糊。&lt;/p>
&lt;h2 id="小介面先描述需要什麼">小介面先描述需要什麼&lt;/h2>
&lt;p>interface 的核心責任是描述呼叫端需要的行為。Go 的介面通常很小，常見的好介面只有一到三個方法。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">UserFinder&lt;/span> &lt;span class="kd">interface&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nf">FindUser&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">id&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">User&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">UserHandler&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="nx">finder&lt;/span> &lt;span class="nx">UserFinder&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>UserHandler&lt;/code> 不需要知道資料來自資料庫、快取或遠端 API。它只需要「可以用 id 找使用者」這個能力，因此介面只放 &lt;code>FindUser&lt;/code>。&lt;/p>
&lt;p>介面應由替換、測試或隔離需求推動。只有一個具體型別，而且沒有測試替身或替換需求時，先直接依賴具體型別通常更簡單。過度抽象的代價是把原本簡單的依賴藏成難追蹤的間接關係，反而比直接依賴更難閱讀。&lt;/p>
&lt;h2 id="依賴由外層組裝">依賴由外層組裝&lt;/h2>
&lt;p>Go 應用組裝依賴的核心策略是讓外層建立具體型別，內層只接收自己需要的依賴。常見位置是 &lt;code>main()&lt;/code> 或專門的 constructor。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">NewUserHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">finder&lt;/span> &lt;span class="nx">UserFinder&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">UserHandler&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">UserHandler&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">finder&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">finder&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">main&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">db&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewDatabase&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;postgres://localhost/app&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">repository&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewUserRepository&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">db&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">handler&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewUserHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">repository&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>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">HandleFunc&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;/users/&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">handler&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ServeHTTP&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式讓資料流與依賴關係保持可見：repository 依賴 db，handler 依賴 repository 的查詢能力。Go 的組合方式偏好把這些關係寫出來，而不是藏在 framework magic 裡。&lt;/p>
&lt;h2 id="行為可以用-embedding-重用">行為可以用 embedding 重用&lt;/h2>
&lt;p>embedding 的核心用途是把一個型別的欄位或方法提升到外層型別。它是組合工具；若需要表達明確擁有關係，named field 會比繼承式心智模型更清楚。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">AuditFields&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">CreatedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">UpdatedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">User&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">ID&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">Email&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">AuditFields&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;code>User&lt;/code> 透過 embedding 擁有 &lt;code>CreatedAt&lt;/code> 與 &lt;code>UpdatedAt&lt;/code>。這適合重用資料欄位，但不代表 &lt;code>User&lt;/code> 在概念上「繼承」了 &lt;code>AuditFields&lt;/code> 的完整行為。&lt;/p>
&lt;p>embedding 應該用在語意自然的地方。若提升方法會讓外層型別出現不該公開的能力，明確寫欄位名稱通常更安全。&lt;/p>
&lt;h2 id="組合讓測試替換更自然">組合讓測試替換更自然&lt;/h2>
&lt;p>組合的測試價值是可以替換依賴，而不需要啟動整個系統。只要 production code 依賴小介面，測試就能提供 fake。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">fakeUserFinder&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">user&lt;/span> &lt;span class="nx">User&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">err&lt;/span> &lt;span class="kt">error&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;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">f&lt;/span> &lt;span class="nx">fakeUserFinder&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">FindUser&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">id&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">User&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">f&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">User&lt;/span>&lt;span class="p">{},&lt;/span> &lt;span class="nx">f&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">err&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">f&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">user&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>測試 handler 時，可以把 &lt;code>fakeUserFinder&lt;/code> 傳進去，專注檢查 HTTP response。這種替換的目標是讓測試只覆蓋當前邊界的行為。&lt;/p></description><content:encoded><![CDATA[<p>Go 組合的核心原則是用小型型別與小介面拼出行為。程式需要什麼能力，就依賴那個能力；型別擁有哪些資料，就把資料明確放在 struct 裡。這種設計讓高併發服務更容易拆解責任，也更容易在選型成立時維持邊界清楚。</p>
<h2 id="為什麼這章在第零章">為什麼這章在第零章</h2>
<p>當你的工作負載本來就適合 Go 時，真正需要確認的就不只是語法，而是 Go 如何幫你把服務邊界維持清楚。組合優先讓 <code>main()</code>、constructor、handler、worker 與 repository 的責任能被直接看見，這是 Go 在服務型專案中很重要的可維護性來源。</p>
<h2 id="組合先描述擁有什麼">組合先描述擁有什麼</h2>
<p>struct 的核心責任是把一組資料與依賴放在同一個明確邊界內。Go 不用 class inheritance 表達「某個型別繼承另一個型別」，而是用欄位組合出需要的結構。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">Logger</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"> <span class="nf">Info</span><span class="p">(</span><span class="nx">message</span> <span class="kt">string</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="kd">type</span> <span class="nx">Server</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"> <span class="nx">addr</span>   <span class="kt">string</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"> <span class="nx">logger</span> <span class="nx">Logger</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>Server</code> 擁有一個地址，也依賴一個 logger。這些資訊都在欄位上直接呈現，讀者不需要追蹤隱式容器或父類別初始化順序。</p>
<p>當依賴變多時，struct 仍然應該只保留這個型別真正需要的依賴。把整個 application container 塞進 struct，通常會讓依賴邊界變模糊。</p>
<h2 id="小介面先描述需要什麼">小介面先描述需要什麼</h2>
<p>interface 的核心責任是描述呼叫端需要的行為。Go 的介面通常很小，常見的好介面只有一到三個方法。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">UserFinder</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"> <span class="nf">FindUser</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">User</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="kd">type</span> <span class="nx">UserHandler</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"> <span class="nx">finder</span> <span class="nx">UserFinder</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>UserHandler</code> 不需要知道資料來自資料庫、快取或遠端 API。它只需要「可以用 id 找使用者」這個能力，因此介面只放 <code>FindUser</code>。</p>
<p>介面應由替換、測試或隔離需求推動。只有一個具體型別，而且沒有測試替身或替換需求時，先直接依賴具體型別通常更簡單。過度抽象的代價是把原本簡單的依賴藏成難追蹤的間接關係，反而比直接依賴更難閱讀。</p>
<h2 id="依賴由外層組裝">依賴由外層組裝</h2>
<p>Go 應用組裝依賴的核心策略是讓外層建立具體型別，內層只接收自己需要的依賴。常見位置是 <code>main()</code> 或專門的 constructor。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">NewUserHandler</span><span class="p">(</span><span class="nx">finder</span> <span class="nx">UserFinder</span><span class="p">)</span> <span class="nx">UserHandler</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"> <span class="k">return</span> <span class="nx">UserHandler</span><span class="p">{</span><span class="nx">finder</span><span class="p">:</span> <span class="nx">finder</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kd">func</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"> <span class="nx">db</span> <span class="o">:=</span> <span class="nf">NewDatabase</span><span class="p">(</span><span class="s">&#34;postgres://localhost/app&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"> <span class="nx">repository</span> <span class="o">:=</span> <span class="nf">NewUserRepository</span><span class="p">(</span><span class="nx">db</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"> <span class="nx">handler</span> <span class="o">:=</span> <span class="nf">NewUserHandler</span><span class="p">(</span><span class="nx">repository</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"> <span class="nx">http</span><span class="p">.</span><span class="nf">HandleFunc</span><span class="p">(</span><span class="s">&#34;/users/&#34;</span><span class="p">,</span> <span class="nx">handler</span><span class="p">.</span><span class="nx">ServeHTTP</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式讓資料流與依賴關係保持可見：repository 依賴 db，handler 依賴 repository 的查詢能力。Go 的組合方式偏好把這些關係寫出來，而不是藏在 framework magic 裡。</p>
<h2 id="行為可以用-embedding-重用">行為可以用 embedding 重用</h2>
<p>embedding 的核心用途是把一個型別的欄位或方法提升到外層型別。它是組合工具；若需要表達明確擁有關係，named field 會比繼承式心智模型更清楚。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">AuditFields</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"> <span class="nx">CreatedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"> <span class="nx">UpdatedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kd">type</span> <span class="nx">User</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"> <span class="nx">ID</span>    <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"> <span class="nx">Email</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"> <span class="nx">AuditFields</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>User</code> 透過 embedding 擁有 <code>CreatedAt</code> 與 <code>UpdatedAt</code>。這適合重用資料欄位，但不代表 <code>User</code> 在概念上「繼承」了 <code>AuditFields</code> 的完整行為。</p>
<p>embedding 應該用在語意自然的地方。若提升方法會讓外層型別出現不該公開的能力，明確寫欄位名稱通常更安全。</p>
<h2 id="組合讓測試替換更自然">組合讓測試替換更自然</h2>
<p>組合的測試價值是可以替換依賴，而不需要啟動整個系統。只要 production code 依賴小介面，測試就能提供 fake。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">fakeUserFinder</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">user</span> <span class="nx">User</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">err</span>  <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">f</span> <span class="nx">fakeUserFinder</span><span class="p">)</span> <span class="nf">FindUser</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">User</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"> <span class="k">if</span> <span class="nx">f</span><span class="p">.</span><span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">     <span class="k">return</span> <span class="nx">User</span><span class="p">{},</span> <span class="nx">f</span><span class="p">.</span><span class="nx">err</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"> <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"> <span class="k">return</span> <span class="nx">f</span><span class="p">.</span><span class="nx">user</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>測試 handler 時，可以把 <code>fakeUserFinder</code> 傳進去，專注檢查 HTTP response。這種替換的目標是讓測試只覆蓋當前邊界的行為。</p>
]]></content:encoded></item><item><title>4.2 事件去重與語義鍵設計</title><link>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/dedup-key/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/dedup-key/</guid><description>&lt;p>事件去重的核心規則是用領域語意判斷「哪兩筆事件代表同一件事」。原始 payload、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/request-id/" data-link-title="Request ID" data-link-desc="說明單次 request 的識別碼如何支援 log 搜尋與問題定位">request ID&lt;/a>、收到時間和重試次數常常每次都不同，直接拿來比對會讓去重失效。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨 event ID 去重與 domain key 去重的差異&lt;/li>
&lt;li>用 subject、event type、source group 與時間窗口設計 &lt;code>DedupKey&lt;/code>&lt;/li>
&lt;li>避免把不穩定欄位放進去重鍵&lt;/li>
&lt;li>設計去重表的過期與清理策略&lt;/li>
&lt;li>用 table-driven test 驗證去重邊界&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察重複事件不一定長得一樣">【觀察】重複事件不一定長得一樣&lt;/h2>
&lt;p>重複事件的核心困難是外觀可能不同。HTTP callback 可能每次都有新的 request ID，&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> message 可能因 retry 改變 delivery tag，timer 可能在下一輪掃描再次產生類似事件。&lt;/p>
&lt;p>兩筆外部輸入可能長這樣：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;request_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;req_1001&amp;#34;&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="nt">&amp;#34;event_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;provider_7788&amp;#34;&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="nt">&amp;#34;account_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;acct_1&amp;#34;&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="nt">&amp;#34;event_name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;activated&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;timestamp&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;2026-04-22T10:00:03Z&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;request_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;req_1002&amp;#34;&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="nt">&amp;#34;event_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;provider_7788_retry&amp;#34;&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="nt">&amp;#34;account_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;acct_1&amp;#34;&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="nt">&amp;#34;event_name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;activated&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;timestamp&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;2026-04-22T10:00:05Z&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>如果直接比對整包 JSON，這兩筆不同；如果從 domain 看，它們可能都是「同一個 account 在同一小段時間內變成 active」。&lt;/p>
&lt;h2 id="判讀去重鍵是語意決策">【判讀】去重鍵是語意決策&lt;/h2>
&lt;p>去重鍵的核心責任是把「相同事件」的定義寫進型別。它不是單純把 payload 做 hash；hash 只能回答 bytes 是否相同，不能回答領域事件是否相同。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">DedupKey&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">SubjectID&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">Type&lt;/span> &lt;span class="nx">EventType&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">SourceSet&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nx">Window&lt;/span> &lt;span class="kt">int64&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個 key 表示：同一個 subject、同一種 event type、同一組來源語意、落在同一個時間窗口的事件，視為同一件事。&lt;/p>
&lt;p>&lt;code>SourceSet&lt;/code> 不一定等於原始來源名稱。多個來源若只是同一件事的不同傳輸管道，可以映射到同一個 source set；若兩個來源代表不同權威資料，則應分開。&lt;/p>
&lt;h2 id="策略先選擇去重層級">【策略】先選擇去重層級&lt;/h2>
&lt;p>去重層級的核心選擇是 event ID、domain key 或兩者並用。不同層級解決的問題不同。&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>event ID&lt;/td>
 &lt;td>外部或內部 event ID 相同&lt;/td>
 &lt;td>上游提供穩定唯一 ID&lt;/td>
 &lt;td>上游 retry 可能換 ID&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>domain key&lt;/td>
 &lt;td>subject、type、時間窗口相同&lt;/td>
 &lt;td>多來源可能描述同一件事&lt;/td>
 &lt;td>key 設太粗會誤殺事件&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>兩者並用&lt;/td>
 &lt;td>event ID 先判斷，再用 domain key 補強&lt;/td>
 &lt;td>上游 ID 大多可信但不完全穩定&lt;/td>
 &lt;td>實作與測試較複雜&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>小型服務可以先使用 domain key。若上游提供可靠 event ID，則 event ID 可以成為第一層快速去重，domain key 作為跨來源重複的保護。&lt;/p>
&lt;h2 id="執行用內部事件建立-dedupkey">【執行】用內部事件建立 DedupKey&lt;/h2>
&lt;p>&lt;code>DedupKey&lt;/code> 應該建立在 &lt;code>DomainEvent&lt;/code> 上，而不是 raw input 上。這能讓 HTTP、queue、timer 進來的同類事件共用去重規則。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">NewDedupKey&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">event&lt;/span> &lt;span class="nx">DomainEvent&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">window&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Duration&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">DedupKey&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">DedupKey&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="nx">SubjectID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SubjectID&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="nx">Type&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Type&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="nx">SourceSet&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nf">sourceSet&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Source&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">Window&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">OccurredAt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">UnixNano&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">/&lt;/span> &lt;span class="nb">int64&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">window&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">sourceSet&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">source&lt;/span> &lt;span class="nx">EventSource&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">switch&lt;/span> &lt;span class="nx">source&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">SourceHTTPCallback&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">SourceQueue&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s">&amp;#34;external_delivery&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">SourceTimer&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s">&amp;#34;internal_scan&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="k">default&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nb">string&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">source&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>OccurredAt&lt;/code> 通常比 &lt;code>ReceivedAt&lt;/code> 更適合事件語意去重。兩筆 retry 可能收到時間不同，但實際描述的發生時間相近；若使用收到時間，系統忙碌或網路延遲就會改變去重結果。&lt;/p>
&lt;h2 id="判讀哪些欄位不該放進-key">【判讀】哪些欄位不該放進 key&lt;/h2>
&lt;p>去重鍵的核心限制是不能包含每次都會變的欄位。這類欄位適合用於追蹤、除錯或觀測，不適合用於判斷是否同一事件。&lt;/p>
&lt;p>不適合放進 key 的欄位：&lt;/p>
&lt;ul>
&lt;li>&lt;code>request_id&lt;/code>：每次 request 都可能不同。&lt;/li>
&lt;li>&lt;code>received_at&lt;/code>：取決於系統接收時間，不一定是事件語意。&lt;/li>
&lt;li>&lt;code>delivery_attempt&lt;/code>：重試次數本身就是重複事件的證據。&lt;/li>
&lt;li>raw payload hash：欄位順序、metadata 或非語意欄位可能改變。&lt;/li>
&lt;li>client IP、瀏覽器識別字串：代表傳輸脈絡，不代表事件本身。&lt;/li>
&lt;/ul>
&lt;p>適合放進 key 的欄位：&lt;/p></description><content:encoded><![CDATA[<p>事件去重的核心規則是用領域語意判斷「哪兩筆事件代表同一件事」。原始 payload、<a href="/blog/backend/knowledge-cards/request-id/" data-link-title="Request ID" data-link-desc="說明單次 request 的識別碼如何支援 log 搜尋與問題定位">request ID</a>、收到時間和重試次數常常每次都不同，直接拿來比對會讓去重失效。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨 event ID 去重與 domain key 去重的差異</li>
<li>用 subject、event type、source group 與時間窗口設計 <code>DedupKey</code></li>
<li>避免把不穩定欄位放進去重鍵</li>
<li>設計去重表的過期與清理策略</li>
<li>用 table-driven test 驗證去重邊界</li>
</ol>
<hr>
<h2 id="觀察重複事件不一定長得一樣">【觀察】重複事件不一定長得一樣</h2>
<p>重複事件的核心困難是外觀可能不同。HTTP callback 可能每次都有新的 request ID，<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> message 可能因 retry 改變 delivery tag，timer 可能在下一輪掃描再次產生類似事件。</p>
<p>兩筆外部輸入可能長這樣：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;request_id&#34;</span><span class="p">:</span> <span class="s2">&#34;req_1001&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;event_id&#34;</span><span class="p">:</span> <span class="s2">&#34;provider_7788&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nt">&#34;account_id&#34;</span><span class="p">:</span> <span class="s2">&#34;acct_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nt">&#34;event_name&#34;</span><span class="p">:</span> <span class="s2">&#34;activated&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nt">&#34;timestamp&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-04-22T10:00:03Z&#34;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;request_id&#34;</span><span class="p">:</span> <span class="s2">&#34;req_1002&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;event_id&#34;</span><span class="p">:</span> <span class="s2">&#34;provider_7788_retry&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nt">&#34;account_id&#34;</span><span class="p">:</span> <span class="s2">&#34;acct_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nt">&#34;event_name&#34;</span><span class="p">:</span> <span class="s2">&#34;activated&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nt">&#34;timestamp&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-04-22T10:00:05Z&#34;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>如果直接比對整包 JSON，這兩筆不同；如果從 domain 看，它們可能都是「同一個 account 在同一小段時間內變成 active」。</p>
<h2 id="判讀去重鍵是語意決策">【判讀】去重鍵是語意決策</h2>
<p>去重鍵的核心責任是把「相同事件」的定義寫進型別。它不是單純把 payload 做 hash；hash 只能回答 bytes 是否相同，不能回答領域事件是否相同。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">DedupKey</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">SubjectID</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">Type</span>      <span class="nx">EventType</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">SourceSet</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">Window</span>    <span class="kt">int64</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 key 表示：同一個 subject、同一種 event type、同一組來源語意、落在同一個時間窗口的事件，視為同一件事。</p>
<p><code>SourceSet</code> 不一定等於原始來源名稱。多個來源若只是同一件事的不同傳輸管道，可以映射到同一個 source set；若兩個來源代表不同權威資料，則應分開。</p>
<h2 id="策略先選擇去重層級">【策略】先選擇去重層級</h2>
<p>去重層級的核心選擇是 event ID、domain key 或兩者並用。不同層級解決的問題不同。</p>
<table>
  <thead>
      <tr>
          <th>去重方式</th>
          <th>判斷依據</th>
          <th>適用情境</th>
          <th>風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>event ID</td>
          <td>外部或內部 event ID 相同</td>
          <td>上游提供穩定唯一 ID</td>
          <td>上游 retry 可能換 ID</td>
      </tr>
      <tr>
          <td>domain key</td>
          <td>subject、type、時間窗口相同</td>
          <td>多來源可能描述同一件事</td>
          <td>key 設太粗會誤殺事件</td>
      </tr>
      <tr>
          <td>兩者並用</td>
          <td>event ID 先判斷，再用 domain key 補強</td>
          <td>上游 ID 大多可信但不完全穩定</td>
          <td>實作與測試較複雜</td>
      </tr>
  </tbody>
</table>
<p>小型服務可以先使用 domain key。若上游提供可靠 event ID，則 event ID 可以成為第一層快速去重，domain key 作為跨來源重複的保護。</p>
<h2 id="執行用內部事件建立-dedupkey">【執行】用內部事件建立 DedupKey</h2>
<p><code>DedupKey</code> 應該建立在 <code>DomainEvent</code> 上，而不是 raw input 上。這能讓 HTTP、queue、timer 進來的同類事件共用去重規則。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">NewDedupKey</span><span class="p">(</span><span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">,</span> <span class="nx">window</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span><span class="p">)</span> <span class="nx">DedupKey</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">return</span> <span class="nx">DedupKey</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span> <span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>      <span class="nx">event</span><span class="p">.</span><span class="nx">Type</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">SourceSet</span><span class="p">:</span> <span class="nf">sourceSet</span><span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">Source</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">Window</span><span class="p">:</span>    <span class="nx">event</span><span class="p">.</span><span class="nx">OccurredAt</span><span class="p">.</span><span class="nf">UnixNano</span><span class="p">()</span> <span class="o">/</span> <span class="nb">int64</span><span class="p">(</span><span class="nx">window</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="kd">func</span> <span class="nf">sourceSet</span><span class="p">(</span><span class="nx">source</span> <span class="nx">EventSource</span><span class="p">)</span> <span class="kt">string</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">switch</span> <span class="nx">source</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">case</span> <span class="nx">SourceHTTPCallback</span><span class="p">,</span> <span class="nx">SourceQueue</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="k">return</span> <span class="s">&#34;external_delivery&#34;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">case</span> <span class="nx">SourceTimer</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="k">return</span> <span class="s">&#34;internal_scan&#34;</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="k">return</span> <span class="nb">string</span><span class="p">(</span><span class="nx">source</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>OccurredAt</code> 通常比 <code>ReceivedAt</code> 更適合事件語意去重。兩筆 retry 可能收到時間不同，但實際描述的發生時間相近；若使用收到時間，系統忙碌或網路延遲就會改變去重結果。</p>
<h2 id="判讀哪些欄位不該放進-key">【判讀】哪些欄位不該放進 key</h2>
<p>去重鍵的核心限制是不能包含每次都會變的欄位。這類欄位適合用於追蹤、除錯或觀測，不適合用於判斷是否同一事件。</p>
<p>不適合放進 key 的欄位：</p>
<ul>
<li><code>request_id</code>：每次 request 都可能不同。</li>
<li><code>received_at</code>：取決於系統接收時間，不一定是事件語意。</li>
<li><code>delivery_attempt</code>：重試次數本身就是重複事件的證據。</li>
<li>raw payload hash：欄位順序、metadata 或非語意欄位可能改變。</li>
<li>client IP、瀏覽器識別字串：代表傳輸脈絡，不代表事件本身。</li>
</ul>
<p>適合放進 key 的欄位：</p>
<ul>
<li>subject ID：事件作用的對象。</li>
<li>event type：發生了什麼事。</li>
<li>source set：資料權威或來源語意。</li>
<li>occurred time window：同一事件可接受的時間範圍。</li>
</ul>
<h2 id="策略時間窗口是取捨">【策略】時間窗口是取捨</h2>
<p>時間窗口的核心作用是容忍短時間內的重送。窗口越短，越不容易誤殺不同事件；窗口越長，越能吸收延遲與 retry。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">const</span> <span class="nx">defaultDedupWindow</span> <span class="p">=</span> <span class="mi">30</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span></span></span></code></pre></div><p>窗口大小應該依事件語意決定：</p>
<table>
  <thead>
      <tr>
          <th>事件類型</th>
          <th>可用窗口</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>account activated</td>
          <td>1-5 分鐘</td>
          <td>同一 account 短時間重複啟用通常是 retry</td>
      </tr>
      <tr>
          <td>notification created</td>
          <td>不一定適合時間窗口</td>
          <td>使用者可能短時間建立多筆通知</td>
      </tr>
      <tr>
          <td>job finished</td>
          <td>30 秒-2 分鐘</td>
          <td>job 完成事件通常只應發生一次</td>
      </tr>
      <tr>
          <td>heartbeat received</td>
          <td>不應去重成單一事件</td>
          <td>heartbeat 本身就是週期訊號</td>
      </tr>
  </tbody>
</table>
<p>時間窗口不是萬用答案。若事件本身允許短時間內多次發生，就需要更細的 subject 或 event ID，而不是把窗口調小到碰運氣。</p>
<h2 id="執行deduper-要保護共享-map">【執行】Deduper 要保護共享 map</h2>
<p>in-memory deduper 的核心責任是記住近期看過的 key，並在多 goroutine 下保持安全。只要 processor 可能同時處理事件，就需要 mutex 或單一 goroutine 擁有去重表。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">Deduper</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">mu</span>      <span class="nx">sync</span><span class="p">.</span><span class="nx">Mutex</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">seen</span>    <span class="kd">map</span><span class="p">[</span><span class="nx">DedupKey</span><span class="p">]</span><span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">window</span>  <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">expires</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="kd">func</span> <span class="nf">NewDeduper</span><span class="p">(</span><span class="nx">window</span><span class="p">,</span> <span class="nx">expires</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span><span class="p">)</span> <span class="o">*</span><span class="nx">Deduper</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">return</span> <span class="o">&amp;</span><span class="nx">Deduper</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">seen</span><span class="p">:</span>    <span class="nb">make</span><span class="p">(</span><span class="kd">map</span><span class="p">[</span><span class="nx">DedupKey</span><span class="p">]</span><span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">window</span><span class="p">:</span>  <span class="nx">window</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">expires</span><span class="p">:</span> <span class="nx">expires</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">d</span> <span class="o">*</span><span class="nx">Deduper</span><span class="p">)</span> <span class="nf">Seen</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="p">(</span><span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="nx">d</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Lock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">defer</span> <span class="nx">d</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Unlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="nx">key</span> <span class="o">:=</span> <span class="nf">NewDedupKey</span><span class="p">(</span><span class="nx">event</span><span class="p">,</span> <span class="nx">d</span><span class="p">.</span><span class="nx">window</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="k">if</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">d</span><span class="p">.</span><span class="nx">seen</span><span class="p">[</span><span class="nx">key</span><span class="p">];</span> <span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="k">return</span> <span class="kc">true</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="nx">d</span><span class="p">.</span><span class="nx">seen</span><span class="p">[</span><span class="nx">key</span><span class="p">]</span> <span class="p">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">ReceivedAt</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="k">return</span> <span class="kc">false</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>ctx</code> 在 memory 實作中可能用不到，但保留在 port 上能讓未來改成 Redis、資料庫或遠端服務時支援取消與逾時。</p>
<h2 id="執行去重表必須清理">【執行】去重表必須清理</h2>
<p>去重表的核心風險是無限制成長。只要把 key 放進 map，就必須定義 key 何時過期。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">d</span> <span class="o">*</span><span class="nx">Deduper</span><span class="p">)</span> <span class="nf">Cleanup</span><span class="p">(</span><span class="nx">now</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">d</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Lock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">d</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Unlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">for</span> <span class="nx">key</span><span class="p">,</span> <span class="nx">seenAt</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">d</span><span class="p">.</span><span class="nx">seen</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">if</span> <span class="nx">now</span><span class="p">.</span><span class="nf">Sub</span><span class="p">(</span><span class="nx">seenAt</span><span class="p">)</span> <span class="p">&gt;</span> <span class="nx">d</span><span class="p">.</span><span class="nx">expires</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="nb">delete</span><span class="p">(</span><span class="nx">d</span><span class="p">.</span><span class="nx">seen</span><span class="p">,</span> <span class="nx">key</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>expires</code> 通常應該大於 <code>window</code>。窗口決定兩筆事件是否可能被視為相同，過期時間決定 key 在記憶體中保留多久；兩者不是同一個概念。</p>
<h2 id="測試用-table-driven-test-固定語意">【測試】用 table-driven test 固定語意</h2>
<p>去重測試的核心目標是把「什麼算相同」寫成案例。這比只測 map 是否有資料更重要。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestDedupKey</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">base</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">tests</span> <span class="o">:=</span> <span class="p">[]</span><span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">name</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">a</span>    <span class="nx">DomainEvent</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">b</span>    <span class="nx">DomainEvent</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">same</span> <span class="kt">bool</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="nx">name</span><span class="p">:</span> <span class="s">&#34;same subject type and window&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="nx">a</span><span class="p">:</span> <span class="nx">DomainEvent</span><span class="p">{</span><span class="nx">SubjectID</span><span class="p">:</span> <span class="s">&#34;acct_1&#34;</span><span class="p">,</span> <span class="nx">Type</span><span class="p">:</span> <span class="nx">EventAccountActivated</span><span class="p">,</span> <span class="nx">Source</span><span class="p">:</span> <span class="nx">SourceHTTPCallback</span><span class="p">,</span> <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">base</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="nx">b</span><span class="p">:</span> <span class="nx">DomainEvent</span><span class="p">{</span><span class="nx">SubjectID</span><span class="p">:</span> <span class="s">&#34;acct_1&#34;</span><span class="p">,</span> <span class="nx">Type</span><span class="p">:</span> <span class="nx">EventAccountActivated</span><span class="p">,</span> <span class="nx">Source</span><span class="p">:</span> <span class="nx">SourceQueue</span><span class="p">,</span> <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">base</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">5</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">)},</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="nx">same</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">            <span class="nx">name</span><span class="p">:</span> <span class="s">&#34;different subject&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">            <span class="nx">a</span><span class="p">:</span> <span class="nx">DomainEvent</span><span class="p">{</span><span class="nx">SubjectID</span><span class="p">:</span> <span class="s">&#34;acct_1&#34;</span><span class="p">,</span> <span class="nx">Type</span><span class="p">:</span> <span class="nx">EventAccountActivated</span><span class="p">,</span> <span class="nx">Source</span><span class="p">:</span> <span class="nx">SourceHTTPCallback</span><span class="p">,</span> <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">base</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">            <span class="nx">b</span><span class="p">:</span> <span class="nx">DomainEvent</span><span class="p">{</span><span class="nx">SubjectID</span><span class="p">:</span> <span class="s">&#34;acct_2&#34;</span><span class="p">,</span> <span class="nx">Type</span><span class="p">:</span> <span class="nx">EventAccountActivated</span><span class="p">,</span> <span class="nx">Source</span><span class="p">:</span> <span class="nx">SourceHTTPCallback</span><span class="p">,</span> <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">base</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">            <span class="nx">same</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">            <span class="nx">name</span><span class="p">:</span> <span class="s">&#34;outside window&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">            <span class="nx">a</span><span class="p">:</span> <span class="nx">DomainEvent</span><span class="p">{</span><span class="nx">SubjectID</span><span class="p">:</span> <span class="s">&#34;acct_1&#34;</span><span class="p">,</span> <span class="nx">Type</span><span class="p">:</span> <span class="nx">EventAccountActivated</span><span class="p">,</span> <span class="nx">Source</span><span class="p">:</span> <span class="nx">SourceHTTPCallback</span><span class="p">,</span> <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">base</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">            <span class="nx">b</span><span class="p">:</span> <span class="nx">DomainEvent</span><span class="p">{</span><span class="nx">SubjectID</span><span class="p">:</span> <span class="s">&#34;acct_1&#34;</span><span class="p">,</span> <span class="nx">Type</span><span class="p">:</span> <span class="nx">EventAccountActivated</span><span class="p">,</span> <span class="nx">Source</span><span class="p">:</span> <span class="nx">SourceHTTPCallback</span><span class="p">,</span> <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">base</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">)},</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">            <span class="nx">same</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">tt</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">tests</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Run</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">name</span><span class="p">,</span> <span class="kd">func</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">            <span class="nx">got</span> <span class="o">:=</span> <span class="nf">NewDedupKey</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">a</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">)</span> <span class="o">==</span> <span class="nf">NewDedupKey</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">b</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">            <span class="k">if</span> <span class="nx">got</span> <span class="o">!=</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">same</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">                <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;same key = %v, want %v&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">same</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">        <span class="p">})</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試把來源融合、subject 差異與時間窗口都明確化。未來調整 key 時，測試會提醒你正在改變事件語意，而不只是改一個 struct。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一服務內的事件去重語意；跨節點一致性與 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> store，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/backend/02-cache-redis/" data-link-title="模組二：快取與 Redis" data-link-desc="整理快取策略、Redis 資料型別與分散式狀態輔助能力">Backend：快取與 Redis</a></li>
<li><a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">Backend：資料庫與持久化</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Go 進階：Durable queue、outbox 與 idempotency</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 event normalization、processor 與 source priority；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">Go：如何新增一種 domain event</a></li>
<li><a href="/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程</a></li>
<li><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
<li><a href="/blog/go/07-refactoring/dedup-refactor/" data-link-title="7.3 事件去重邏輯的重構策略" data-link-desc="保留語義鍵並降低重複流程">Go：事件去重邏輯的重構策略</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>事件去重是領域語意設計，不是 payload 比對。好的 <code>DedupKey</code> 會使用 subject、event type、source set 與合適的 occurred time window，並避免 request ID、收到時間與 raw payload hash 這類不穩定欄位。去重表還必須有清理策略，否則事件系統會用記憶體 leak 換取短期正確性。</p>
]]></content:encoded></item><item><title>6.2 如何新增一種 domain event</title><link>https://tarrragon.github.io/blog/go/06-practical/new-event-type/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/06-practical/new-event-type/</guid><description>&lt;p>新增 domain event 的核心流程是先定義事件語意，再決定哪些外部來源可以轉成這個事件。事件是系統內部對「發生了什麼」的穩定合約，常數清單只是其中一種表達方式。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>判斷 event type 是否代表穩定 domain fact&lt;/li>
&lt;li>用 &lt;code>DomainEvent&lt;/code> envelope 承接不同外部來源&lt;/li>
&lt;li>把 raw input 轉成內部事件，而不是直接更新狀態&lt;/li>
&lt;li>設計 validation 與 dedup key&lt;/li>
&lt;li>把 normalize、processor 與 repository 測試分開&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察event-type-代表已經發生的事">【觀察】event type 代表已經發生的事&lt;/h2>
&lt;p>domain event 的核心語意是「某件對系統有意義的事已經發生」。command 表達意圖，event 表達事實；兩者的命名與處理責任要分開。&lt;/p>
&lt;p>例如：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>名稱&lt;/th>
 &lt;th>類型&lt;/th>
 &lt;th>語意&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>subscribe_topic&lt;/code>&lt;/td>
 &lt;td>command/action&lt;/td>
 &lt;td>client 想訂閱 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>notification.created&lt;/code>&lt;/td>
 &lt;td>event&lt;/td>
 &lt;td>一筆通知已建立&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>job.started&lt;/code>&lt;/td>
 &lt;td>event&lt;/td>
 &lt;td>一個 job 已開始&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>account.status_changed&lt;/code>&lt;/td>
 &lt;td>event&lt;/td>
 &lt;td>account 狀態已改變&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>event type 應該用過去式或事實語氣。&lt;code>notification.created&lt;/code> 比 &lt;code>notification.create&lt;/code> 更能表達事件語意。這個命名差異會影響後續設計：事件處理器應該處理已發生事實，授權與意圖判斷則留在 command/usecase。&lt;/p>
&lt;p>用 typed constant 可以集中 event type：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">EventType&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="kd">const&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="nx">EventNotificationCreated&lt;/span> &lt;span class="nx">EventType&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;notification.created&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nx">EventJobStarted&lt;/span> &lt;span class="nx">EventType&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;job.started&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="nx">EventAccountStatusChanged&lt;/span> &lt;span class="nx">EventType&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;account.status_changed&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>若 event type 只是為了對應外部欄位名稱，還沒有內部語意，應先把外部 raw input normalize，再判斷它是否真的代表一個穩定事實。&lt;/p>
&lt;h2 id="判讀domainevent-是內部事件合約">【判讀】DomainEvent 是內部事件合約&lt;/h2>
&lt;p>&lt;code>DomainEvent&lt;/code> 的核心責任是提供統一 envelope，讓不同來源的事件進入系統後使用同一種語意模型。HTTP callback、client action、timer 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> message 都可以被 adapter 轉成 &lt;code>DomainEvent&lt;/code>。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">EventSource&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="kd">const&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="nx">SourceClientCommand&lt;/span> &lt;span class="nx">EventSource&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;client_command&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">SourceHTTPCallback&lt;/span> &lt;span class="nx">EventSource&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;http_callback&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">SourceTimer&lt;/span> &lt;span class="nx">EventSource&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;timer&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">SubjectKind&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="kd">const&lt;/span> &lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">SubjectNotification&lt;/span> &lt;span class="nx">SubjectKind&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;notification&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">SubjectJob&lt;/span> &lt;span class="nx">SubjectKind&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;job&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">SubjectAccount&lt;/span> &lt;span class="nx">SubjectKind&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;account&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">DomainEvent&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="nx">ID&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;id&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="nx">Source&lt;/span> &lt;span class="nx">EventSource&lt;/span> &lt;span class="s">`json:&amp;#34;source&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="nx">Type&lt;/span> &lt;span class="nx">EventType&lt;/span> &lt;span class="s">`json:&amp;#34;type&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="nx">SubjectID&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;subjectId&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="nx">SubjectKind&lt;/span> &lt;span class="nx">SubjectKind&lt;/span> &lt;span class="s">`json:&amp;#34;subjectKind&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl"> &lt;span class="nx">CorrelationID&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;correlationId,omitempty&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="nx">CausationID&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;causationId,omitempty&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl"> &lt;span class="nx">OccurredAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span> &lt;span class="s">`json:&amp;#34;occurredAt&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl"> &lt;span class="nx">ReceivedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span> &lt;span class="s">`json:&amp;#34;receivedAt&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl"> &lt;span class="nx">SchemaVersion&lt;/span> &lt;span class="kt">int&lt;/span> &lt;span class="s">`json:&amp;#34;schemaVersion&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl"> &lt;span class="nx">Payload&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">RawMessage&lt;/span> &lt;span class="s">`json:&amp;#34;payload,omitempty&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>SubjectID&lt;/code> 和 &lt;code>SubjectKind&lt;/code> 用來描述事件作用在哪個對象上。&lt;code>OccurredAt&lt;/code> 表示事件實際發生時間，&lt;code>ReceivedAt&lt;/code> 表示系統收到事件的時間。這兩個時間不能混用，因為外部事件可能延遲送達。&lt;/p>
&lt;p>&lt;code>CorrelationID&lt;/code> 用來串起同一個使用者操作或 request 造成的一串事件。&lt;code>CausationID&lt;/code> 用來記錄這筆事件是由哪個 command 或事件引起。初期可以先保留欄位，不必一開始就建立完整 tracing 系統。&lt;/p>
&lt;h2 id="策略payload-是補充資料">【策略】payload 是補充資料&lt;/h2>
&lt;p>event envelope 的核心語意應該放在固定欄位。&lt;code>Payload&lt;/code> 適合存事件特有資料，事件分類、主體、時間與來源則應維持在第一層欄位。&lt;/p>
&lt;p>例如通知建立事件的 payload 可以是：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">NotificationCreatedPayload&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">Topic&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;topic&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">Title&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;title&amp;#34;`&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>建立 event 時，把穩定欄位放在 envelope：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">NewNotificationCreatedEvent&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">id&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">notificationID&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">payload&lt;/span> &lt;span class="nx">NotificationCreatedPayload&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">now&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">DomainEvent&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">data&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Marshal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">payload&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">DomainEvent&lt;/span>&lt;span class="p">{},&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Errorf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;marshal notification payload: %w&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&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="k">return&lt;/span> &lt;span class="nx">DomainEvent&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">ID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">id&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="nx">Source&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">SourceClientCommand&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="nx">Type&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">EventNotificationCreated&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">SubjectID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">notificationID&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">SubjectKind&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">SubjectNotification&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">OccurredAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">now&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">ReceivedAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">now&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nx">SchemaVersion&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="nx">Payload&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="p">},&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個函式把事件建立規則集中起來。未來若要補 schema version、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/correlation-id/" data-link-title="Correlation ID" data-link-desc="說明跨事件或跨服務的關聯識別碼如何支援排障">correlation ID&lt;/a> 或預設時間，也不需要在每個呼叫端重複組 struct。&lt;/p></description><content:encoded><![CDATA[<p>新增 domain event 的核心流程是先定義事件語意，再決定哪些外部來源可以轉成這個事件。事件是系統內部對「發生了什麼」的穩定合約，常數清單只是其中一種表達方式。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>判斷 event type 是否代表穩定 domain fact</li>
<li>用 <code>DomainEvent</code> envelope 承接不同外部來源</li>
<li>把 raw input 轉成內部事件，而不是直接更新狀態</li>
<li>設計 validation 與 dedup key</li>
<li>把 normalize、processor 與 repository 測試分開</li>
</ol>
<hr>
<h2 id="觀察event-type-代表已經發生的事">【觀察】event type 代表已經發生的事</h2>
<p>domain event 的核心語意是「某件對系統有意義的事已經發生」。command 表達意圖，event 表達事實；兩者的命名與處理責任要分開。</p>
<p>例如：</p>
<table>
  <thead>
      <tr>
          <th>名稱</th>
          <th>類型</th>
          <th>語意</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>subscribe_topic</code></td>
          <td>command/action</td>
          <td>client 想訂閱 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a></td>
      </tr>
      <tr>
          <td><code>notification.created</code></td>
          <td>event</td>
          <td>一筆通知已建立</td>
      </tr>
      <tr>
          <td><code>job.started</code></td>
          <td>event</td>
          <td>一個 job 已開始</td>
      </tr>
      <tr>
          <td><code>account.status_changed</code></td>
          <td>event</td>
          <td>account 狀態已改變</td>
      </tr>
  </tbody>
</table>
<p>event type 應該用過去式或事實語氣。<code>notification.created</code> 比 <code>notification.create</code> 更能表達事件語意。這個命名差異會影響後續設計：事件處理器應該處理已發生事實，授權與意圖判斷則留在 command/usecase。</p>
<p>用 typed constant 可以集中 event type：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">EventType</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kd">const</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">EventNotificationCreated</span> <span class="nx">EventType</span> <span class="p">=</span> <span class="s">&#34;notification.created&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">EventJobStarted</span>          <span class="nx">EventType</span> <span class="p">=</span> <span class="s">&#34;job.started&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">EventAccountStatusChanged</span> <span class="nx">EventType</span> <span class="p">=</span> <span class="s">&#34;account.status_changed&#34;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p>若 event type 只是為了對應外部欄位名稱，還沒有內部語意，應先把外部 raw input normalize，再判斷它是否真的代表一個穩定事實。</p>
<h2 id="判讀domainevent-是內部事件合約">【判讀】DomainEvent 是內部事件合約</h2>
<p><code>DomainEvent</code> 的核心責任是提供統一 envelope，讓不同來源的事件進入系統後使用同一種語意模型。HTTP callback、client action、timer 或 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> message 都可以被 adapter 轉成 <code>DomainEvent</code>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">EventSource</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kd">const</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">SourceClientCommand</span> <span class="nx">EventSource</span> <span class="p">=</span> <span class="s">&#34;client_command&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">SourceHTTPCallback</span>  <span class="nx">EventSource</span> <span class="p">=</span> <span class="s">&#34;http_callback&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">SourceTimer</span>         <span class="nx">EventSource</span> <span class="p">=</span> <span class="s">&#34;timer&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">type</span> <span class="nx">SubjectKind</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="kd">const</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">SubjectNotification</span> <span class="nx">SubjectKind</span> <span class="p">=</span> <span class="s">&#34;notification&#34;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">SubjectJob</span>          <span class="nx">SubjectKind</span> <span class="p">=</span> <span class="s">&#34;job&#34;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">SubjectAccount</span>      <span class="nx">SubjectKind</span> <span class="p">=</span> <span class="s">&#34;account&#34;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="kd">type</span> <span class="nx">DomainEvent</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="nx">ID</span>            <span class="kt">string</span>          <span class="s">`json:&#34;id&#34;`</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="nx">Source</span>        <span class="nx">EventSource</span>     <span class="s">`json:&#34;source&#34;`</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="nx">Type</span>          <span class="nx">EventType</span>       <span class="s">`json:&#34;type&#34;`</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="nx">SubjectID</span>     <span class="kt">string</span>          <span class="s">`json:&#34;subjectId&#34;`</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="nx">SubjectKind</span>   <span class="nx">SubjectKind</span>     <span class="s">`json:&#34;subjectKind&#34;`</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="nx">CorrelationID</span> <span class="kt">string</span>          <span class="s">`json:&#34;correlationId,omitempty&#34;`</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="nx">CausationID</span>   <span class="kt">string</span>          <span class="s">`json:&#34;causationId,omitempty&#34;`</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="nx">OccurredAt</span>    <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>       <span class="s">`json:&#34;occurredAt&#34;`</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="nx">ReceivedAt</span>    <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>       <span class="s">`json:&#34;receivedAt&#34;`</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="nx">SchemaVersion</span> <span class="kt">int</span>             <span class="s">`json:&#34;schemaVersion&#34;`</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="nx">Payload</span>       <span class="nx">json</span><span class="p">.</span><span class="nx">RawMessage</span> <span class="s">`json:&#34;payload,omitempty&#34;`</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>SubjectID</code> 和 <code>SubjectKind</code> 用來描述事件作用在哪個對象上。<code>OccurredAt</code> 表示事件實際發生時間，<code>ReceivedAt</code> 表示系統收到事件的時間。這兩個時間不能混用，因為外部事件可能延遲送達。</p>
<p><code>CorrelationID</code> 用來串起同一個使用者操作或 request 造成的一串事件。<code>CausationID</code> 用來記錄這筆事件是由哪個 command 或事件引起。初期可以先保留欄位，不必一開始就建立完整 tracing 系統。</p>
<h2 id="策略payload-是補充資料">【策略】payload 是補充資料</h2>
<p>event envelope 的核心語意應該放在固定欄位。<code>Payload</code> 適合存事件特有資料，事件分類、主體、時間與來源則應維持在第一層欄位。</p>
<p>例如通知建立事件的 payload 可以是：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">NotificationCreatedPayload</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">Topic</span> <span class="kt">string</span> <span class="s">`json:&#34;topic&#34;`</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">Title</span> <span class="kt">string</span> <span class="s">`json:&#34;title&#34;`</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>建立 event 時，把穩定欄位放在 envelope：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">NewNotificationCreatedEvent</span><span class="p">(</span><span class="nx">id</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">notificationID</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">payload</span> <span class="nx">NotificationCreatedPayload</span><span class="p">,</span> <span class="nx">now</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">)</span> <span class="p">(</span><span class="nx">DomainEvent</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">data</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">Marshal</span><span class="p">(</span><span class="nx">payload</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">return</span> <span class="nx">DomainEvent</span><span class="p">{},</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;marshal notification payload: %w&#34;</span><span class="p">,</span> <span class="nx">err</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="k">return</span> <span class="nx">DomainEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>            <span class="nx">id</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">Source</span><span class="p">:</span>        <span class="nx">SourceClientCommand</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>          <span class="nx">EventNotificationCreated</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span>     <span class="nx">notificationID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">SubjectKind</span><span class="p">:</span>   <span class="nx">SubjectNotification</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span>    <span class="nx">now</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="nx">ReceivedAt</span><span class="p">:</span>    <span class="nx">now</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="nx">SchemaVersion</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="nx">Payload</span><span class="p">:</span>       <span class="nx">data</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">},</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個函式把事件建立規則集中起來。未來若要補 schema version、<a href="/blog/backend/knowledge-cards/correlation-id/" data-link-title="Correlation ID" data-link-desc="說明跨事件或跨服務的關聯識別碼如何支援排障">correlation ID</a> 或預設時間，也不需要在每個呼叫端重複組 struct。</p>
<h2 id="執行adapter-負責-raw-input-轉換">【執行】adapter 負責 raw input 轉換</h2>
<p>adapter 的核心責任是把外部格式轉成內部事件。repository 更新交給 processor 或 usecase，raw payload 也應先轉成內部穩定模型再進入 domain layer。</p>
<p>假設外部 HTTP callback 長這樣：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">RawNotificationCallback</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">EventID</span>        <span class="kt">string</span> <span class="s">`json:&#34;event_id&#34;`</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">NotificationID</span> <span class="kt">string</span> <span class="s">`json:&#34;notification_id&#34;`</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">EventName</span>      <span class="kt">string</span> <span class="s">`json:&#34;event_name&#34;`</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">Topic</span>          <span class="kt">string</span> <span class="s">`json:&#34;topic&#34;`</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">Title</span>          <span class="kt">string</span> <span class="s">`json:&#34;title&#34;`</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nx">Timestamp</span>      <span class="kt">string</span> <span class="s">`json:&#34;timestamp&#34;`</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>normalize 函式可以把它轉成 <code>DomainEvent</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">NormalizeNotificationCallback</span><span class="p">(</span><span class="nx">raw</span> <span class="nx">RawNotificationCallback</span><span class="p">,</span> <span class="nx">receivedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">)</span> <span class="p">(</span><span class="nx">DomainEvent</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">occurredAt</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Parse</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">RFC3339</span><span class="p">,</span> <span class="nx">raw</span><span class="p">.</span><span class="nx">Timestamp</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">return</span> <span class="nx">DomainEvent</span><span class="p">{},</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;parse callback timestamp: %w&#34;</span><span class="p">,</span> <span class="nx">err</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="nx">payload</span> <span class="o">:=</span> <span class="nx">NotificationCreatedPayload</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span> <span class="nx">raw</span><span class="p">.</span><span class="nx">Topic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span> <span class="nx">raw</span><span class="p">.</span><span class="nx">Title</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">data</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">Marshal</span><span class="p">(</span><span class="nx">payload</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="k">return</span> <span class="nx">DomainEvent</span><span class="p">{},</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;marshal callback payload: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="nx">event</span> <span class="o">:=</span> <span class="nx">DomainEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>            <span class="nx">raw</span><span class="p">.</span><span class="nx">EventID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="nx">Source</span><span class="p">:</span>        <span class="nx">SourceHTTPCallback</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>          <span class="nx">EventNotificationCreated</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span>     <span class="nx">raw</span><span class="p">.</span><span class="nx">NotificationID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="nx">SubjectKind</span><span class="p">:</span>   <span class="nx">SubjectNotification</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span>    <span class="nx">occurredAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">        <span class="nx">ReceivedAt</span><span class="p">:</span>    <span class="nx">receivedAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="nx">SchemaVersion</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">        <span class="nx">Payload</span><span class="p">:</span>       <span class="nx">data</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">event</span><span class="p">.</span><span class="nf">Validate</span><span class="p">();</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">        <span class="k">return</span> <span class="nx">DomainEvent</span><span class="p">{},</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">
</span></span><span class="line"><span class="ln">33</span><span class="cl">    <span class="k">return</span> <span class="nx">event</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式把外部欄位名稱、時間格式與 payload 組裝限制在 adapter 內。下游 processor 只需要理解 <code>DomainEvent</code>，不需要知道 callback 原始格式。</p>
<h2 id="判讀validation-保護事件合約">【判讀】validation 保護事件合約</h2>
<p>event validation 的核心目標是確保事件語意完整。缺少 ID、type、subject 或時間的 event 進入 processor 後，狀態更新與去重都會失去依據。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">e</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="nf">Validate</span><span class="p">()</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">if</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">ID</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;event id is required&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">if</span> <span class="nx">e</span><span class="p">.</span><span class="nx">Type</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;event type is required&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">if</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;subject id is required&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">if</span> <span class="nx">e</span><span class="p">.</span><span class="nx">SubjectKind</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;subject kind is required&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">if</span> <span class="nx">e</span><span class="p">.</span><span class="nx">OccurredAt</span><span class="p">.</span><span class="nf">IsZero</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;occurred at is required&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">if</span> <span class="nx">e</span><span class="p">.</span><span class="nx">ReceivedAt</span><span class="p">.</span><span class="nf">IsZero</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;received at is required&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">if</span> <span class="nx">e</span><span class="p">.</span><span class="nx">SchemaVersion</span> <span class="o">&lt;=</span> <span class="mi">0</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;schema version must be positive&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>validation 應該檢查事件 envelope 的基本合約。更細的 payload 規則可以在 normalize 或 processor 中處理，依資料來源與用途決定。</p>
<h2 id="策略去重鍵應建立在-domain-語意上">【策略】去重鍵應建立在 domain 語意上</h2>
<p>event dedup 的核心規則是使用語意鍵。不同來源可能用不同格式描述同一件事，但只要 subject、type 與時間窗口相同，就可能是重複事件。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">DedupKey</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">SubjectKind</span> <span class="nx">SubjectKind</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">SubjectID</span>   <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">Type</span>        <span class="nx">EventType</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">Window</span>      <span class="kt">int64</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="kd">func</span> <span class="nf">NewDedupKey</span><span class="p">(</span><span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">,</span> <span class="nx">window</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span><span class="p">)</span> <span class="nx">DedupKey</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">return</span> <span class="nx">DedupKey</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">SubjectKind</span><span class="p">:</span> <span class="nx">event</span><span class="p">.</span><span class="nx">SubjectKind</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span>   <span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>        <span class="nx">event</span><span class="p">.</span><span class="nx">Type</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">Window</span><span class="p">:</span>      <span class="nx">event</span><span class="p">.</span><span class="nx">OccurredAt</span><span class="p">.</span><span class="nf">UnixNano</span><span class="p">()</span> <span class="o">/</span> <span class="nb">int64</span><span class="p">(</span><span class="nx">window</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>去重鍵應排除 <code>ReceivedAt</code>、raw metadata 或 <a href="/blog/backend/knowledge-cards/request-id/" data-link-title="Request ID" data-link-desc="說明單次 request 的識別碼如何支援 log 搜尋與問題定位">request ID</a> 這類每次都可能不同的欄位。那些欄位適合記錄觀測資訊，不適合作為「是否同一件事」的判斷依據。</p>
<p>若事件 ID 由可靠上游產生，可以優先用 event ID 去重。若上游 ID 不穩定，才需要 domain dedup key。這個選擇應該寫成明確規則，讓 map key 的組成方式對應可理解的去重語意。</p>
<h2 id="執行processor-負責套用事件規則">【執行】processor 負責套用事件規則</h2>
<p>event processor 的核心責任是驗證、去重、更新狀態與發布結果。processor 不負責讀 HTTP request，也不負責解析 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> message。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">EventRepository</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nf">Apply</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kd">type</span> <span class="nx">EventPublisher</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nf">Publish</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">type</span> <span class="nx">Deduper</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nf">Seen</span><span class="p">(</span><span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">bool</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>processor 可以依賴這些小介面：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">EventProcessor</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">repository</span> <span class="nx">EventRepository</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">publisher</span>  <span class="nx">EventPublisher</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">deduper</span>    <span class="nx">Deduper</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="kd">func</span> <span class="nf">NewEventProcessor</span><span class="p">(</span><span class="nx">repository</span> <span class="nx">EventRepository</span><span class="p">,</span> <span class="nx">publisher</span> <span class="nx">EventPublisher</span><span class="p">,</span> <span class="nx">deduper</span> <span class="nx">Deduper</span><span class="p">)</span> <span class="o">*</span><span class="nx">EventProcessor</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">return</span> <span class="o">&amp;</span><span class="nx">EventProcessor</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">repository</span><span class="p">:</span> <span class="nx">repository</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">publisher</span><span class="p">:</span>  <span class="nx">publisher</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">deduper</span><span class="p">:</span>    <span class="nx">deduper</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>處理流程保持短而明確：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">p</span> <span class="o">*</span><span class="nx">EventProcessor</span><span class="p">)</span> <span class="nf">Process</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">event</span><span class="p">.</span><span class="nf">Validate</span><span class="p">();</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;validate event: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">if</span> <span class="nx">p</span><span class="p">.</span><span class="nx">deduper</span><span class="p">.</span><span class="nf">Seen</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">p</span><span class="p">.</span><span class="nx">repository</span><span class="p">.</span><span class="nf">Apply</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;apply event: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">p</span><span class="p">.</span><span class="nx">publisher</span><span class="p">.</span><span class="nf">Publish</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;publish event: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 processor 不知道資料來自哪裡，也不知道 repository 是 memory map 還是資料庫。這種邊界讓新增 event source 時不需要重寫狀態規則。</p>
<h2 id="執行normalize-測試要固定外部輸入">【執行】normalize 測試要固定外部輸入</h2>
<p>normalize 測試的核心目標是確認 raw input 會被轉成正確內部事件。這類測試應該固定時間，避免測試依賴真實現在時間。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestNormalizeNotificationCallback</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">receivedAt</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">raw</span> <span class="o">:=</span> <span class="nx">RawNotificationCallback</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">EventID</span><span class="p">:</span>        <span class="s">&#34;evt_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">NotificationID</span><span class="p">:</span> <span class="s">&#34;ntf_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">EventName</span><span class="p">:</span>      <span class="s">&#34;notification_created&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span>          <span class="s">&#34;deployments&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span>          <span class="s">&#34;Deploy finished&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">Timestamp</span><span class="p">:</span>      <span class="s">&#34;2026-04-22T10:00:00Z&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">event</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">NormalizeNotificationCallback</span><span class="p">(</span><span class="nx">raw</span><span class="p">,</span> <span class="nx">receivedAt</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;normalize callback: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">if</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Type</span> <span class="o">!=</span> <span class="nx">EventNotificationCreated</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;event type = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Type</span><span class="p">,</span> <span class="nx">EventNotificationCreated</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">if</span> <span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</span> <span class="o">!=</span> <span class="s">&#34;ntf_1&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;subject ID = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">,</span> <span class="s">&#34;ntf_1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">event</span><span class="p">.</span><span class="nx">ReceivedAt</span><span class="p">.</span><span class="nf">Equal</span><span class="p">(</span><span class="nx">receivedAt</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;received at = %v, want %v&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">ReceivedAt</span><span class="p">,</span> <span class="nx">receivedAt</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試不需要 repository，也不需要 publisher。它只保護 adapter 的轉換規則。</p>
<h2 id="執行processor-測試要隔離外部來源">【執行】processor 測試要隔離外部來源</h2>
<p>processor 測試的核心目標是確認事件規則被正確套用。測試應該直接建立 <code>DomainEvent</code>，讓 HTTP 或 WebSocket 解析留在 adapter 測試。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">fakeEventRepository</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">applied</span> <span class="p">[]</span><span class="nx">DomainEvent</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">f</span> <span class="o">*</span><span class="nx">fakeEventRepository</span><span class="p">)</span> <span class="nf">Apply</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">f</span><span class="p">.</span><span class="nx">applied</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">f</span><span class="p">.</span><span class="nx">applied</span><span class="p">,</span> <span class="nx">event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="kd">type</span> <span class="nx">fakeEventPublisher</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">published</span> <span class="p">[]</span><span class="nx">DomainEvent</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">f</span> <span class="o">*</span><span class="nx">fakeEventPublisher</span><span class="p">)</span> <span class="nf">Publish</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">f</span><span class="p">.</span><span class="nx">published</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">f</span><span class="p">.</span><span class="nx">published</span><span class="p">,</span> <span class="nx">event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="kd">type</span> <span class="nx">neverSeenDeduper</span> <span class="kd">struct</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">neverSeenDeduper</span><span class="p">)</span> <span class="nf">Seen</span><span class="p">(</span><span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">return</span> <span class="kc">false</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>成功案例可以確認 repository 與 publisher 都被呼叫：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestEventProcessorProcess</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">repo</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">fakeEventRepository</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">publisher</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">fakeEventPublisher</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">processor</span> <span class="o">:=</span> <span class="nf">NewEventProcessor</span><span class="p">(</span><span class="nx">repo</span><span class="p">,</span> <span class="nx">publisher</span><span class="p">,</span> <span class="nx">neverSeenDeduper</span><span class="p">{})</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">event</span> <span class="o">:=</span> <span class="nx">DomainEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>            <span class="s">&#34;evt_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">Source</span><span class="p">:</span>        <span class="nx">SourceHTTPCallback</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>          <span class="nx">EventNotificationCreated</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span>     <span class="s">&#34;ntf_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">SubjectKind</span><span class="p">:</span>   <span class="nx">SubjectNotification</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span>    <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">ReceivedAt</span><span class="p">:</span>    <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="nx">SchemaVersion</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">processor</span><span class="p">.</span><span class="nf">Process</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">(),</span> <span class="nx">event</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;process event: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">repo</span><span class="p">.</span><span class="nx">applied</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">1</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;applied events = %d, want 1&#34;</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">repo</span><span class="p">.</span><span class="nx">applied</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">publisher</span><span class="p">.</span><span class="nx">published</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">1</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;published events = %d, want 1&#34;</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">publisher</span><span class="p">.</span><span class="nx">published</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>processor 測試使用 fake repository、publisher 或 deduper，就能隔離事件規則。真實 message <a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a> 或資料庫屬於 adapter integration test。</p>
<h2 id="實作檢查清單">實作檢查清單</h2>
<p>新增 domain event 時，可以依序檢查：</p>
<ol>
<li>event type 是否描述已經發生的事</li>
<li>event type 是否是內部穩定語意，而不是外部欄位名稱</li>
<li>envelope 是否包含 source、type、subject、occurred/received time</li>
<li>payload 是否只放事件特有補充資料</li>
<li>raw input 是否先經過 adapter normalize</li>
<li>adapter 是否不直接更新 repository</li>
<li>validation 是否保護事件基本合約</li>
<li>dedup key 是否建立在 domain 語意上</li>
<li>normalize、processor、repository 是否分開測試</li>
</ol>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="檢查一command-和-event-分開命名">檢查一：command 和 event 分開命名</h3>
<p><code>create_notification</code> 是想做某件事，<code>notification.created</code> 是某件事已發生。把兩者混在一起，會讓 processor 不清楚自己是在判斷授權、執行行為，還是在套用事實。</p>
<h3 id="檢查二raw-payload-停在-adapter">檢查二：raw payload 停在 adapter</h3>
<p>raw payload 會帶有外部來源格式、命名與缺漏。domain layer 應該面對內部穩定模型，外部格式應該停在 adapter。</p>
<h3 id="檢查三穩定欄位放在-envelope">檢查三：穩定欄位放在 envelope</h3>
<p>如果 type、subject、time 都藏在 payload，processor、deduper、repository 都必須解析 payload 才能做事。這會讓事件系統難測，也難以演進 schema。</p>
<h3 id="檢查四事件順序使用發生時間">檢查四：事件順序使用發生時間</h3>
<p><code>ReceivedAt</code> 是系統看到事件的時間，不一定是事件發生時間。狀態轉移通常應該優先看 <code>OccurredAt</code>，再根據延遲與來源可靠度設計補償規則。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理 domain event 的定義、轉換與發布；event store 與 broker 傳遞，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">Go 進階：多來源 event 融合</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Go 進階：Durable queue、outbox 與 idempotency</a></li>
<li><a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 event envelope、normalize 與 processor；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/new-websocket-action/" data-link-title="6.1 如何新增一個即時訊息 action" data-link-desc="修改 client message、路由與 handler">Go：如何新增一個即時訊息 action</a></li>
<li><a href="/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程</a></li>
<li><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
<li><a href="/blog/go/07-refactoring/dedup-refactor/" data-link-title="7.3 事件去重邏輯的重構策略" data-link-desc="保留語義鍵並降低重複流程">Go：事件去重邏輯的重構策略</a></li>
</ul>
]]></content:encoded></item><item><title>7.2 Durable queue、outbox 與 idempotency</title><link>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/outbox-idempotency/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/outbox-idempotency/</guid><description>&lt;p>跨 process 事件傳遞的核心責任是讓事件在失敗、重試與重複投遞下仍維持可預期語意。Channel 只能處理單一 process 內的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a> ；[durable &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a>](/go-advanced/backend/knowledge-cards/durable-queue)、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/outbox-pattern/" data-link-title="Outbox Pattern" data-link-desc="說明資料庫狀態變更與事件發布如何透過 outbox 維持一致">outbox&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a> store 才能處理服務重啟、網路失敗與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer&lt;/a> 重試。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>理解 outbox 為什麼能避免半成功&lt;/li>
&lt;li>分辨 domain dedup key 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a> key 的用途&lt;/li>
&lt;li>設計可重入的 consumer / processor&lt;/li>
&lt;li>用 retry、DLQ 與回補流程處理失敗事件&lt;/li>
&lt;li>把事件可靠性寫進資料結構，讓規則可以被程式與測試驗證&lt;/li>
&lt;/ol>
&lt;h2 id="前置章節">前置章節&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/non-blocking-send/" data-link-title="1.3 非阻塞送出與事件丟棄策略" data-link-desc="設計 channel 滿載時的服務行為">Go 進階：非阻塞送出與事件丟棄策略&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/dedup-key/" data-link-title="4.2 事件去重與語義鍵設計" data-link-desc="用 entity ID、event type、來源語意與時間窗口建立去重鍵">Go 進階：事件去重與語義鍵設計&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">Go 進階：多來源 event 融合&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">Backend：Ack / Nack&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/retry-policy/" data-link-title="Retry Policy" data-link-desc="說明重試策略如何區分暫時性錯誤、永久錯誤與副作用風險">Backend：Retry Policy&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">Backend：Dead-Letter Queue&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">Backend：Consumer Lag&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="後續撰寫方向">後續撰寫方向&lt;/h2>
&lt;ol>
&lt;li>Outbox 如何避免「狀態已寫入，但事件沒送出」的半成功。&lt;/li>
&lt;li>Idempotency key 如何和 domain dedup key 分工。&lt;/li>
&lt;li>Consumer retry、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/poison-message/" data-link-title="Poison Message" data-link-desc="說明特定訊息內容如何穩定造成 consumer 失敗">poison message&lt;/a> 如何設計處理流程。&lt;/li>
&lt;li>At-least-once delivery 下，processor 如何保持可重入。&lt;/li>
&lt;li>Queue lag、retry count、dead-letter count 應如何進入 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a> 與 metric。&lt;/li>
&lt;/ol>
&lt;h2 id="觀察outbox-是把資料與事件綁在同一個-transaction">【觀察】outbox 是把資料與事件綁在同一個 transaction&lt;/h2>
&lt;p>outbox 的核心概念是：先把業務狀態與待發事件一起寫進資料庫，再由獨立 publisher 把 outbox 內容送到 queue 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker&lt;/a>。這樣即使 process 在寫完資料後當機，也不會丟掉事件。&lt;/p>
&lt;p>典型流程是：&lt;/p>
&lt;ol>
&lt;li>usecase 開 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction&lt;/a>。&lt;/li>
&lt;li>寫入 domain data。&lt;/li>
&lt;li>寫入 outbox record。&lt;/li>
&lt;li>commit。&lt;/li>
&lt;li>background publisher 讀出未送出的 outbox。&lt;/li>
&lt;li>成功後把 outbox 標成已送出。&lt;/li>
&lt;/ol>
&lt;p>這個模型的重點是讓「至少會被發現並補送」成為可能。它承認跨 process 傳遞很難保證絕對只送一次，所以後續還要搭配 idempotency。&lt;/p>
&lt;h2 id="判讀idempotency-是跨-process-的必要邊界">【判讀】idempotency 是跨 process 的必要邊界&lt;/h2>
&lt;p>只要事件可能重送，consumer 就要能承受重複訊息。idempotent processor 的核心是讓同一筆事件重複進來時，結果仍然穩定。&lt;/p></description><content:encoded><![CDATA[<p>跨 process 事件傳遞的核心責任是讓事件在失敗、重試與重複投遞下仍維持可預期語意。Channel 只能處理單一 process 內的 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> ；[durable <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a>](/go-advanced/backend/knowledge-cards/durable-queue)、<a href="/blog/backend/knowledge-cards/outbox-pattern/" data-link-title="Outbox Pattern" data-link-desc="說明資料庫狀態變更與事件發布如何透過 outbox 維持一致">outbox</a> 與 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> store 才能處理服務重啟、網路失敗與 <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a> 重試。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>理解 outbox 為什麼能避免半成功</li>
<li>分辨 domain dedup key 與 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> key 的用途</li>
<li>設計可重入的 consumer / processor</li>
<li>用 retry、DLQ 與回補流程處理失敗事件</li>
<li>把事件可靠性寫進資料結構，讓規則可以被程式與測試驗證</li>
</ol>
<h2 id="前置章節">前置章節</h2>
<ul>
<li><a href="/blog/go-advanced/01-concurrency-patterns/non-blocking-send/" data-link-title="1.3 非阻塞送出與事件丟棄策略" data-link-desc="設計 channel 滿載時的服務行為">Go 進階：非阻塞送出與事件丟棄策略</a></li>
<li><a href="/blog/go-advanced/04-architecture-boundaries/dedup-key/" data-link-title="4.2 事件去重與語義鍵設計" data-link-desc="用 entity ID、event type、來源語意與時間窗口建立去重鍵">Go 進階：事件去重與語義鍵設計</a></li>
<li><a href="/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">Go 進階：多來源 event 融合</a></li>
<li><a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">Backend：Ack / Nack</a></li>
<li><a href="/blog/backend/knowledge-cards/retry-policy/" data-link-title="Retry Policy" data-link-desc="說明重試策略如何區分暫時性錯誤、永久錯誤與副作用風險">Backend：Retry Policy</a></li>
<li><a href="/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">Backend：Dead-Letter Queue</a></li>
<li><a href="/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">Backend：Consumer Lag</a></li>
</ul>
<h2 id="後續撰寫方向">後續撰寫方向</h2>
<ol>
<li>Outbox 如何避免「狀態已寫入，但事件沒送出」的半成功。</li>
<li>Idempotency key 如何和 domain dedup key 分工。</li>
<li>Consumer retry、<a href="/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue</a> 與 <a href="/blog/backend/knowledge-cards/poison-message/" data-link-title="Poison Message" data-link-desc="說明特定訊息內容如何穩定造成 consumer 失敗">poison message</a> 如何設計處理流程。</li>
<li>At-least-once delivery 下，processor 如何保持可重入。</li>
<li>Queue lag、retry count、dead-letter count 應如何進入 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a> 與 metric。</li>
</ol>
<h2 id="觀察outbox-是把資料與事件綁在同一個-transaction">【觀察】outbox 是把資料與事件綁在同一個 transaction</h2>
<p>outbox 的核心概念是：先把業務狀態與待發事件一起寫進資料庫，再由獨立 publisher 把 outbox 內容送到 queue 或 <a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a>。這樣即使 process 在寫完資料後當機，也不會丟掉事件。</p>
<p>典型流程是：</p>
<ol>
<li>usecase 開 <a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a>。</li>
<li>寫入 domain data。</li>
<li>寫入 outbox record。</li>
<li>commit。</li>
<li>background publisher 讀出未送出的 outbox。</li>
<li>成功後把 outbox 標成已送出。</li>
</ol>
<p>這個模型的重點是讓「至少會被發現並補送」成為可能。它承認跨 process 傳遞很難保證絕對只送一次，所以後續還要搭配 idempotency。</p>
<h2 id="判讀idempotency-是跨-process-的必要邊界">【判讀】idempotency 是跨 process 的必要邊界</h2>
<p>只要事件可能重送，consumer 就要能承受重複訊息。idempotent processor 的核心是讓同一筆事件重複進來時，結果仍然穩定。</p>
<p>常見做法包括：</p>
<ul>
<li>用 event ID 記錄已處理過的訊息</li>
<li>用 domain key 去重，讓同一個業務操作不會重複套用</li>
<li>用狀態機檢查 transition 是否已發生</li>
</ul>
<h2 id="策略dlq-是流程的一部分">【策略】DLQ 是流程的一部分</h2>
<p>當事件重試失敗，dead-letter queue 要變成可處理的操作流程。你要知道：</p>
<ul>
<li>為什麼失敗</li>
<li>要重試幾次</li>
<li>什麼錯誤可以直接放棄</li>
<li>什麼錯誤需要人工回補</li>
</ul>
<p>如果沒有這些規則，DLQ 只會變成看不完的黑洞。</p>
<h2 id="執行可重入-processor-的基本形式">【執行】可重入 processor 的基本形式</h2>
<p>可重入的核心要求是同一事件重跑時，不會把資料弄壞。簡化的處理流程通常長這樣：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">p</span> <span class="o">*</span><span class="nx">Processor</span><span class="p">)</span> <span class="nf">Handle</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">evt</span> <span class="nx">Event</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">if</span> <span class="nx">p</span><span class="p">.</span><span class="nx">store</span><span class="p">.</span><span class="nf">Seen</span><span class="p">(</span><span class="nx">evt</span><span class="p">.</span><span class="nx">ID</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">p</span><span class="p">.</span><span class="nx">store</span><span class="p">.</span><span class="nf">Apply</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">evt</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">return</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">return</span> <span class="nx">p</span><span class="p">.</span><span class="nx">store</span><span class="p">.</span><span class="nf">MarkSeen</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">evt</span><span class="p">.</span><span class="nx">ID</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>實際實作時，<code>Seen</code> 與 <code>MarkSeen</code> 通常要跟業務狀態放在同一個一致性邊界裡，避免競態。</p>
<h2 id="延伸queue-lag-與-retry-需要被觀測">【延伸】queue lag 與 retry 需要被觀測</h2>
<p>只要有 durable queue，就一定會有 backlog、retry 與 failure pattern。這些訊號應進入 log 與 metric，讓工程師知道是 <a href="/blog/backend/knowledge-cards/producer/" data-link-title="Producer" data-link-desc="說明 producer 如何把工作、事件或資料送入後續處理路徑">producer</a> 變慢、consumer 壞掉，還是下游依賴正在抖動。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章不追求 exactly-once 的口號。教材重點會放在 Go 服務如何承認 at-least-once 的現實，並用 idempotent processor、outbox 與可觀測欄位降低風險。</p>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接 Go 的事件邊界與非阻塞送出；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go-advanced/01-concurrency-patterns/non-blocking-send/" data-link-title="1.3 非阻塞送出與事件丟棄策略" data-link-desc="設計 channel 滿載時的服務行為">Go 進階：非阻塞送出與事件丟棄策略</a></li>
<li><a href="/blog/go-advanced/04-architecture-boundaries/dedup-key/" data-link-title="4.2 事件去重與語義鍵設計" data-link-desc="用 entity ID、event type、來源語意與時間窗口建立去重鍵">Go 進階：事件去重與語義鍵設計</a></li>
<li><a href="/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">Go 進階：多來源 event 融合</a></li>
</ul>
]]></content:encoded></item><item><title>2.3 訂閱模型與訊息路由</title><link>https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/subscription-routing/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/subscription-routing/</guid><description>&lt;p>訂閱模型的核心目標是把 client action 轉成明確的連線狀態與回應訊息。&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> 是長連線，單次 action 失敗通常不應直接關閉連線；router 應把錯誤轉成可理解的 server message。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>設計穩定的 client action envelope&lt;/li>
&lt;li>把 router、handler、usecase 與 client state 分開&lt;/li>
&lt;li>用訂閱集合表達 client 想收到的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a>&lt;/li>
&lt;li>在 broadcast 前檢查訂閱狀態&lt;/li>
&lt;li>測試 action routing、payload validation 與 error response&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察client-message-很容易變成臨時協定">【觀察】client message 很容易變成臨時協定&lt;/h2>
&lt;p>WebSocket action 的核心風險是前後端快速加功能時，訊息格式變成一堆臨時欄位。若 action 名稱依賴按鈕、畫面或短期 UI 狀態，server 很快會累積難以維護的分支。&lt;/p>
&lt;p>不穩定的訊息格式：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;button&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;watch&amp;#34;&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="nt">&amp;#34;tab&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;jobs&amp;#34;&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="nt">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;topic_1&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這種訊息描述 UI 發生什麼，不是描述 client 想對服務做什麼。服務端應該接收穩定 action，例如 &lt;code>subscribe_topic&lt;/code>、&lt;code>unsubscribe_topic&lt;/code>、&lt;code>list_subscriptions&lt;/code>。&lt;/p>
&lt;h2 id="判讀action-是-client-intent">【判讀】action 是 client intent&lt;/h2>
&lt;p>Client action 的核心語意是「client 想做什麼」。它不是 domain event，因為它還沒被驗證、授權或套用規則。Domain event 表示已經發生的事，action 表示請求。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">ClientAction&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="kd">const&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="nx">ActionSubscribeTopic&lt;/span> &lt;span class="nx">ClientAction&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;subscribe_topic&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">ActionUnsubscribeTopic&lt;/span> &lt;span class="nx">ClientAction&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;unsubscribe_topic&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">ActionListTopics&lt;/span> &lt;span class="nx">ClientAction&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;list_topics&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">ClientMessage&lt;/span> &lt;span class="kd">struct&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="nx">Action&lt;/span> &lt;span class="nx">ClientAction&lt;/span> &lt;span class="s">`json:&amp;#34;action&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">Data&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">RawMessage&lt;/span> &lt;span class="s">`json:&amp;#34;data,omitempty&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>外層 envelope 穩定，內層 &lt;code>Data&lt;/code> 依 action 解析。這讓 read pump 可以先解析 envelope，router 再依 action 決定 payload 型別。&lt;/p>
&lt;h2 id="策略router-負責分派不擁有全部規則">【策略】router 負責分派，不擁有全部規則&lt;/h2>
&lt;p>Router 的核心責任是把 action 分派到對應 handler。它應該知道有哪些 action，但不應把訂閱規則、權限檢查、資料查詢全部塞在一個巨大 switch 裡。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Router&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">subscriptions&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">SubscriptionService&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="nx">Router&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Route&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">client&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Client&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">message&lt;/span> &lt;span class="nx">ClientMessage&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">switch&lt;/span> &lt;span class="nx">message&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Action&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">ActionSubscribeTopic&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">handleSubscribe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">client&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">message&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Data&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="k">case&lt;/span> &lt;span class="nx">ActionUnsubscribeTopic&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="k">return&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">handleUnsubscribe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">client&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">message&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Data&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">ActionListTopics&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">handleListTopics&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">client&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="k">default&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Errorf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;unknown action: %s&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">message&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Action&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>switch&lt;/code> 讓支援的 action 集中可見。真正的訂閱狀態修改可以交給 &lt;code>SubscriptionService&lt;/code> 或 client method，避免 router 變成所有規則的聚集地。&lt;/p>
&lt;h2 id="執行payload-validation-在-action-邊界完成">【執行】payload validation 在 action 邊界完成&lt;/h2>
&lt;p>Payload validation 的核心責任是讓內部服務只收到有效 command。訂閱 topic 至少要檢查 JSON 格式、topic 是否空白、topic 名稱是否符合規則。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">SubscribeTopicRequest&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">Topic&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;topic&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">SubscribeTopicCommand&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">ClientID&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">Topic&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="nx">Router&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">handleSubscribe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">client&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Client&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">raw&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">RawMessage&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">req&lt;/span> &lt;span class="nx">SubscribeTopicRequest&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Unmarshal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">raw&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">req&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Errorf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;decode subscribe request: %w&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="nx">topic&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">TrimSpace&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Topic&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">topic&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Errorf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;topic is required&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="nx">cmd&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">SubscribeTopicCommand&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="nx">ClientID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">client&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ID&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl"> &lt;span class="nx">Topic&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">topic&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">subscriptions&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Subscribe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">client&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">cmd&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Request struct 是 wire format，command 是內部意圖。兩者分開後，JSON 命名、驗證錯誤與內部服務規則不會混在同一個型別。&lt;/p></description><content:encoded><![CDATA[<p>訂閱模型的核心目標是把 client action 轉成明確的連線狀態與回應訊息。<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> 是長連線，單次 action 失敗通常不應直接關閉連線；router 應把錯誤轉成可理解的 server message。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>設計穩定的 client action envelope</li>
<li>把 router、handler、usecase 與 client state 分開</li>
<li>用訂閱集合表達 client 想收到的 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a></li>
<li>在 broadcast 前檢查訂閱狀態</li>
<li>測試 action routing、payload validation 與 error response</li>
</ol>
<hr>
<h2 id="觀察client-message-很容易變成臨時協定">【觀察】client message 很容易變成臨時協定</h2>
<p>WebSocket action 的核心風險是前後端快速加功能時，訊息格式變成一堆臨時欄位。若 action 名稱依賴按鈕、畫面或短期 UI 狀態，server 很快會累積難以維護的分支。</p>
<p>不穩定的訊息格式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;button&#34;</span><span class="p">:</span> <span class="s2">&#34;watch&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;tab&#34;</span><span class="p">:</span> <span class="s2">&#34;jobs&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;topic_1&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這種訊息描述 UI 發生什麼，不是描述 client 想對服務做什麼。服務端應該接收穩定 action，例如 <code>subscribe_topic</code>、<code>unsubscribe_topic</code>、<code>list_subscriptions</code>。</p>
<h2 id="判讀action-是-client-intent">【判讀】action 是 client intent</h2>
<p>Client action 的核心語意是「client 想做什麼」。它不是 domain event，因為它還沒被驗證、授權或套用規則。Domain event 表示已經發生的事，action 表示請求。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">ClientAction</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kd">const</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">ActionSubscribeTopic</span>   <span class="nx">ClientAction</span> <span class="p">=</span> <span class="s">&#34;subscribe_topic&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">ActionUnsubscribeTopic</span> <span class="nx">ClientAction</span> <span class="p">=</span> <span class="s">&#34;unsubscribe_topic&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">ActionListTopics</span>       <span class="nx">ClientAction</span> <span class="p">=</span> <span class="s">&#34;list_topics&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">type</span> <span class="nx">ClientMessage</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">Action</span> <span class="nx">ClientAction</span>    <span class="s">`json:&#34;action&#34;`</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">Data</span>   <span class="nx">json</span><span class="p">.</span><span class="nx">RawMessage</span> <span class="s">`json:&#34;data,omitempty&#34;`</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>外層 envelope 穩定，內層 <code>Data</code> 依 action 解析。這讓 read pump 可以先解析 envelope，router 再依 action 決定 payload 型別。</p>
<h2 id="策略router-負責分派不擁有全部規則">【策略】router 負責分派，不擁有全部規則</h2>
<p>Router 的核心責任是把 action 分派到對應 handler。它應該知道有哪些 action，但不應把訂閱規則、權限檢查、資料查詢全部塞在一個巨大 switch 裡。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">Router</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">subscriptions</span> <span class="o">*</span><span class="nx">SubscriptionService</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="nx">Router</span><span class="p">)</span> <span class="nf">Route</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">client</span> <span class="o">*</span><span class="nx">Client</span><span class="p">,</span> <span class="nx">message</span> <span class="nx">ClientMessage</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">switch</span> <span class="nx">message</span><span class="p">.</span><span class="nx">Action</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">case</span> <span class="nx">ActionSubscribeTopic</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">return</span> <span class="nx">r</span><span class="p">.</span><span class="nf">handleSubscribe</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">client</span><span class="p">,</span> <span class="nx">message</span><span class="p">.</span><span class="nx">Data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">case</span> <span class="nx">ActionUnsubscribeTopic</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">return</span> <span class="nx">r</span><span class="p">.</span><span class="nf">handleUnsubscribe</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">client</span><span class="p">,</span> <span class="nx">message</span><span class="p">.</span><span class="nx">Data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">case</span> <span class="nx">ActionListTopics</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">return</span> <span class="nx">r</span><span class="p">.</span><span class="nf">handleListTopics</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">client</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;unknown action: %s&#34;</span><span class="p">,</span> <span class="nx">message</span><span class="p">.</span><span class="nx">Action</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>switch</code> 讓支援的 action 集中可見。真正的訂閱狀態修改可以交給 <code>SubscriptionService</code> 或 client method，避免 router 變成所有規則的聚集地。</p>
<h2 id="執行payload-validation-在-action-邊界完成">【執行】payload validation 在 action 邊界完成</h2>
<p>Payload validation 的核心責任是讓內部服務只收到有效 command。訂閱 topic 至少要檢查 JSON 格式、topic 是否空白、topic 名稱是否符合規則。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">SubscribeTopicRequest</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">Topic</span> <span class="kt">string</span> <span class="s">`json:&#34;topic&#34;`</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kd">type</span> <span class="nx">SubscribeTopicCommand</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">ClientID</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">Topic</span>    <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="nx">Router</span><span class="p">)</span> <span class="nf">handleSubscribe</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">client</span> <span class="o">*</span><span class="nx">Client</span><span class="p">,</span> <span class="nx">raw</span> <span class="nx">json</span><span class="p">.</span><span class="nx">RawMessage</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="kd">var</span> <span class="nx">req</span> <span class="nx">SubscribeTopicRequest</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">Unmarshal</span><span class="p">(</span><span class="nx">raw</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">req</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;decode subscribe request: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">topic</span> <span class="o">:=</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">Topic</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">if</span> <span class="nx">topic</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;topic is required&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="nx">cmd</span> <span class="o">:=</span> <span class="nx">SubscribeTopicCommand</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="nx">ClientID</span><span class="p">:</span> <span class="nx">client</span><span class="p">.</span><span class="nf">ID</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span>    <span class="nx">topic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="k">return</span> <span class="nx">r</span><span class="p">.</span><span class="nx">subscriptions</span><span class="p">.</span><span class="nf">Subscribe</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">client</span><span class="p">,</span> <span class="nx">cmd</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Request struct 是 wire format，command 是內部意圖。兩者分開後，JSON 命名、驗證錯誤與內部服務規則不會混在同一個型別。</p>
<h2 id="執行訂閱集合是連線狀態">【執行】訂閱集合是連線狀態</h2>
<p>訂閱集合的核心語意是「這個 client 目前想收到哪些 topic」。它可以放在 client 上，也可以由 hub 集中保存；重點是 owner 要明確。</p>
<p>Client owner 版本：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">Client</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">id</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">mu</span>            <span class="nx">sync</span><span class="p">.</span><span class="nx">RWMutex</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">subscriptions</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kd">struct</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">Subscribe</span><span class="p">(</span><span class="nx">topic</span> <span class="kt">string</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Lock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">defer</span> <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Unlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nx">subscriptions</span><span class="p">[</span><span class="nx">topic</span><span class="p">]</span> <span class="p">=</span> <span class="kd">struct</span><span class="p">{}{}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">Unsubscribe</span><span class="p">(</span><span class="nx">topic</span> <span class="kt">string</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Lock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">defer</span> <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Unlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="nb">delete</span><span class="p">(</span><span class="nx">c</span><span class="p">.</span><span class="nx">subscriptions</span><span class="p">,</span> <span class="nx">topic</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">IsSubscribed</span><span class="p">(</span><span class="nx">topic</span> <span class="kt">string</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RLock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">defer</span> <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RUnlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="nx">_</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">c</span><span class="p">.</span><span class="nx">subscriptions</span><span class="p">[</span><span class="nx">topic</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="k">return</span> <span class="nx">ok</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>map[string]struct{}</code> 是 Go 常見 set 表示法。若 read pump 修改訂閱，hub broadcast 讀取訂閱，就需要 lock 或把所有訂閱操作集中到 hub event loop。</p>
<h2 id="策略訂閱狀態也需要-copy-boundary">【策略】訂閱狀態也需要 copy boundary</h2>
<p>訂閱列表的核心風險是直接回傳 map 會暴露內部狀態。若需要列出目前訂閱，應回傳 slice 或 map copy。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">Subscriptions</span><span class="p">()</span> <span class="p">[]</span><span class="kt">string</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RLock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RUnlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">topics</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="kt">string</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">c</span><span class="p">.</span><span class="nx">subscriptions</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">for</span> <span class="nx">topic</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">c</span><span class="p">.</span><span class="nx">subscriptions</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">topics</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">topics</span><span class="p">,</span> <span class="nx">topic</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">sort</span><span class="p">.</span><span class="nf">Strings</span><span class="p">(</span><span class="nx">topics</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">return</span> <span class="nx">topics</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>回傳 sorted slice 讓測試更穩定，也避免呼叫端修改內部 map。排序不是業務必要條件，但對 API response 與測試可讀性有幫助。</p>
<h2 id="執行成功與失敗都應回-server-message">【執行】成功與失敗都應回 server message</h2>
<p>WebSocket action 的核心互動模式是 request-like，但連線不會因單次 action 結束。成功或失敗都應回一筆 server message，讓 client 能更新 UI 或顯示錯誤。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">ServerMessage</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">Type</span>  <span class="kt">string</span> <span class="s">`json:&#34;type&#34;`</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">Topic</span> <span class="kt">string</span> <span class="s">`json:&#34;topic,omitempty&#34;`</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">Error</span> <span class="kt">string</span> <span class="s">`json:&#34;error,omitempty&#34;`</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="kd">func</span> <span class="p">(</span><span class="nx">s</span> <span class="o">*</span><span class="nx">SubscriptionService</span><span class="p">)</span> <span class="nf">Subscribe</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">client</span> <span class="o">*</span><span class="nx">Client</span><span class="p">,</span> <span class="nx">cmd</span> <span class="nx">SubscribeTopicCommand</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">client</span><span class="p">.</span><span class="nf">Subscribe</span><span class="p">(</span><span class="nx">cmd</span><span class="p">.</span><span class="nx">Topic</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">client</span><span class="p">.</span><span class="nf">TrySend</span><span class="p">(</span><span class="nx">ServerMessage</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>  <span class="s">&#34;topic_subscribed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span> <span class="nx">cmd</span><span class="p">.</span><span class="nx">Topic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="k">return</span> <span class="nx">ErrClientQueueFull</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>若 action 失敗，read pump 或 router wrapper 可以把錯誤轉成 <code>ServerMessage{Type: &quot;error&quot;}</code>。不要只寫 server <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>，因為 client 需要知道該 action 沒有成功。</p>
<h2 id="執行broadcast-前檢查訂閱">【執行】broadcast 前檢查訂閱</h2>
<p>Broadcast 的核心規則是 <a href="/blog/backend/knowledge-cards/producer/" data-link-title="Producer" data-link-desc="說明 producer 如何把工作、事件或資料送入後續處理路徑">producer</a> 只產生 topic 與 message，hub 決定哪些 client 應該收到。訂閱邏輯不應散落在每個 producer 裡。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="o">*</span><span class="nx">Hub</span><span class="p">)</span> <span class="nf">Broadcast</span><span class="p">(</span><span class="nx">topic</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">message</span> <span class="nx">ServerMessage</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">for</span> <span class="nx">client</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">h</span><span class="p">.</span><span class="nx">clients</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">if</span> <span class="p">!</span><span class="nx">client</span><span class="p">.</span><span class="nf">IsSubscribed</span><span class="p">(</span><span class="nx">topic</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">            <span class="k">continue</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="k">if</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">client</span><span class="p">.</span><span class="nf">TrySend</span><span class="p">(</span><span class="nx">message</span><span class="p">);</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="nx">h</span><span class="p">.</span><span class="nx">unregister</span> <span class="o">&lt;-</span> <span class="nx">client</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式先檢查訂閱，再嘗試送出。若 client 的 send <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 滿了，hub 可以 unregister 或採用其他慢 client 策略；下一章會專門處理。</p>
<h2 id="測試router-test-不需要真實-websocket">【測試】router test 不需要真實 WebSocket</h2>
<p>Router 的測試核心是 action 到行為的對應。它不需要真實 WebSocket connection，只需要 fake client 或檢查 client state。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestSubscribeActionAddsTopic</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">client</span> <span class="o">:=</span> <span class="nf">NewTestClient</span><span class="p">(</span><span class="s">&#34;client_1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">router</span> <span class="o">:=</span> <span class="nx">Router</span><span class="p">{</span><span class="nx">subscriptions</span><span class="p">:</span> <span class="nf">NewSubscriptionService</span><span class="p">()}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">data</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">RawMessage</span><span class="p">(</span><span class="s">`{&#34;topic&#34;:&#34;alerts&#34;}`</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">err</span> <span class="o">:=</span> <span class="nx">router</span><span class="p">.</span><span class="nf">Route</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">(),</span> <span class="nx">client</span><span class="p">,</span> <span class="nx">ClientMessage</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Action</span><span class="p">:</span> <span class="nx">ActionSubscribeTopic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">Data</span><span class="p">:</span>   <span class="nx">data</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;route subscribe: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">client</span><span class="p">.</span><span class="nf">IsSubscribed</span><span class="p">(</span><span class="s">&#34;alerts&#34;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;client should subscribe to alerts&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Payload validation 也應獨立測：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestSubscribeActionRequiresTopic</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">client</span> <span class="o">:=</span> <span class="nf">NewTestClient</span><span class="p">(</span><span class="s">&#34;client_1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">router</span> <span class="o">:=</span> <span class="nx">Router</span><span class="p">{</span><span class="nx">subscriptions</span><span class="p">:</span> <span class="nf">NewSubscriptionService</span><span class="p">()}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">err</span> <span class="o">:=</span> <span class="nx">router</span><span class="p">.</span><span class="nf">Route</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">(),</span> <span class="nx">client</span><span class="p">,</span> <span class="nx">ClientMessage</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">Action</span><span class="p">:</span> <span class="nx">ActionSubscribeTopic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Data</span><span class="p">:</span>   <span class="nx">json</span><span class="p">.</span><span class="nf">RawMessage</span><span class="p">(</span><span class="s">`{&#34;topic&#34;:&#34; &#34;}`</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">==</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;empty topic should return error&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>WebSocket integration test 留給「真實 client/server 互動」；router 單元測試先確保協定語意正確。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理 action envelope 到 subscription 的路由與 ownership；授權、presence 與跨節點同步，會在下列章節延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/cross-node-websocket/" data-link-title="7.3 跨節點 WebSocket、presence 與重連協定" data-link-desc="把單一 server 的 WebSocket hub 擴展到多節點推送與連線狀態">Go 進階：跨節點 WebSocket、presence 與重連協定</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 WebSocket action、event fusion 與 handler boundary；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/new-websocket-action/" data-link-title="6.1 如何新增一個即時訊息 action" data-link-desc="修改 client message、路由與 handler">Go：如何新增一個即時訊息 action</a></li>
<li><a href="/blog/go/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">Go：如何新增一種 domain event</a></li>
<li><a href="/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">Go：事件融合</a></li>
<li><a href="/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">Go：把 handler 邏輯拆成可測單元</a></li>
<li><a href="/blog/backend/02-cache-redis/" data-link-title="模組二：快取與 Redis" data-link-desc="整理快取策略、Redis 資料型別與分散式狀態輔助能力">Backend：快取與 Redis</a></li>
<li><a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>訂閱模型把 client action 轉成連線狀態與 server response。Action 是 client intent，不是 domain event；router 負責分派，payload validation 在邊界完成，訂閱集合要有明確 owner，broadcast 由 hub 統一檢查訂閱。這樣新增 action 或 topic 時，修改範圍會清楚且可測。</p>
]]></content:encoded></item><item><title>4.3 Source of Truth：狀態邊界</title><link>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/source-of-truth/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/source-of-truth/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Source of truth&lt;/a> 的核心原則是系統中只有一個地方負責判定目前狀態。其他元件可以請求更新、讀取快照或訂閱變化，但不能各自保存一份會被當成真相的資料。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>判斷狀態真相應該由哪個元件擁有&lt;/li>
&lt;li>把狀態轉移集中在 repository 或 state owner&lt;/li>
&lt;li>同步更新 current state 與 history&lt;/li>
&lt;li>用 copy boundary 保護 slice、map、pointer&lt;/li>
&lt;li>分辨 internal state、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection&lt;/a> 與 response view&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察狀態分散會讓系統失去真相">【觀察】狀態分散會讓系統失去真相&lt;/h2>
&lt;p>狀態分散的核心風險是每個元件都以為自己看到的是最新資料。handler 可能有 map，worker 可能有 cache，publisher 可能有最後推送狀態；當三者不一致時，系統很難回答「現在到底是什麼狀態」。&lt;/p>
&lt;p>反模式示意：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">handlerStates&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">{}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">workerStates&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">{}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">publisherLastSent&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">{}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這三份資料可能都叫做 state，但只有一份應該是 source of truth。其他資料如果存在，應該明確標示為 cache、projection 或 delivery record，不能被當成狀態判斷依據。&lt;/p>
&lt;h2 id="判讀source-of-truth-是寫入權責">【判讀】source of truth 是寫入權責&lt;/h2>
&lt;p>source of truth 的核心不是「資料存在哪裡」，而是「誰有權決定狀態如何轉移」。memory map、SQLite、PostgreSQL、Redis 都可以承擔儲存；真正的邊界是所有寫入都經過同一組規則。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">AccountStatus&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="kd">const&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="nx">AccountPending&lt;/span> &lt;span class="nx">AccountStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;pending&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">AccountActive&lt;/span> &lt;span class="nx">AccountStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;active&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">AccountBlocked&lt;/span> &lt;span class="nx">AccountStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;blocked&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">AccountState&lt;/span> &lt;span class="kd">struct&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="nx">ID&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">Status&lt;/span> &lt;span class="nx">AccountStatus&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">UpdatedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>AccountState&lt;/code> 是 domain 狀態，不是 HTTP response。它應該表達系統內部真正需要維護的資料，而不是直接迎合某個 API 的輸出格式。&lt;/p>
&lt;h2 id="策略用明確方法集中狀態轉移">【策略】用明確方法集中狀態轉移&lt;/h2>
&lt;p>狀態轉移的核心規則是呼叫端不能直接改欄位。外部元件應該送進事件或 command，由 state owner 決定是否合法、如何更新、是否記錄 history。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">StateRepository&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">mu&lt;/span> &lt;span class="nx">sync&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">RWMutex&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">records&lt;/span> &lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="nx">AccountRecord&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;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">AccountRecord&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">Current&lt;/span> &lt;span class="nx">AccountState&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">History&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="nx">AccountState&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">NewStateRepository&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">StateRepository&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">StateRepository&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">records&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="nx">AccountRecord&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>repository 擁有 &lt;code>records&lt;/code> map。其他元件不應取得這個 map 的 reference，也不應繞過 repository 修改狀態。&lt;/p>
&lt;h2 id="執行apply-把事件轉成狀態變化">【執行】Apply 把事件轉成狀態變化&lt;/h2>
&lt;p>&lt;code>Apply&lt;/code> 的核心責任是把 domain event 套用到 state。它是事件系統與狀態系統的交界。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">StateRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Apply&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span> &lt;span class="nx">DomainEvent&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Lock&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Unlock&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">record&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">records&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SubjectID&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">next&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">transition&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">record&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Current&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">err&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">record&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Current&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">next&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">record&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">History&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nb">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">record&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">History&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">next&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">records&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SubjectID&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">record&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式在同一個 lock 內更新 current 與 history。讀者可以相信目前狀態與歷史紀錄來自同一筆事件，不會出現 current 已更新但 history 漏記的情境。&lt;/p>
&lt;p>&lt;code>transition&lt;/code> 可以是純函式：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">transition&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">current&lt;/span> &lt;span class="nx">AccountState&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span> &lt;span class="nx">DomainEvent&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">AccountState&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="k">switch&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Type&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">EventAccountActivated&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">AccountState&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="nx">ID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SubjectID&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">Status&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">AccountActive&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">UpdatedAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">OccurredAt&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="p">},&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">default&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="k">return&lt;/span> &lt;span class="nx">AccountState&lt;/span>&lt;span class="p">{},&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Errorf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;unsupported event type: %s&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Type&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>純函式讓狀態規則更容易測試。repository 負責 concurrency 與保存，transition 負責 domain 規則。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Source of truth</a> 的核心原則是系統中只有一個地方負責判定目前狀態。其他元件可以請求更新、讀取快照或訂閱變化，但不能各自保存一份會被當成真相的資料。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>判斷狀態真相應該由哪個元件擁有</li>
<li>把狀態轉移集中在 repository 或 state owner</li>
<li>同步更新 current state 與 history</li>
<li>用 copy boundary 保護 slice、map、pointer</li>
<li>分辨 internal state、<a href="/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection</a> 與 response view</li>
</ol>
<hr>
<h2 id="觀察狀態分散會讓系統失去真相">【觀察】狀態分散會讓系統失去真相</h2>
<p>狀態分散的核心風險是每個元件都以為自己看到的是最新資料。handler 可能有 map，worker 可能有 cache，publisher 可能有最後推送狀態；當三者不一致時，系統很難回答「現在到底是什麼狀態」。</p>
<p>反模式示意：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">var</span> <span class="nx">handlerStates</span> <span class="p">=</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">string</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">var</span> <span class="nx">workerStates</span> <span class="p">=</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">string</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kd">var</span> <span class="nx">publisherLastSent</span> <span class="p">=</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">string</span><span class="p">{}</span></span></span></code></pre></div><p>這三份資料可能都叫做 state，但只有一份應該是 source of truth。其他資料如果存在，應該明確標示為 cache、projection 或 delivery record，不能被當成狀態判斷依據。</p>
<h2 id="判讀source-of-truth-是寫入權責">【判讀】source of truth 是寫入權責</h2>
<p>source of truth 的核心不是「資料存在哪裡」，而是「誰有權決定狀態如何轉移」。memory map、SQLite、PostgreSQL、Redis 都可以承擔儲存；真正的邊界是所有寫入都經過同一組規則。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">AccountStatus</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kd">const</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">AccountPending</span> <span class="nx">AccountStatus</span> <span class="p">=</span> <span class="s">&#34;pending&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">AccountActive</span>  <span class="nx">AccountStatus</span> <span class="p">=</span> <span class="s">&#34;active&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">AccountBlocked</span> <span class="nx">AccountStatus</span> <span class="p">=</span> <span class="s">&#34;blocked&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">type</span> <span class="nx">AccountState</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">ID</span>        <span class="kt">string</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">Status</span>    <span class="nx">AccountStatus</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">UpdatedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>AccountState</code> 是 domain 狀態，不是 HTTP response。它應該表達系統內部真正需要維護的資料，而不是直接迎合某個 API 的輸出格式。</p>
<h2 id="策略用明確方法集中狀態轉移">【策略】用明確方法集中狀態轉移</h2>
<p>狀態轉移的核心規則是呼叫端不能直接改欄位。外部元件應該送進事件或 command，由 state owner 決定是否合法、如何更新、是否記錄 history。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">StateRepository</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">mu</span>      <span class="nx">sync</span><span class="p">.</span><span class="nx">RWMutex</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">records</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">AccountRecord</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kd">type</span> <span class="nx">AccountRecord</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">Current</span> <span class="nx">AccountState</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">History</span> <span class="p">[]</span><span class="nx">AccountState</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="kd">func</span> <span class="nf">NewStateRepository</span><span class="p">()</span> <span class="o">*</span><span class="nx">StateRepository</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">return</span> <span class="o">&amp;</span><span class="nx">StateRepository</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">records</span><span class="p">:</span> <span class="nb">make</span><span class="p">(</span><span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">AccountRecord</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>repository 擁有 <code>records</code> map。其他元件不應取得這個 map 的 reference，也不應繞過 repository 修改狀態。</p>
<h2 id="執行apply-把事件轉成狀態變化">【執行】Apply 把事件轉成狀態變化</h2>
<p><code>Apply</code> 的核心責任是把 domain event 套用到 state。它是事件系統與狀態系統的交界。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">StateRepository</span><span class="p">)</span> <span class="nf">Apply</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Lock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Unlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">record</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">records</span><span class="p">[</span><span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">next</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">transition</span><span class="p">(</span><span class="nx">record</span><span class="p">.</span><span class="nx">Current</span><span class="p">,</span> <span class="nx">event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">return</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">record</span><span class="p">.</span><span class="nx">Current</span> <span class="p">=</span> <span class="nx">next</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">record</span><span class="p">.</span><span class="nx">History</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">record</span><span class="p">.</span><span class="nx">History</span><span class="p">,</span> <span class="nx">next</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">records</span><span class="p">[</span><span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">]</span> <span class="p">=</span> <span class="nx">record</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式在同一個 lock 內更新 current 與 history。讀者可以相信目前狀態與歷史紀錄來自同一筆事件，不會出現 current 已更新但 history 漏記的情境。</p>
<p><code>transition</code> 可以是純函式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">transition</span><span class="p">(</span><span class="nx">current</span> <span class="nx">AccountState</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="p">(</span><span class="nx">AccountState</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">switch</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Type</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">case</span> <span class="nx">EventAccountActivated</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">return</span> <span class="nx">AccountState</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="nx">ID</span><span class="p">:</span>        <span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">            <span class="nx">Status</span><span class="p">:</span>    <span class="nx">AccountActive</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="nx">UpdatedAt</span><span class="p">:</span> <span class="nx">event</span><span class="p">.</span><span class="nx">OccurredAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="p">},</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">return</span> <span class="nx">AccountState</span><span class="p">{},</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;unsupported event type: %s&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Type</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>純函式讓狀態規則更容易測試。repository 負責 concurrency 與保存，transition 負責 domain 規則。</p>
<h2 id="判讀currenthistoryprojection-是不同資料">【判讀】current、history、projection 是不同資料</h2>
<p>狀態資料的核心分類是 internal state、history 與 projection。三者用途不同，不應混成同一個 struct 到處傳。</p>
<table>
  <thead>
      <tr>
          <th>類型</th>
          <th>角色</th>
          <th>範例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>internal state</td>
          <td>系統判斷真相的資料</td>
          <td><code>AccountState</code></td>
      </tr>
      <tr>
          <td>history</td>
          <td>狀態變化紀錄</td>
          <td><code>[]AccountState</code></td>
      </tr>
      <tr>
          <td>projection</td>
          <td>查詢或 UI 需要的讀取模型</td>
          <td><code>AccountSummary</code></td>
      </tr>
      <tr>
          <td>response view</td>
          <td>特定 API 的輸出格式</td>
          <td><code>accountResponse</code></td>
      </tr>
  </tbody>
</table>
<p>projection 可以從 state 與 history 組出來，但 projection 不應反過來成為狀態真相。API 需要新增欄位時，優先新增 response view 或 projection，不要直接污染 internal state。</p>
<h2 id="執行查詢要回傳-copy">【執行】查詢要回傳 copy</h2>
<p>copy boundary 的核心目標是防止呼叫端修改 repository 內部資料。Go 的 slice、map、pointer 都可能讓內部狀態外洩。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">StateRepository</span><span class="p">)</span> <span class="nf">Current</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">AccountState</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RLock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RUnlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">record</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">records</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">return</span> <span class="nx">AccountState</span><span class="p">{},</span> <span class="kc">false</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">return</span> <span class="nx">record</span><span class="p">.</span><span class="nx">Current</span><span class="p">,</span> <span class="kc">true</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>AccountState</code> 目前只有值型別欄位，直接回傳值即可。history 是 slice，必須複製：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">StateRepository</span><span class="p">)</span> <span class="nf">History</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">([]</span><span class="nx">AccountState</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RLock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RUnlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">history</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">records</span><span class="p">[</span><span class="nx">id</span><span class="p">].</span><span class="nx">History</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">result</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="nx">AccountState</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">history</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nb">copy</span><span class="p">(</span><span class="nx">result</span><span class="p">,</span> <span class="nx">history</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="k">return</span> <span class="nx">result</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>若 state 內含 map、slice 或 pointer，還需要 deep copy。copy 有成本，但它是狀態邊界的保護；資料量大時應用分頁或 projection，不應直接暴露內部 slice。</p>
<h2 id="策略projection-讓查詢需求不污染狀態">【策略】projection 讓查詢需求不污染狀態</h2>
<p>projection 的核心用途是服務讀取需求。列表頁、儀表板、即時推送可能需要不同欄位，這些需求不應全部塞進 domain state。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">AccountSummary</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">ID</span>              <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">Status</span>          <span class="nx">AccountStatus</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">LastChangedAt</span>   <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">HistoryCount</span>    <span class="kt">int</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">StateRepository</span><span class="p">)</span> <span class="nf">Summary</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">AccountSummary</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RLock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RUnlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">record</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">records</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="k">return</span> <span class="nx">AccountSummary</span><span class="p">{},</span> <span class="kc">false</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">return</span> <span class="nx">AccountSummary</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>            <span class="nx">record</span><span class="p">.</span><span class="nx">Current</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="nx">Status</span><span class="p">:</span>        <span class="nx">record</span><span class="p">.</span><span class="nx">Current</span><span class="p">.</span><span class="nx">Status</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nx">LastChangedAt</span><span class="p">:</span> <span class="nx">record</span><span class="p">.</span><span class="nx">Current</span><span class="p">.</span><span class="nx">UpdatedAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="nx">HistoryCount</span><span class="p">:</span>  <span class="nb">len</span><span class="p">(</span><span class="nx">record</span><span class="p">.</span><span class="nx">History</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="p">},</span> <span class="kc">true</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>projection 可以由 repository 即時計算，也可以由背景 worker 預先維護。選擇哪一種取決於讀取量、資料量與一致性要求；小型服務先即時計算通常更容易理解。</p>
<h2 id="判讀mutex-與單一-goroutine-都能成為-state-owner">【判讀】mutex 與單一 goroutine 都能成為 state owner</h2>
<p>狀態擁有權的核心要求是同一時間只有受控路徑能修改資料。mutex 是常見選擇，單一 goroutine 擁有 state 也是 Go 常見模式。</p>
<p>mutex 版本適合直接方法呼叫：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">repository</span><span class="p">.</span><span class="nf">Apply</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><span class="p">)</span></span></span></code></pre></div><p>單一 goroutine 版本適合事件流：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">stateCommand</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">event</span> <span class="nx">DomainEvent</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">reply</span> <span class="kd">chan</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>兩者都可以正確。選擇 mutex 時要小心 copy boundary；選擇 goroutine owner 時要設計 shutdown、reply channel 與 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a>。不要為了使用 channel 而使用 channel，狀態模型簡單時 mutex 通常更直接。</p>
<h2 id="測試狀態測試要覆蓋轉移與外洩">【測試】狀態測試要覆蓋轉移與外洩</h2>
<p>狀態邊界的測試目標是確認轉移一致、history 同步、呼叫端不能修改內部資料。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestHistoryReturnsCopy</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">repo</span> <span class="o">:=</span> <span class="nf">NewStateRepository</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">event</span> <span class="o">:=</span> <span class="nx">DomainEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>         <span class="s">&#34;evt_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>       <span class="nx">EventAccountActivated</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span>  <span class="s">&#34;acct_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">ReceivedAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Apply</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">(),</span> <span class="nx">event</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;apply event: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">history</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">History</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">(),</span> <span class="s">&#34;acct_1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;history: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="nx">history</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">Status</span> <span class="p">=</span> <span class="nx">AccountBlocked</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="nx">again</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">History</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">(),</span> <span class="s">&#34;acct_1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;history again: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="k">if</span> <span class="nx">again</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">Status</span> <span class="o">!=</span> <span class="nx">AccountActive</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;repository state was modified through returned history&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試檢查的是邊界，不只是結果值。對 Go 服務來說，防止 slice/map 外洩是狀態設計的重要一環。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一服務內誰有寫入權責；資料庫 <a href="/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">migration</a> 與 CQRS，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">Go 進階：資料庫 transaction 與 schema migration</a></li>
<li><a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">Backend：資料庫與持久化</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 repository、state owner 與 projection 的邊界；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go：如何新增 repository port</a></li>
<li><a href="/blog/go/06-practical/state-fields/" data-link-title="6.3 如何擴展狀態投影欄位" data-link-desc="更新狀態模型、repository 與 API 輸出">Go：如何擴展狀態投影欄位</a></li>
<li><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go：狀態管理的安全邊界</a></li>
<li><a href="/blog/go/07-refactoring/domain-packages/" data-link-title="7.5 以 domain 重新整理 package" data-link-desc="讓 account、job、event、workflow 這類領域邊界在目錄中可見">Go：以 domain 重新整理 package</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>Source of truth 是寫入權責，不是某個特定資料庫。狀態轉移應集中在 repository 或 state owner，current 與 history 要在同一邊界更新，查詢要回傳 copy 或 projection。當狀態真相清楚時，handler、worker、publisher 都能保持簡單，系統也能更容易加入資料庫或新的讀取模型。</p>
]]></content:encoded></item><item><title>6.3 如何擴展狀態投影欄位</title><link>https://tarrragon.github.io/blog/go/06-practical/state-fields/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/06-practical/state-fields/</guid><description>&lt;p>擴展狀態投影欄位的核心流程是先確認欄位屬於 domain state、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="說明為查詢場景建立的讀取模型，與正式狀態的責任分離">read model&lt;/a> 還是 response view。欄位加在哪一層，會決定寫入規則、相容性與測試方式。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨 domain state、read model 與 response view&lt;/li>
&lt;li>判斷新欄位的零值是否有語意&lt;/li>
&lt;li>把狀態轉移集中在 repository 或 state owner&lt;/li>
&lt;li>用 copy boundary 保護內部 slice/map&lt;/li>
&lt;li>測試 state transition、repository copy 與 JSON response&lt;/li>
&lt;/ol>
&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>domain state&lt;/td>
 &lt;td>影響業務規則與狀態轉移&lt;/td>
 &lt;td>job 是否 running、failed、completed&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection&lt;/a>/read model&lt;/td>
 &lt;td>方便查詢、列表或即時顯示&lt;/td>
 &lt;td>最近更新時間、目前進度百分比&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>response view&lt;/td>
 &lt;td>只影響對外輸出格式&lt;/td>
 &lt;td>顯示文字、前端用 badge 顏色&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>例如「job 狀態」是 domain state，因為它會影響是否能重試、取消或完成。相反地，「狀態顯示文字」通常是 response view，因為它只是把內部狀態轉成 client 更容易顯示的文字。&lt;/p>
&lt;p>本章使用一個簡化的 job 狀態投影作為範例。事件進入系統後，repository 會把事件套用成目前 projection，再由 response layer 輸出給 client。&lt;/p>
&lt;h2 id="判讀domain-state-要用明確型別">【判讀】domain state 要用明確型別&lt;/h2>
&lt;p>domain state 的核心規則是用型別表達可用狀態，而不是讓任意字串在系統裡流動。當欄位會影響規則時，應該優先考慮 typed constant。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">JobStatus&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="kd">const&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="nx">JobStatusPending&lt;/span> &lt;span class="nx">JobStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;pending&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nx">JobStatusRunning&lt;/span> &lt;span class="nx">JobStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;running&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="nx">JobStatusSucceeded&lt;/span> &lt;span class="nx">JobStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;succeeded&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="nx">JobStatusFailed&lt;/span> &lt;span class="nx">JobStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;failed&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>接著定義狀態投影：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">JobProjection&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">ID&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">Status&lt;/span> &lt;span class="nx">JobStatus&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">Progress&lt;/span> &lt;span class="kt">int&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nx">UpdatedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="nx">FinishedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>Status&lt;/code> 是 domain state。&lt;code>Progress&lt;/code> 可以是 read model 欄位，代表目前顯示用進度。&lt;code>FinishedAt&lt;/code> 需要進一步判斷：如果完成時間會影響重試、保留時間或排序，它就不只是 response 欄位。&lt;/p>
&lt;p>零值也要有語意。&lt;code>FinishedAt&lt;/code> 的零值可以代表「尚未完成」，但這個語意必須被程式明確處理；若零值會造成混淆，可以改用 pointer：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">JobProjection&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">ID&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">Status&lt;/span> &lt;span class="nx">JobStatus&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">Progress&lt;/span> &lt;span class="kt">int&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nx">UpdatedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="nx">FinishedAt&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>pointer 在這裡的用途是區分「沒有值」和「有一個零值」。時間、數字與 bool 欄位最常遇到這個問題。&lt;/p>
&lt;h2 id="策略狀態轉移要集中在同一個入口">【策略】狀態轉移要集中在同一個入口&lt;/h2>
&lt;p>狀態轉移的核心規則是所有寫入都經過同一組方法。handler、worker 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> router 應把狀態變更交給 repository 或 state owner，而不是自行修改 map 或 projection 欄位。&lt;/p>
&lt;p>先定義內部 event：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">JobEvent&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">JobID&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">Type&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">Progress&lt;/span> &lt;span class="kt">int&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nx">OccurredAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>repository 可以集中套用事件：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">JobRepository&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">mu&lt;/span> &lt;span class="nx">sync&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">RWMutex&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">jobs&lt;/span> &lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="nx">JobProjection&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;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">NewJobRepository&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">JobRepository&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">JobRepository&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">jobs&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="nx">JobProjection&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="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;code>Apply&lt;/code> 是狀態寫入入口：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">JobRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Apply&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">event&lt;/span> &lt;span class="nx">JobEvent&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Lock&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Unlock&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">job&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">jobs&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">JobID&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">JobID&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">UpdatedAt&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">OccurredAt&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">switch&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Type&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="k">case&lt;/span> &lt;span class="s">&amp;#34;job.started&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Status&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">JobStatusRunning&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Progress&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="s">&amp;#34;job.progressed&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Progress&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Progress&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="s">&amp;#34;job.succeeded&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="nx">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Status&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">JobStatusSucceeded&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="nx">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Progress&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">100&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="nx">finishedAt&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">OccurredAt&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="nx">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">FinishedAt&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">finishedAt&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="s">&amp;#34;job.failed&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="nx">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Status&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">JobStatusFailed&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="nx">finishedAt&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">OccurredAt&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl"> &lt;span class="nx">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">FinishedAt&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">finishedAt&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="k">default&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Errorf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;unsupported job event type %q&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Type&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">jobs&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">JobID&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">job&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式讓狀態轉移規則集中在 repository。未來如果要禁止 failed job 重新變成 running，或要求 progress 不可倒退，可以在同一個入口加規則與測試。&lt;/p></description><content:encoded><![CDATA[<p>擴展狀態投影欄位的核心流程是先確認欄位屬於 domain state、<a href="/blog/backend/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="說明為查詢場景建立的讀取模型，與正式狀態的責任分離">read model</a> 還是 response view。欄位加在哪一層，會決定寫入規則、相容性與測試方式。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨 domain state、read model 與 response view</li>
<li>判斷新欄位的零值是否有語意</li>
<li>把狀態轉移集中在 repository 或 state owner</li>
<li>用 copy boundary 保護內部 slice/map</li>
<li>測試 state transition、repository copy 與 JSON response</li>
</ol>
<hr>
<h2 id="觀察先判斷欄位屬於哪一層">【觀察】先判斷欄位屬於哪一層</h2>
<p>狀態欄位的核心問題是「這個欄位代表哪一種資料責任」。同一個欄位放在不同層，代表不同寫入規則與相容性承諾。</p>
<table>
  <thead>
      <tr>
          <th>層次</th>
          <th>意義</th>
          <th>範例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>domain state</td>
          <td>影響業務規則與狀態轉移</td>
          <td>job 是否 running、failed、completed</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection</a>/read model</td>
          <td>方便查詢、列表或即時顯示</td>
          <td>最近更新時間、目前進度百分比</td>
      </tr>
      <tr>
          <td>response view</td>
          <td>只影響對外輸出格式</td>
          <td>顯示文字、前端用 badge 顏色</td>
      </tr>
  </tbody>
</table>
<p>例如「job 狀態」是 domain state，因為它會影響是否能重試、取消或完成。相反地，「狀態顯示文字」通常是 response view，因為它只是把內部狀態轉成 client 更容易顯示的文字。</p>
<p>本章使用一個簡化的 job 狀態投影作為範例。事件進入系統後，repository 會把事件套用成目前 projection，再由 response layer 輸出給 client。</p>
<h2 id="判讀domain-state-要用明確型別">【判讀】domain state 要用明確型別</h2>
<p>domain state 的核心規則是用型別表達可用狀態，而不是讓任意字串在系統裡流動。當欄位會影響規則時，應該優先考慮 typed constant。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">JobStatus</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kd">const</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">JobStatusPending</span>   <span class="nx">JobStatus</span> <span class="p">=</span> <span class="s">&#34;pending&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">JobStatusRunning</span>   <span class="nx">JobStatus</span> <span class="p">=</span> <span class="s">&#34;running&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">JobStatusSucceeded</span> <span class="nx">JobStatus</span> <span class="p">=</span> <span class="s">&#34;succeeded&#34;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nx">JobStatusFailed</span>    <span class="nx">JobStatus</span> <span class="p">=</span> <span class="s">&#34;failed&#34;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p>接著定義狀態投影：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">JobProjection</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">ID</span>         <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">Status</span>     <span class="nx">JobStatus</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">Progress</span>   <span class="kt">int</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">UpdatedAt</span>  <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">FinishedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>Status</code> 是 domain state。<code>Progress</code> 可以是 read model 欄位，代表目前顯示用進度。<code>FinishedAt</code> 需要進一步判斷：如果完成時間會影響重試、保留時間或排序，它就不只是 response 欄位。</p>
<p>零值也要有語意。<code>FinishedAt</code> 的零值可以代表「尚未完成」，但這個語意必須被程式明確處理；若零值會造成混淆，可以改用 pointer：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">JobProjection</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">ID</span>         <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">Status</span>     <span class="nx">JobStatus</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">Progress</span>   <span class="kt">int</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">UpdatedAt</span>  <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">FinishedAt</span> <span class="o">*</span><span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>pointer 在這裡的用途是區分「沒有值」和「有一個零值」。時間、數字與 bool 欄位最常遇到這個問題。</p>
<h2 id="策略狀態轉移要集中在同一個入口">【策略】狀態轉移要集中在同一個入口</h2>
<p>狀態轉移的核心規則是所有寫入都經過同一組方法。handler、worker 或 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> router 應把狀態變更交給 repository 或 state owner，而不是自行修改 map 或 projection 欄位。</p>
<p>先定義內部 event：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">JobEvent</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">JobID</span>      <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">Type</span>       <span class="kt">string</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">Progress</span>   <span class="kt">int</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">OccurredAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>repository 可以集中套用事件：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">JobRepository</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">mu</span>   <span class="nx">sync</span><span class="p">.</span><span class="nx">RWMutex</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">jobs</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">JobProjection</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kd">func</span> <span class="nf">NewJobRepository</span><span class="p">()</span> <span class="o">*</span><span class="nx">JobRepository</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">return</span> <span class="o">&amp;</span><span class="nx">JobRepository</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">jobs</span><span class="p">:</span> <span class="nb">make</span><span class="p">(</span><span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">JobProjection</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <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><code>Apply</code> 是狀態寫入入口：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">JobRepository</span><span class="p">)</span> <span class="nf">Apply</span><span class="p">(</span><span class="nx">event</span> <span class="nx">JobEvent</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Lock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Unlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">job</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">jobs</span><span class="p">[</span><span class="nx">event</span><span class="p">.</span><span class="nx">JobID</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">job</span><span class="p">.</span><span class="nx">ID</span> <span class="p">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">JobID</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">job</span><span class="p">.</span><span class="nx">UpdatedAt</span> <span class="p">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">OccurredAt</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">switch</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Type</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">case</span> <span class="s">&#34;job.started&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">job</span><span class="p">.</span><span class="nx">Status</span> <span class="p">=</span> <span class="nx">JobStatusRunning</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">job</span><span class="p">.</span><span class="nx">Progress</span> <span class="p">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">case</span> <span class="s">&#34;job.progressed&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="nx">job</span><span class="p">.</span><span class="nx">Progress</span> <span class="p">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Progress</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">case</span> <span class="s">&#34;job.succeeded&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="nx">job</span><span class="p">.</span><span class="nx">Status</span> <span class="p">=</span> <span class="nx">JobStatusSucceeded</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="nx">job</span><span class="p">.</span><span class="nx">Progress</span> <span class="p">=</span> <span class="mi">100</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="nx">finishedAt</span> <span class="o">:=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">OccurredAt</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="nx">job</span><span class="p">.</span><span class="nx">FinishedAt</span> <span class="p">=</span> <span class="o">&amp;</span><span class="nx">finishedAt</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">case</span> <span class="s">&#34;job.failed&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="nx">job</span><span class="p">.</span><span class="nx">Status</span> <span class="p">=</span> <span class="nx">JobStatusFailed</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="nx">finishedAt</span> <span class="o">:=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">OccurredAt</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="nx">job</span><span class="p">.</span><span class="nx">FinishedAt</span> <span class="p">=</span> <span class="o">&amp;</span><span class="nx">finishedAt</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;unsupported job event type %q&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Type</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">jobs</span><span class="p">[</span><span class="nx">event</span><span class="p">.</span><span class="nx">JobID</span><span class="p">]</span> <span class="p">=</span> <span class="nx">job</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式讓狀態轉移規則集中在 repository。未來如果要禁止 failed job 重新變成 running，或要求 progress 不可倒退，可以在同一個入口加規則與測試。</p>
<h2 id="判讀read-model-可以為查詢服務">【判讀】read model 可以為查詢服務</h2>
<p>read model 的核心用途是讓查詢與顯示更直接。它不一定等同完整 domain state，而是為某種讀取需求整理出的投影。</p>
<p>例如列表頁可能只需要 summary：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">JobSummary</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">ID</span>        <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">Status</span>    <span class="nx">JobStatus</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">Progress</span>  <span class="kt">int</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">UpdatedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>repository 可以提供查詢方法：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">JobRepository</span><span class="p">)</span> <span class="nf">ListSummaries</span><span class="p">()</span> <span class="p">[]</span><span class="nx">JobSummary</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RLock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RUnlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">result</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="nx">JobSummary</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">jobs</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">job</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">r</span><span class="p">.</span><span class="nx">jobs</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">result</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">result</span><span class="p">,</span> <span class="nx">JobSummary</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="nx">ID</span><span class="p">:</span>        <span class="nx">job</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="nx">Status</span><span class="p">:</span>    <span class="nx">job</span><span class="p">.</span><span class="nx">Status</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="nx">Progress</span><span class="p">:</span>  <span class="nx">job</span><span class="p">.</span><span class="nx">Progress</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="nx">UpdatedAt</span><span class="p">:</span> <span class="nx">job</span><span class="p">.</span><span class="nx">UpdatedAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="p">})</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">return</span> <span class="nx">result</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個方法回傳新 slice，而不是暴露 repository 內部資料。若 read model 包含 slice、map 或 pointer，也要確認呼叫端不能修改內部狀態。</p>
<h2 id="執行讀取方法要保護-copy-boundary">【執行】讀取方法要保護 copy boundary</h2>
<p>copy boundary 的核心目標是避免外部呼叫者修改 repository 內部資料。Go 的 slice、map、pointer 都可能讓內部狀態外洩。</p>
<p>單筆查詢可以回傳值與 bool：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">JobRepository</span><span class="p">)</span> <span class="nf">Get</span><span class="p">(</span><span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">JobProjection</span><span class="p">,</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RLock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RUnlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">job</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">jobs</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">return</span> <span class="nx">JobProjection</span><span class="p">{},</span> <span class="kc">false</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">return</span> <span class="nf">cloneJobProjection</span><span class="p">(</span><span class="nx">job</span><span class="p">),</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>若 struct 內含 pointer，clone 函式要複製 pointer 指向的值：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="nf">cloneJobProjection</span><span class="p">(</span><span class="nx">job</span> <span class="nx">JobProjection</span><span class="p">)</span> <span class="nx">JobProjection</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">cloned</span> <span class="o">:=</span> <span class="nx">job</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">if</span> <span class="nx">job</span><span class="p">.</span><span class="nx">FinishedAt</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="nx">finishedAt</span> <span class="o">:=</span> <span class="o">*</span><span class="nx">job</span><span class="p">.</span><span class="nx">FinishedAt</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="nx">cloned</span><span class="p">.</span><span class="nx">FinishedAt</span> <span class="p">=</span> <span class="o">&amp;</span><span class="nx">finishedAt</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="k">return</span> <span class="nx">cloned</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 clone 看似瑣碎，但它保護了 repository 的擁有權。呼叫端拿到資料後，即使修改 <code>FinishedAt</code> 指向的時間，也不會影響 repository 內部狀態。</p>
<h2 id="策略response-view-負責對外格式">【策略】response view 負責對外格式</h2>
<p>response view 的核心責任是把內部狀態轉成外部 contract。JSON tag、<code>omitempty</code>、顯示文字與相容性都應該在 response struct 中處理，讓 domain model 保持在業務狀態語意上。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">JobResponse</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">ID</span>          <span class="kt">string</span>     <span class="s">`json:&#34;id&#34;`</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">Status</span>      <span class="nx">JobStatus</span>  <span class="s">`json:&#34;status&#34;`</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">Progress</span>    <span class="kt">int</span>        <span class="s">`json:&#34;progress&#34;`</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">UpdatedAt</span>   <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>  <span class="s">`json:&#34;updatedAt&#34;`</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">FinishedAt</span>  <span class="o">*</span><span class="nx">time</span><span class="p">.</span><span class="nx">Time</span> <span class="s">`json:&#34;finishedAt,omitempty&#34;`</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nx">DisplayText</span> <span class="kt">string</span>     <span class="s">`json:&#34;displayText,omitempty&#34;`</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>轉換函式可以集中 response 規則：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">NewJobResponse</span><span class="p">(</span><span class="nx">job</span> <span class="nx">JobProjection</span><span class="p">)</span> <span class="nx">JobResponse</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">return</span> <span class="nx">JobResponse</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>          <span class="nx">job</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">Status</span><span class="p">:</span>      <span class="nx">job</span><span class="p">.</span><span class="nx">Status</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">Progress</span><span class="p">:</span>    <span class="nx">job</span><span class="p">.</span><span class="nx">Progress</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">UpdatedAt</span><span class="p">:</span>   <span class="nx">job</span><span class="p">.</span><span class="nx">UpdatedAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">FinishedAt</span><span class="p">:</span>  <span class="nx">job</span><span class="p">.</span><span class="nx">FinishedAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">DisplayText</span><span class="p">:</span> <span class="nf">displayText</span><span class="p">(</span><span class="nx">job</span><span class="p">.</span><span class="nx">Status</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="kd">func</span> <span class="nf">displayText</span><span class="p">(</span><span class="nx">status</span> <span class="nx">JobStatus</span><span class="p">)</span> <span class="kt">string</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">switch</span> <span class="nx">status</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">case</span> <span class="nx">JobStatusPending</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="k">return</span> <span class="s">&#34;Waiting&#34;</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">case</span> <span class="nx">JobStatusRunning</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="k">return</span> <span class="s">&#34;Running&#34;</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">case</span> <span class="nx">JobStatusSucceeded</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="k">return</span> <span class="s">&#34;Completed&#34;</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">case</span> <span class="nx">JobStatusFailed</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="k">return</span> <span class="s">&#34;Failed&#34;</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="k">return</span> <span class="s">&#34;Unknown&#34;</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>DisplayText</code> 是 response view，負責呈現用文字。若未來前端改文案，response 轉換函式可以調整，repository 狀態轉移規則應保持穩定。</p>
<h2 id="判讀omitempty-是相容性語意">【判讀】<code>omitempty</code> 是相容性語意</h2>
<p><code>omitempty</code> 的核心語意是欄位在某些情境中可以不存在。它在對外 contract 中表示「這個欄位可能不存在」，而不是只為了縮短 JSON。</p>
<p>例如 <code>FinishedAt</code> 只有 job 完成或失敗後才有值：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">FinishedAt</span> <span class="o">*</span><span class="nx">time</span><span class="p">.</span><span class="nx">Time</span> <span class="s">`json:&#34;finishedAt,omitempty&#34;`</span></span></span></code></pre></div><p>舊 client 如果不知道 <code>finishedAt</code>，通常會忽略新欄位。新 client 如果需要這個欄位，也必須處理它不存在的情況。</p>
<p>必填欄位的 JSON contract 應保持穩定輸出。<code>id</code> 或 <code>status</code> 這類欄位消失會讓 client 無法理解資料，因此它們屬於必要欄位，應維持固定輸出。</p>
<h2 id="執行state-transition-測試要鎖定規則">【執行】state transition 測試要鎖定規則</h2>
<p>state transition 測試的核心目標是確認事件會產生正確狀態。這類測試不需要 HTTP，也不需要 WebSocket。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestJobRepositoryApplySucceeded</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">repo</span> <span class="o">:=</span> <span class="nf">NewJobRepository</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">finishedAt</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Apply</span><span class="p">(</span><span class="nx">JobEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">JobID</span><span class="p">:</span>      <span class="s">&#34;job_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>       <span class="s">&#34;job.succeeded&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">finishedAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;apply event: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">job</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;job_1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;job should exist&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">if</span> <span class="nx">job</span><span class="p">.</span><span class="nx">Status</span> <span class="o">!=</span> <span class="nx">JobStatusSucceeded</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;status = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">job</span><span class="p">.</span><span class="nx">Status</span><span class="p">,</span> <span class="nx">JobStatusSucceeded</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="k">if</span> <span class="nx">job</span><span class="p">.</span><span class="nx">Progress</span> <span class="o">!=</span> <span class="mi">100</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;progress = %d, want 100&#34;</span><span class="p">,</span> <span class="nx">job</span><span class="p">.</span><span class="nx">Progress</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="k">if</span> <span class="nx">job</span><span class="p">.</span><span class="nx">FinishedAt</span> <span class="o">==</span> <span class="kc">nil</span> <span class="o">||</span> <span class="p">!</span><span class="nx">job</span><span class="p">.</span><span class="nx">FinishedAt</span><span class="p">.</span><span class="nf">Equal</span><span class="p">(</span><span class="nx">finishedAt</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;finished at = %v, want %v&#34;</span><span class="p">,</span> <span class="nx">job</span><span class="p">.</span><span class="nx">FinishedAt</span><span class="p">,</span> <span class="nx">finishedAt</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試保護的是狀態規則，而不是輸出 JSON 格式。</p>
<h2 id="執行copy-boundary-測試要嘗試修改回傳值">【執行】copy boundary 測試要嘗試修改回傳值</h2>
<p>copy boundary 測試的核心目標是證明呼叫端不能透過回傳資料改到 repository 內部狀態。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestJobRepositoryGetReturnsCopy</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">repo</span> <span class="o">:=</span> <span class="nf">NewJobRepository</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">finishedAt</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Apply</span><span class="p">(</span><span class="nx">JobEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">JobID</span><span class="p">:</span>      <span class="s">&#34;job_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>       <span class="s">&#34;job.succeeded&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">finishedAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">job</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;job_1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;job should exist&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">changed</span> <span class="o">:=</span> <span class="nx">finishedAt</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">Hour</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="nx">job</span><span class="p">.</span><span class="nx">FinishedAt</span> <span class="p">=</span> <span class="o">&amp;</span><span class="nx">changed</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="nx">again</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;job_1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">if</span> <span class="nx">again</span><span class="p">.</span><span class="nx">FinishedAt</span> <span class="o">==</span> <span class="kc">nil</span> <span class="o">||</span> <span class="p">!</span><span class="nx">again</span><span class="p">.</span><span class="nx">FinishedAt</span><span class="p">.</span><span class="nf">Equal</span><span class="p">(</span><span class="nx">finishedAt</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;repository state was modified through returned value&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這種測試對 map、slice、pointer 特別重要。值型別欄位通常不需要額外 clone，但一旦 struct 包含可變參照，就要測邊界。</p>
<h2 id="執行response-json-測試要檢查-contract">【執行】response JSON 測試要檢查 contract</h2>
<p>response 測試的核心目標是確認對外 JSON 欄位符合 contract。測試應該解析 JSON 或檢查欄位存在性，而不是只比對整段字串。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestJobResponseOmitsFinishedAtWhenNil</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">response</span> <span class="o">:=</span> <span class="nf">NewJobResponse</span><span class="p">(</span><span class="nx">JobProjection</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>        <span class="s">&#34;job_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">Status</span><span class="p">:</span>    <span class="nx">JobStatusRunning</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">Progress</span><span class="p">:</span>  <span class="mi">40</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">UpdatedAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">data</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">Marshal</span><span class="p">(</span><span class="nx">response</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;marshal response: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="kd">var</span> <span class="nx">body</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">any</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">Unmarshal</span><span class="p">(</span><span class="nx">data</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">body</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;unmarshal response: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">if</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">body</span><span class="p">[</span><span class="s">&#34;finishedAt&#34;</span><span class="p">];</span> <span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;finishedAt should be omitted when nil&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試是測對外承諾的欄位語意。</p>
<h2 id="實作檢查清單">實作檢查清單</h2>
<p>擴展狀態投影欄位時，可以依序檢查：</p>
<ol>
<li>欄位屬於 domain state、read model 還是 response view</li>
<li>零值是否有明確語意</li>
<li>是否需要 typed constant</li>
<li>寫入是否集中在 repository 或 state owner</li>
<li>handler、router、worker 是否沒有直接修改內部 map/slice</li>
<li>查詢是否回傳 copy</li>
<li>response 是否使用正確 JSON tag</li>
<li><code>omitempty</code> 是否真的代表可選欄位</li>
<li>測試是否分成 state transition、copy boundary、response JSON</li>
</ol>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="檢查一顯示欄位放在-response-view">檢查一：顯示欄位放在 response view</h3>
<p>顯示文字、顏色或前端 badge 通常是 response view。只有影響業務規則的欄位，才需要進入 domain state。</p>
<h3 id="檢查二handler-透過狀態入口修改-projection">檢查二：handler 透過狀態入口修改 projection</h3>
<p>handler 透過 repository 或 state owner 修改 projection，可以讓狀態規則集中。handler 直接改 projection 時，新增第二個入口容易漏掉同一套規則。</p>
<h3 id="檢查三回傳資料保護-copy-boundary">檢查三：回傳資料保護 copy boundary</h3>
<p>只要呼叫端能修改 repository 內部資料，狀態邊界就失效。回傳值時要檢查是否需要 clone。</p>
<h3 id="檢查四omitempty-對應可選欄位">檢查四：<code>omitempty</code> 對應可選欄位</h3>
<p>必填欄位加上 <code>omitempty</code> 會讓 response contract 變模糊。欄位是否可省略，應由資料語意決定，而不是由欄位零值方便性決定。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理狀態欄位如何影響 response contract；資料庫 <a href="/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">migration</a> 與前端相容性策略，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Go 進階：Source of Truth：狀態邊界</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">Go 進階：資料庫 transaction 與 schema migration</a></li>
<li><a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">Backend：資料庫與持久化</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 repository、event 與 response view 的邊界；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go：如何新增 repository port</a></li>
<li><a href="/blog/go/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">Go：如何新增一種 domain event</a></li>
<li><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go：狀態管理的安全邊界</a></li>
<li><a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Go 進階：Source of Truth</a></li>
</ul>
]]></content:encoded></item><item><title>7.3 事件去重邏輯的重構策略</title><link>https://tarrragon.github.io/blog/go/07-refactoring/dedup-refactor/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/07-refactoring/dedup-refactor/</guid><description>&lt;p>事件去重重構的核心目標是把語義鍵、時間窗口與來源優先順序整理成可測規則。本章用一般事件處理流程說明如何降低重複邏輯，同時保留事件合併的判斷依據。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>辨識 raw payload 去重的風險&lt;/li>
&lt;li>用 domain dedup key 表達同一件事&lt;/li>
&lt;li>把去重邏輯抽成 &lt;code>Deduper&lt;/code>&lt;/li>
&lt;li>設計時間窗口與 cleanup&lt;/li>
&lt;li>測試同窗口、跨窗口、不同來源與過期清理&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察重複事件通常先散落在入口層">【觀察】重複事件通常先散落在入口層&lt;/h2>
&lt;p>去重邏輯重構的核心觸發點是多個入口開始各自判斷「這筆事件看過了嗎」。HTTP callback、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer&lt;/a>、background worker 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> action 都可能收到同一件事，若每個入口各自去重，規則很快會不一致。&lt;/p>
&lt;p>重構前常見寫法：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">seenHTTPEvents&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="kt">bool&lt;/span>&lt;span class="p">{}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">handleCallback&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">raw&lt;/span> &lt;span class="nx">RawNotificationCallback&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">_&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewDecoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Decode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">raw&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">key&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">raw&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">NotificationID&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="s">&amp;#34;:&amp;#34;&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nx">raw&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">EventName&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="s">&amp;#34;:&amp;#34;&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nx">raw&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Timestamp&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">seenHTTPEvents&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">key&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusOK&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="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">seenHTTPEvents&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">key&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="kc">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="c1">// update state...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>worker 裡又有另一套：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">seenWorkerEvents&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>&lt;span class="p">{}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">handleWorkerUpdate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">update&lt;/span> &lt;span class="nx">RawNotificationUpdate&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">key&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">update&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">seenWorkerEvents&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">key&lt;/span>&lt;span class="p">];&lt;/span> &lt;span class="nx">ok&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">seenWorkerEvents&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">key&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Now&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>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="c1">// update state...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這兩段程式都在去重，但依據不同。一個用 notification ID、event name、timestamp；另一個用 raw event ID。當兩個來源描述同一件 domain event 時，它們無法互相辨識。&lt;/p>
&lt;h2 id="判讀raw-payload-不適合當去重依據">【判讀】raw payload 不適合當去重依據&lt;/h2>
&lt;p>raw payload 去重的核心問題是來源格式不是 domain 語意。不同來源可能使用不同欄位名稱、timestamp 精度、metadata 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/request-id/" data-link-title="Request ID" data-link-desc="說明單次 request 的識別碼如何支援 log 搜尋與問題定位">request ID&lt;/a>，但仍然描述同一件事。&lt;/p>
&lt;p>容易造成誤判的欄位：&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>request ID&lt;/td>
 &lt;td>每次重送都可能不同&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>received timestamp&lt;/td>
 &lt;td>取決於系統收到時間，不是發生時間&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>raw payload hash&lt;/td>
 &lt;td>欄位順序或 metadata 變化會改變 hash&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>source-specific ID&lt;/td>
 &lt;td>不同來源可能沒有共同 ID&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>debug metadata&lt;/td>
 &lt;td>不代表事件語意&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>去重應該發生在 normalized &lt;code>DomainEvent&lt;/code> 上，而不是 raw HTTP body、queue message 或 worker update 上。&lt;/p>
&lt;h2 id="策略domain-dedup-key-表達同一件事">【策略】domain dedup key 表達同一件事&lt;/h2>
&lt;p>domain dedup key 的核心責任是回答「哪兩筆事件應該被視為同一件 domain fact」。常見欄位是 subject kind、subject ID、event type 與時間窗口。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">DedupKey&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">SubjectKind&lt;/span> &lt;span class="nx">SubjectKind&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">SubjectID&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">EventType&lt;/span> &lt;span class="nx">EventType&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">Window&lt;/span> &lt;span class="kt">int64&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">NewDedupKey&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">event&lt;/span> &lt;span class="nx">DomainEvent&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">window&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Duration&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">DedupKey&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="k">return&lt;/span> &lt;span class="nx">DedupKey&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="nx">SubjectKind&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SubjectKind&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">SubjectID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SubjectID&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">EventType&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Type&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">Window&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">OccurredAt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">UnixNano&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">/&lt;/span> &lt;span class="nb">int64&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">window&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個 key 不包含 &lt;code>Source&lt;/code>，因為不同來源可能送來同一件事。是否包含 source 是一個 domain 決策：如果不同來源代表不同事實，就包含；如果不同來源只是同一事實的不同通道，就不要包含。&lt;/p>
&lt;p>時間窗口是容忍來源時間差的折衷。窗口太小會漏掉重複事件，窗口太大可能合併兩件獨立事件。&lt;/p>
&lt;h2 id="執行抽出-deduper">【執行】抽出 Deduper&lt;/h2>
&lt;p>&lt;code>Deduper&lt;/code> 的核心責任是保存已看過的 key，並回報目前事件是否重複。它不應知道 HTTP、WebSocket 或 queue，也不應更新狀態。&lt;/p></description><content:encoded><![CDATA[<p>事件去重重構的核心目標是把語義鍵、時間窗口與來源優先順序整理成可測規則。本章用一般事件處理流程說明如何降低重複邏輯，同時保留事件合併的判斷依據。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>辨識 raw payload 去重的風險</li>
<li>用 domain dedup key 表達同一件事</li>
<li>把去重邏輯抽成 <code>Deduper</code></li>
<li>設計時間窗口與 cleanup</li>
<li>測試同窗口、跨窗口、不同來源與過期清理</li>
</ol>
<hr>
<h2 id="觀察重複事件通常先散落在入口層">【觀察】重複事件通常先散落在入口層</h2>
<p>去重邏輯重構的核心觸發點是多個入口開始各自判斷「這筆事件看過了嗎」。HTTP callback、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a>、background worker 或 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> action 都可能收到同一件事，若每個入口各自去重，規則很快會不一致。</p>
<p>重構前常見寫法：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">var</span> <span class="nx">seenHTTPEvents</span> <span class="p">=</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">bool</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kd">func</span> <span class="nf">handleCallback</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="kd">var</span> <span class="nx">raw</span> <span class="nx">RawNotificationCallback</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">NewDecoder</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">).</span><span class="nf">Decode</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">raw</span><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="nx">key</span> <span class="o">:=</span> <span class="nx">raw</span><span class="p">.</span><span class="nx">NotificationID</span> <span class="o">+</span> <span class="s">&#34;:&#34;</span> <span class="o">+</span> <span class="nx">raw</span><span class="p">.</span><span class="nx">EventName</span> <span class="o">+</span> <span class="s">&#34;:&#34;</span> <span class="o">+</span> <span class="nx">raw</span><span class="p">.</span><span class="nx">Timestamp</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">if</span> <span class="nx">seenHTTPEvents</span><span class="p">[</span><span class="nx">key</span><span class="p">]</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusOK</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">seenHTTPEvents</span><span class="p">[</span><span class="nx">key</span><span class="p">]</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="c1">// update state...</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>worker 裡又有另一套：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">var</span> <span class="nx">seenWorkerEvents</span> <span class="p">=</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kd">func</span> <span class="nf">handleWorkerUpdate</span><span class="p">(</span><span class="nx">update</span> <span class="nx">RawNotificationUpdate</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">key</span> <span class="o">:=</span> <span class="nx">update</span><span class="p">.</span><span class="nx">ID</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">if</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">seenWorkerEvents</span><span class="p">[</span><span class="nx">key</span><span class="p">];</span> <span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">seenWorkerEvents</span><span class="p">[</span><span class="nx">key</span><span class="p">]</span> <span class="p">=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="c1">// update state...</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這兩段程式都在去重，但依據不同。一個用 notification ID、event name、timestamp；另一個用 raw event ID。當兩個來源描述同一件 domain event 時，它們無法互相辨識。</p>
<h2 id="判讀raw-payload-不適合當去重依據">【判讀】raw payload 不適合當去重依據</h2>
<p>raw payload 去重的核心問題是來源格式不是 domain 語意。不同來源可能使用不同欄位名稱、timestamp 精度、metadata 或 <a href="/blog/backend/knowledge-cards/request-id/" data-link-title="Request ID" data-link-desc="說明單次 request 的識別碼如何支援 log 搜尋與問題定位">request ID</a>，但仍然描述同一件事。</p>
<p>容易造成誤判的欄位：</p>
<table>
  <thead>
      <tr>
          <th>欄位</th>
          <th>問題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>request ID</td>
          <td>每次重送都可能不同</td>
      </tr>
      <tr>
          <td>received timestamp</td>
          <td>取決於系統收到時間，不是發生時間</td>
      </tr>
      <tr>
          <td>raw payload hash</td>
          <td>欄位順序或 metadata 變化會改變 hash</td>
      </tr>
      <tr>
          <td>source-specific ID</td>
          <td>不同來源可能沒有共同 ID</td>
      </tr>
      <tr>
          <td>debug metadata</td>
          <td>不代表事件語意</td>
      </tr>
  </tbody>
</table>
<p>去重應該發生在 normalized <code>DomainEvent</code> 上，而不是 raw HTTP body、queue message 或 worker update 上。</p>
<h2 id="策略domain-dedup-key-表達同一件事">【策略】domain dedup key 表達同一件事</h2>
<p>domain dedup key 的核心責任是回答「哪兩筆事件應該被視為同一件 domain fact」。常見欄位是 subject kind、subject ID、event type 與時間窗口。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">DedupKey</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">SubjectKind</span> <span class="nx">SubjectKind</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">SubjectID</span>   <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">EventType</span>   <span class="nx">EventType</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">Window</span>      <span class="kt">int64</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="kd">func</span> <span class="nf">NewDedupKey</span><span class="p">(</span><span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">,</span> <span class="nx">window</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span><span class="p">)</span> <span class="nx">DedupKey</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">return</span> <span class="nx">DedupKey</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">SubjectKind</span><span class="p">:</span> <span class="nx">event</span><span class="p">.</span><span class="nx">SubjectKind</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span>   <span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">EventType</span><span class="p">:</span>   <span class="nx">event</span><span class="p">.</span><span class="nx">Type</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">Window</span><span class="p">:</span>      <span class="nx">event</span><span class="p">.</span><span class="nx">OccurredAt</span><span class="p">.</span><span class="nf">UnixNano</span><span class="p">()</span> <span class="o">/</span> <span class="nb">int64</span><span class="p">(</span><span class="nx">window</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 key 不包含 <code>Source</code>，因為不同來源可能送來同一件事。是否包含 source 是一個 domain 決策：如果不同來源代表不同事實，就包含；如果不同來源只是同一事實的不同通道，就不要包含。</p>
<p>時間窗口是容忍來源時間差的折衷。窗口太小會漏掉重複事件，窗口太大可能合併兩件獨立事件。</p>
<h2 id="執行抽出-deduper">【執行】抽出 Deduper</h2>
<p><code>Deduper</code> 的核心責任是保存已看過的 key，並回報目前事件是否重複。它不應知道 HTTP、WebSocket 或 queue，也不應更新狀態。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">Deduper</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">mu</span>      <span class="nx">sync</span><span class="p">.</span><span class="nx">Mutex</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">seen</span>    <span class="kd">map</span><span class="p">[</span><span class="nx">DedupKey</span><span class="p">]</span><span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">window</span>  <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">expires</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="kd">func</span> <span class="nf">NewDeduper</span><span class="p">(</span><span class="nx">window</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span><span class="p">,</span> <span class="nx">expires</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span><span class="p">)</span> <span class="o">*</span><span class="nx">Deduper</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">return</span> <span class="o">&amp;</span><span class="nx">Deduper</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">seen</span><span class="p">:</span>    <span class="nb">make</span><span class="p">(</span><span class="kd">map</span><span class="p">[</span><span class="nx">DedupKey</span><span class="p">]</span><span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">window</span><span class="p">:</span>  <span class="nx">window</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">expires</span><span class="p">:</span> <span class="nx">expires</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>Seen</code> 判斷是否看過：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">d</span> <span class="o">*</span><span class="nx">Deduper</span><span class="p">)</span> <span class="nf">Seen</span><span class="p">(</span><span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">d</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Lock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">d</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Unlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">key</span> <span class="o">:=</span> <span class="nf">NewDedupKey</span><span class="p">(</span><span class="nx">event</span><span class="p">,</span> <span class="nx">d</span><span class="p">.</span><span class="nx">window</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">if</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">d</span><span class="p">.</span><span class="nx">seen</span><span class="p">[</span><span class="nx">key</span><span class="p">];</span> <span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">return</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">d</span><span class="p">.</span><span class="nx">seen</span><span class="p">[</span><span class="nx">key</span><span class="p">]</span> <span class="p">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">ReceivedAt</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">return</span> <span class="kc">false</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這裡用 <code>ReceivedAt</code> 作為清理基準，因為清理是系統內部記憶體管理問題；去重 key 則用 <code>OccurredAt</code>，因為那是事件發生語意。兩個時間各有用途，不應混用。</p>
<h2 id="執行processor-使用-deduper">【執行】processor 使用 Deduper</h2>
<p>重構後的核心方向是讓所有來源先 normalize 成 <code>DomainEvent</code>，再交給同一個 processor 去重與套用規則。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">EventProcessor</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">deduper</span>    <span class="o">*</span><span class="nx">Deduper</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">repository</span> <span class="nx">EventRepository</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">publisher</span>  <span class="nx">Publisher</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="kd">func</span> <span class="p">(</span><span class="nx">p</span> <span class="o">*</span><span class="nx">EventProcessor</span><span class="p">)</span> <span class="nf">Process</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">event</span><span class="p">.</span><span class="nf">Validate</span><span class="p">();</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;validate event: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">if</span> <span class="nx">p</span><span class="p">.</span><span class="nx">deduper</span><span class="p">.</span><span class="nf">Seen</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">p</span><span class="p">.</span><span class="nx">repository</span><span class="p">.</span><span class="nf">Apply</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;apply event: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">return</span> <span class="nx">p</span><span class="p">.</span><span class="nx">publisher</span><span class="p">.</span><span class="nf">Publish</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個位置比 handler 或 worker 更適合去重，因為 processor 已經面對 normalized domain event。新增事件來源時，只要它走同一個 processor，就自然共用同一套去重規則。</p>
<h2 id="策略來源優先順序要顯式化">【策略】來源優先順序要顯式化</h2>
<p>來源優先順序的核心問題是重複事件不一定完全相同。有些來源即時但資料少，有些來源延遲但資料完整。若需要合併資料，就要把 priority rule 寫成可測規則。</p>
<p>先定義 priority：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">SourcePriority</span><span class="p">(</span><span class="nx">source</span> <span class="nx">EventSource</span><span class="p">)</span> <span class="kt">int</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">switch</span> <span class="nx">source</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">case</span> <span class="nx">SourceHTTPCallback</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">return</span> <span class="mi">100</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">case</span> <span class="nx">SourceClientCommand</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">return</span> <span class="mi">80</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">case</span> <span class="nx">SourceTimer</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">return</span> <span class="mi">50</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">return</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>若 deduper 只需要判斷 seen，就不處理 priority。若系統需要「較高 priority 事件可以取代較低 priority 事件」，應把 deduper 改成更明確的 result：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">DedupDecision</span> <span class="kt">int</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kd">const</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">DedupAccept</span> <span class="nx">DedupDecision</span> <span class="p">=</span> <span class="kc">iota</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">DedupDrop</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">DedupReplace</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p>不要把 priority 規則藏在 <code>if</code> 裡。它是 domain policy，應該可以被直接測試。</p>
<h2 id="執行cleanup-防止去重表無限成長">【執行】cleanup 防止去重表無限成長</h2>
<p>cleanup 的核心責任是移除過期 key，防止去重表變成記憶體 leak。只要 <code>seen</code> 是 map，就必須設計生命週期。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">d</span> <span class="o">*</span><span class="nx">Deduper</span><span class="p">)</span> <span class="nf">Cleanup</span><span class="p">(</span><span class="nx">now</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">d</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Lock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">d</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Unlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">for</span> <span class="nx">key</span><span class="p">,</span> <span class="nx">seenAt</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">d</span><span class="p">.</span><span class="nx">seen</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">if</span> <span class="nx">now</span><span class="p">.</span><span class="nf">Sub</span><span class="p">(</span><span class="nx">seenAt</span><span class="p">)</span> <span class="p">&gt;</span> <span class="nx">d</span><span class="p">.</span><span class="nx">expires</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="nb">delete</span><span class="p">(</span><span class="nx">d</span><span class="p">.</span><span class="nx">seen</span><span class="p">,</span> <span class="nx">key</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>cleanup 可以由 background worker 定期呼叫：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">RunDeduperCleanup</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">deduper</span> <span class="o">*</span><span class="nx">Deduper</span><span class="p">,</span> <span class="nx">interval</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span><span class="p">,</span> <span class="nx">now</span> <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">ticker</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">NewTicker</span><span class="p">(</span><span class="nx">interval</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">ticker</span><span class="p">.</span><span class="nf">Stop</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ticker</span><span class="p">.</span><span class="nx">C</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="nx">deduper</span><span class="p">.</span><span class="nf">Cleanup</span><span class="p">(</span><span class="nf">now</span><span class="p">())</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這裡注入 <code>now</code> 是為了測試。清理策略不應依賴測試中的真實等待。</p>
<h2 id="執行同窗口去重測試">【執行】同窗口去重測試</h2>
<p>同窗口測試的核心目標是確認兩筆語意相同、時間接近的事件會共用 key。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestDeduperSeenSameWindow</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">deduper</span> <span class="o">:=</span> <span class="nf">NewDeduper</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Hour</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">base</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">first</span> <span class="o">:=</span> <span class="nx">DomainEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>          <span class="s">&#34;evt_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>        <span class="nx">EventNotificationCreated</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">SubjectKind</span><span class="p">:</span> <span class="nx">SubjectNotification</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span>   <span class="s">&#34;ntf_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span>  <span class="nx">base</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">ReceivedAt</span><span class="p">:</span>  <span class="nx">base</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">second</span> <span class="o">:=</span> <span class="nx">first</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">second</span><span class="p">.</span><span class="nx">ID</span> <span class="p">=</span> <span class="s">&#34;evt_2&#34;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">second</span><span class="p">.</span><span class="nx">ReceivedAt</span> <span class="p">=</span> <span class="nx">base</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">5</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">if</span> <span class="nx">deduper</span><span class="p">.</span><span class="nf">Seen</span><span class="p">(</span><span class="nx">first</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;first event should not be duplicate&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">deduper</span><span class="p">.</span><span class="nf">Seen</span><span class="p">(</span><span class="nx">second</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;second event in same window should be duplicate&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試刻意讓 ID 不同，證明去重依賴 domain key。</p>
<h2 id="執行跨窗口不去重測試">【執行】跨窗口不去重測試</h2>
<p>跨窗口測試的核心目標是確認兩件不同時間窗口的事件不會被誤合併。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestDeduperSeenDifferentWindow</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">deduper</span> <span class="o">:=</span> <span class="nf">NewDeduper</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Hour</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">base</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">first</span> <span class="o">:=</span> <span class="nx">DomainEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>          <span class="s">&#34;evt_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>        <span class="nx">EventNotificationCreated</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">SubjectKind</span><span class="p">:</span> <span class="nx">SubjectNotification</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span>   <span class="s">&#34;ntf_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span>  <span class="nx">base</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">ReceivedAt</span><span class="p">:</span>  <span class="nx">base</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">second</span> <span class="o">:=</span> <span class="nx">first</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">second</span><span class="p">.</span><span class="nx">ID</span> <span class="p">=</span> <span class="s">&#34;evt_2&#34;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">second</span><span class="p">.</span><span class="nx">OccurredAt</span> <span class="p">=</span> <span class="nx">base</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">second</span><span class="p">.</span><span class="nx">ReceivedAt</span> <span class="p">=</span> <span class="nx">base</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nx">deduper</span><span class="p">.</span><span class="nf">Seen</span><span class="p">(</span><span class="nx">first</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">if</span> <span class="nx">deduper</span><span class="p">.</span><span class="nf">Seen</span><span class="p">(</span><span class="nx">second</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;event in different window should not be duplicate&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>窗口大小是一個業務取捨，測試可以讓這個取捨變成明確規格。</p>
<h2 id="執行cleanup-測試不應-sleep">【執行】cleanup 測試不應 sleep</h2>
<p>cleanup 測試的核心目標是確認過期 key 會被移除。測試應直接傳入時間，不要真的等待過期。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestDeduperCleanup</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">deduper</span> <span class="o">:=</span> <span class="nf">NewDeduper</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">base</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">event</span> <span class="o">:=</span> <span class="nx">DomainEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>          <span class="s">&#34;evt_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>        <span class="nx">EventNotificationCreated</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">SubjectKind</span><span class="p">:</span> <span class="nx">SubjectNotification</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span>   <span class="s">&#34;ntf_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span>  <span class="nx">base</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">ReceivedAt</span><span class="p">:</span>  <span class="nx">base</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nx">deduper</span><span class="p">.</span><span class="nf">Seen</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">deduper</span><span class="p">.</span><span class="nf">Cleanup</span><span class="p">(</span><span class="nx">base</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">if</span> <span class="nx">deduper</span><span class="p">.</span><span class="nf">Seen</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;event should be accepted after cleanup&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試能快速完成，也不受機器速度影響。</p>
<h2 id="重構步驟">重構步驟</h2>
<p>把散落的去重邏輯收斂到 <code>Deduper</code> 時，可以按這個順序：</p>
<ol>
<li>先列出所有入口目前的去重 key。</li>
<li>找出它們真正想表達的 domain 語意。</li>
<li>建立 <code>DedupKey</code>，使用 subject、event type 與時間窗口。</li>
<li>把 raw input 先 normalize 成 <code>DomainEvent</code>。</li>
<li>在 processor 中呼叫 <code>Deduper.Seen</code>。</li>
<li>移除 handler、worker 內的重複 map。</li>
<li>補同窗口、跨窗口、不同來源與 cleanup 測試。</li>
</ol>
<p>不要一開始就把所有事件融合規則做完。先把「是否看過」集中，再處理 priority 或 replace policy。</p>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="檢查一去重鍵使用-domain-語意">檢查一：去重鍵使用 domain 語意</h3>
<p>payload hash 對格式變化太敏感。欄位順序、metadata 或 timestamp 精度改變，都會讓同一件事看起來不同。</p>
<h3 id="檢查二事件順序使用-occurredat">檢查二：事件順序使用 OccurredAt</h3>
<p><code>ReceivedAt</code> 是系統收到時間。事件是否同一件事，通常應看 <code>OccurredAt</code> 與 subject 語意。</p>
<h3 id="檢查三去重表需要-cleanup">檢查三：去重表需要 cleanup</h3>
<p>任何「看過的 key」map 都會成長。沒有 cleanup 的 deduper 會在長時間服務中累積記憶體壓力。</p>
<h3 id="檢查四來源-priority-需要測試">檢查四：來源 priority 需要測試</h3>
<p>若不同來源資料完整度不同，priority 是 domain policy。它應該有明確函式與測試，而不是散落在 processor 的條件判斷裡。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一服務內的去重規則如何集中；分散式去重與 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> store，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Go 進階：Durable queue、outbox 與 idempotency</a></li>
<li><a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 event normalization、processor 與 source priority；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">Go：如何新增一種 domain event</a></li>
<li><a href="/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程</a></li>
<li><a href="/blog/go/06-practical/new-websocket-action/" data-link-title="6.1 如何新增一個即時訊息 action" data-link-desc="修改 client message、路由與 handler">Go：如何新增一個即時訊息 action</a></li>
<li><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
</ul>
]]></content:encoded></item><item><title>規模分級應對表</title><link>https://tarrragon.github.io/blog/devops/07-burst-traffic/scale-tier-response/</link><pubDate>Sat, 20 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/devops/07-burst-traffic/scale-tier-response/</guid><description>&lt;p>突發流量的應對方案隨服務規模分成四級。每一級在前一級的基礎上增加元件，複雜度和成本同步上升。選擇哪一級取決於「預期的峰值流量」和「可接受的降級程度」。&lt;/p>
&lt;h2 id="四級分級">四級分級&lt;/h2>
&lt;h3 id="tier-1自用級-100-eventssec">Tier 1：自用級（&amp;lt; 100 events/sec）&lt;/h3>





&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">SDK ──→ Collector (單 binary + SQLite)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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;/td>
 &lt;td>單 Go binary、SQLite embedded&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>流量控制&lt;/td>
 &lt;td>背壓（channel buffer 10000 + 429）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>突發應對&lt;/td>
 &lt;td>SDK 離線 buffer 吸收短暫 burst&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>降級&lt;/td>
 &lt;td>無（流量不會到需要降級的程度）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>成本&lt;/td>
 &lt;td>零（自有主機、零外部依賴）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>適用&lt;/td>
 &lt;td>自用工具、開發期測試、小型團隊&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Tier 1 的假設是峰值流量不超過 SQLite WAL mode 的寫入能力（每秒數千筆）。自用場景下這個假設幾乎永遠成立。&lt;/p>
&lt;h3 id="tier-2中型100-10000-eventssec">Tier 2：中型（100-10000 events/sec）&lt;/h3>





&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"> ┌─ Collector A ──→ PostgreSQL
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">SDK ──→ LB ─┤
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> └─ Collector B ──→ PostgreSQL&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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;/td>
 &lt;td>多 collector + load balancer + PostgreSQL&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>流量控制&lt;/td>
 &lt;td>背壓 + per-SDK rate limit&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>突發應對&lt;/td>
 &lt;td>LB 分散流量 + collector 水平擴展&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>降級&lt;/td>
 &lt;td>動態取樣（超載時 SDK 降到 10%）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>成本&lt;/td>
 &lt;td>PostgreSQL + LB 的維護（可用 managed service 降低維護成本）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>適用&lt;/td>
 &lt;td>使用者數百到數千、有付費能力&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Tier 1 → Tier 2 的觸發：SQLite 的 &lt;code>database is locked&lt;/code> 頻繁出現，或 dashboard 的聚合查詢需要 PostgreSQL 的能力。&lt;/p>
&lt;h3 id="tier-3大型10000-100000-eventssec">Tier 3：大型（10000-100000 events/sec）&lt;/h3>





&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"> ┌─ Collector A ─┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">SDK ──→ LB ─┤ ├─→ Queue ──→ Worker 群 ──→ PostgreSQL
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> └─ Collector B ─┘&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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;/td>
 &lt;td>Collector 群 + queue（NATS / Kafka）+ worker 群 + PostgreSQL&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>流量控制&lt;/td>
 &lt;td>背壓 + rate limit + bulkhead&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>突發應對&lt;/td>
 &lt;td>Queue 做時間緩衝（積壓 → 追趕）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>降級&lt;/td>
 &lt;td>動態取樣 + 事件優先級 + 功能降級&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>成本&lt;/td>
 &lt;td>Queue + worker 的基礎設施（顯著上升）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>適用&lt;/td>
 &lt;td>中大型 SaaS、使用者數萬&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Tier 2 → Tier 3 的觸發：直接寫 PostgreSQL 的背壓頻繁觸發（即使有多個 collector 寫入）。&lt;/p>
&lt;h3 id="tier-4商業網站級-100000-eventssec">Tier 4：商業網站級（&amp;gt; 100000 events/sec）&lt;/h3>





&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">SDK ──→ CDN/Edge ──→ LB ──→ Collector 群 ──→ Kafka ──→ Worker 群 ──→ 分層 DB
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> ├─ 即時查詢 DB（ClickHouse / TimescaleDB）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> └─ 歸檔 DB（S3 + Athena）&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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;/td>
 &lt;td>CDN edge 收集 + Kafka + 分層存儲&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>流量控制&lt;/td>
 &lt;td>CDN rate limit + 全鏈路背壓&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>突發應對&lt;/td>
 &lt;td>Kafka partition 水平擴展 + auto-scaling worker&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>降級&lt;/td>
 &lt;td>全套（動態取樣 + 優先級 + 聚合前移 + 功能降級）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>成本&lt;/td>
 &lt;td>基礎設施團隊級別的投入&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>適用&lt;/td>
 &lt;td>大型 SaaS、電商、社群平台&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Tier 3 → Tier 4 的觸發：Kafka 單 cluster 的吞吐不夠、或查詢需要跨日誌級的時間序列分析。&lt;/p></description><content:encoded><![CDATA[<p>突發流量的應對方案隨服務規模分成四級。每一級在前一級的基礎上增加元件，複雜度和成本同步上升。選擇哪一級取決於「預期的峰值流量」和「可接受的降級程度」。</p>
<h2 id="四級分級">四級分級</h2>
<h3 id="tier-1自用級-100-eventssec">Tier 1：自用級（&lt; 100 events/sec）</h3>





<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">SDK ──→ Collector (單 binary + SQLite)</span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>維度</th>
          <th>設定</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>架構</td>
          <td>單 Go binary、SQLite embedded</td>
      </tr>
      <tr>
          <td>流量控制</td>
          <td>背壓（channel buffer 10000 + 429）</td>
      </tr>
      <tr>
          <td>突發應對</td>
          <td>SDK 離線 buffer 吸收短暫 burst</td>
      </tr>
      <tr>
          <td>降級</td>
          <td>無（流量不會到需要降級的程度）</td>
      </tr>
      <tr>
          <td>成本</td>
          <td>零（自有主機、零外部依賴）</td>
      </tr>
      <tr>
          <td>適用</td>
          <td>自用工具、開發期測試、小型團隊</td>
      </tr>
  </tbody>
</table>
<p>Tier 1 的假設是峰值流量不超過 SQLite WAL mode 的寫入能力（每秒數千筆）。自用場景下這個假設幾乎永遠成立。</p>
<h3 id="tier-2中型100-10000-eventssec">Tier 2：中型（100-10000 events/sec）</h3>





<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">         ┌─ Collector A ──→ PostgreSQL
</span></span><span class="line"><span class="ln">2</span><span class="cl">SDK ──→ LB ─┤
</span></span><span class="line"><span class="ln">3</span><span class="cl">         └─ Collector B ──→ PostgreSQL</span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>維度</th>
          <th>設定</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>架構</td>
          <td>多 collector + load balancer + PostgreSQL</td>
      </tr>
      <tr>
          <td>流量控制</td>
          <td>背壓 + per-SDK rate limit</td>
      </tr>
      <tr>
          <td>突發應對</td>
          <td>LB 分散流量 + collector 水平擴展</td>
      </tr>
      <tr>
          <td>降級</td>
          <td>動態取樣（超載時 SDK 降到 10%）</td>
      </tr>
      <tr>
          <td>成本</td>
          <td>PostgreSQL + LB 的維護（可用 managed service 降低維護成本）</td>
      </tr>
      <tr>
          <td>適用</td>
          <td>使用者數百到數千、有付費能力</td>
      </tr>
  </tbody>
</table>
<p>Tier 1 → Tier 2 的觸發：SQLite 的 <code>database is locked</code> 頻繁出現，或 dashboard 的聚合查詢需要 PostgreSQL 的能力。</p>
<h3 id="tier-3大型10000-100000-eventssec">Tier 3：大型（10000-100000 events/sec）</h3>





<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">         ┌─ Collector A ─┐
</span></span><span class="line"><span class="ln">2</span><span class="cl">SDK ──→ LB ─┤               ├─→ Queue ──→ Worker 群 ──→ PostgreSQL
</span></span><span class="line"><span class="ln">3</span><span class="cl">         └─ Collector B ─┘</span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>維度</th>
          <th>設定</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>架構</td>
          <td>Collector 群 + queue（NATS / Kafka）+ worker 群 + PostgreSQL</td>
      </tr>
      <tr>
          <td>流量控制</td>
          <td>背壓 + rate limit + bulkhead</td>
      </tr>
      <tr>
          <td>突發應對</td>
          <td>Queue 做時間緩衝（積壓 → 追趕）</td>
      </tr>
      <tr>
          <td>降級</td>
          <td>動態取樣 + 事件優先級 + 功能降級</td>
      </tr>
      <tr>
          <td>成本</td>
          <td>Queue + worker 的基礎設施（顯著上升）</td>
      </tr>
      <tr>
          <td>適用</td>
          <td>中大型 SaaS、使用者數萬</td>
      </tr>
  </tbody>
</table>
<p>Tier 2 → Tier 3 的觸發：直接寫 PostgreSQL 的背壓頻繁觸發（即使有多個 collector 寫入）。</p>
<h3 id="tier-4商業網站級-100000-eventssec">Tier 4：商業網站級（&gt; 100000 events/sec）</h3>





<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">SDK ──→ CDN/Edge ──→ LB ──→ Collector 群 ──→ Kafka ──→ Worker 群 ──→ 分層 DB
</span></span><span class="line"><span class="ln">2</span><span class="cl">                                                                      ├─ 即時查詢 DB（ClickHouse / TimescaleDB）
</span></span><span class="line"><span class="ln">3</span><span class="cl">                                                                      └─ 歸檔 DB（S3 + Athena）</span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>維度</th>
          <th>設定</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>架構</td>
          <td>CDN edge 收集 + Kafka + 分層存儲</td>
      </tr>
      <tr>
          <td>流量控制</td>
          <td>CDN rate limit + 全鏈路背壓</td>
      </tr>
      <tr>
          <td>突發應對</td>
          <td>Kafka partition 水平擴展 + auto-scaling worker</td>
      </tr>
      <tr>
          <td>降級</td>
          <td>全套（動態取樣 + 優先級 + 聚合前移 + 功能降級）</td>
      </tr>
      <tr>
          <td>成本</td>
          <td>基礎設施團隊級別的投入</td>
      </tr>
      <tr>
          <td>適用</td>
          <td>大型 SaaS、電商、社群平台</td>
      </tr>
  </tbody>
</table>
<p>Tier 3 → Tier 4 的觸發：Kafka 單 cluster 的吞吐不夠、或查詢需要跨日誌級的時間序列分析。</p>
<p>多數自架開源工具不需要超過 Tier 2。Tier 3 和 Tier 4 是商業 SaaS 的領域。</p>
<h2 id="規模遷移路徑">規模遷移路徑</h2>
<table>
  <thead>
      <tr>
          <th>遷移</th>
          <th>改什麼</th>
          <th>停機</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Tier 1 → 2</td>
          <td>Storage backend 切 PostgreSQL + 加 LB + 加 collector</td>
          <td>config change + 資料遷移（分鐘級停機）</td>
      </tr>
      <tr>
          <td>Tier 2 → 3</td>
          <td>加 queue + 改 collector 為 ingestion-only + 加 worker</td>
          <td>架構重構（需要開發時間）</td>
      </tr>
      <tr>
          <td>Tier 3 → 4</td>
          <td>加 CDN edge + 分層 DB + auto-scaling</td>
          <td>基礎設施工程（需要專職團隊）</td>
      </tr>
  </tbody>
</table>
<p>每一級的遷移成本遞增。Tier 1 → 2 是 config change 級、Tier 2 → 3 是架構重構級、Tier 3 → 4 是團隊級。選擇起始 tier 時選最低的足夠 tier — 過早引入高 tier 的複雜度是浪費。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>流量管控的四種機制 → <a href="/blog/devops/03-traffic-management/" data-link-title="模組三：流量管控" data-link-desc="收到的流量超過處理能力時怎麼辦 — 背壓、rate limit、熔斷、bulkhead 四種防護機制">模組三 流量管控</a></li>
<li>容量預備和壓力測試 → <a href="/blog/devops/05-capacity-planning/" data-link-title="模組五：容量規劃與壓力測試" data-link-desc="要準備多少資源才夠 — 壓力測試方法、峰值估算、成本模型、規模拐點的判斷">模組五 容量規劃</a></li>
<li>Collector 的可插拔 storage 架構 → <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">monitoring 模組四 規模演進</a></li>
<li>Queue 的選型 → <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">backend 非同步佇列</a></li>
</ul>
]]></content:encoded></item><item><title>0.4 什麼時候選 Go</title><link>https://tarrragon.github.io/blog/go/00-philosophy/selecting-go/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/00-philosophy/selecting-go/</guid><description>&lt;p>選擇 Go 的核心判斷是工作場景是否需要長時間運行、明確邊界、穩定併發與簡單部署。這一章用工程條件判斷 Go 是否適合目前問題；若工作更依賴框架模板、快速表單 CRUD、動態行為或大量 runtime magic，其他語言或框架可能更符合需求。&lt;/p>
&lt;p>選型文章的目標是建立判斷路徑。讀者未來面對的可能是 API service、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> server、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> worker、內部工具或資料處理流程；同一個語言在不同工作負載下會有不同價值。先理解判斷條件，再進入語法細節，才不會把「我會寫 Go」誤解成「所有問題都該用 Go」。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>判斷哪些工作負載適合 Go&lt;/li>
&lt;li>看出哪些系統型態特別適合 Go&lt;/li>
&lt;li>區分 Go 的強項與不適合硬上的場景&lt;/li>
&lt;li>用工程條件取代語言偏好來做選型&lt;/li>
&lt;li>為後續語法與實作章節建立正確的閱讀順序&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察先看工作負載再看語言">【觀察】先看工作負載，再看語言&lt;/h2>
&lt;p>Go 最常被選中的場景，是需要穩定處理大量服務型工作的系統。這些工作通常包含等待外部 I/O、管理長生命週期、持續處理背景任務或維持清楚服務邊界：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>工作型態&lt;/th>
 &lt;th>為什麼適合 Go&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>高併發 I/O&lt;/td>
 &lt;td>goroutine 成本低，適合大量等待型工作&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>長連線服務&lt;/td>
 &lt;td>容易管理生命週期、取消與資源清理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>背景 worker&lt;/td>
 &lt;td>可以把工作拆成小單位並持續處理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>事件處理&lt;/td>
 &lt;td>channel、select 與明確邊界很適合事件流&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>API service&lt;/td>
 &lt;td>標準庫直接支撐 HTTP、context、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>如果工作本質就是一堆等待外部 I/O 的操作，Go 往往比把整個問題放進單線程迴圈更自然。&lt;/p>
&lt;h3 id="高併發-io先看服務是否長時間等待外部回應">高併發 I/O：先看服務是否長時間等待外部回應&lt;/h3>
&lt;p>高併發 I/O 的核心特徵是「同時有很多工作在等待網路、檔案、資料庫或外部 API」。判斷時可以先看 request 的時間花在哪裡：如果大部分時間都在等下游回應，CPU 計算只占一小段，這就是等待型工作。&lt;/p>
&lt;p>接近真實網路服務的例子包括：&lt;/p>
&lt;ul>
&lt;li>API gateway 同時轉送請求到多個下游服務&lt;/li>
&lt;li>價格比較網站同時查詢多個供應商 API&lt;/li>
&lt;li>檔案上傳服務同時處理大量 client 的上傳進度&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/webhook/" data-link-title="Webhook" data-link-desc="說明外部系統回呼事件的接收、驗證與處理邊界">webhook&lt;/a> receiver 同時接收付款、物流、通知平台的 callback&lt;/li>
&lt;/ul>
&lt;p>這類服務的主要工程問題是「同時有很多等待中的工作，而且每個工作都需要容量邊界」。Go 的 goroutine 可以讓每個等待中的 request 有清楚的執行單位，&lt;code>context&lt;/code> 可以控制 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a> 與取消，channel 或 semaphore 可以限制同時打到下游的數量。Go 的價值在於讓等待、取消與容量控制都變成明確程式結構。&lt;/p>
&lt;h3 id="長連線服務先看-client-是否會持續留在線上">長連線服務：先看 client 是否會持續留在線上&lt;/h3>
&lt;p>長連線服務的核心特徵是「client 連上來之後不會立刻結束」。判斷時可以看連線是否需要維持數分鐘、數小時，甚至整個工作階段；只要 server 要持續追蹤 client 狀態，就會遇到生命週期管理問題。&lt;/p>
&lt;p>接近真實網路服務的例子包括：&lt;/p>
&lt;ul>
&lt;li>即時聊天室與客服對話&lt;/li>
&lt;li>線上協作文件的多人編輯狀態&lt;/li>
&lt;li>股票、運動比分或遊戲狀態的即時推送&lt;/li>
&lt;li>後台任務進度頁面的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> / &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/sse/" data-link-title="Server-Sent Events (SSE)" data-link-desc="說明 SSE 如何透過 HTTP 長連線向 client 單向推送事件">SSE&lt;/a> 更新&lt;/li>
&lt;/ul>
&lt;p>這類服務的主要工程問題是「連線會斷、client 會變慢、server 要清理資源」。Go 很適合把 read loop、write loop、heartbeat、subscription、shutdown 拆成不同 goroutine 與 channel 邊界。當連線失效時，&lt;code>context&lt;/code>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline&lt;/a> 與 unregister 流程可以把清理責任收斂到同一個地方。&lt;/p>
&lt;h3 id="背景-worker先看工作是否不適合卡住-request">背景 worker：先看工作是否不適合卡住 request&lt;/h3>
&lt;p>背景 worker 的核心特徵是「工作需要持續處理，並且適合從使用者 request 的等待時間中拆出來」。判斷時可以看某個操作是否需要重試、排程、批次處理，或等待外部系統完成。&lt;/p>
&lt;p>接近真實網路服務的例子包括：&lt;/p>
&lt;ul>
&lt;li>寄送 email、簡訊或推播通知&lt;/li>
&lt;li>影片轉檔、圖片壓縮與報表產生&lt;/li>
&lt;li>每晚同步 CRM、金流或庫存資料&lt;/li>
&lt;li>消費 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> message 並更新內部狀態&lt;/li>
&lt;/ul>
&lt;p>這類服務的主要工程問題是「工作要能開始、停止、重試、記錄錯誤並控制速率」。Go 的 &lt;code>Run(ctx)&lt;/code>、ticker、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">worker pool&lt;/a>、channel &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> 與 structured &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a> 可以把 worker 生命週期寫清楚。Go 的好處是讓背景流程仍然可取消、可觀測、可測試，而不只是把工作丟到背景。&lt;/p>
&lt;h3 id="事件處理先看系統是否圍繞已發生的事流動">事件處理：先看系統是否圍繞已發生的事流動&lt;/h3>
&lt;p>事件處理的核心特徵是「系統收到某件已發生的事，再依規則更新狀態或觸發後續行為」。判斷時可以看資料是否常以 &lt;code>created&lt;/code>、&lt;code>updated&lt;/code>、&lt;code>failed&lt;/code>、&lt;code>completed&lt;/code> 這類事實形式流動。&lt;/p></description><content:encoded><![CDATA[<p>選擇 Go 的核心判斷是工作場景是否需要長時間運行、明確邊界、穩定併發與簡單部署。這一章用工程條件判斷 Go 是否適合目前問題；若工作更依賴框架模板、快速表單 CRUD、動態行為或大量 runtime magic，其他語言或框架可能更符合需求。</p>
<p>選型文章的目標是建立判斷路徑。讀者未來面對的可能是 API service、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> server、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> worker、內部工具或資料處理流程；同一個語言在不同工作負載下會有不同價值。先理解判斷條件，再進入語法細節，才不會把「我會寫 Go」誤解成「所有問題都該用 Go」。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>判斷哪些工作負載適合 Go</li>
<li>看出哪些系統型態特別適合 Go</li>
<li>區分 Go 的強項與不適合硬上的場景</li>
<li>用工程條件取代語言偏好來做選型</li>
<li>為後續語法與實作章節建立正確的閱讀順序</li>
</ol>
<hr>
<h2 id="觀察先看工作負載再看語言">【觀察】先看工作負載，再看語言</h2>
<p>Go 最常被選中的場景，是需要穩定處理大量服務型工作的系統。這些工作通常包含等待外部 I/O、管理長生命週期、持續處理背景任務或維持清楚服務邊界：</p>
<table>
  <thead>
      <tr>
          <th>工作型態</th>
          <th>為什麼適合 Go</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>高併發 I/O</td>
          <td>goroutine 成本低，適合大量等待型工作</td>
      </tr>
      <tr>
          <td>長連線服務</td>
          <td>容易管理生命週期、取消與資源清理</td>
      </tr>
      <tr>
          <td>背景 worker</td>
          <td>可以把工作拆成小單位並持續處理</td>
      </tr>
      <tr>
          <td>事件處理</td>
          <td>channel、select 與明確邊界很適合事件流</td>
      </tr>
      <tr>
          <td>API service</td>
          <td>標準庫直接支撐 HTTP、context、<a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a> 與 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a></td>
      </tr>
  </tbody>
</table>
<p>如果工作本質就是一堆等待外部 I/O 的操作，Go 往往比把整個問題放進單線程迴圈更自然。</p>
<h3 id="高併發-io先看服務是否長時間等待外部回應">高併發 I/O：先看服務是否長時間等待外部回應</h3>
<p>高併發 I/O 的核心特徵是「同時有很多工作在等待網路、檔案、資料庫或外部 API」。判斷時可以先看 request 的時間花在哪裡：如果大部分時間都在等下游回應，CPU 計算只占一小段，這就是等待型工作。</p>
<p>接近真實網路服務的例子包括：</p>
<ul>
<li>API gateway 同時轉送請求到多個下游服務</li>
<li>價格比較網站同時查詢多個供應商 API</li>
<li>檔案上傳服務同時處理大量 client 的上傳進度</li>
<li><a href="/blog/backend/knowledge-cards/webhook/" data-link-title="Webhook" data-link-desc="說明外部系統回呼事件的接收、驗證與處理邊界">webhook</a> receiver 同時接收付款、物流、通知平台的 callback</li>
</ul>
<p>這類服務的主要工程問題是「同時有很多等待中的工作，而且每個工作都需要容量邊界」。Go 的 goroutine 可以讓每個等待中的 request 有清楚的執行單位，<code>context</code> 可以控制 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a> 與取消，channel 或 semaphore 可以限制同時打到下游的數量。Go 的價值在於讓等待、取消與容量控制都變成明確程式結構。</p>
<h3 id="長連線服務先看-client-是否會持續留在線上">長連線服務：先看 client 是否會持續留在線上</h3>
<p>長連線服務的核心特徵是「client 連上來之後不會立刻結束」。判斷時可以看連線是否需要維持數分鐘、數小時，甚至整個工作階段；只要 server 要持續追蹤 client 狀態，就會遇到生命週期管理問題。</p>
<p>接近真實網路服務的例子包括：</p>
<ul>
<li>即時聊天室與客服對話</li>
<li>線上協作文件的多人編輯狀態</li>
<li>股票、運動比分或遊戲狀態的即時推送</li>
<li>後台任務進度頁面的 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> / <a href="/blog/backend/knowledge-cards/sse/" data-link-title="Server-Sent Events (SSE)" data-link-desc="說明 SSE 如何透過 HTTP 長連線向 client 單向推送事件">SSE</a> 更新</li>
</ul>
<p>這類服務的主要工程問題是「連線會斷、client 會變慢、server 要清理資源」。Go 很適合把 read loop、write loop、heartbeat、subscription、shutdown 拆成不同 goroutine 與 channel 邊界。當連線失效時，<code>context</code>、<a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline</a> 與 unregister 流程可以把清理責任收斂到同一個地方。</p>
<h3 id="背景-worker先看工作是否不適合卡住-request">背景 worker：先看工作是否不適合卡住 request</h3>
<p>背景 worker 的核心特徵是「工作需要持續處理，並且適合從使用者 request 的等待時間中拆出來」。判斷時可以看某個操作是否需要重試、排程、批次處理，或等待外部系統完成。</p>
<p>接近真實網路服務的例子包括：</p>
<ul>
<li>寄送 email、簡訊或推播通知</li>
<li>影片轉檔、圖片壓縮與報表產生</li>
<li>每晚同步 CRM、金流或庫存資料</li>
<li>消費 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> message 並更新內部狀態</li>
</ul>
<p>這類服務的主要工程問題是「工作要能開始、停止、重試、記錄錯誤並控制速率」。Go 的 <code>Run(ctx)</code>、ticker、<a href="/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">worker pool</a>、channel <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> 與 structured <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a> 可以把 worker 生命週期寫清楚。Go 的好處是讓背景流程仍然可取消、可觀測、可測試，而不只是把工作丟到背景。</p>
<h3 id="事件處理先看系統是否圍繞已發生的事流動">事件處理：先看系統是否圍繞已發生的事流動</h3>
<p>事件處理的核心特徵是「系統收到某件已發生的事，再依規則更新狀態或觸發後續行為」。判斷時可以看資料是否常以 <code>created</code>、<code>updated</code>、<code>failed</code>、<code>completed</code> 這類事實形式流動。</p>
<p>接近真實網路服務的例子包括：</p>
<ul>
<li>訂單付款成功後更新訂單狀態並發送通知</li>
<li>使用者註冊完成後建立歡迎流程與分析事件</li>
<li>CI job 狀態改變後推送到 <a href="/blog/backend/knowledge-cards/dashboard/" data-link-title="Dashboard" data-link-desc="說明 dashboard 如何把關鍵訊號組成可判讀的服務狀態畫面">dashboard</a></li>
<li>IoT 裝置上報 sensor reading 後觸發告警</li>
</ul>
<p>這類服務的主要工程問題是「事件來源多、順序可能不同、重複事件需要處理」。Go 的型別可以定義穩定 event envelope，channel 或 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> adapter 可以把來源收斂到 processor，processor 再集中處理 validation、dedup、state transition 與 publish。Go 的價值在於讓事件流的每一段責任清楚可測。</p>
<h3 id="api-service先看服務是否需要清楚的-request-邊界">API service：先看服務是否需要清楚的 request 邊界</h3>
<p>API service 的核心特徵是「外部 client 用明確 request 取得資料或要求系統執行動作」。判斷時可以看服務是否需要穩定路由、輸入驗證、timeout、error response、<a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a> 與 <a href="/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics</a>。</p>
<p>接近真實網路服務的例子包括：</p>
<ul>
<li>手機 App 的會員、訂單、通知 API</li>
<li>SaaS 產品提供給客戶整合的 public API</li>
<li>內部微服務之間的 HTTP/gRPC API</li>
<li><a href="/blog/backend/knowledge-cards/dashboard/" data-link-title="Dashboard" data-link-desc="說明 dashboard 如何把關鍵訊號組成可判讀的服務狀態畫面">dashboard</a> 查詢目前狀態與操作後端任務的 API</li>
</ul>
<p>這類服務的主要工程問題是「request 進來後要有清楚邊界」。Go 標準庫的 <code>net/http</code>、<code>context</code>、<code>encoding/json</code>、<code>log/slog</code> 與 testing package 已經提供服務骨架需要的基本能力。當 API 邊界清楚時，handler 可以專注在傳輸格式，usecase 處理行為規則，repository 或 external client 處理資料依賴。</p>
<p>例如一個通知服務需要同時處理三件事：接收 HTTP callback、把事件放進背景處理流程、再把結果推送給已訂閱的 client。這個服務的主要成本通常在於同時等待網路、管理 client 連線、控制 queue 滿載與清理失效資源；單次計算反而只是其中一小部分。Go 的 goroutine、channel、context 與標準庫 HTTP 可以把這些生命週期寫成明確的程式結構。</p>
<p>相反地，一個只需要三個表單頁面、幾個後台列表和現成權限模板的內部管理系統，主要成本可能在 UI、表單驗證、ORM convention 與後台 scaffolding。這種工作也能用 Go 完成，但選型時應先問：「主要成本是在服務生命週期，還是在框架已經提供的業務頁面組裝？」這個問題比語言效能更接近真正瓶頸。</p>
<h2 id="判讀架構邊界是否清楚">【判讀】架構邊界是否清楚</h2>
<p>Go 特別適合邊界清楚的後端服務。當一個系統可以自然拆成輸入、協調、狀態、輸出幾層時，Go 的 struct、interface、package 與明確依賴會讓責任更容易看見。</p>
<p>例如以下這類系統通常是 Go 的好候選：</p>
<ul>
<li><a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> 即時服務</li>
<li>notification service</li>
<li>queue <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a></li>
<li>log / event pipeline</li>
<li>需要清楚 ports/adapters 的 backend service</li>
</ul>
<p>產品若高度依賴框架提供的大量現成功能，或核心價值在於快速拼接大量業務頁面與模板，選型時應把框架生態列為主要條件。這類情境可以先評估 Python、Ruby、JavaScript/TypeScript 或其他更貼近既有生態的方案。</p>
<p>判斷架構邊界時，可以先畫出資料如何通過系統：</p>
<ol>
<li>外部請求或事件從哪裡進來</li>
<li>哪一層負責驗證與轉換</li>
<li>哪一層負責狀態轉移或業務規則</li>
<li>哪一層負責回應、推送或記錄</li>
</ol>
<p>如果這四個問題能自然拆成幾個責任清楚的元件，Go 會讓這些邊界很容易被程式碼表達。handler 可以處理傳輸格式，usecase 可以處理行為規則，repository 或 state owner 可以處理狀態，publisher 或 response layer 可以處理輸出。這種設計不需要大型框架先定義所有路徑，Go 的簡單型別與小介面就能支撐。</p>
<p>如果團隊還在探索商業流程，連資料模型、頁面流程與權限規則都會每天改，框架的 convention 可能更重要。這時候語言選型的重點是「顯式設計的成本是否值得現在承擔」。Go 會鼓勵你把邊界寫清楚；當邊界本身仍在頻繁變動，這種清楚有時會變成前置設計成本。</p>
<h2 id="策略runtime-與部署條件也是選型的一部分">【策略】runtime 與部署條件也是選型的一部分</h2>
<p>Go 的優勢不只是語法，還包括 runtime 與部署形態：</p>
<ul>
<li>單一 binary，部署流程簡單</li>
<li>啟動速度快，適合 container 與短週期交付</li>
<li>標準庫完整，很多服務不需要先找一堆框架</li>
<li>可讀性高，長期維護成本較容易控制</li>
</ul>
<p>如果你的團隊很在意：</p>
<ul>
<li>記憶體用量</li>
<li>啟動時間</li>
<li>觀測與除錯</li>
<li>服務在高流量下的穩定性</li>
</ul>
<p>那 Go 的工程價值就會很明顯。</p>
<p>部署條件會影響語言價值，因為服務最終要在開發機之外的環境長期運行。假設一個團隊要把多個小服務放進 container，每個服務都需要 <a href="/blog/backend/knowledge-cards/health-check-liveness/" data-link-title="Liveness" data-link-desc="說明平台如何判斷 process 是否仍然存活，以及何時應重啟">health check</a>、timeout、structured log、<a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown</a> 與固定資源限制。Go 的單一 binary 和標準庫讓這些能力可以用相對少的外部依賴完成；服務啟動、部署與回滾也比較容易被平台工程師理解。</p>
<p>另一個常見例子是 CLI 工具或 sidecar service。這類程式常被放進 CI、Kubernetes job、systemd service 或部署腳本中。Go 編譯後的 binary 可以降低 runtime 安裝與版本衝突問題。這是交付形態優勢：當程式要在很多環境中穩定啟動，少一層 runtime 依賴就是一個可觀的工程收益。</p>
<h2 id="執行哪些情況要先評估其他方案">【執行】哪些情況要先評估其他方案</h2>
<p>選型的執行規則是先確認主要瓶頸，再決定語言。以下情境通常應先評估其他語言、框架或平台能力：</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>原因</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>極度偏 CRUD 模板系統</td>
          <td>框架生態可能比語言特性更重要</td>
      </tr>
      <tr>
          <td>大量動態行為與 runtime 配置</td>
          <td>Go 會要求更多顯式設計</td>
      </tr>
      <tr>
          <td>團隊主要目標是快速試錯</td>
          <td>Go 的工程紀律可能比腳本型語言更有前置成本</td>
      </tr>
      <tr>
          <td>主要瓶頸在前端整合流程</td>
          <td>主要解法在前端工具鏈、元件生態與產品流程</td>
      </tr>
  </tbody>
</table>
<p>Go 可以處理其中部分情境，但它的工程價值未必對準主要瓶頸。當主要成本在框架生態、動態流程或前端整合時，下一步應先比較那些領域更成熟的工具。</p>
<h3 id="極度偏-crud-模板系統先看頁面是否圍繞資料表轉">極度偏 CRUD 模板系統：先看頁面是否圍繞資料表轉</h3>
<p>CRUD 模板系統的核心特徵是「大部分功能都在新增、查詢、修改、刪除資料」。判斷時可以先看產品畫面：如果主要頁面都是列表、篩選、表單、詳情頁、權限設定與匯出報表，系統很可能偏 CRUD。</p>
<p>接近真實網路服務的例子包括：</p>
<ul>
<li>電商平台的商品、訂單、會員、優惠券後台</li>
<li>餐廳訂位系統的店家、桌位、時段、訂單管理</li>
<li>客服工單系統的 ticket 列表、狀態修改、負責人指派</li>
<li>活動報名系統的活動、票種、參加者、付款狀態管理</li>
</ul>
<p>這類系統的主要工作通常在「快速產生穩定後台功能」。框架如果已經提供 <a href="/blog/backend/knowledge-cards/authentication/" data-link-title="Authentication" data-link-desc="說明系統如何確認呼叫者身份">authentication</a>、<a href="/blog/backend/knowledge-cards/authorization/" data-link-title="Authorization" data-link-desc="說明授權如何判斷誰能對哪些資源執行哪些操作">authorization</a>、ORM、form validation、admin scaffolding、pagination 與 search，團隊會先從框架生態獲得產能。Go 仍然可以負責其中的高流量 API、背景同步、付款 callback 或報表生成，但整個後台產品的主體未必需要先用 Go 開始。</p>
<p>判斷問題可以這樣問：如果拿掉後台表單、列表與權限頁，系統還剩下什麼核心工程問題？如果答案很少，框架模板可能就是主要能力。</p>
<h3 id="大量動態行為與-runtime-配置先看規則是否由使用者定義">大量動態行為與 runtime 配置：先看規則是否由使用者定義</h3>
<p>動態行為系統的核心特徵是「行為在執行期間由設定、腳本、規則或使用者操作改變」。判斷時可以先觀察：工程師是否常常需要讓非工程使用者自己新增欄位、調整流程、改驗證規則或配置通知條件。</p>
<p>接近真實網路服務的例子包括：</p>
<ul>
<li>表單建置器：使用者可以自己新增欄位、驗證規則與送出後動作</li>
<li>工作流系統：管理者可以設定「訂單超過金額就送審」、「狀態改變就寄信」</li>
<li>CMS：編輯可以建立不同內容模型、欄位與發布流程</li>
<li>行銷自動化工具：使用者可以用條件組合出不同受眾與觸發規則</li>
</ul>
<p>這類系統的主要工程問題在於「如何安全表達動態規則」。Go 的靜態型別會鼓勵你把資料結構與行為先定義清楚；這對穩定服務很有價值，但對高度動態的產品，可能需要額外設計 rule engine、schema registry、plugin boundary 或 DSL。若產品核心就是讓使用者自由配置流程，選型時應把動態模型能力列為主要評估項目。</p>
<p>Go 的合理位置通常在規則執行引擎、事件處理器或高併發 delivery service。管理介面、規則編輯器與 schema 設計器則可能更依賴前端與動態框架生態。</p>
<h3 id="團隊主要目標是快速試錯先看需求是否每天改方向">團隊主要目標是快速試錯：先看需求是否每天改方向</h3>
<p>快速試錯情境的核心特徵是「產品問題尚未被驗證」。判斷時可以觀察需求文件與會議紀錄：如果資料模型、頁面流程、定價方式、權限規則與使用者角色都還在頻繁改，團隊此時最需要的是降低改方向的成本。</p>
<p>接近真實網路服務的例子包括：</p>
<ul>
<li>新創產品的第一版 MVP</li>
<li>內部工具的概念驗證</li>
<li>尚未確定商業模式的 marketplace</li>
<li>正在測試轉換率的報名、購買或 onboarding 流程</li>
</ul>
<p>這類階段的主要問題是「學到使用者真正需要什麼」。腳本型語言、full-stack framework、低程式碼工具或現成 SaaS 可能更適合先驗證流程。Go 的型別、錯誤處理與顯式邊界會提高長期可維護性，但在問題尚未穩定時，過早建立完整邊界可能會讓每次方向調整都需要較多工程變更。</p>
<p>Go 仍然可以在試錯產品中出現，但通常適合放在已經確定會留下的部分，例如 <a href="/blog/backend/knowledge-cards/webhook/" data-link-title="Webhook" data-link-desc="說明外部系統回呼事件的接收、驗證與處理邊界">webhook</a> receiver、背景任務、匯入匯出服務或需要穩定運行的小型 API。產品流程本身可以先用更容易改動的工具探索。</p>
<h3 id="主要瓶頸在前端整合流程先看使用者價值是否發生在介面">主要瓶頸在前端整合流程：先看使用者價值是否發生在介面</h3>
<p>前端整合型產品的核心特徵是「使用者價值主要發生在互動介面」。判斷時可以先看產品成功條件：如果最重要的是頁面轉換率、互動動畫、表單體驗、SEO、設計系統、第三方前端 SDK 或多裝置呈現，後端語言通常只是整體解法的一部分。</p>
<p>接近真實網路服務的例子包括：</p>
<ul>
<li>行銷 landing page 與 A/B testing 流程</li>
<li>電商結帳頁、購物車、折扣碼與付款 UI</li>
<li>內容網站、文件站、會員訂閱頁</li>
<li>需要大量拖拉、預覽、即時編輯的設計工具</li>
</ul>
<p>這類系統的主要工程問題在於前端狀態、元件設計、瀏覽器限制、SEO、分析追蹤與使用者流程。Go 可以提供穩定 API、授權、訂單狀態或事件接收，但選型時要先確認後端 runtime 是否真的在解主要瓶頸。若瓶頸集中在 UI 與產品流程，Next.js、Remix、Nuxt 或其他前端框架生態可能是更直接的評估起點。</p>
<p>判斷問題可以這樣問：使用者感受到的主要差異來自 server 的併發能力，還是來自畫面反應、表單流程、SEO 與第三方整合？如果主要差異在後者，Go 可以是後端配角，不一定是產品主體的第一個選型決策。</p>
<p>選型可以用三個問題收斂：</p>
<ol>
<li><strong>主要瓶頸是什麼？</strong> 如果瓶頸是大量 I/O、長連線、背景處理、部署穩定性，Go 值得優先評估；如果瓶頸是 UI scaffolding、資料後台或快速試錯，框架生態可能更關鍵。</li>
<li><strong>邊界是否已經清楚？</strong> 如果輸入、規則、狀態與輸出能被穩定拆開，Go 的顯式設計會帶來可讀性；如果流程每天改，先用 convention 強的工具驗證產品可能更合理。</li>
<li><strong>團隊要優化短期探索還是長期維護？</strong> Go 會把錯誤處理、型別、依賴與生命週期攤開寫清楚；這對長期服務是優點，對一次性探索則可能顯得偏重。</li>
</ol>
<p>因此，選 Go 的結論應該長得像工程判斷。例如：「這個服務會維持上千條長連線，需要明確 timeout、<a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 與 <a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">shutdown</a> 所以 Go 是好候選。」或是：「這個系統主要是後台 CRUD 和權限頁面，框架產能比 runtime 特性更重要，所以先評估 Django、Rails 或 Next.js 生態。」這樣的句子能讓團隊看見選型依據，也能在條件改變時重新評估。</p>
]]></content:encoded></item><item><title>1.4 package、檔案與可見性</title><link>https://tarrragon.github.io/blog/go/01-basics/packages/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/01-basics/packages/</guid><description>&lt;p>Go 用 package 組織程式碼。package 不只是資料夾名稱，而是 API 邊界：哪些名稱能被其他 package 使用，哪些名稱只在內部可見，都由 package 與命名共同決定。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>理解 &lt;code>package main&lt;/code> 和一般 package 的差異&lt;/li>
&lt;li>看懂同一個 package 如何拆成多個檔案&lt;/li>
&lt;li>用大小寫判斷 exported 與 unexported 名稱&lt;/li>
&lt;li>設計不暴露過多細節的 package API&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察每個-go-檔案都從-package-開始">【觀察】每個 Go 檔案都從 package 開始&lt;/h2>
&lt;p>package 宣告的核心規則是：每個 Go 檔案都必須先宣告自己屬於哪個 package。可執行程式使用 &lt;code>package main&lt;/code>，例如：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kn">package&lt;/span> &lt;span class="nx">main&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="s">&amp;#34;fmt&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">main&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;hello&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>第一行 &lt;code>package main&lt;/code> 表示這個檔案屬於 &lt;code>main&lt;/code> package。Go 編譯器會尋找 &lt;code>main&lt;/code> package 裡的 &lt;code>main()&lt;/code> 函式，將它編譯成可執行程式。&lt;/p>
&lt;p>一般 package 的核心規則是：它不負責啟動程式，而是提供型別、函式或方法給其他 package 使用。可被其他程式引用的工具 package 通常會使用自己的 package 名稱：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kn">package&lt;/span> &lt;span class="nx">config&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Config&lt;/span> &lt;span class="kd">struct&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="nx">Port&lt;/span> &lt;span class="kt">int&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個 package 不會自己啟動程式，而是提供型別、函式或方法給其他 package 使用。&lt;/p>
&lt;h2 id="判讀package-是一組共同編譯的檔案">【判讀】package 是一組共同編譯的檔案&lt;/h2>
&lt;p>package 的編譯單位是一組同 package 檔案。同一個資料夾中的 Go 檔案通常必須宣告同一個 package；這些檔案會被一起編譯，也可以直接互相使用彼此的 unexported 名稱。&lt;/p>
&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">config/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">├── config.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">├── defaults.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">└── validate.go&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>三個檔案都可以是：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kn">package&lt;/span> &lt;span class="nx">config&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這表示 &lt;code>config.go&lt;/code> 裡的函式可以直接呼叫 &lt;code>validate.go&lt;/code> 裡的小工具函式，即使那個工具函式沒有 exported。&lt;/p>
&lt;h2 id="策略用大小寫控制-api-邊界">【策略】用大小寫控制 API 邊界&lt;/h2>
&lt;p>Go 可見性的核心規則是：大寫開頭 exported，小寫開頭 unexported。Go 沒有 &lt;code>public&lt;/code>、&lt;code>private&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>Config&lt;/code>&lt;/td>
 &lt;td>exported&lt;/td>
 &lt;td>其他 package 可使用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Load&lt;/code>&lt;/td>
 &lt;td>exported&lt;/td>
 &lt;td>其他 package 可呼叫&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>defaultPort&lt;/code>&lt;/td>
 &lt;td>unexported&lt;/td>
 &lt;td>只在目前 package 內可用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>validatePath&lt;/code>&lt;/td>
 &lt;td>unexported&lt;/td>
 &lt;td>內部實作細節&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>範例：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kn">package&lt;/span> &lt;span class="nx">config&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Config&lt;/span> &lt;span class="kd">struct&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="nx">Port&lt;/span> &lt;span class="kt">int&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="kd">const&lt;/span> &lt;span class="nx">defaultPort&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">8080&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">Load&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">path&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">Config&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">path&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">Config&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">Port&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">defaultPort&lt;/span>&lt;span class="p">},&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nf">readConfig&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">path&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">readConfig&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">path&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">Config&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 內部解析邏輯&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">Config&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">Port&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">defaultPort&lt;/span>&lt;span class="p">},&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>其他 package 可以使用 &lt;code>config.Config&lt;/code> 和 &lt;code>config.Load&lt;/code>，但不能直接使用 &lt;code>config.defaultPort&lt;/code> 或 &lt;code>config.readConfig&lt;/code>。&lt;/p>
&lt;h2 id="執行把檔案切分成認知單位">【執行】把檔案切分成認知單位&lt;/h2>
&lt;p>檔案切分的核心規則是：依照讀者理解程式的方式分組，而不是機械地一個型別一個檔案。Go 不要求「一個型別一個檔案」。&lt;/p>
&lt;p>例如設定讀取 package 可以這樣切：&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">config/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">├── config.go # Config 型別與 Load 入口
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">├── defaults.go # 預設值
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">├── validate.go # 驗證規則
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">└── config_test.go # 測試&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這樣切分的好處是：&lt;/p>
&lt;ul>
&lt;li>&lt;code>config.go&lt;/code> 作為 package 入口，讀者先看這裡&lt;/li>
&lt;li>&lt;code>defaults.go&lt;/code> 集中預設值，不和解析流程混在一起&lt;/li>
&lt;li>&lt;code>validate.go&lt;/code> 集中驗證規則，方便測試&lt;/li>
&lt;li>unexported helper 留在 package 內，不污染外部 API&lt;/li>
&lt;/ul>
&lt;h2 id="設計檢查">設計檢查&lt;/h2>
&lt;h3 id="檢查一控制-exported-api">檢查一：控制 exported API&lt;/h3>
&lt;p>如果你把所有型別、函式、常數都用大寫開頭，其他 package 就會開始依賴你的內部細節。未來你想重構時，會發現很多名稱都不能改。&lt;/p></description><content:encoded><![CDATA[<p>Go 用 package 組織程式碼。package 不只是資料夾名稱，而是 API 邊界：哪些名稱能被其他 package 使用，哪些名稱只在內部可見，都由 package 與命名共同決定。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>理解 <code>package main</code> 和一般 package 的差異</li>
<li>看懂同一個 package 如何拆成多個檔案</li>
<li>用大小寫判斷 exported 與 unexported 名稱</li>
<li>設計不暴露過多細節的 package API</li>
</ol>
<hr>
<h2 id="觀察每個-go-檔案都從-package-開始">【觀察】每個 Go 檔案都從 package 開始</h2>
<p>package 宣告的核心規則是：每個 Go 檔案都必須先宣告自己屬於哪個 package。可執行程式使用 <code>package main</code>，例如：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kn">package</span> <span class="nx">main</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kn">import</span> <span class="s">&#34;fmt&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="kd">func</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;hello&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>第一行 <code>package main</code> 表示這個檔案屬於 <code>main</code> package。Go 編譯器會尋找 <code>main</code> package 裡的 <code>main()</code> 函式，將它編譯成可執行程式。</p>
<p>一般 package 的核心規則是：它不負責啟動程式，而是提供型別、函式或方法給其他 package 使用。可被其他程式引用的工具 package 通常會使用自己的 package 名稱：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kn">package</span> <span class="nx">config</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kd">type</span> <span class="nx">Config</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">Port</span> <span class="kt">int</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 package 不會自己啟動程式，而是提供型別、函式或方法給其他 package 使用。</p>
<h2 id="判讀package-是一組共同編譯的檔案">【判讀】package 是一組共同編譯的檔案</h2>
<p>package 的編譯單位是一組同 package 檔案。同一個資料夾中的 Go 檔案通常必須宣告同一個 package；這些檔案會被一起編譯，也可以直接互相使用彼此的 unexported 名稱。</p>
<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">config/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── config.go
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── defaults.go
</span></span><span class="line"><span class="ln">4</span><span class="cl">└── validate.go</span></span></code></pre></div><p>三個檔案都可以是：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kn">package</span> <span class="nx">config</span></span></span></code></pre></div><p>這表示 <code>config.go</code> 裡的函式可以直接呼叫 <code>validate.go</code> 裡的小工具函式，即使那個工具函式沒有 exported。</p>
<h2 id="策略用大小寫控制-api-邊界">【策略】用大小寫控制 API 邊界</h2>
<p>Go 可見性的核心規則是：大寫開頭 exported，小寫開頭 unexported。Go 沒有 <code>public</code>、<code>private</code> 關鍵字，而是用命名大小寫決定可見性：</p>
<table>
  <thead>
      <tr>
          <th>名稱</th>
          <th>可見性</th>
          <th>意義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>Config</code></td>
          <td>exported</td>
          <td>其他 package 可使用</td>
      </tr>
      <tr>
          <td><code>Load</code></td>
          <td>exported</td>
          <td>其他 package 可呼叫</td>
      </tr>
      <tr>
          <td><code>defaultPort</code></td>
          <td>unexported</td>
          <td>只在目前 package 內可用</td>
      </tr>
      <tr>
          <td><code>validatePath</code></td>
          <td>unexported</td>
          <td>內部實作細節</td>
      </tr>
  </tbody>
</table>
<p>範例：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">package</span> <span class="nx">config</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kd">type</span> <span class="nx">Config</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">Port</span> <span class="kt">int</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="kd">const</span> <span class="nx">defaultPort</span> <span class="p">=</span> <span class="mi">8080</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">func</span> <span class="nf">Load</span><span class="p">(</span><span class="nx">path</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">Config</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="nx">path</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="k">return</span> <span class="nx">Config</span><span class="p">{</span><span class="nx">Port</span><span class="p">:</span> <span class="nx">defaultPort</span><span class="p">},</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">return</span> <span class="nf">readConfig</span><span class="p">(</span><span class="nx">path</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="kd">func</span> <span class="nf">readConfig</span><span class="p">(</span><span class="nx">path</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">Config</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="c1">// 內部解析邏輯</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">return</span> <span class="nx">Config</span><span class="p">{</span><span class="nx">Port</span><span class="p">:</span> <span class="nx">defaultPort</span><span class="p">},</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>其他 package 可以使用 <code>config.Config</code> 和 <code>config.Load</code>，但不能直接使用 <code>config.defaultPort</code> 或 <code>config.readConfig</code>。</p>
<h2 id="執行把檔案切分成認知單位">【執行】把檔案切分成認知單位</h2>
<p>檔案切分的核心規則是：依照讀者理解程式的方式分組，而不是機械地一個型別一個檔案。Go 不要求「一個型別一個檔案」。</p>
<p>例如設定讀取 package 可以這樣切：</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">config/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── config.go       # Config 型別與 Load 入口
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── defaults.go     # 預設值
</span></span><span class="line"><span class="ln">4</span><span class="cl">├── validate.go     # 驗證規則
</span></span><span class="line"><span class="ln">5</span><span class="cl">└── config_test.go  # 測試</span></span></code></pre></div><p>這樣切分的好處是：</p>
<ul>
<li><code>config.go</code> 作為 package 入口，讀者先看這裡</li>
<li><code>defaults.go</code> 集中預設值，不和解析流程混在一起</li>
<li><code>validate.go</code> 集中驗證規則，方便測試</li>
<li>unexported helper 留在 package 內，不污染外部 API</li>
</ul>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="檢查一控制-exported-api">檢查一：控制 exported API</h3>
<p>如果你把所有型別、函式、常數都用大寫開頭，其他 package 就會開始依賴你的內部細節。未來你想重構時，會發現很多名稱都不能改。</p>
<h3 id="檢查二package-名稱表達責任">檢查二：package 名稱表達責任</h3>
<p><code>utils</code>、<code>common</code>、<code>helpers</code> 這類名稱常讓 package 變成雜物間。Go 更偏好用資料或能力命名，例如 <code>config</code>、<code>auth</code>、<code>metrics</code>、<code>parser</code>。</p>
<h3 id="檢查三檔案切分服務閱讀">檢查三：檔案切分服務閱讀</h3>
<p>過度切分會讓讀者一直跳檔案。Go 的檔案可以稍微長一點，只要同一個檔案仍然圍繞同一組概念。</p>
]]></content:encoded></item><item><title>4.4 多來源 event 融合</title><link>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/event-fusion/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/event-fusion/</guid><description>&lt;p>事件融合的核心目標是讓不同來源的同類事件進入同一套內部規則。HTTP callback、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> message、timer scan 與檔案 reader 都只是輸入方式；進入 processor 前，它們應該被轉成一致的 &lt;code>DomainEvent&lt;/code>。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨來源差異與 domain 規則差異&lt;/li>
&lt;li>為不同來源設計 adapter 與 normalize&lt;/li>
&lt;li>用 channel 或直接呼叫收斂事件入口&lt;/li>
&lt;li>為突發流量設計 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a> 策略&lt;/li>
&lt;li>決定錯誤應回給上游、重試、丟棄或記錄&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察來源增加後規則容易分裂">【觀察】來源增加後規則容易分裂&lt;/h2>
&lt;p>事件來源增加的核心風險是每個來源各自實作一套處理規則。HTTP handler 有一套 validation，queue &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer&lt;/a> 有一套 retry 判斷，timer worker 又有一套狀態更新；最後同一種 domain event 在不同入口產生不同結果。&lt;/p>
&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">HTTP callback ──&amp;gt; validate A ──&amp;gt; update state A
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">queue message ──&amp;gt; validate B ──&amp;gt; update state B
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">timer scan ──&amp;gt; validate C ──&amp;gt; update state C&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這種結構的問題是 domain 規則分裂。新增來源時，應該新增 adapter，不應複製 processor。&lt;/p>
&lt;h2 id="判讀來源差異應限制在-adapter">【判讀】來源差異應限制在 adapter&lt;/h2>
&lt;p>事件融合的核心原則是來源差異停在 adapter 與 normalizer。來源可以有不同 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/authentication/" data-link-title="Authentication" data-link-desc="說明系統如何確認呼叫者身份">authentication&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack&lt;/a>、HTTP status、payload 格式與重試語意；但轉成 &lt;code>DomainEvent&lt;/code> 後，processor 應該面對一致模型。&lt;/p>
&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">HTTP callback ─┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">queue message ─┼─&amp;gt; normalize ─&amp;gt; DomainEvent ─&amp;gt; processor
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">timer scan ─┘&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個結構讓新增來源變成局部擴充。你新增一個 adapter 與 normalize test，而不是複製 validation、dedup、repository update 與 publish 邏輯。&lt;/p>
&lt;h2 id="策略先定義每個來源的責任">【策略】先定義每個來源的責任&lt;/h2>
&lt;p>來源設計的核心動作是明確寫出每個 adapter 對上游的承諾。不同來源的錯誤回應方式不同，但進入 processor 的事件語意應一致。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>來源&lt;/th>
 &lt;th>adapter 責任&lt;/th>
 &lt;th>失敗回應&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>HTTP callback&lt;/td>
 &lt;td>decode JSON、驗證簽章、normalize&lt;/td>
 &lt;td>回 4xx/5xx&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>queue consumer&lt;/td>
 &lt;td>decode message、控制 ack/nack、normalize&lt;/td>
 &lt;td>ack、nack 或 retry&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>timer scan&lt;/td>
 &lt;td>讀取本地狀態、產生內部事件&lt;/td>
 &lt;td>記錄錯誤或下次再掃&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>file reader&lt;/td>
 &lt;td>讀取增量資料、normalize&lt;/td>
 &lt;td>記錄 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset&lt;/a> 或停下&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>表格是設計工具。若某一列寫不清楚，代表 adapter 與 processor 的邊界還不清楚。&lt;/p>
&lt;h2 id="執行http-adapter-轉成-domainevent">【執行】HTTP adapter 轉成 DomainEvent&lt;/h2>
&lt;p>HTTP adapter 的核心責任是處理 HTTP 協定與外部 payload。它可以回應 status code，但不應直接決定狀態如何更新。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">HTTPEventHandler&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">processor&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">EventProcessor&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">now&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">h&lt;/span> &lt;span class="nx">HTTPEventHandler&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">ServeHTTP&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">raw&lt;/span> &lt;span class="nx">RawHTTPEvent&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewDecoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Decode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">raw&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&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="nf">writeError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;invalid_json&amp;#34;&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="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">event&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NormalizeHTTPEvent&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">raw&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">h&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">now&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;invalid_event&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">h&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">processor&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Process&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Context&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusServiceUnavailable&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;event_not_accepted&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusAccepted&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>StatusAccepted&lt;/code> 表示事件已被系統接收，不一定表示所有下游推送都完成。若 API 語意要求同步完成，就需要在文件與測試中明確定義成功條件。&lt;/p></description><content:encoded><![CDATA[<p>事件融合的核心目標是讓不同來源的同類事件進入同一套內部規則。HTTP callback、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> message、timer scan 與檔案 reader 都只是輸入方式；進入 processor 前，它們應該被轉成一致的 <code>DomainEvent</code>。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨來源差異與 domain 規則差異</li>
<li>為不同來源設計 adapter 與 normalize</li>
<li>用 channel 或直接呼叫收斂事件入口</li>
<li>為突發流量設計 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 策略</li>
<li>決定錯誤應回給上游、重試、丟棄或記錄</li>
</ol>
<hr>
<h2 id="觀察來源增加後規則容易分裂">【觀察】來源增加後規則容易分裂</h2>
<p>事件來源增加的核心風險是每個來源各自實作一套處理規則。HTTP handler 有一套 validation，queue <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a> 有一套 retry 判斷，timer worker 又有一套狀態更新；最後同一種 domain event 在不同入口產生不同結果。</p>
<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">HTTP callback ──&gt; validate A ──&gt; update state A
</span></span><span class="line"><span class="ln">2</span><span class="cl">queue message ──&gt; validate B ──&gt; update state B
</span></span><span class="line"><span class="ln">3</span><span class="cl">timer scan    ──&gt; validate C ──&gt; update state C</span></span></code></pre></div><p>這種結構的問題是 domain 規則分裂。新增來源時，應該新增 adapter，不應複製 processor。</p>
<h2 id="判讀來源差異應限制在-adapter">【判讀】來源差異應限制在 adapter</h2>
<p>事件融合的核心原則是來源差異停在 adapter 與 normalizer。來源可以有不同 <a href="/blog/backend/knowledge-cards/authentication/" data-link-title="Authentication" data-link-desc="說明系統如何確認呼叫者身份">authentication</a>、<a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack</a>、HTTP status、payload 格式與重試語意；但轉成 <code>DomainEvent</code> 後，processor 應該面對一致模型。</p>
<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">HTTP callback ─┐
</span></span><span class="line"><span class="ln">2</span><span class="cl">queue message ─┼─&gt; normalize ─&gt; DomainEvent ─&gt; processor
</span></span><span class="line"><span class="ln">3</span><span class="cl">timer scan    ─┘</span></span></code></pre></div><p>這個結構讓新增來源變成局部擴充。你新增一個 adapter 與 normalize test，而不是複製 validation、dedup、repository update 與 publish 邏輯。</p>
<h2 id="策略先定義每個來源的責任">【策略】先定義每個來源的責任</h2>
<p>來源設計的核心動作是明確寫出每個 adapter 對上游的承諾。不同來源的錯誤回應方式不同，但進入 processor 的事件語意應一致。</p>
<table>
  <thead>
      <tr>
          <th>來源</th>
          <th>adapter 責任</th>
          <th>失敗回應</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>HTTP callback</td>
          <td>decode JSON、驗證簽章、normalize</td>
          <td>回 4xx/5xx</td>
      </tr>
      <tr>
          <td>queue consumer</td>
          <td>decode message、控制 ack/nack、normalize</td>
          <td>ack、nack 或 retry</td>
      </tr>
      <tr>
          <td>timer scan</td>
          <td>讀取本地狀態、產生內部事件</td>
          <td>記錄錯誤或下次再掃</td>
      </tr>
      <tr>
          <td>file reader</td>
          <td>讀取增量資料、normalize</td>
          <td>記錄 <a href="/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset</a> 或停下</td>
      </tr>
  </tbody>
</table>
<p>表格是設計工具。若某一列寫不清楚，代表 adapter 與 processor 的邊界還不清楚。</p>
<h2 id="執行http-adapter-轉成-domainevent">【執行】HTTP adapter 轉成 DomainEvent</h2>
<p>HTTP adapter 的核心責任是處理 HTTP 協定與外部 payload。它可以回應 status code，但不應直接決定狀態如何更新。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">HTTPEventHandler</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">processor</span> <span class="o">*</span><span class="nx">EventProcessor</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">now</span>       <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="nx">HTTPEventHandler</span><span class="p">)</span> <span class="nf">ServeHTTP</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="kd">var</span> <span class="nx">raw</span> <span class="nx">RawHTTPEvent</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">NewDecoder</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">).</span><span class="nf">Decode</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">raw</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">,</span> <span class="s">&#34;invalid_json&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">event</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">NormalizeHTTPEvent</span><span class="p">(</span><span class="nx">raw</span><span class="p">,</span> <span class="nx">h</span><span class="p">.</span><span class="nf">now</span><span class="p">())</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">,</span> <span class="s">&#34;invalid_event&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">h</span><span class="p">.</span><span class="nx">processor</span><span class="p">.</span><span class="nf">Process</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nf">Context</span><span class="p">(),</span> <span class="nx">event</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusServiceUnavailable</span><span class="p">,</span> <span class="s">&#34;event_not_accepted&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusAccepted</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>StatusAccepted</code> 表示事件已被系統接收，不一定表示所有下游推送都完成。若 API 語意要求同步完成，就需要在文件與測試中明確定義成功條件。</p>
<h2 id="執行queue-adapter-控制-acknack">【執行】queue adapter 控制 ack/nack</h2>
<p>queue adapter 的核心責任是把 message lifecycle 對應到 processor 結果。processor 不應知道 ack、nack 或 delivery tag。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">QueueMessage</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">Body</span>        <span class="p">[]</span><span class="kt">byte</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">Ack</span>         <span class="kd">func</span><span class="p">()</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">Nack</span>        <span class="kd">func</span><span class="p">(</span><span class="nx">requeue</span> <span class="kt">bool</span><span class="p">)</span> <span class="kt">error</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="kd">type</span> <span class="nx">QueueConsumer</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">processor</span> <span class="o">*</span><span class="nx">EventProcessor</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">now</span>       <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="nx">QueueConsumer</span><span class="p">)</span> <span class="nf">Handle</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">msg</span> <span class="nx">QueueMessage</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">event</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">NormalizeQueueMessage</span><span class="p">(</span><span class="nx">msg</span><span class="p">.</span><span class="nx">Body</span><span class="p">,</span> <span class="nx">c</span><span class="p">.</span><span class="nf">now</span><span class="p">())</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="k">return</span> <span class="nx">msg</span><span class="p">.</span><span class="nf">Nack</span><span class="p">(</span><span class="kc">false</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">c</span><span class="p">.</span><span class="nx">processor</span><span class="p">.</span><span class="nf">Process</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="k">return</span> <span class="nx">msg</span><span class="p">.</span><span class="nf">Nack</span><span class="p">(</span><span class="kc">true</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">return</span> <span class="nx">msg</span><span class="p">.</span><span class="nf">Ack</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式把 queue 的重試決策留在 adapter。對 processor 來說，事件只是一筆 <code>DomainEvent</code>；對 queue 來說，錯誤需要轉成 ack/nack 策略。</p>
<h2 id="策略共用-channel-需要-backpressure">【策略】共用 channel 需要 backpressure</h2>
<p>共用 channel 的核心用途是把多個來源收斂到同一個處理 loop。它不是必要架構，但在多來源、突發流量或單一 worker 順序處理時很有用。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">events</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">DomainEvent</span><span class="p">,</span> <span class="mi">1024</span><span class="p">)</span></span></span></code></pre></div><p>channel 一旦有容量限制，就必須設計滿載策略。沒有滿載策略的 channel 只會把問題延後到 goroutine 堆積或 request 卡住。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">EnqueueEvent</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">events</span> <span class="kd">chan</span><span class="o">&lt;-</span> <span class="nx">DomainEvent</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">case</span> <span class="nx">events</span> <span class="o">&lt;-</span> <span class="nx">event</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">return</span> <span class="nx">ctx</span><span class="p">.</span><span class="nf">Err</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">return</span> <span class="nx">ErrEventQueueFull</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <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>HTTP handler 遇到 <code>ErrEventQueueFull</code> 可以回 <code>503</code>。queue consumer 可以 nack 並 <a href="/blog/backend/knowledge-cards/requeue/" data-link-title="Requeue" data-link-desc="說明處理失敗的訊息重新排回 queue 時的風險與控制條件">requeue</a>。timer scan 可以跳過本輪。不同來源的上游回應不同，但進入 channel 的事件模型相同。</p>
<h2 id="執行processor-loop-擁有消費節奏">【執行】processor loop 擁有消費節奏</h2>
<p>processor loop 的核心責任是決定事件如何被消費與停止。它應該接受 context，並在 shutdown 時停止讀取新事件。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">EventLoop</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">processor</span> <span class="o">*</span><span class="nx">EventProcessor</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">events</span>    <span class="o">&lt;-</span><span class="kd">chan</span> <span class="nx">DomainEvent</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">logger</span>    <span class="o">*</span><span class="nx">slog</span><span class="p">.</span><span class="nx">Logger</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="kd">func</span> <span class="p">(</span><span class="nx">l</span> <span class="nx">EventLoop</span><span class="p">)</span> <span class="nf">Run</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="k">return</span> <span class="nx">ctx</span><span class="p">.</span><span class="nf">Err</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">case</span> <span class="nx">event</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">l</span><span class="p">.</span><span class="nx">events</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">l</span><span class="p">.</span><span class="nx">processor</span><span class="p">.</span><span class="nf">Process</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">                <span class="nx">l</span><span class="p">.</span><span class="nx">logger</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="s">&#34;process event failed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">                    <span class="s">&#34;event_type&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Type</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">                    <span class="s">&#34;subject_id&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">                    <span class="s">&#34;error&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">                <span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>正式實作還要處理 channel close。若事件來源會關閉 channel，讀取時應使用 <code>event, ok := &lt;-l.events</code>；若 channel 由長生命週期服務持有，通常由 context 控制 shutdown。</p>
<h2 id="判讀錯誤策略要依來源與資料語意決定">【判讀】錯誤策略要依來源與資料語意決定</h2>
<p>錯誤策略的核心問題是「失敗後誰能重送，重送是否安全」。HTTP、queue、timer 的答案不同。</p>
<table>
  <thead>
      <tr>
          <th>錯誤位置</th>
          <th>HTTP callback</th>
          <th>queue message</th>
          <th>timer scan</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>decode 失敗</td>
          <td>400，不重試</td>
          <td>nack(false) 或 dead-letter</td>
          <td>記錄錯誤</td>
      </tr>
      <tr>
          <td>normalize 失敗</td>
          <td>400，不重試</td>
          <td>nack(false) 或 dead-letter</td>
          <td>記錄錯誤</td>
      </tr>
      <tr>
          <td>processor 暫時失敗</td>
          <td>503，可重試</td>
          <td>nack(true)</td>
          <td>下次再掃</td>
      </tr>
      <tr>
          <td>duplicate event</td>
          <td>202 或 204</td>
          <td>ack</td>
          <td>忽略</td>
      </tr>
      <tr>
          <td>publisher 失敗</td>
          <td>視語意而定</td>
          <td>視語意而定</td>
          <td>視語意而定</td>
      </tr>
  </tbody>
</table>
<p>錯誤策略不能只看技術來源，也要看資料語意。若事件已經成功更新狀態但即時推送失敗，HTTP 是否要回錯取決於 API 是否承諾推送已完成。</p>
<h2 id="策略觀測欄位要跨來源一致">【策略】觀測欄位要跨來源一致</h2>
<p>事件融合後的 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a> 與 metric 也應使用共同欄位。這讓你能跨 HTTP、queue、timer 比較同一類事件的行為。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">LogAttrsForEvent</span><span class="p">(</span><span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="p">[]</span><span class="nx">slog</span><span class="p">.</span><span class="nx">Attr</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">return</span> <span class="p">[]</span><span class="nx">slog</span><span class="p">.</span><span class="nx">Attr</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">slog</span><span class="p">.</span><span class="nf">String</span><span class="p">(</span><span class="s">&#34;event_id&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">ID</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">slog</span><span class="p">.</span><span class="nf">String</span><span class="p">(</span><span class="s">&#34;event_type&#34;</span><span class="p">,</span> <span class="nb">string</span><span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">Type</span><span class="p">)),</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">slog</span><span class="p">.</span><span class="nf">String</span><span class="p">(</span><span class="s">&#34;event_source&#34;</span><span class="p">,</span> <span class="nb">string</span><span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">Source</span><span class="p">)),</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">slog</span><span class="p">.</span><span class="nf">String</span><span class="p">(</span><span class="s">&#34;subject_id&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">slog</span><span class="p">.</span><span class="nf">Time</span><span class="p">(</span><span class="s">&#34;occurred_at&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">OccurredAt</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">slog</span><span class="p">.</span><span class="nf">Time</span><span class="p">(</span><span class="s">&#34;received_at&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">ReceivedAt</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <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>adapter 可以額外記錄 HTTP path、queue name 或 timer name，但共同欄位應該來自 <code>DomainEvent</code>。這樣排查問題時，讀者不用先知道事件從哪個來源進來。</p>
<h2 id="測試融合測試要驗證同類事件走同一規則">【測試】融合測試要驗證同類事件走同一規則</h2>
<p>多來源測試的核心目標是確認不同 adapter 產生同一種 <code>DomainEvent</code>，並且 processor 對它們套用同一組規則。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestHTTPAndQueueNormalizeToSameDomainEvent</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">receivedAt</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">httpEvent</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">NormalizeHTTPEvent</span><span class="p">(</span><span class="nx">RawHTTPEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">EventID</span><span class="p">:</span>   <span class="s">&#34;evt_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">AccountID</span><span class="p">:</span> <span class="s">&#34;acct_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">EventName</span><span class="p">:</span> <span class="s">&#34;activated&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">Timestamp</span><span class="p">:</span> <span class="s">&#34;2026-04-22T10:00:00Z&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">},</span> <span class="nx">receivedAt</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;normalize http event: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">queueEvent</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">NormalizeQueueMessage</span><span class="p">([]</span><span class="nb">byte</span><span class="p">(</span><span class="s">`{
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="s">        &#34;id&#34;:&#34;evt_1&#34;,
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="s">        &#34;subject&#34;:&#34;acct_1&#34;,
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="s">        &#34;type&#34;:&#34;account.activated&#34;,
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="s">        &#34;occurred_at&#34;:&#34;2026-04-22T10:00:00Z&#34;
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="s">    }`</span><span class="p">),</span> <span class="nx">receivedAt</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;normalize queue event: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="k">if</span> <span class="nx">httpEvent</span><span class="p">.</span><span class="nx">Type</span> <span class="o">!=</span> <span class="nx">queueEvent</span><span class="p">.</span><span class="nx">Type</span> <span class="o">||</span> <span class="nx">httpEvent</span><span class="p">.</span><span class="nx">SubjectID</span> <span class="o">!=</span> <span class="nx">queueEvent</span><span class="p">.</span><span class="nx">SubjectID</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;sources should normalize to same domain semantics&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試不是要求兩個 event 完全相同。<code>Source</code> 可以不同；重點是 domain semantics 一致，processor 才能共用規則。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理多來源事件如何在單一服務內融合；queue driver、outbox 與 tracing，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Go 進階：Durable queue、outbox 與 idempotency</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">Go 進階：Observability pipeline、metrics 與 tracing</a></li>
<li><a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是多來源 adapter、normalize 與 processor 的路線；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/new-websocket-action/" data-link-title="6.1 如何新增一個即時訊息 action" data-link-desc="修改 client message、路由與 handler">Go：如何新增一個即時訊息 action</a></li>
<li><a href="/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程</a></li>
<li><a href="/blog/go/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">Go：如何新增一種 domain event</a></li>
<li><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>事件融合的核心是把來源差異限制在 adapter 與 normalizer，讓 processor 只面對一致的 <code>DomainEvent</code>。HTTP、queue、timer 可以有不同的 backpressure 與錯誤回應，但不應複製 domain 規則。當來源增加時，系統應該增加 adapter，而不是增加另一套狀態更新流程。</p>
]]></content:encoded></item><item><title>模組四：架構邊界與事件系統</title><link>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/</guid><description>&lt;p>架構邊界的核心目標是讓每個元件只承擔一種責任。事件來源負責接收外部訊號，normalize 階段負責轉成內部事件，processor 負責套用規則，repository 負責保存狀態真相，publisher 負責把結果送出去。&lt;/p>
&lt;p>事件驅動不是把所有東西都丟進 channel。Go 的事件系統需要明確的型別、清楚的擁有者、可測的狀態轉移，以及能在多來源輸入下維持一致的處理流程。&lt;/p>
&lt;p>本模組承接入門篇的 practical 與 refactoring：前面學會新增事件、建立 repository port、拆 handler、整理 domain package；這裡進一步處理「系統開始變大後，事件與狀態如何不失控」。&lt;/p>
&lt;h2 id="章節列表">章節列表&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>章節&lt;/th>
 &lt;th>主題&lt;/th>
 &lt;th>關鍵收穫&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/component-boundaries/" data-link-title="4.1 事件來源、處理流程與狀態邊界" data-link-desc="分辨事件來源、事件融合、處理流程、狀態真相與推送邊界">4.1&lt;/a>&lt;/td>
 &lt;td>事件來源、處理流程與狀態邊界&lt;/td>
 &lt;td>用邊界拆開 reader、normalizer、processor、repository、publisher&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/dedup-key/" data-link-title="4.2 事件去重與語義鍵設計" data-link-desc="用 entity ID、event type、來源語意與時間窗口建立去重鍵">4.2&lt;/a>&lt;/td>
 &lt;td>事件去重與語義鍵設計&lt;/td>
 &lt;td>用 domain key、時間窗口與清理策略管理重複事件&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/source-of-truth/" data-link-title="4.3 Source of Truth：狀態邊界" data-link-desc="集中狀態更新、保護可變資料、設計查詢 projection">4.3&lt;/a>&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Source of Truth&lt;/a>：狀態邊界&lt;/td>
 &lt;td>集中狀態轉移、保護可變資料、設計 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">4.4&lt;/a>&lt;/td>
 &lt;td>多來源 event 融合&lt;/td>
 &lt;td>把 HTTP、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a>、timer 等來源收斂到同一套 domain event 流程&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組使用的範例主題">本模組使用的範例主題&lt;/h2>
&lt;p>本模組使用虛構的通知與工作處理服務作為範例。服務可能從 HTTP callback、queue message、timer 或檔案 reader 收到事件，最後更新內部狀態並推送通知。&lt;/p>
&lt;p>範例只用來展示 Go 的設計方法，不假設讀者正在維護任何特定專案。&lt;/p>
&lt;h2 id="本模組的-go-核心概念">本模組的 Go 核心概念&lt;/h2>
&lt;ul>
&lt;li>用 struct 定義穩定的內部事件模型。&lt;/li>
&lt;li>用 interface 表達 reader、repository、publisher 這類能力。&lt;/li>
&lt;li>用 context 傳遞 request lifecycle、取消與逾時。&lt;/li>
&lt;li>用 mutex 或單一 goroutine 保護共享狀態。&lt;/li>
&lt;li>用 package 邊界限制 adapter、application、domain 的依賴方向。&lt;/li>
&lt;li>用 table-driven test 驗證 normalize、dedup 與狀態轉移。&lt;/li>
&lt;/ul>
&lt;h2 id="學習重點">學習重點&lt;/h2>
&lt;p>學完本模組後，你應該能判斷：&lt;/p>
&lt;ol>
&lt;li>外部訊號是否應該轉成 domain event&lt;/li>
&lt;li>去重應該使用哪些欄位，哪些欄位不應進入 key&lt;/li>
&lt;li>狀態真相應該由哪個元件擁有&lt;/li>
&lt;li>新事件來源應該新增 adapter，還是修改 processor&lt;/li>
&lt;li>ports/adapters 與 event-driven service 如何在 Go 中自然結合&lt;/li>
&lt;/ol>
&lt;h2 id="章節粒度說明">章節粒度說明&lt;/h2>
&lt;p>本模組的四章分別處理事件系統的四個核心面向，不建議硬拆成更小的孤立段落。事件來源、去重、狀態真相與多來源融合會互相影響；拆得太碎會讓讀者看不到一筆事件如何從外部輸入走到狀態更新與推送。&lt;/p>
&lt;p>閱讀時可以把四章視為一條路線：&lt;/p>
&lt;ol>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/component-boundaries/" data-link-title="4.1 事件來源、處理流程與狀態邊界" data-link-desc="分辨事件來源、事件融合、處理流程、狀態真相與推送邊界">事件來源、處理流程與狀態邊界&lt;/a>：先建立元件分工。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/dedup-key/" data-link-title="4.2 事件去重與語義鍵設計" data-link-desc="用 entity ID、event type、來源語意與時間窗口建立去重鍵">事件去重與語義鍵設計&lt;/a>：再定義「同一事件」的語意。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/source-of-truth/" data-link-title="4.3 Source of Truth：狀態邊界" data-link-desc="集中狀態更新、保護可變資料、設計查詢 projection">Source of Truth：狀態邊界&lt;/a>：接著決定誰能改狀態。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">多來源 event 融合&lt;/a>：最後處理 HTTP、queue、timer 等多入口協作。&lt;/li>
&lt;/ol>
&lt;h2 id="本模組不處理">本模組不處理&lt;/h2>
&lt;p>本模組不實作完整 message queue、分散式 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction&lt;/a> 或 event sourcing 平台。這些主題需要更多基礎設施與操作細節；本模組先聚焦 Go 程式內部如何建立清楚的事件與狀態邊界。後續可接 &lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">資料庫 transaction 與 schema migration&lt;/a> 以及 &lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Durable queue、outbox 與 idempotency&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>架構邊界的核心目標是讓每個元件只承擔一種責任。事件來源負責接收外部訊號，normalize 階段負責轉成內部事件，processor 負責套用規則，repository 負責保存狀態真相，publisher 負責把結果送出去。</p>
<p>事件驅動不是把所有東西都丟進 channel。Go 的事件系統需要明確的型別、清楚的擁有者、可測的狀態轉移，以及能在多來源輸入下維持一致的處理流程。</p>
<p>本模組承接入門篇的 practical 與 refactoring：前面學會新增事件、建立 repository port、拆 handler、整理 domain package；這裡進一步處理「系統開始變大後，事件與狀態如何不失控」。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/go-advanced/04-architecture-boundaries/component-boundaries/" data-link-title="4.1 事件來源、處理流程與狀態邊界" data-link-desc="分辨事件來源、事件融合、處理流程、狀態真相與推送邊界">4.1</a></td>
          <td>事件來源、處理流程與狀態邊界</td>
          <td>用邊界拆開 reader、normalizer、processor、repository、publisher</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/04-architecture-boundaries/dedup-key/" data-link-title="4.2 事件去重與語義鍵設計" data-link-desc="用 entity ID、event type、來源語意與時間窗口建立去重鍵">4.2</a></td>
          <td>事件去重與語義鍵設計</td>
          <td>用 domain key、時間窗口與清理策略管理重複事件</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/04-architecture-boundaries/source-of-truth/" data-link-title="4.3 Source of Truth：狀態邊界" data-link-desc="集中狀態更新、保護可變資料、設計查詢 projection">4.3</a></td>
          <td><a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Source of Truth</a>：狀態邊界</td>
          <td>集中狀態轉移、保護可變資料、設計 <a href="/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection</a></td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">4.4</a></td>
          <td>多來源 event 融合</td>
          <td>把 HTTP、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a>、timer 等來源收斂到同一套 domain event 流程</td>
      </tr>
  </tbody>
</table>
<h2 id="本模組使用的範例主題">本模組使用的範例主題</h2>
<p>本模組使用虛構的通知與工作處理服務作為範例。服務可能從 HTTP callback、queue message、timer 或檔案 reader 收到事件，最後更新內部狀態並推送通知。</p>
<p>範例只用來展示 Go 的設計方法，不假設讀者正在維護任何特定專案。</p>
<h2 id="本模組的-go-核心概念">本模組的 Go 核心概念</h2>
<ul>
<li>用 struct 定義穩定的內部事件模型。</li>
<li>用 interface 表達 reader、repository、publisher 這類能力。</li>
<li>用 context 傳遞 request lifecycle、取消與逾時。</li>
<li>用 mutex 或單一 goroutine 保護共享狀態。</li>
<li>用 package 邊界限制 adapter、application、domain 的依賴方向。</li>
<li>用 table-driven test 驗證 normalize、dedup 與狀態轉移。</li>
</ul>
<h2 id="學習重點">學習重點</h2>
<p>學完本模組後，你應該能判斷：</p>
<ol>
<li>外部訊號是否應該轉成 domain event</li>
<li>去重應該使用哪些欄位，哪些欄位不應進入 key</li>
<li>狀態真相應該由哪個元件擁有</li>
<li>新事件來源應該新增 adapter，還是修改 processor</li>
<li>ports/adapters 與 event-driven service 如何在 Go 中自然結合</li>
</ol>
<h2 id="章節粒度說明">章節粒度說明</h2>
<p>本模組的四章分別處理事件系統的四個核心面向，不建議硬拆成更小的孤立段落。事件來源、去重、狀態真相與多來源融合會互相影響；拆得太碎會讓讀者看不到一筆事件如何從外部輸入走到狀態更新與推送。</p>
<p>閱讀時可以把四章視為一條路線：</p>
<ol>
<li><a href="/blog/go-advanced/04-architecture-boundaries/component-boundaries/" data-link-title="4.1 事件來源、處理流程與狀態邊界" data-link-desc="分辨事件來源、事件融合、處理流程、狀態真相與推送邊界">事件來源、處理流程與狀態邊界</a>：先建立元件分工。</li>
<li><a href="/blog/go-advanced/04-architecture-boundaries/dedup-key/" data-link-title="4.2 事件去重與語義鍵設計" data-link-desc="用 entity ID、event type、來源語意與時間窗口建立去重鍵">事件去重與語義鍵設計</a>：再定義「同一事件」的語意。</li>
<li><a href="/blog/go-advanced/04-architecture-boundaries/source-of-truth/" data-link-title="4.3 Source of Truth：狀態邊界" data-link-desc="集中狀態更新、保護可變資料、設計查詢 projection">Source of Truth：狀態邊界</a>：接著決定誰能改狀態。</li>
<li><a href="/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">多來源 event 融合</a>：最後處理 HTTP、queue、timer 等多入口協作。</li>
</ol>
<h2 id="本模組不處理">本模組不處理</h2>
<p>本模組不實作完整 message queue、分散式 <a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a> 或 event sourcing 平台。這些主題需要更多基礎設施與操作細節；本模組先聚焦 Go 程式內部如何建立清楚的事件與狀態邊界。後續可接 <a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">資料庫 transaction 與 schema migration</a> 以及 <a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Durable queue、outbox 與 idempotency</a>。</p>
<h2 id="學習時間">學習時間</h2>
<p>預計 3-4 小時</p>
]]></content:encoded></item><item><title>1.5 從單檔到多檔案</title><link>https://tarrragon.github.io/blog/go/01-basics/growing-files-packages/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/01-basics/growing-files-packages/</guid><description>&lt;p>Go 程式變大的第一個拆分單位通常是檔案，不是架構。學習與開發常從一個 &lt;code>main.go&lt;/code> 開始，等到入口程式太長，再把相關函式拆到同一個 package 的其他檔案；只有當某組概念需要形成獨立 API 邊界時，才搬到新的資料夾成為新的 package。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>判斷何時保留單一 &lt;code>main.go&lt;/code>&lt;/li>
&lt;li>理解同 package 多檔案如何互相呼叫&lt;/li>
&lt;li>分辨「拆檔案」和「拆 package」的差異&lt;/li>
&lt;li>看懂跨 package 呼叫、exported 名稱與 import path 的關係&lt;/li>
&lt;li>避免過早把小程式拆成複雜架構&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察單檔是合理起點">【觀察】單檔是合理起點&lt;/h2>
&lt;p>單一 &lt;code>main.go&lt;/code> 的核心價值是降低初期理解成本。程式還小的時候，把入口、設定、簡單函式放在同一個檔案，通常比一開始拆成多個資料夾更容易閱讀。&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">notify/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">├── go.mod
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">└── main.go&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>一個最小 HTTP 服務可以先長這樣：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kn">package&lt;/span> &lt;span class="nx">main&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="kn">import&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="s">&amp;#34;fmt&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="s">&amp;#34;net/http&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">main&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">HandleFunc&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;/health&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">healthHandler&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ListenAndServe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;:8080&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">nil&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nb">panic&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">err&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">healthHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fprintln&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;ok&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個階段不需要急著建立 &lt;code>handler&lt;/code>、&lt;code>service&lt;/code> 或 &lt;code>domain&lt;/code> 資料夾。讀者一眼能看懂程式如何啟動，比形式上的分層更重要。&lt;/p>
&lt;h2 id="判讀maingo-膨脹時先拆同-package-多檔案">【判讀】main.go 膨脹時，先拆同 package 多檔案&lt;/h2>
&lt;p>同 package 多檔案的核心規則是：同一個資料夾、同一個 package 名稱的 Go 檔案會被一起編譯，彼此可以直接呼叫，不需要 import。&lt;/p>
&lt;p>當 &lt;code>main.go&lt;/code> 開始同時包含設定、handler、資料型別與啟動流程，可以先拆成這樣：&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">notify/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">├── go.mod
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">├── main.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">├── config.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">├── server.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">└── message.go&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每個檔案仍然使用：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kn">package&lt;/span> &lt;span class="nx">main&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>因此 &lt;code>main.go&lt;/code> 可以直接呼叫 &lt;code>loadConfig()&lt;/code>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">main&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">cfg&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">loadConfig&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="nx">server&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">newServer&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">cfg&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">server&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ListenAndServe&lt;/span>&lt;span class="p">();&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="nb">panic&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">err&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>config.go&lt;/code> 可以提供這個函式：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kn">package&lt;/span> &lt;span class="nx">main&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">config&lt;/span> &lt;span class="kd">struct&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="nx">Port&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="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="kd">func&lt;/span> &lt;span class="nf">loadConfig&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="nx">config&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">config&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">Port&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;:8080&amp;#34;&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這是同 package 內的檔案切分。&lt;code>loadConfig&lt;/code> 即使用小寫開頭，&lt;code>main.go&lt;/code> 也可以呼叫，因為它們都屬於 &lt;code>package main&lt;/code>。&lt;/p>
&lt;h2 id="策略先拆檔案再拆-package">【策略】先拆檔案，再拆 package&lt;/h2>
&lt;p>拆分的核心判斷是：檔案用來降低閱讀負擔，package 用來建立 API 邊界。這兩者成本不同，不應混在一起。&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>同 package 多檔案&lt;/td>
 &lt;td>檔案太長、概念需要分段&lt;/td>
 &lt;td>直接呼叫&lt;/td>
 &lt;td>成本低，沒有新 API 邊界&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>新 package&lt;/td>
 &lt;td>概念可獨立、需要被其他 package 使用&lt;/td>
 &lt;td>import 後呼叫 exported 名稱&lt;/td>
 &lt;td>成本較高，需要設計 API&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>例如 &lt;code>message.go&lt;/code> 只是放一些內部型別時，可以留在 &lt;code>package main&lt;/code>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kn">package&lt;/span> &lt;span class="nx">main&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">message&lt;/span> &lt;span class="kd">struct&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="nx">Title&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nx">Body&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>如果 notification 概念開始有自己的建構規則、驗證規則與測試需求，就可以搬成獨立 package：&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">notify/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">├── go.mod
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">├── main.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">└── notification/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> ├── notification.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> └── validate.go&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>此時 &lt;code>notification&lt;/code> package 要明確決定哪些名稱是對外 API。&lt;/p>
&lt;h2 id="執行跨-package-呼叫需要-import-與-exported-名稱">【執行】跨 package 呼叫需要 import 與 exported 名稱&lt;/h2>
&lt;p>跨 package 呼叫的核心規則是：其他 package 只能使用大寫開頭的 exported 名稱，並且必須透過 import path 引入。&lt;/p>
&lt;p>假設 module path 是：&lt;/p></description><content:encoded><![CDATA[<p>Go 程式變大的第一個拆分單位通常是檔案，不是架構。學習與開發常從一個 <code>main.go</code> 開始，等到入口程式太長，再把相關函式拆到同一個 package 的其他檔案；只有當某組概念需要形成獨立 API 邊界時，才搬到新的資料夾成為新的 package。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>判斷何時保留單一 <code>main.go</code></li>
<li>理解同 package 多檔案如何互相呼叫</li>
<li>分辨「拆檔案」和「拆 package」的差異</li>
<li>看懂跨 package 呼叫、exported 名稱與 import path 的關係</li>
<li>避免過早把小程式拆成複雜架構</li>
</ol>
<hr>
<h2 id="觀察單檔是合理起點">【觀察】單檔是合理起點</h2>
<p>單一 <code>main.go</code> 的核心價值是降低初期理解成本。程式還小的時候，把入口、設定、簡單函式放在同一個檔案，通常比一開始拆成多個資料夾更容易閱讀。</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">notify/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── go.mod
</span></span><span class="line"><span class="ln">3</span><span class="cl">└── main.go</span></span></code></pre></div><p>一個最小 HTTP 服務可以先長這樣：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">package</span> <span class="nx">main</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kn">import</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="s">&#34;fmt&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="s">&#34;net/http&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="kd">func</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">http</span><span class="p">.</span><span class="nf">HandleFunc</span><span class="p">(</span><span class="s">&#34;/health&#34;</span><span class="p">,</span> <span class="nx">healthHandler</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">http</span><span class="p">.</span><span class="nf">ListenAndServe</span><span class="p">(</span><span class="s">&#34;:8080&#34;</span><span class="p">,</span> <span class="kc">nil</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nb">panic</span><span class="p">(</span><span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="kd">func</span> <span class="nf">healthHandler</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Fprintln</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;ok&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個階段不需要急著建立 <code>handler</code>、<code>service</code> 或 <code>domain</code> 資料夾。讀者一眼能看懂程式如何啟動，比形式上的分層更重要。</p>
<h2 id="判讀maingo-膨脹時先拆同-package-多檔案">【判讀】main.go 膨脹時，先拆同 package 多檔案</h2>
<p>同 package 多檔案的核心規則是：同一個資料夾、同一個 package 名稱的 Go 檔案會被一起編譯，彼此可以直接呼叫，不需要 import。</p>
<p>當 <code>main.go</code> 開始同時包含設定、handler、資料型別與啟動流程，可以先拆成這樣：</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">notify/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── go.mod
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── main.go
</span></span><span class="line"><span class="ln">4</span><span class="cl">├── config.go
</span></span><span class="line"><span class="ln">5</span><span class="cl">├── server.go
</span></span><span class="line"><span class="ln">6</span><span class="cl">└── message.go</span></span></code></pre></div><p>每個檔案仍然使用：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kn">package</span> <span class="nx">main</span></span></span></code></pre></div><p>因此 <code>main.go</code> 可以直接呼叫 <code>loadConfig()</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">cfg</span> <span class="o">:=</span> <span class="nf">loadConfig</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">server</span> <span class="o">:=</span> <span class="nf">newServer</span><span class="p">(</span><span class="nx">cfg</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">server</span><span class="p">.</span><span class="nf">ListenAndServe</span><span class="p">();</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="nb">panic</span><span class="p">(</span><span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>config.go</code> 可以提供這個函式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kn">package</span> <span class="nx">main</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kd">type</span> <span class="nx">config</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">Port</span> <span class="kt">string</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="kd">func</span> <span class="nf">loadConfig</span><span class="p">()</span> <span class="nx">config</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="k">return</span> <span class="nx">config</span><span class="p">{</span><span class="nx">Port</span><span class="p">:</span> <span class="s">&#34;:8080&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這是同 package 內的檔案切分。<code>loadConfig</code> 即使用小寫開頭，<code>main.go</code> 也可以呼叫，因為它們都屬於 <code>package main</code>。</p>
<h2 id="策略先拆檔案再拆-package">【策略】先拆檔案，再拆 package</h2>
<p>拆分的核心判斷是：檔案用來降低閱讀負擔，package 用來建立 API 邊界。這兩者成本不同，不應混在一起。</p>
<table>
  <thead>
      <tr>
          <th>拆分方式</th>
          <th>使用時機</th>
          <th>呼叫方式</th>
          <th>成本</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>同 package 多檔案</td>
          <td>檔案太長、概念需要分段</td>
          <td>直接呼叫</td>
          <td>成本低，沒有新 API 邊界</td>
      </tr>
      <tr>
          <td>新 package</td>
          <td>概念可獨立、需要被其他 package 使用</td>
          <td>import 後呼叫 exported 名稱</td>
          <td>成本較高，需要設計 API</td>
      </tr>
  </tbody>
</table>
<p>例如 <code>message.go</code> 只是放一些內部型別時，可以留在 <code>package main</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kn">package</span> <span class="nx">main</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kd">type</span> <span class="nx">message</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">Title</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">Body</span>  <span class="kt">string</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>如果 notification 概念開始有自己的建構規則、驗證規則與測試需求，就可以搬成獨立 package：</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">notify/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── go.mod
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── main.go
</span></span><span class="line"><span class="ln">4</span><span class="cl">└── notification/
</span></span><span class="line"><span class="ln">5</span><span class="cl">    ├── notification.go
</span></span><span class="line"><span class="ln">6</span><span class="cl">    └── validate.go</span></span></code></pre></div><p>此時 <code>notification</code> package 要明確決定哪些名稱是對外 API。</p>
<h2 id="執行跨-package-呼叫需要-import-與-exported-名稱">【執行】跨 package 呼叫需要 import 與 exported 名稱</h2>
<p>跨 package 呼叫的核心規則是：其他 package 只能使用大寫開頭的 exported 名稱，並且必須透過 import path 引入。</p>
<p>假設 module path 是：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">module</span> <span class="nx">example</span><span class="p">.</span><span class="nx">com</span><span class="o">/</span><span class="nx">notify</span></span></span></code></pre></div><p><code>notification/notification.go</code> 可以這樣寫：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">package</span> <span class="nx">notification</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kn">import</span> <span class="s">&#34;strings&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kd">type</span> <span class="nx">Notification</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">Title</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">Body</span>  <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="kd">func</span> <span class="nf">New</span><span class="p">(</span><span class="nx">title</span><span class="p">,</span> <span class="nx">body</span> <span class="kt">string</span><span class="p">)</span> <span class="nx">Notification</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">return</span> <span class="nx">Notification</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">title</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">Body</span><span class="p">:</span>  <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">body</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="kd">func</span> <span class="nf">isEmpty</span><span class="p">(</span><span class="nx">n</span> <span class="nx">Notification</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">return</span> <span class="nx">n</span><span class="p">.</span><span class="nx">Title</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="o">&amp;&amp;</span> <span class="nx">n</span><span class="p">.</span><span class="nx">Body</span> <span class="o">==</span> <span class="s">&#34;&#34;</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>main.go</code> 要使用這個 package，必須 import：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">package</span> <span class="nx">main</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kn">import</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="s">&#34;fmt&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="s">&#34;example.com/notify/notification&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">func</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">n</span> <span class="o">:=</span> <span class="nx">notification</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="s">&#34;Deploy finished&#34;</span><span class="p">,</span> <span class="s">&#34;Version 1.2.0 is live&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">n</span><span class="p">.</span><span class="nx">Title</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>notification.New</code> 和 <code>notification.Notification</code> 可以被外部使用，因為它們是大寫開頭。<code>isEmpty</code> 不能被 <code>main.go</code> 呼叫，因為它是 package 內部實作細節。</p>
<h2 id="判讀import-cycle-是依賴方向錯了">【判讀】import cycle 是依賴方向錯了</h2>
<p>import cycle 的核心意義是兩個 package 互相依賴，Go 會直接拒絕編譯。Go 工具鏈透過這個限制強迫你把依賴方向想清楚。</p>
<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">notify/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── main.go
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── handler/
</span></span><span class="line"><span class="ln">4</span><span class="cl">│   └── handler.go
</span></span><span class="line"><span class="ln">5</span><span class="cl">└── notification/
</span></span><span class="line"><span class="ln">6</span><span class="cl">    └── notification.go</span></span></code></pre></div><p>如果 <code>handler</code> import <code>notification</code>，同時 <code>notification</code> 又 import <code>handler</code>，就會形成循環依賴。</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">handler -&gt; notification -&gt; handler</span></span></code></pre></div><p>修正的核心做法是讓低層概念不要依賴高層協定。<code>notification</code> 應該描述通知資料與規則，不應知道 HTTP handler；handler 可以把 HTTP request 轉成 notification command 或 value。</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">handler -&gt; notification</span></span></code></pre></div><p>這個方向比「互相知道」更容易測試，也更容易重構。</p>
<h2 id="常見拆分路線">常見拆分路線</h2>
<p>Go 服務常見的成長路線是漸進式的。每一步都應該解決當下的閱讀、測試或依賴問題，而不是為了看起來正式。</p>
<h3 id="階段一單一檔案">階段一：單一檔案</h3>





<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">notify/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── go.mod
</span></span><span class="line"><span class="ln">3</span><span class="cl">└── main.go</span></span></code></pre></div><p>適合小工具、實驗程式、剛開始的服務雛形。</p>
<h3 id="階段二同-package-多檔案">階段二：同 package 多檔案</h3>





<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">notify/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── go.mod
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── main.go
</span></span><span class="line"><span class="ln">4</span><span class="cl">├── config.go
</span></span><span class="line"><span class="ln">5</span><span class="cl">├── server.go
</span></span><span class="line"><span class="ln">6</span><span class="cl">└── notification.go</span></span></code></pre></div><p>適合 <code>main.go</code> 開始太長，但概念還沒有明確 API 邊界的階段。</p>
<h3 id="階段三多-package">階段三：多 package</h3>





<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">notify/
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">├── go.mod
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">├── main.go
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">├── config/
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">│   └── config.go
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">├── notification/
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">│   ├── notification.go
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">│   └── validate.go
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">└── transport/
</span></span><span class="line"><span class="ln">10</span><span class="cl">    └── http.go</span></span></code></pre></div><p>適合設定、通知規則、HTTP transport 已經能清楚分成不同責任的階段。</p>
<h3 id="階段四服務邊界更清楚">階段四：服務邊界更清楚</h3>





<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">notify/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── go.mod
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── cmd/
</span></span><span class="line"><span class="ln">4</span><span class="cl">│   └── notify-server/
</span></span><span class="line"><span class="ln">5</span><span class="cl">│       └── main.go
</span></span><span class="line"><span class="ln">6</span><span class="cl">└── internal/
</span></span><span class="line"><span class="ln">7</span><span class="cl">    ├── notification/
</span></span><span class="line"><span class="ln">8</span><span class="cl">    ├── transport/
</span></span><span class="line"><span class="ln">9</span><span class="cl">    └── storage/</span></span></code></pre></div><p>適合服務已經有明確部署入口、內部 package 不想被外部 module 引用的階段。這是程式長大後的選擇。</p>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="檢查一檔案切分跟著-go-package-模型">檢查一：檔案切分跟著 Go package 模型</h3>
<p>Go 的檔案不是 class。把每個 struct 都拆成一個檔案，通常只會增加跳轉成本，不會讓設計更清楚。</p>
<h3 id="檢查二資料夾跟著邊界成長">檢查二：資料夾跟著邊界成長</h3>
<p>程式還小時就建立 <code>domain</code>、<code>application</code>、<code>infrastructure</code>，會讓讀者先學資料夾，再學行為本身。Go 更適合先讓程式跑起來，再根據壓力拆邊界。</p>
<h3 id="檢查三exported-名稱代表公開承諾">檢查三：exported 名稱代表公開承諾</h3>
<p>exported 名稱就是 package 對外承諾。還不確定會被外部使用的型別與函式，先保持 unexported，等 API 真的穩定再開放。</p>
]]></content:encoded></item><item><title>7.5 以 domain 重新整理 package</title><link>https://tarrragon.github.io/blog/go/07-refactoring/domain-packages/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/07-refactoring/domain-packages/</guid><description>&lt;p>以 domain 重新整理 package 的核心目標是讓程式結構反映業務語意，而不是只反映技術元件。當系統開始有 account、job、event、workflow 這些不同概念時，平面檔案會讓邊界越來越難看見。&lt;/p>
&lt;p>Go package 是語意邊界，不只是檔案分類。好的 package 名稱應該讓讀者知道這裡負責哪一組概念；如果只能命名成 &lt;code>utils&lt;/code>、&lt;code>common&lt;/code> 或 &lt;code>helpers&lt;/code>，通常代表邊界還沒有想清楚。&lt;/p>
&lt;p>這一章承接入門篇的「單檔到多檔案」路線。平面多檔案是 Go 程式自然長大的中間階段；只有當檔案切分已經無法表達業務邊界時，才需要把概念搬成更清楚的 domain package。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>判斷何時該從平面多檔案拆出 package&lt;/li>
&lt;li>用業務語意命名 package&lt;/li>
&lt;li>依照純型別、純規則、usecase/repository 的順序搬移&lt;/li>
&lt;li>避免 import cycle&lt;/li>
&lt;li>用 type alias 與測試降低搬移風險&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察平面多檔案是自然成長階段">【觀察】平面多檔案是自然成長階段&lt;/h2>
&lt;p>平面 package 的核心價值是初期簡單。服務還小時，&lt;code>main.go&lt;/code>、&lt;code>models.go&lt;/code>、&lt;code>handlers.go&lt;/code>、&lt;code>repository.go&lt;/code> 放在同一層，常常比一開始切十幾個資料夾更容易理解。&lt;/p>
&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">notify/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">├── go.mod
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">├── main.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">├── models.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">├── handlers.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">├── repository.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">├── events.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">└── worker.go&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個結構不是問題本身。真正的問題通常出現在概念開始混在一起：HTTP request struct、domain state、event type、repository model 都放在 &lt;code>models.go&lt;/code>；handler、worker、processor 都直接引用同一批可變資料。&lt;/p>
&lt;h2 id="判讀拆-package-的訊號是語意邊界變模糊">【判讀】拆 package 的訊號是語意邊界變模糊&lt;/h2>
&lt;p>拆 package 的核心判斷是讀者是否能從結構看出概念邊界。若只是檔案變多，先拆檔案即可；若業務概念混在一起，才需要拆 package。&lt;/p>
&lt;p>適合拆 package 的訊號：&lt;/p>
&lt;ul>
&lt;li>&lt;code>models.go&lt;/code> 同時包含 request DTO、domain state、response view。&lt;/li>
&lt;li>新增功能時不知道型別該放哪個檔案。&lt;/li>
&lt;li>event、job、account 規則互相 import 或互相修改。&lt;/li>
&lt;li>測試一個 domain 規則必須初始化 handler 或 server。&lt;/li>
&lt;li>package 內 unexported helper 太多，讀者很難判斷哪些屬於哪個概念。&lt;/li>
&lt;/ul>
&lt;p>不一定要拆 package 的情境：&lt;/p>
&lt;ul>
&lt;li>檔案只是稍長，但仍圍繞同一個概念。&lt;/li>
&lt;li>只有單一 main package 的小工具。&lt;/li>
&lt;li>邊界還不穩，拆完很可能馬上搬回來。&lt;/li>
&lt;li>只是為了符合某個目錄模板。&lt;/li>
&lt;/ul>
&lt;h2 id="策略package-名稱要表達業務概念">【策略】package 名稱要表達業務概念&lt;/h2>
&lt;p>domain package 的核心要求是名稱要讓讀者知道這裡負責哪組概念。&lt;code>job&lt;/code>、&lt;code>event&lt;/code>、&lt;code>account&lt;/code>、&lt;code>workflow&lt;/code> 比 &lt;code>common&lt;/code>、&lt;code>types&lt;/code>、&lt;code>utils&lt;/code> 更有語意。&lt;/p>
&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">notify/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">├── go.mod
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">├── main.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">├── domain/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">│ ├── account/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">│ ├── job/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">│ ├── event/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">│ └── workflow/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">├── transport/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">│ └── http/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">└── storage/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> └── memory/&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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">notify/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">├── main.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">├── notification/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">└── httpapi/&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Go package 不需要層數多才算成熟。好的 package 是讓 import 讀起來自然：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="s">&amp;#34;example.com/notify/domain/job&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>如果 package 名稱只能叫 &lt;code>misc&lt;/code> 或 &lt;code>helpers&lt;/code>，代表邊界還沒有清楚。&lt;/p>
&lt;h2 id="執行先搬純型別">【執行】先搬純型別&lt;/h2>
&lt;p>搬移 package 的核心順序是先搬依賴最少的東西。純型別通常最安全，因為它不呼叫外部元件。&lt;/p>
&lt;p>重構前：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// models.go&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="kn">package&lt;/span> &lt;span class="nx">main&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">JobStatus&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="kd">const&lt;/span> &lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">JobStatusPending&lt;/span> &lt;span class="nx">JobStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;pending&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">JobStatusRunning&lt;/span> &lt;span class="nx">JobStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;running&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">JobStatusSucceeded&lt;/span> &lt;span class="nx">JobStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;succeeded&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">JobStatusFailed&lt;/span> &lt;span class="nx">JobStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;failed&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">JobProjection&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">ID&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nx">Status&lt;/span> &lt;span class="nx">JobStatus&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="nx">UpdatedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&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></description><content:encoded><![CDATA[<p>以 domain 重新整理 package 的核心目標是讓程式結構反映業務語意，而不是只反映技術元件。當系統開始有 account、job、event、workflow 這些不同概念時，平面檔案會讓邊界越來越難看見。</p>
<p>Go package 是語意邊界，不只是檔案分類。好的 package 名稱應該讓讀者知道這裡負責哪一組概念；如果只能命名成 <code>utils</code>、<code>common</code> 或 <code>helpers</code>，通常代表邊界還沒有想清楚。</p>
<p>這一章承接入門篇的「單檔到多檔案」路線。平面多檔案是 Go 程式自然長大的中間階段；只有當檔案切分已經無法表達業務邊界時，才需要把概念搬成更清楚的 domain package。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>判斷何時該從平面多檔案拆出 package</li>
<li>用業務語意命名 package</li>
<li>依照純型別、純規則、usecase/repository 的順序搬移</li>
<li>避免 import cycle</li>
<li>用 type alias 與測試降低搬移風險</li>
</ol>
<hr>
<h2 id="觀察平面多檔案是自然成長階段">【觀察】平面多檔案是自然成長階段</h2>
<p>平面 package 的核心價值是初期簡單。服務還小時，<code>main.go</code>、<code>models.go</code>、<code>handlers.go</code>、<code>repository.go</code> 放在同一層，常常比一開始切十幾個資料夾更容易理解。</p>
<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">notify/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── go.mod
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── main.go
</span></span><span class="line"><span class="ln">4</span><span class="cl">├── models.go
</span></span><span class="line"><span class="ln">5</span><span class="cl">├── handlers.go
</span></span><span class="line"><span class="ln">6</span><span class="cl">├── repository.go
</span></span><span class="line"><span class="ln">7</span><span class="cl">├── events.go
</span></span><span class="line"><span class="ln">8</span><span class="cl">└── worker.go</span></span></code></pre></div><p>這個結構不是問題本身。真正的問題通常出現在概念開始混在一起：HTTP request struct、domain state、event type、repository model 都放在 <code>models.go</code>；handler、worker、processor 都直接引用同一批可變資料。</p>
<h2 id="判讀拆-package-的訊號是語意邊界變模糊">【判讀】拆 package 的訊號是語意邊界變模糊</h2>
<p>拆 package 的核心判斷是讀者是否能從結構看出概念邊界。若只是檔案變多，先拆檔案即可；若業務概念混在一起，才需要拆 package。</p>
<p>適合拆 package 的訊號：</p>
<ul>
<li><code>models.go</code> 同時包含 request DTO、domain state、response view。</li>
<li>新增功能時不知道型別該放哪個檔案。</li>
<li>event、job、account 規則互相 import 或互相修改。</li>
<li>測試一個 domain 規則必須初始化 handler 或 server。</li>
<li>package 內 unexported helper 太多，讀者很難判斷哪些屬於哪個概念。</li>
</ul>
<p>不一定要拆 package 的情境：</p>
<ul>
<li>檔案只是稍長，但仍圍繞同一個概念。</li>
<li>只有單一 main package 的小工具。</li>
<li>邊界還不穩，拆完很可能馬上搬回來。</li>
<li>只是為了符合某個目錄模板。</li>
</ul>
<h2 id="策略package-名稱要表達業務概念">【策略】package 名稱要表達業務概念</h2>
<p>domain package 的核心要求是名稱要讓讀者知道這裡負責哪組概念。<code>job</code>、<code>event</code>、<code>account</code>、<code>workflow</code> 比 <code>common</code>、<code>types</code>、<code>utils</code> 更有語意。</p>
<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">notify/
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">├── go.mod
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">├── main.go
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">├── domain/
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">│   ├── account/
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">│   ├── job/
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">│   ├── event/
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">│   └── workflow/
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">├── transport/
</span></span><span class="line"><span class="ln">10</span><span class="cl">│   └── http/
</span></span><span class="line"><span class="ln">11</span><span class="cl">└── storage/
</span></span><span class="line"><span class="ln">12</span><span class="cl">    └── memory/</span></span></code></pre></div><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">notify/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── main.go
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── notification/
</span></span><span class="line"><span class="ln">4</span><span class="cl">└── httpapi/</span></span></code></pre></div><p>Go package 不需要層數多才算成熟。好的 package 是讓 import 讀起來自然：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kn">import</span> <span class="s">&#34;example.com/notify/domain/job&#34;</span></span></span></code></pre></div><p>如果 package 名稱只能叫 <code>misc</code> 或 <code>helpers</code>，代表邊界還沒有清楚。</p>
<h2 id="執行先搬純型別">【執行】先搬純型別</h2>
<p>搬移 package 的核心順序是先搬依賴最少的東西。純型別通常最安全，因為它不呼叫外部元件。</p>
<p>重構前：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// models.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">package</span> <span class="nx">main</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="kd">type</span> <span class="nx">JobStatus</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kd">const</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">JobStatusPending</span>   <span class="nx">JobStatus</span> <span class="p">=</span> <span class="s">&#34;pending&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">JobStatusRunning</span>   <span class="nx">JobStatus</span> <span class="p">=</span> <span class="s">&#34;running&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">JobStatusSucceeded</span> <span class="nx">JobStatus</span> <span class="p">=</span> <span class="s">&#34;succeeded&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">JobStatusFailed</span>    <span class="nx">JobStatus</span> <span class="p">=</span> <span class="s">&#34;failed&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="kd">type</span> <span class="nx">JobProjection</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">ID</span>        <span class="kt">string</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">Status</span>    <span class="nx">JobStatus</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">UpdatedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>重構後：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// domain/job/job.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">package</span> <span class="nx">job</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="kn">import</span> <span class="s">&#34;time&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kd">type</span> <span class="nx">Status</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="kd">const</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">StatusPending</span>   <span class="nx">Status</span> <span class="p">=</span> <span class="s">&#34;pending&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">StatusRunning</span>   <span class="nx">Status</span> <span class="p">=</span> <span class="s">&#34;running&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">StatusSucceeded</span> <span class="nx">Status</span> <span class="p">=</span> <span class="s">&#34;succeeded&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">StatusFailed</span>    <span class="nx">Status</span> <span class="p">=</span> <span class="s">&#34;failed&#34;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="kd">type</span> <span class="nx">Projection</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">ID</span>        <span class="kt">string</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="nx">Status</span>    <span class="nx">Status</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="nx">UpdatedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>使用端改成：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kn">import</span> <span class="s">&#34;example.com/notify/domain/job&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kd">var</span> <span class="nx">projection</span> <span class="nx">job</span><span class="p">.</span><span class="nx">Projection</span></span></span></code></pre></div><p>package 名稱已經提供語境，所以型別不必再叫 <code>JobProjection</code>。在 <code>job</code> package 裡叫 <code>Projection</code> 就夠清楚。</p>
<h2 id="策略用-type-alias-過渡">【策略】用 type alias 過渡</h2>
<p>type alias 的核心用途是降低搬移風險。若一次改完所有 import 太大，可以先在舊位置保留 alias，讓既有程式逐步遷移。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// models.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">package</span> <span class="nx">main</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="kn">import</span> <span class="s">&#34;example.com/notify/domain/job&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kd">type</span> <span class="nx">JobStatus</span> <span class="p">=</span> <span class="nx">job</span><span class="p">.</span><span class="nx">Status</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="kd">type</span> <span class="nx">JobProjection</span> <span class="p">=</span> <span class="nx">job</span><span class="p">.</span><span class="nx">Projection</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">const</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">JobStatusPending</span>   <span class="p">=</span> <span class="nx">job</span><span class="p">.</span><span class="nx">StatusPending</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">JobStatusRunning</span>   <span class="p">=</span> <span class="nx">job</span><span class="p">.</span><span class="nx">StatusRunning</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">JobStatusSucceeded</span> <span class="p">=</span> <span class="nx">job</span><span class="p">.</span><span class="nx">StatusSucceeded</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">JobStatusFailed</span>    <span class="p">=</span> <span class="nx">job</span><span class="p">.</span><span class="nx">StatusFailed</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p>這是過渡工具。等呼叫端逐步改成直接 import <code>domain/job</code>，再移除 alias。</p>
<p>type alias 適合降低大型搬移風險，但不要讓新舊命名長期並存，否則讀者會不知道哪個才是正式 API。</p>
<h2 id="執行再搬純規則">【執行】再搬純規則</h2>
<p>純規則的核心特徵是輸入值、回傳值，不依賴 handler、repository 或外部 I/O。這類函式也適合早期搬入 domain package。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// domain/job/transition.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">package</span> <span class="nx">job</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="kn">import</span> <span class="s">&#34;fmt&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kd">func</span> <span class="nf">CanTransition</span><span class="p">(</span><span class="nx">from</span> <span class="nx">Status</span><span class="p">,</span> <span class="nx">to</span> <span class="nx">Status</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">switch</span> <span class="nx">from</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">case</span> <span class="nx">StatusPending</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">return</span> <span class="nx">to</span> <span class="o">==</span> <span class="nx">StatusRunning</span> <span class="o">||</span> <span class="nx">to</span> <span class="o">==</span> <span class="nx">StatusFailed</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">case</span> <span class="nx">StatusRunning</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="k">return</span> <span class="nx">to</span> <span class="o">==</span> <span class="nx">StatusSucceeded</span> <span class="o">||</span> <span class="nx">to</span> <span class="o">==</span> <span class="nx">StatusFailed</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="k">return</span> <span class="kc">false</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="kd">func</span> <span class="nf">Transition</span><span class="p">(</span><span class="nx">current</span> <span class="nx">Projection</span><span class="p">,</span> <span class="nx">next</span> <span class="nx">Status</span><span class="p">)</span> <span class="p">(</span><span class="nx">Projection</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nf">CanTransition</span><span class="p">(</span><span class="nx">current</span><span class="p">.</span><span class="nx">Status</span><span class="p">,</span> <span class="nx">next</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="k">return</span> <span class="nx">Projection</span><span class="p">{},</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;invalid job status transition: %s -&gt; %s&#34;</span><span class="p">,</span> <span class="nx">current</span><span class="p">.</span><span class="nx">Status</span><span class="p">,</span> <span class="nx">next</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="nx">current</span><span class="p">.</span><span class="nx">Status</span> <span class="p">=</span> <span class="nx">next</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">return</span> <span class="nx">current</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這些規則不應 import HTTP package，也不應知道 repository。它們是 domain 的穩定核心。</p>
<h2 id="判讀domain-不依賴-adapter">【判讀】domain 不依賴 adapter</h2>
<p>避免 import cycle 的核心規則是低層 domain 不依賴高層 adapter。domain 可以被 HTTP、worker、repository 使用；但 domain 不應 import 這些外部層。</p>
<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">domain/job -&gt; transport/http -&gt; domain/job</span></span></code></pre></div><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">transport/http -&gt; application -&gt; domain/job
</span></span><span class="line"><span class="ln">2</span><span class="cl">storage/memory -&gt; domain/job</span></span></code></pre></div><p>如果 <code>domain/job</code> 需要知道 HTTP request struct，代表 request DTO 沒有停在 transport layer。應把 HTTP request 轉成 command 或 domain value，再交給下層。</p>
<h2 id="執行最後搬-repositoryusecase">【執行】最後搬 repository/usecase</h2>
<p>repository 和 usecase 的核心特徵是它們開始協調多個概念，所以搬移時要更謹慎。通常先搬 domain 型別與規則，再處理 application layer。</p>
<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">notify/
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">├── domain/
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">│   ├── job/
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">│   └── event/
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">├── application/
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">│   ├── command.go
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">│   └── processor.go
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">├── transport/
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">│   └── http/
</span></span><span class="line"><span class="ln">10</span><span class="cl">└── storage/
</span></span><span class="line"><span class="ln">11</span><span class="cl">    └── memory/</span></span></code></pre></div><p>application 可以協調 domain：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">package</span> <span class="nx">application</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kn">import</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="s">&#34;context&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="s">&#34;example.com/notify/domain/event&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="s">&#34;example.com/notify/domain/job&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="kd">type</span> <span class="nx">JobRepository</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nf">Apply</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">projection</span> <span class="nx">job</span><span class="p">.</span><span class="nx">Projection</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="kd">type</span> <span class="nx">Processor</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">jobs</span> <span class="nx">JobRepository</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">p</span> <span class="o">*</span><span class="nx">Processor</span><span class="p">)</span> <span class="nf">Process</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">e</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Event</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="nx">projection</span> <span class="o">:=</span> <span class="nx">job</span><span class="p">.</span><span class="nx">Projection</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>     <span class="nx">e</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="nx">Status</span><span class="p">:</span> <span class="nx">job</span><span class="p">.</span><span class="nx">StatusRunning</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="k">return</span> <span class="nx">p</span><span class="p">.</span><span class="nx">jobs</span><span class="p">.</span><span class="nf">Apply</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">projection</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>application 可以依賴多個 domain package，因為它負責協調 usecase。domain package 之間若互相依賴太多，通常代表邊界切得不對。</p>
<h2 id="策略每次只搬一個邊界">【策略】每次只搬一個邊界</h2>
<p>package 重構的核心風險是 import 修改範圍太大。每次只搬一個語意邊界，測試通過後再搬下一個。</p>
<p>建議順序：</p>
<ol>
<li>搬 <code>domain/job</code> 純型別。</li>
<li>搬 <code>domain/job</code> 純規則。</li>
<li>修正使用端 import。</li>
<li>搬 <code>domain/event</code> 純型別。</li>
<li>搬 event validation/normalize helper。</li>
<li>搬 application processor。</li>
<li>搬 adapter implementation。</li>
</ol>
<p>不要同時搬 job、event、repository、handler。一次搬太多會讓失敗原因難以定位。</p>
<h2 id="執行測試保護搬移">【執行】測試保護搬移</h2>
<p>package 搬移的核心驗證是行為不變。搬檔案本身不是功能變更，所以測試應該確認原本行為仍然存在。</p>
<p>domain 規則測試：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestJobCanTransition</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">job</span><span class="p">.</span><span class="nf">CanTransition</span><span class="p">(</span><span class="nx">job</span><span class="p">.</span><span class="nx">StatusPending</span><span class="p">,</span> <span class="nx">job</span><span class="p">.</span><span class="nx">StatusRunning</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;pending should transition to running&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">if</span> <span class="nx">job</span><span class="p">.</span><span class="nf">CanTransition</span><span class="p">(</span><span class="nx">job</span><span class="p">.</span><span class="nx">StatusSucceeded</span><span class="p">,</span> <span class="nx">job</span><span class="p">.</span><span class="nx">StatusRunning</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;succeeded should not transition to running&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>application 測試：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestProcessorAppliesJobProjection</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">repo</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">fakeJobRepository</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">processor</span> <span class="o">:=</span> <span class="nx">application</span><span class="p">.</span><span class="nf">NewProcessor</span><span class="p">(</span><span class="nx">repo</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">err</span> <span class="o">:=</span> <span class="nx">processor</span><span class="p">.</span><span class="nf">Process</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">(),</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Event</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span> <span class="s">&#34;job_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>      <span class="nx">event</span><span class="p">.</span><span class="nx">JobStarted</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;process event: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">if</span> <span class="nx">repo</span><span class="p">.</span><span class="nx">got</span><span class="p">.</span><span class="nx">ID</span> <span class="o">!=</span> <span class="s">&#34;job_1&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;job ID = %q, want job_1&#34;</span><span class="p">,</span> <span class="nx">repo</span><span class="p">.</span><span class="nx">got</span><span class="p">.</span><span class="nx">ID</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>測試不需要關心檔案搬到哪裡，它只確認 package API 與行為仍然正確。</p>
<h2 id="重構步驟">重構步驟</h2>
<p>從平面 package 重構成 domain package，可以按這個順序：</p>
<ol>
<li>列出現有檔案中的概念：request、response、domain state、event、repository、worker。</li>
<li>找出最穩定的 domain 名稱，例如 <code>job</code>、<code>event</code>、<code>account</code>。</li>
<li>先建立一個 domain package，不要一次建立整棵架構。</li>
<li>搬純型別與 typed constant。</li>
<li>用 type alias 過渡大型呼叫端。</li>
<li>搬純規則與測試。</li>
<li>修正 import，避免 domain 依賴 adapter。</li>
<li>測試通過後，再搬下一個 domain。</li>
</ol>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="檢查一目錄跟著概念壓力成長">檢查一：目錄跟著概念壓力成長</h3>
<p>服務還小時，一次建立 <code>domain/application/infrastructure/interfaces</code> 可能只會增加跳轉成本。先拆最痛的語意邊界。</p>
<h3 id="檢查二package-名稱表達-domain-概念">檢查二：package 名稱表達 domain 概念</h3>
<p><code>models</code>、<code>types</code>、<code>helpers</code> 通常不夠好。它們說明了程式碼形狀，沒有說明業務語意。</p>
<h3 id="檢查三domain-package-保持技術無關">檢查三：domain package 保持技術無關</h3>
<p>domain 應保存業務語意，不應知道傳輸協定。若 domain import adapter，依賴方向已經反了。</p>
<h3 id="檢查四搬移和行為變更分開">檢查四：搬移和行為變更分開</h3>
<p>package 重構應先保持行為不變。若同時改規則與搬檔案，測試失敗時很難判斷是搬移錯誤還是行為改動。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理 package 結構如何反映 domain 語意；更大型的 module 拆分與 monorepo 策略，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go/01-basics/growing-files-packages/" data-link-title="1.5 從單檔到多檔案" data-link-desc="理解 Go 程式如何從 main.go 長成多檔案與多 package">Go 入門：從單檔到多檔案</a></li>
<li><a href="/blog/go/01-basics/modules/" data-link-title="1.1 Go 專案結構與 module" data-link-desc="理解 go.mod、module path 與 Go 專案的依賴邊界">Go 入門：Go 專案結構與 module</a></li>
<li><a href="/blog/go/00-philosophy/composition/" data-link-title="0.2 組合優先：小介面與明確依賴" data-link-desc="用小介面與 struct 組合取代大型繼承結構">Go 入門：composition 優先：小介面與明確依賴</a></li>
<li><a href="/blog/go/07-refactoring/hexagonal-migration/" data-link-title="7.6 逐步遷移到 ports/adapters 架構" data-link-desc="用 ports 與 adapters 控制 Go 服務的依賴方向">Go 進階：逐步遷移到 ports/adapters 架構</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 type、rule 與 package 邊界；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
<li><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go：狀態管理的安全邊界</a></li>
<li><a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go：如何新增 repository port</a></li>
<li><a href="/blog/go/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">Go：如何新增一種 domain event</a></li>
</ul>
]]></content:encoded></item><item><title>功能分層與 Backend 選擇</title><link>https://tarrragon.github.io/blog/monitoring/04-collector/feature-tier-boundary/</link><pubDate>Sat, 20 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/04-collector/feature-tier-boundary/</guid><description>&lt;p>Collector 的可插拔 Storage Backend 分成兩個功能層級。分界線是查詢模式 — SQLite 能高效處理的查詢定義了簡單版的功能邊界，超出的查詢需求觸發 PostgreSQL 的引入。所有事件都經過同一個 Ingestion domain，差異在 Query 和 Dashboard domain 能提供什麼能力。&lt;/p>
&lt;h2 id="sqlite-層開發者工具">SQLite 層：開發者工具&lt;/h2>
&lt;p>SQLite 層提供的功能聚焦在「開發者自己 debug 和監控」。所有查詢都是單一維度的 — 按時間、按類型、按名稱過濾，不需要跨事件 JOIN 或跨使用者聚合。&lt;/p>
&lt;h3 id="承載的功能">承載的功能&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>功能&lt;/th>
 &lt;th>查詢模式&lt;/th>
 &lt;th>SQL 範例&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>最近 error 列表&lt;/td>
 &lt;td>按 type + 時間過濾&lt;/td>
 &lt;td>&lt;code>WHERE type='error' ORDER BY ts DESC LIMIT 20&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Error 計數（按 name 分群）&lt;/td>
 &lt;td>單表 GROUP BY&lt;/td>
 &lt;td>&lt;code>SELECT name, COUNT(*) FROM events WHERE type='error' GROUP BY name&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>單次 session 回放&lt;/td>
 &lt;td>按 session_id 過濾&lt;/td>
 &lt;td>&lt;code>WHERE session_id='xxx' ORDER BY ts&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>事件時間軸&lt;/td>
 &lt;td>按時間排序&lt;/td>
 &lt;td>&lt;code>WHERE ts BETWEEN ? AND ? ORDER BY ts&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>基本 rule engine&lt;/td>
 &lt;td>逐筆事件評估&lt;/td>
 &lt;td>收到事件時逐條比對 rule（不需要查歷史）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CLI 查詢&lt;/td>
 &lt;td>任意過濾&lt;/td>
 &lt;td>&lt;code>WHERE type=? AND name LIKE ? AND ts &amp;gt; ?&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這些功能覆蓋開發者日常 debug 和監控的核心操作 — 查錯誤、看時間軸、回放 session、設規則告警。&lt;/p>
&lt;h3 id="對應的-dashboard-視圖">對應的 Dashboard 視圖&lt;/h3>
&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;/td>
 &lt;td>最近 1 小時的事件計數（按 type 分）+ 最近 error 列表&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>事件詳情&lt;/td>
 &lt;td>單筆事件的完整 JSON&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Session 回放&lt;/td>
 &lt;td>單次 session 內的事件序列&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="對應的事件消費">對應的事件消費&lt;/h3>
&lt;p>SQLite 層消費所有四類事件，但消費方式是「單筆或單 session 級查詢」：&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>event&lt;/td>
 &lt;td>按名稱計數、按 session 排列&lt;/td>
 &lt;td>原始 7 天（debug）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>error&lt;/td>
 &lt;td>按名稱分群、按時間排列、看 stack trace&lt;/td>
 &lt;td>原始 30 天（error 追蹤價值較長）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>metric&lt;/td>
 &lt;td>按名稱查最近 N 筆的值&lt;/td>
 &lt;td>原始 7 天 + 每小時聚合 90 天&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>lifecycle&lt;/td>
 &lt;td>按 session 排列、看狀態轉換&lt;/td>
 &lt;td>原始 7 天&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="postgresql-層行為分析">PostgreSQL 層：行為分析&lt;/h2>
&lt;p>PostgreSQL 層在 SQLite 層的基礎上加入「跨 session、跨使用者的聚合分析」。這些查詢需要 JOIN 多張表、計算時間窗口、處理大量資料的 GROUP BY — SQLite 的單寫者模型和有限的查詢最佳化器在這些場景下效能不足。&lt;/p>
&lt;h3 id="觸發引入-postgresql-的功能需求">觸發引入 PostgreSQL 的功能需求&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>功能需求&lt;/th>
 &lt;th>為什麼 SQLite 不夠&lt;/th>
 &lt;th>PostgreSQL 提供什麼&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>Funnel 分析&lt;/strong>&lt;/td>
 &lt;td>跨大量 session 的 multi-step JOIN 和聚合效能不足&lt;/td>
 &lt;td>Window functions + 高效 JOIN&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Cohort 留存&lt;/strong>&lt;/td>
 &lt;td>需要按「註冊週」分群、計算每週的回訪率&lt;/td>
 &lt;td>Date functions + 大規模 GROUP BY&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>RFM 分群&lt;/strong>&lt;/td>
 &lt;td>需要跨所有使用者計算 recency/frequency/monetary&lt;/td>
 &lt;td>全表聚合 + 分位數計算&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>時間趨勢 dashboard&lt;/strong>&lt;/td>
 &lt;td>需要「過去 30 天每小時的 error P95」&lt;/td>
 &lt;td>時間分桶 + percentile 函數&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>高併發寫入&lt;/strong>&lt;/td>
 &lt;td>多個 SDK 同時 flush 且持續出現 database is locked&lt;/td>
 &lt;td>連線池 + 並行寫入&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>長期保留 + 聚合&lt;/strong>&lt;/td>
 &lt;td>降採樣的 materialized view&lt;/td>
 &lt;td>REFRESH MATERIALIZED VIEW&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="判斷公式">判斷公式&lt;/h3>





&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">需要 funnel / cohort / RFM 任一 → PostgreSQL
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">需要跨使用者聚合（不只看自己的資料） → PostgreSQL
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">需要高併發寫入（多個 SDK 同時 flush 且持續出現 database is locked 錯誤） → PostgreSQL
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">以上都不需要 → SQLite 足夠&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="對應的-dashboard-視圖sqlite-層不提供">對應的 Dashboard 視圖（SQLite 層不提供）&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>視圖&lt;/th>
 &lt;th>查詢模式&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Funnel 漏斗&lt;/td>
 &lt;td>多步驟轉換率（session 級 JOIN）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Cohort 留存表&lt;/td>
 &lt;td>時間窗口 × 群組矩陣&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RFM 分群散佈&lt;/td>
 &lt;td>三維度分位數計算&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Error 趨勢圖（長期）&lt;/td>
 &lt;td>30 天 × 每小時的時間序列&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>效能 P95 趨勢&lt;/td>
 &lt;td>percentile_cont 視窗函數&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="對應的事件消費-1">對應的事件消費&lt;/h3>
&lt;p>PostgreSQL 層消費的事件和 SQLite 相同（Ingestion 不變），但消費方式從「單筆/單 session」擴展到「跨 session/跨使用者」：&lt;/p></description><content:encoded><![CDATA[<p>Collector 的可插拔 Storage Backend 分成兩個功能層級。分界線是查詢模式 — SQLite 能高效處理的查詢定義了簡單版的功能邊界，超出的查詢需求觸發 PostgreSQL 的引入。所有事件都經過同一個 Ingestion domain，差異在 Query 和 Dashboard domain 能提供什麼能力。</p>
<h2 id="sqlite-層開發者工具">SQLite 層：開發者工具</h2>
<p>SQLite 層提供的功能聚焦在「開發者自己 debug 和監控」。所有查詢都是單一維度的 — 按時間、按類型、按名稱過濾，不需要跨事件 JOIN 或跨使用者聚合。</p>
<h3 id="承載的功能">承載的功能</h3>
<table>
  <thead>
      <tr>
          <th>功能</th>
          <th>查詢模式</th>
          <th>SQL 範例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>最近 error 列表</td>
          <td>按 type + 時間過濾</td>
          <td><code>WHERE type='error' ORDER BY ts DESC LIMIT 20</code></td>
      </tr>
      <tr>
          <td>Error 計數（按 name 分群）</td>
          <td>單表 GROUP BY</td>
          <td><code>SELECT name, COUNT(*) FROM events WHERE type='error' GROUP BY name</code></td>
      </tr>
      <tr>
          <td>單次 session 回放</td>
          <td>按 session_id 過濾</td>
          <td><code>WHERE session_id='xxx' ORDER BY ts</code></td>
      </tr>
      <tr>
          <td>事件時間軸</td>
          <td>按時間排序</td>
          <td><code>WHERE ts BETWEEN ? AND ? ORDER BY ts</code></td>
      </tr>
      <tr>
          <td>基本 rule engine</td>
          <td>逐筆事件評估</td>
          <td>收到事件時逐條比對 rule（不需要查歷史）</td>
      </tr>
      <tr>
          <td>CLI 查詢</td>
          <td>任意過濾</td>
          <td><code>WHERE type=? AND name LIKE ? AND ts &gt; ?</code></td>
      </tr>
  </tbody>
</table>
<p>這些功能覆蓋開發者日常 debug 和監控的核心操作 — 查錯誤、看時間軸、回放 session、設規則告警。</p>
<h3 id="對應的-dashboard-視圖">對應的 Dashboard 視圖</h3>
<table>
  <thead>
      <tr>
          <th>視圖</th>
          <th>顯示</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>總覽頁</td>
          <td>最近 1 小時的事件計數（按 type 分）+ 最近 error 列表</td>
      </tr>
      <tr>
          <td>事件詳情</td>
          <td>單筆事件的完整 JSON</td>
      </tr>
      <tr>
          <td>Session 回放</td>
          <td>單次 session 內的事件序列</td>
      </tr>
  </tbody>
</table>
<h3 id="對應的事件消費">對應的事件消費</h3>
<p>SQLite 層消費所有四類事件，但消費方式是「單筆或單 session 級查詢」：</p>
<table>
  <thead>
      <tr>
          <th>事件類型</th>
          <th>消費方式</th>
          <th>保留需求</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>event</td>
          <td>按名稱計數、按 session 排列</td>
          <td>原始 7 天（debug）</td>
      </tr>
      <tr>
          <td>error</td>
          <td>按名稱分群、按時間排列、看 stack trace</td>
          <td>原始 30 天（error 追蹤價值較長）</td>
      </tr>
      <tr>
          <td>metric</td>
          <td>按名稱查最近 N 筆的值</td>
          <td>原始 7 天 + 每小時聚合 90 天</td>
      </tr>
      <tr>
          <td>lifecycle</td>
          <td>按 session 排列、看狀態轉換</td>
          <td>原始 7 天</td>
      </tr>
  </tbody>
</table>
<h2 id="postgresql-層行為分析">PostgreSQL 層：行為分析</h2>
<p>PostgreSQL 層在 SQLite 層的基礎上加入「跨 session、跨使用者的聚合分析」。這些查詢需要 JOIN 多張表、計算時間窗口、處理大量資料的 GROUP BY — SQLite 的單寫者模型和有限的查詢最佳化器在這些場景下效能不足。</p>
<h3 id="觸發引入-postgresql-的功能需求">觸發引入 PostgreSQL 的功能需求</h3>
<table>
  <thead>
      <tr>
          <th>功能需求</th>
          <th>為什麼 SQLite 不夠</th>
          <th>PostgreSQL 提供什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Funnel 分析</strong></td>
          <td>跨大量 session 的 multi-step JOIN 和聚合效能不足</td>
          <td>Window functions + 高效 JOIN</td>
      </tr>
      <tr>
          <td><strong>Cohort 留存</strong></td>
          <td>需要按「註冊週」分群、計算每週的回訪率</td>
          <td>Date functions + 大規模 GROUP BY</td>
      </tr>
      <tr>
          <td><strong>RFM 分群</strong></td>
          <td>需要跨所有使用者計算 recency/frequency/monetary</td>
          <td>全表聚合 + 分位數計算</td>
      </tr>
      <tr>
          <td><strong>時間趨勢 dashboard</strong></td>
          <td>需要「過去 30 天每小時的 error P95」</td>
          <td>時間分桶 + percentile 函數</td>
      </tr>
      <tr>
          <td><strong>高併發寫入</strong></td>
          <td>多個 SDK 同時 flush 且持續出現 database is locked</td>
          <td>連線池 + 並行寫入</td>
      </tr>
      <tr>
          <td><strong>長期保留 + 聚合</strong></td>
          <td>降採樣的 materialized view</td>
          <td>REFRESH MATERIALIZED VIEW</td>
      </tr>
  </tbody>
</table>
<h3 id="判斷公式">判斷公式</h3>





<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">需要 funnel / cohort / RFM 任一 → PostgreSQL
</span></span><span class="line"><span class="ln">2</span><span class="cl">需要跨使用者聚合（不只看自己的資料） → PostgreSQL
</span></span><span class="line"><span class="ln">3</span><span class="cl">需要高併發寫入（多個 SDK 同時 flush 且持續出現 database is locked 錯誤） → PostgreSQL
</span></span><span class="line"><span class="ln">4</span><span class="cl">以上都不需要 → SQLite 足夠</span></span></code></pre></div><h3 id="對應的-dashboard-視圖sqlite-層不提供">對應的 Dashboard 視圖（SQLite 層不提供）</h3>
<table>
  <thead>
      <tr>
          <th>視圖</th>
          <th>查詢模式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Funnel 漏斗</td>
          <td>多步驟轉換率（session 級 JOIN）</td>
      </tr>
      <tr>
          <td>Cohort 留存表</td>
          <td>時間窗口 × 群組矩陣</td>
      </tr>
      <tr>
          <td>RFM 分群散佈</td>
          <td>三維度分位數計算</td>
      </tr>
      <tr>
          <td>Error 趨勢圖（長期）</td>
          <td>30 天 × 每小時的時間序列</td>
      </tr>
      <tr>
          <td>效能 P95 趨勢</td>
          <td>percentile_cont 視窗函數</td>
      </tr>
  </tbody>
</table>
<h3 id="對應的事件消費-1">對應的事件消費</h3>
<p>PostgreSQL 層消費的事件和 SQLite 相同（Ingestion 不變），但消費方式從「單筆/單 session」擴展到「跨 session/跨使用者」：</p>
<table>
  <thead>
      <tr>
          <th>事件類型</th>
          <th>SQLite 層消費</th>
          <th>PostgreSQL 層新增消費</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>event</td>
          <td>按名稱計數</td>
          <td>funnel 步驟轉換、cohort 行為分群</td>
      </tr>
      <tr>
          <td>error</td>
          <td>按名稱分群</td>
          <td>跨版本 error 率比較、P95 回應時間趨勢</td>
      </tr>
      <tr>
          <td>metric</td>
          <td>最近 N 筆值</td>
          <td>長期趨勢（materialized view 預聚合）</td>
      </tr>
      <tr>
          <td>lifecycle</td>
          <td>單 session 排列</td>
          <td>session 長度分佈、留存率計算</td>
      </tr>
  </tbody>
</table>
<h2 id="domain-的分層影響">Domain 的分層影響</h2>
<table>
  <thead>
      <tr>
          <th>Domain</th>
          <th>SQLite 層</th>
          <th>PostgreSQL 層新增</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Ingestion</strong></td>
          <td>HTTP POST → 驗證 → 寫入</td>
          <td>不變（寫入目標換 backend）</td>
      </tr>
      <tr>
          <td><strong>Storage</strong></td>
          <td>SQLite embedded</td>
          <td>PostgreSQL + 連線池</td>
      </tr>
      <tr>
          <td><strong>Query</strong></td>
          <td>單表過濾 + 單表 GROUP BY</td>
          <td>JOIN + window function + percentile</td>
      </tr>
      <tr>
          <td><strong>Rule</strong></td>
          <td>逐筆事件即時評估</td>
          <td>不變（rule 不依賴聚合查詢）</td>
      </tr>
      <tr>
          <td><strong>Dashboard</strong></td>
          <td>總覽 + 事件詳情 + session 回放</td>
          <td>新增 funnel / cohort / RFM / 趨勢圖</td>
      </tr>
  </tbody>
</table>
<p>Ingestion 和 Rule 兩個 domain 和 storage backend 無關 — 事件進來的方式和規則評估的邏輯不因 backend 改變。Query 和 Dashboard 是分層影響最大的兩個 domain — PostgreSQL 層的查詢能力決定了 Dashboard 能提供什麼視圖。</p>
<h2 id="實作邊界">實作邊界</h2>
<p>Storage interface 用 Go 的 interface composition 分成兩層：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">BasicStorage</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nf">Store</span><span class="p">(</span><span class="nx">event</span> <span class="nx">Event</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nf">Query</span><span class="p">(</span><span class="nx">filter</span> <span class="nx">QueryFilter</span><span class="p">)</span> <span class="p">([]</span><span class="nx">Event</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nf">Close</span><span class="p">()</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nf">Downsample</span><span class="p">()</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nf">Purge</span><span class="p">()</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">type</span> <span class="nx">AnalyticsStorage</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">BasicStorage</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nf">Aggregate</span><span class="p">(</span><span class="nx">spec</span> <span class="nx">AggregateSpec</span><span class="p">)</span> <span class="p">(</span><span class="nx">AggregateResult</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nf">Funnel</span><span class="p">(</span><span class="nx">steps</span> <span class="p">[]</span><span class="kt">string</span><span class="p">,</span> <span class="nx">timeWindow</span> <span class="nx">Duration</span><span class="p">)</span> <span class="p">(</span><span class="nx">FunnelResult</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nf">Cohort</span><span class="p">(</span><span class="nx">groupBy</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">metric</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">CohortResult</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>SQLite implementation 只實作 <code>BasicStorage</code>。PostgreSQL implementation 實作 <code>AnalyticsStorage</code>。Dashboard 用 Go 的 type assertion（<code>if as, ok := storage.(AnalyticsStorage); ok { ... }</code>）判斷能力 — funnel/cohort 視圖在 SQLite 模式下不顯示入口，而非顯示後報錯。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>可插拔 Storage Backend 的架構 → <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a></li>
<li>事件枚舉方法（哪些事件要收） → <a href="/blog/monitoring/01-mental-model/event-enumeration-method/" data-link-title="事件枚舉與補齊檢查" data-link-desc="從操作盤點系統性地推導出完整的事件清單 — 四類補齊檢查確保沒有遺漏、粒度判準確保每個事件只記一個事實">事件枚舉與補齊檢查</a></li>
<li>分層保留策略 → <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進的分層保留段</a></li>
<li>Funnel 分析的完整方法論 → <a href="/blog/monitoring/08-business-analytics/funnel-analysis/" data-link-title="Funnel Analysis" data-link-desc="使用者在哪一步流失 — 從事件序列計算每步轉換率、找出流失最嚴重的步驟、區分設計問題和技術問題">Funnel analysis</a></li>
<li>查詢消費模式（各場景需要什麼事件）→ <a href="/blog/monitoring/04-collector/query-consumption-patterns/" data-link-title="查詢消費模式" data-link-desc="Debug / Alerting / 產品決策 / 安全審計 / 效能監控 — 五種查詢場景各需要什麼事件、什麼欄位、什麼查詢模式">查詢消費模式</a></li>
</ul>
]]></content:encoded></item><item><title>2.6 struct embedding 與組合式設計</title><link>https://tarrragon.github.io/blog/go/02-types-data/embedding-composition/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/02-types-data/embedding-composition/</guid><description>&lt;p>struct embedding 的核心用途是組合既有能力。它可以讓欄位與方法被提升到外層型別，但設計重點仍然是清楚表達責任，而不是模擬繼承階層。&lt;/p>
&lt;h2 id="預計補充內容">預計補充內容&lt;/h2>
&lt;p>這些組合邊界會在下列章節展開：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/00-philosophy/composition/" data-link-title="0.2 組合優先：小介面與明確依賴" data-link-desc="用小介面與 struct 組合取代大型繼承結構">Go 入門：組合優先：小介面與明確依賴&lt;/a>：先理解 Go 為什麼偏好組合，才能判斷 embedding 是在表達能力，還是在模仿繼承。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/02-types-data/interfaces/" data-link-title="2.3 interface：用行為定義依賴" data-link-desc="用小介面描述元件需要的能力">Go 入門：interface：用行為定義依賴&lt;/a>：這裡會把 embedding 和 interface 的責任分開，避免欄位提升變成隱性耦合。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">Go 進階：composition root 與依賴組裝&lt;/a>：當組合開始影響 wiring 時，就要看依賴是在哪一層被建立的。&lt;/li>
&lt;/ul>
&lt;h2 id="與其他章節的關係">與其他章節的關係&lt;/h2>
&lt;p>本章承接 &lt;a href="https://tarrragon.github.io/blog/go/00-philosophy/composition/" data-link-title="0.2 組合優先：小介面與明確依賴" data-link-desc="用小介面與 struct 組合取代大型繼承結構">組合優先：小介面與明確依賴&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/go/02-types-data/interfaces/" data-link-title="2.3 interface：用行為定義依賴" data-link-desc="用小介面描述元件需要的能力">interface：用行為定義依賴&lt;/a>，後續會連到 &lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">composition root 與依賴組裝&lt;/a>。&lt;/p>
&lt;h2 id="和-go-教材的關係">和 Go 教材的關係&lt;/h2>
&lt;p>這一章承接的是組合、interface 與依賴組裝；如果你要先回看語言教材，可以讀：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/00-philosophy/composition/" data-link-title="0.2 組合優先：小介面與明確依賴" data-link-desc="用小介面與 struct 組合取代大型繼承結構">Go：組合優先：小介面與明確依賴&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/02-types-data/interfaces/" data-link-title="2.3 interface：用行為定義依賴" data-link-desc="用小介面描述元件需要的能力">Go：interface：用行為定義依賴&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">Go：composition root 與依賴組裝&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/domain-packages/" data-link-title="7.5 以 domain 重新整理 package" data-link-desc="讓 account、job、event、workflow 這類領域邊界在目錄中可見">Go：以 domain 重新整理 package&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>struct embedding 的核心用途是組合既有能力。它可以讓欄位與方法被提升到外層型別，但設計重點仍然是清楚表達責任，而不是模擬繼承階層。</p>
<h2 id="預計補充內容">預計補充內容</h2>
<p>這些組合邊界會在下列章節展開：</p>
<ul>
<li><a href="/blog/go/00-philosophy/composition/" data-link-title="0.2 組合優先：小介面與明確依賴" data-link-desc="用小介面與 struct 組合取代大型繼承結構">Go 入門：組合優先：小介面與明確依賴</a>：先理解 Go 為什麼偏好組合，才能判斷 embedding 是在表達能力，還是在模仿繼承。</li>
<li><a href="/blog/go/02-types-data/interfaces/" data-link-title="2.3 interface：用行為定義依賴" data-link-desc="用小介面描述元件需要的能力">Go 入門：interface：用行為定義依賴</a>：這裡會把 embedding 和 interface 的責任分開，避免欄位提升變成隱性耦合。</li>
<li><a href="/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">Go 進階：composition root 與依賴組裝</a>：當組合開始影響 wiring 時，就要看依賴是在哪一層被建立的。</li>
</ul>
<h2 id="與其他章節的關係">與其他章節的關係</h2>
<p>本章承接 <a href="/blog/go/00-philosophy/composition/" data-link-title="0.2 組合優先：小介面與明確依賴" data-link-desc="用小介面與 struct 組合取代大型繼承結構">組合優先：小介面與明確依賴</a> 與 <a href="/blog/go/02-types-data/interfaces/" data-link-title="2.3 interface：用行為定義依賴" data-link-desc="用小介面描述元件需要的能力">interface：用行為定義依賴</a>，後續會連到 <a href="/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">composition root 與依賴組裝</a>。</p>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是組合、interface 與依賴組裝；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/00-philosophy/composition/" data-link-title="0.2 組合優先：小介面與明確依賴" data-link-desc="用小介面與 struct 組合取代大型繼承結構">Go：組合優先：小介面與明確依賴</a></li>
<li><a href="/blog/go/02-types-data/interfaces/" data-link-title="2.3 interface：用行為定義依賴" data-link-desc="用小介面描述元件需要的能力">Go：interface：用行為定義依賴</a></li>
<li><a href="/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">Go：composition root 與依賴組裝</a></li>
<li><a href="/blog/go/07-refactoring/domain-packages/" data-link-title="7.5 以 domain 重新整理 package" data-link-desc="讓 account、job、event、workflow 這類領域邊界在目錄中可見">Go：以 domain 重新整理 package</a></li>
</ul>
]]></content:encoded></item><item><title>6.6 如何新增 repository port</title><link>https://tarrragon.github.io/blog/go/06-practical/repository-port/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/06-practical/repository-port/</guid><description>&lt;p>新增 repository port 的核心目標是讓 application layer 依賴資料能力，而不是依賴具體儲存技術。先建立 port，才能在 memory、SQLite 或其他資料庫之間替換。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>判斷何時需要 repository port&lt;/li>
&lt;li>由 usecase 定義小而明確的 repository interface&lt;/li>
&lt;li>實作 map + mutex 的 in-memory repository&lt;/li>
&lt;li>保留 context、error 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database&lt;/a>-ready 邊界&lt;/li>
&lt;li>分開撰寫 usecase fake test 與 repository contract test&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察repository-port-表達資料能力">【觀察】repository port 表達資料能力&lt;/h2>
&lt;p>repository port 的核心語意是 usecase 需要哪些資料能力。它應描述 application layer 的讀寫需求，而不是照搬資料庫 CRUD 方法或把所有資料操作塞進同一個巨大 &lt;code>Repository&lt;/code>。&lt;/p>
&lt;p>例如通知服務的 usecase 可能需要三種能力：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>usecase 需求&lt;/th>
 &lt;th>repository 能力&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>建立通知&lt;/td>
 &lt;td>儲存一筆 notification&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>查詢 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a> 通知&lt;/td>
 &lt;td>依 topic 列出 notification&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>避免重複建立&lt;/td>
 &lt;td>依 ID 查詢 notification 是否存在&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這些能力可以先用 memory 實作，未來再換成 SQLite、PostgreSQL 或外部服務。usecase 不應知道底層儲存技術。&lt;/p>
&lt;h2 id="判讀repository-服務共享讀寫邊界">【判讀】repository 服務共享讀寫邊界&lt;/h2>
&lt;p>是否需要 repository 的核心判斷是資料是否需要一致的讀寫邊界。暫時變數、單次函式內部結果或不共享的 cache，不一定需要 repository。&lt;/p>
&lt;p>適合 repository 的情境：&lt;/p>
&lt;ul>
&lt;li>多個 usecase 需要一致讀寫同一組資料&lt;/li>
&lt;li>資料需要被測試替身取代&lt;/li>
&lt;li>讀寫規則需要集中&lt;/li>
&lt;li>未來可能從 memory 換成資料庫&lt;/li>
&lt;li>需要保護 map、slice 或 pointer 不被外部修改&lt;/li>
&lt;/ul>
&lt;p>不一定需要 repository 的情境：&lt;/p>
&lt;ul>
&lt;li>只在單一函式內暫存&lt;/li>
&lt;li>只是純計算結果&lt;/li>
&lt;li>還沒有共享或替換需求&lt;/li>
&lt;li>只有一個簡單 struct 可以直接傳遞&lt;/li>
&lt;/ul>
&lt;p>repository 是邊界工具，不是成熟專案的儀式。過早建立 repository 會讓小程式變得難讀。&lt;/p>
&lt;h2 id="策略interface-放在使用端">【策略】interface 放在使用端&lt;/h2>
&lt;p>repository interface 的核心規則是由使用端定義需要的能力。usecase 需要什麼，就在 usecase 所在 package 定義什麼；adapter 或 infrastructure 實作它。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">NotificationRepository&lt;/span> &lt;span class="kd">interface&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nf">Save&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">notification&lt;/span> &lt;span class="nx">Notification&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nf">FindByID&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">id&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">Notification&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">bool&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&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="nf">ListByTopic&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">topic&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">([]&lt;/span>&lt;span class="nx">Notification&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>方法名稱應該表達業務能力，而不是資料庫操作細節。&lt;code>ListByTopic&lt;/code> 比 &lt;code>SelectWhereTopicEquals&lt;/code> 更適合 usecase。&lt;/p>
&lt;p>先定義 domain model：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Notification&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">ID&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">Topic&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">Title&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nx">CreatedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>domain model 不需要 JSON tag。對外 JSON 格式應交給 response struct，repository 儲存的是內部資料。&lt;/p>
&lt;h2 id="執行usecase-依賴-port不依賴-implementation">【執行】usecase 依賴 port，不依賴 implementation&lt;/h2>
&lt;p>usecase 的核心責任是協調資料能力與行為規則。它接收 repository port，而不是具體 memory repository。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">CreateNotificationCommand&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">ID&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">Topic&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">Title&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">CreatedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">NotificationService&lt;/span> &lt;span class="kd">struct&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="nx">repository&lt;/span> &lt;span class="nx">NotificationRepository&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">NewNotificationService&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">repository&lt;/span> &lt;span class="nx">NotificationRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">NotificationService&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">NotificationService&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">repository&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">repository&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>建立通知時，usecase 可以檢查重複與必填欄位：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">s&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">NotificationService&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Create&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">cmd&lt;/span> &lt;span class="nx">CreateNotificationCommand&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">TrimSpace&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">cmd&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Errorf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;notification id is required&amp;#34;&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;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">TrimSpace&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">cmd&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Topic&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Errorf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;topic is required&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">exists&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">s&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">repository&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">FindByID&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">cmd&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&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="k">return&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Errorf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;find notification: %w&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nx">exists&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Errorf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;notification %s already exists&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">cmd&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nx">notification&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">Notification&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="nx">ID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">cmd&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="nx">Topic&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">cmd&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Topic&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="nx">Title&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">cmd&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Title&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="nx">CreatedAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">cmd&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">CreatedAt&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">s&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">repository&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Save&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">notification&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Errorf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;save notification: %w&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段 usecase 不知道資料存在 map、SQLite 或遠端 API。它只依賴「可以查詢與儲存 notification」這個能力。&lt;/p></description><content:encoded><![CDATA[<p>新增 repository port 的核心目標是讓 application layer 依賴資料能力，而不是依賴具體儲存技術。先建立 port，才能在 memory、SQLite 或其他資料庫之間替換。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>判斷何時需要 repository port</li>
<li>由 usecase 定義小而明確的 repository interface</li>
<li>實作 map + mutex 的 in-memory repository</li>
<li>保留 context、error 與 <a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database</a>-ready 邊界</li>
<li>分開撰寫 usecase fake test 與 repository contract test</li>
</ol>
<hr>
<h2 id="觀察repository-port-表達資料能力">【觀察】repository port 表達資料能力</h2>
<p>repository port 的核心語意是 usecase 需要哪些資料能力。它應描述 application layer 的讀寫需求，而不是照搬資料庫 CRUD 方法或把所有資料操作塞進同一個巨大 <code>Repository</code>。</p>
<p>例如通知服務的 usecase 可能需要三種能力：</p>
<table>
  <thead>
      <tr>
          <th>usecase 需求</th>
          <th>repository 能力</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>建立通知</td>
          <td>儲存一筆 notification</td>
      </tr>
      <tr>
          <td>查詢 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a> 通知</td>
          <td>依 topic 列出 notification</td>
      </tr>
      <tr>
          <td>避免重複建立</td>
          <td>依 ID 查詢 notification 是否存在</td>
      </tr>
  </tbody>
</table>
<p>這些能力可以先用 memory 實作，未來再換成 SQLite、PostgreSQL 或外部服務。usecase 不應知道底層儲存技術。</p>
<h2 id="判讀repository-服務共享讀寫邊界">【判讀】repository 服務共享讀寫邊界</h2>
<p>是否需要 repository 的核心判斷是資料是否需要一致的讀寫邊界。暫時變數、單次函式內部結果或不共享的 cache，不一定需要 repository。</p>
<p>適合 repository 的情境：</p>
<ul>
<li>多個 usecase 需要一致讀寫同一組資料</li>
<li>資料需要被測試替身取代</li>
<li>讀寫規則需要集中</li>
<li>未來可能從 memory 換成資料庫</li>
<li>需要保護 map、slice 或 pointer 不被外部修改</li>
</ul>
<p>不一定需要 repository 的情境：</p>
<ul>
<li>只在單一函式內暫存</li>
<li>只是純計算結果</li>
<li>還沒有共享或替換需求</li>
<li>只有一個簡單 struct 可以直接傳遞</li>
</ul>
<p>repository 是邊界工具，不是成熟專案的儀式。過早建立 repository 會讓小程式變得難讀。</p>
<h2 id="策略interface-放在使用端">【策略】interface 放在使用端</h2>
<p>repository interface 的核心規則是由使用端定義需要的能力。usecase 需要什麼，就在 usecase 所在 package 定義什麼；adapter 或 infrastructure 實作它。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">NotificationRepository</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">notification</span> <span class="nx">Notification</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nf">FindByID</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nf">ListByTopic</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">topic</span> <span class="kt">string</span><span class="p">)</span> <span class="p">([]</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>方法名稱應該表達業務能力，而不是資料庫操作細節。<code>ListByTopic</code> 比 <code>SelectWhereTopicEquals</code> 更適合 usecase。</p>
<p>先定義 domain model：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">Notification</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">ID</span>        <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">Topic</span>     <span class="kt">string</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">Title</span>     <span class="kt">string</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">CreatedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>domain model 不需要 JSON tag。對外 JSON 格式應交給 response struct，repository 儲存的是內部資料。</p>
<h2 id="執行usecase-依賴-port不依賴-implementation">【執行】usecase 依賴 port，不依賴 implementation</h2>
<p>usecase 的核心責任是協調資料能力與行為規則。它接收 repository port，而不是具體 memory repository。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">CreateNotificationCommand</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">ID</span>        <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">Topic</span>     <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">Title</span>     <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">CreatedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="kd">type</span> <span class="nx">NotificationService</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">repository</span> <span class="nx">NotificationRepository</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="kd">func</span> <span class="nf">NewNotificationService</span><span class="p">(</span><span class="nx">repository</span> <span class="nx">NotificationRepository</span><span class="p">)</span> <span class="o">*</span><span class="nx">NotificationService</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">return</span> <span class="o">&amp;</span><span class="nx">NotificationService</span><span class="p">{</span><span class="nx">repository</span><span class="p">:</span> <span class="nx">repository</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>建立通知時，usecase 可以檢查重複與必填欄位：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">s</span> <span class="o">*</span><span class="nx">NotificationService</span><span class="p">)</span> <span class="nf">Create</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">cmd</span> <span class="nx">CreateNotificationCommand</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">if</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">cmd</span><span class="p">.</span><span class="nx">ID</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;notification id is required&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">if</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">cmd</span><span class="p">.</span><span class="nx">Topic</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;topic is required&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">exists</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">s</span><span class="p">.</span><span class="nx">repository</span><span class="p">.</span><span class="nf">FindByID</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">cmd</span><span class="p">.</span><span class="nx">ID</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;find notification: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="nx">exists</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;notification %s already exists&#34;</span><span class="p">,</span> <span class="nx">cmd</span><span class="p">.</span><span class="nx">ID</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">notification</span> <span class="o">:=</span> <span class="nx">Notification</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>        <span class="nx">cmd</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span>     <span class="nx">cmd</span><span class="p">.</span><span class="nx">Topic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span>     <span class="nx">cmd</span><span class="p">.</span><span class="nx">Title</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">cmd</span><span class="p">.</span><span class="nx">CreatedAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">s</span><span class="p">.</span><span class="nx">repository</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">notification</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;save notification: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段 usecase 不知道資料存在 map、SQLite 或遠端 API。它只依賴「可以查詢與儲存 notification」這個能力。</p>
<h2 id="執行memory-implementation-要保護-map">【執行】memory implementation 要保護 map</h2>
<p>in-memory repository 的核心責任是提供可用的儲存實作，同時保護共享 map。只要 repository 可能被多個 goroutine 使用，就應該用 mutex 保護。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">InMemoryNotificationRepository</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">mu</span>            <span class="nx">sync</span><span class="p">.</span><span class="nx">RWMutex</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">notifications</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">Notification</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kd">func</span> <span class="nf">NewInMemoryNotificationRepository</span><span class="p">()</span> <span class="o">*</span><span class="nx">InMemoryNotificationRepository</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">return</span> <span class="o">&amp;</span><span class="nx">InMemoryNotificationRepository</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">notifications</span><span class="p">:</span> <span class="nb">make</span><span class="p">(</span><span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">Notification</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <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><code>Save</code> 寫入時要複製資料：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">InMemoryNotificationRepository</span><span class="p">)</span> <span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">notification</span> <span class="nx">Notification</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Lock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Unlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">notifications</span><span class="p">[</span><span class="nx">notification</span><span class="p">.</span><span class="nx">ID</span><span class="p">]</span> <span class="p">=</span> <span class="nx">notification</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>FindByID</code> 回傳值與 bool：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">InMemoryNotificationRepository</span><span class="p">)</span> <span class="nf">FindByID</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RLock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RUnlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">notification</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">notifications</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">return</span> <span class="nx">Notification</span><span class="p">{},</span> <span class="kc">false</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">return</span> <span class="nx">notification</span><span class="p">,</span> <span class="kc">true</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>ListByTopic</code> 回傳新 slice，避免呼叫端修改內部資料：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">InMemoryNotificationRepository</span><span class="p">)</span> <span class="nf">ListByTopic</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">topic</span> <span class="kt">string</span><span class="p">)</span> <span class="p">([]</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RLock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RUnlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">result</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="nx">Notification</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">notification</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">r</span><span class="p">.</span><span class="nx">notifications</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">if</span> <span class="nx">notification</span><span class="p">.</span><span class="nx">Topic</span> <span class="o">==</span> <span class="nx">topic</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="nx">result</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">result</span><span class="p">,</span> <span class="nx">notification</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">return</span> <span class="nx">result</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>目前 <code>Notification</code> 只有值型別欄位，所以回傳值與新 slice 已足夠。若未來加上 slice、map 或 pointer 欄位，就要補 clone 函式。</p>
<h2 id="策略context-和-error-是未來資料庫邊界">【策略】context 和 error 是未來資料庫邊界</h2>
<p>repository method 接收 context 的核心原因是未來可能出現 I/O。memory 實作可能不使用 context，但資料庫查詢、遠端 API 或 <a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a> 會需要取消與逾時。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">notification</span> <span class="nx">Notification</span><span class="p">)</span> <span class="kt">error</span></span></span></code></pre></div><p>error wrapping 的核心規則是保留失敗位置與原始錯誤：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">s</span><span class="p">.</span><span class="nx">repository</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">notification</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;save notification: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>不要過早抽象 transaction。只有當一個 usecase 真的需要多筆寫入同時成功或失敗時，再設計 <a href="/blog/backend/knowledge-cards/transaction-boundary/" data-link-title="Transaction Boundary" data-link-desc="說明哪些資料變更應在同一個交易中一起成功或一起回復">transaction boundary</a>。否則 repository interface 會提前背負資料庫細節。</p>
<h2 id="判讀fake-和-in-memory-用於不同測試">【判讀】fake 和 in-memory 用於不同測試</h2>
<p>測試替身的核心差異是 fake 服務 usecase 測試，in-memory implementation 服務 repository 行為測試。兩者可以長得像，但用途不同。</p>
<p>usecase 測試可以用簡單 fake：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">fakeNotificationRepository</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">existing</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">Notification</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">saved</span>    <span class="p">[]</span><span class="nx">Notification</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">err</span>      <span class="kt">error</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="kd">func</span> <span class="p">(</span><span class="nx">f</span> <span class="o">*</span><span class="nx">fakeNotificationRepository</span><span class="p">)</span> <span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">notification</span> <span class="nx">Notification</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">if</span> <span class="nx">f</span><span class="p">.</span><span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">return</span> <span class="nx">f</span><span class="p">.</span><span class="nx">err</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">f</span><span class="p">.</span><span class="nx">saved</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">f</span><span class="p">.</span><span class="nx">saved</span><span class="p">,</span> <span class="nx">notification</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">f</span> <span class="o">*</span><span class="nx">fakeNotificationRepository</span><span class="p">)</span> <span class="nf">FindByID</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">if</span> <span class="nx">f</span><span class="p">.</span><span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="k">return</span> <span class="nx">Notification</span><span class="p">{},</span> <span class="kc">false</span><span class="p">,</span> <span class="nx">f</span><span class="p">.</span><span class="nx">err</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="nx">notification</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">f</span><span class="p">.</span><span class="nx">existing</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">return</span> <span class="nx">notification</span><span class="p">,</span> <span class="nx">ok</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">f</span> <span class="o">*</span><span class="nx">fakeNotificationRepository</span><span class="p">)</span> <span class="nf">ListByTopic</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">topic</span> <span class="kt">string</span><span class="p">)</span> <span class="p">([]</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 fake 只支援測試需要的行為，不必成為完整 repository。</p>
<h2 id="執行usecase-測試檢查行為規則">【執行】usecase 測試檢查行為規則</h2>
<p>usecase 測試的核心目標是驗證 command 進來後是否呼叫正確資料能力。它不應測 memory map 的 lock 或 copy 行為。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestNotificationServiceCreate</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">repo</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">fakeNotificationRepository</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">existing</span><span class="p">:</span> <span class="nb">make</span><span class="p">(</span><span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">Notification</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">service</span> <span class="o">:=</span> <span class="nf">NewNotificationService</span><span class="p">(</span><span class="nx">repo</span><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="nx">err</span> <span class="o">:=</span> <span class="nx">service</span><span class="p">.</span><span class="nf">Create</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">(),</span> <span class="nx">CreateNotificationCommand</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>        <span class="s">&#34;ntf_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span>     <span class="s">&#34;deployments&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span>     <span class="s">&#34;Deploy finished&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;create notification: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">repo</span><span class="p">.</span><span class="nx">saved</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">1</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;saved notifications = %d, want 1&#34;</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">repo</span><span class="p">.</span><span class="nx">saved</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>重複 ID 測試可以讓 fake 回傳 existing notification：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestNotificationServiceCreateDuplicate</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">repo</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">fakeNotificationRepository</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">existing</span><span class="p">:</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">Notification</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">            <span class="s">&#34;ntf_1&#34;</span><span class="p">:</span> <span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="s">&#34;ntf_1&#34;</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 class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">service</span> <span class="o">:=</span> <span class="nf">NewNotificationService</span><span class="p">(</span><span class="nx">repo</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">err</span> <span class="o">:=</span> <span class="nx">service</span><span class="p">.</span><span class="nf">Create</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">(),</span> <span class="nx">CreateNotificationCommand</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>        <span class="s">&#34;ntf_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span>     <span class="s">&#34;deployments&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span>     <span class="s">&#34;Deploy finished&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">==</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;expected duplicate error&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這些測試讓 usecase 不依賴具體 repository implementation。</p>
<h2 id="執行repository-contract-test-保護實作行為">【執行】repository contract test 保護實作行為</h2>
<p>repository contract test 的核心目標是讓不同 implementation 遵守同一組行為。memory repository、SQLite repository 或其他實作都可以跑同一套測試。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestNotificationRepositoryContract</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nf">runNotificationRepositoryContract</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="kd">func</span><span class="p">()</span> <span class="nx">NotificationRepository</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">return</span> <span class="nf">NewInMemoryNotificationRepository</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <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="kd">func</span> <span class="nf">runNotificationRepositoryContract</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">,</span> <span class="nx">newRepo</span> <span class="kd">func</span><span class="p">()</span> <span class="nx">NotificationRepository</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">t</span><span class="p">.</span><span class="nf">Helper</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">repo</span> <span class="o">:=</span> <span class="nf">newRepo</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">ctx</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">notification</span> <span class="o">:=</span> <span class="nx">Notification</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>        <span class="s">&#34;ntf_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span>     <span class="s">&#34;deployments&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span>     <span class="s">&#34;Deploy finished&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">notification</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;save notification: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="nx">got</span><span class="p">,</span> <span class="nx">ok</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">FindByID</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="s">&#34;ntf_1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;find notification: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;notification should exist&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="k">if</span> <span class="nx">got</span><span class="p">.</span><span class="nx">Topic</span> <span class="o">!=</span> <span class="s">&#34;deployments&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;topic = %q, want deployments&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">.</span><span class="nx">Topic</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">
</span></span><span class="line"><span class="ln">34</span><span class="cl">    <span class="nx">list</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">ListByTopic</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="s">&#34;deployments&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;list notifications: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">list</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">1</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;notifications = %d, want 1&#34;</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">list</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>contract test 不需要知道實作細節。它只驗證 port 承諾的行為。</p>
<h2 id="策略小介面比萬用-repository-穩定">【策略】小介面比萬用 repository 穩定</h2>
<p>repository interface 的核心風險是變成所有資料操作的大型介面。大型介面會讓每個 usecase、fake 與測試都被迫依賴不需要的方法。</p>
<p>不佳：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">Repository</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nf">SaveNotification</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">notification</span> <span class="nx">Notification</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nf">ListNotifications</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">)</span> <span class="p">([]</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nf">SaveJob</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">job</span> <span class="nx">JobProjection</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nf">SaveAccount</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">account</span> <span class="nx">Account</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nf">AppendEvent</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>較佳：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">NotificationRepository</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">notification</span> <span class="nx">Notification</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nf">FindByID</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nf">ListByTopic</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">topic</span> <span class="kt">string</span><span class="p">)</span> <span class="p">([]</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>不同 usecase 可以定義不同 port。若未來多個 port 由同一個 database adapter 實作，那是 adapter 的事，不必讓 usecase 共享巨大介面。</p>
<h2 id="實作檢查清單">實作檢查清單</h2>
<p>新增 repository port 時，可以依序檢查：</p>
<ol>
<li>是否真的需要共享讀寫邊界</li>
<li>interface 是否由 usecase 需要定義</li>
<li>方法名稱是否表達業務能力</li>
<li>方法是否接收 <code>context.Context</code></li>
<li>error 是否被包上操作脈絡</li>
<li>in-memory implementation 是否保護 map</li>
<li>回傳 slice/map/pointer 時是否有 copy boundary</li>
<li>usecase 測試是否使用 fake</li>
<li>repository implementation 是否跑 contract test</li>
<li>是否避免把所有資料操作塞進巨大介面</li>
</ol>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="檢查一repository-來自-usecase-需求">檢查一：repository 來自 usecase 需求</h3>
<p>repository 應該來自 usecase 需求。先建一個萬用 <code>Repository</code>，通常會讓介面快速膨脹，測試也變得笨重。</p>
<h3 id="檢查二interface-放在使用端">檢查二：interface 放在使用端</h3>
<p>若 interface 是由 implementation 定義，usecase 會被迫接受 implementation 想暴露的能力。Go 更常見的做法是讓使用端定義最小需求。</p>
<h3 id="檢查三memory-repository-保護內部資料">檢查三：memory repository 保護內部資料</h3>
<p>回傳內部 slice、map 或 pointer 會讓呼叫端繞過 repository 規則。即使目前只是 memory 實作，也應保護資料擁有權。</p>
<h3 id="檢查四transaction-和-orm-等需求出現再抽象">檢查四：transaction 和 ORM 等需求出現再抽象</h3>
<p>沒有跨多筆寫入一致性需求時，transaction 介面只會增加複雜度。先把 context、error、port 邊界放好，等需求出現再擴展。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理 repository port 如何表達資料能力；特定資料庫、ORM 與 transaction，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">Go 進階：資料庫 transaction 與 schema migration</a></li>
<li><a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">Backend：資料庫與持久化</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 interface、state 與 application boundary；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
<li><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go：狀態管理的安全邊界</a></li>
<li><a href="/blog/go/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">Go：如何新增一種 domain event</a></li>
<li><a href="/blog/go/06-practical/state-fields/" data-link-title="6.3 如何擴展狀態投影欄位" data-link-desc="更新狀態模型、repository 與 API 輸出">Go：如何擴展狀態投影欄位</a></li>
</ul>
]]></content:encoded></item><item><title>7.6 逐步遷移到 ports/adapters 架構</title><link>https://tarrragon.github.io/blog/go/07-refactoring/hexagonal-migration/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/07-refactoring/hexagonal-migration/</guid><description>&lt;p>ports/adapters 遷移的核心目標是讓 application 與 domain 不依賴外部技術細節。HTTP、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a>、callback receiver、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database&lt;/a> 都是 adapters；usecase 透過 ports 使用它們。&lt;/p>
&lt;p>這種重構不需要一次套完整架構。Go 專案更常見的做法是先把過重 handler、外部依賴與狀態寫入切開，再在壓力最大的邊界引入 port。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>用依賴方向理解 ports/adapters&lt;/li>
&lt;li>分辨 inbound adapter 與 outbound adapter&lt;/li>
&lt;li>把 usecase 從 handler、repository、publisher 中切出&lt;/li>
&lt;li>用新功能先走新架構的方式漸進遷移&lt;/li>
&lt;li>驗證 import direction、usecase test 與 adapter integration test&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察portsadapters-是依賴方向">【觀察】ports/adapters 是依賴方向&lt;/h2>
&lt;p>ports/adapters 的核心規則是外部技術依賴 application，而不是 application 依賴外部技術。HTTP、WebSocket、database、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> 都是可替換的邊界；usecase 應該依賴自己定義的 port。&lt;/p>
&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">transport/http ┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">transport/websocket ├─&amp;gt; application ──&amp;gt; domain
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">transport/callback ┘ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> ports defined here
&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">storage/memory ┐ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">eventlog/memory ├───────┘
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">publisher/websocket ┘&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>資料夾名稱可以不同。真正重要的是 import direction：domain 不 import HTTP，application 不 import database implementation，adapter import application 並實作 application 需要的 port。&lt;/p>
&lt;h2 id="判讀inbound-adapter-把外部輸入轉成-command">【判讀】inbound adapter 把外部輸入轉成 command&lt;/h2>
&lt;p>inbound adapter 的核心責任是接收外部訊號，轉成 application command 或 domain event。它不應直接修改 state，也不應保存業務規則。&lt;/p>
&lt;p>常見 inbound adapter：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>adapter&lt;/th>
 &lt;th>輸入&lt;/th>
 &lt;th>轉換結果&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>HTTP handler&lt;/td>
 &lt;td>HTTP request&lt;/td>
 &lt;td>command&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>WebSocket router&lt;/td>
 &lt;td>client message&lt;/td>
 &lt;td>command&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>callback receiver&lt;/td>
 &lt;td>external callback&lt;/td>
 &lt;td>domain event&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>worker&lt;/td>
 &lt;td>timer 或 queue item&lt;/td>
 &lt;td>command/event&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>例如 HTTP adapter：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">HTTPNotificationHandler&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">usecase&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">application&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">CreateNotificationUsecase&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">now&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">h&lt;/span> &lt;span class="nx">HTTPNotificationHandler&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Create&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">req&lt;/span> &lt;span class="nx">createNotificationRequest&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewDecoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Decode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">req&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&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="nf">writeError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;invalid_json&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;request body must be valid JSON&amp;#34;&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="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">cmd&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">application&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">CreateNotificationCommand&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">ID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nx">Topic&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Topic&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="nx">Title&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Title&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="nx">CreatedAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">h&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">now&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">h&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">usecase&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Execute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Context&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="nx">cmd&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeUsecaseError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusCreated&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>HTTP adapter 知道 JSON、status code、request body。usecase 不知道這些協定細節。&lt;/p>
&lt;h2 id="判讀outbound-adapter-實作-application-port">【判讀】outbound adapter 實作 application port&lt;/h2>
&lt;p>outbound adapter 的核心責任是實作 application 定義的 port。application 說「我需要儲存 notification」，adapter 決定用 memory、SQLite 或其他技術完成。&lt;/p></description><content:encoded><![CDATA[<p>ports/adapters 遷移的核心目標是讓 application 與 domain 不依賴外部技術細節。HTTP、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a>、callback receiver、<a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database</a> 都是 adapters；usecase 透過 ports 使用它們。</p>
<p>這種重構不需要一次套完整架構。Go 專案更常見的做法是先把過重 handler、外部依賴與狀態寫入切開，再在壓力最大的邊界引入 port。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>用依賴方向理解 ports/adapters</li>
<li>分辨 inbound adapter 與 outbound adapter</li>
<li>把 usecase 從 handler、repository、publisher 中切出</li>
<li>用新功能先走新架構的方式漸進遷移</li>
<li>驗證 import direction、usecase test 與 adapter integration test</li>
</ol>
<hr>
<h2 id="觀察portsadapters-是依賴方向">【觀察】ports/adapters 是依賴方向</h2>
<p>ports/adapters 的核心規則是外部技術依賴 application，而不是 application 依賴外部技術。HTTP、WebSocket、database、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> 都是可替換的邊界；usecase 應該依賴自己定義的 port。</p>
<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">transport/http      ┐
</span></span><span class="line"><span class="ln">2</span><span class="cl">transport/websocket ├─&gt; application ──&gt; domain
</span></span><span class="line"><span class="ln">3</span><span class="cl">transport/callback  ┘        │
</span></span><span class="line"><span class="ln">4</span><span class="cl">                             ▼
</span></span><span class="line"><span class="ln">5</span><span class="cl">                      ports defined here
</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">storage/memory       ┐       │
</span></span><span class="line"><span class="ln">8</span><span class="cl">eventlog/memory      ├───────┘
</span></span><span class="line"><span class="ln">9</span><span class="cl">publisher/websocket  ┘</span></span></code></pre></div><p>資料夾名稱可以不同。真正重要的是 import direction：domain 不 import HTTP，application 不 import database implementation，adapter import application 並實作 application 需要的 port。</p>
<h2 id="判讀inbound-adapter-把外部輸入轉成-command">【判讀】inbound adapter 把外部輸入轉成 command</h2>
<p>inbound adapter 的核心責任是接收外部訊號，轉成 application command 或 domain event。它不應直接修改 state，也不應保存業務規則。</p>
<p>常見 inbound adapter：</p>
<table>
  <thead>
      <tr>
          <th>adapter</th>
          <th>輸入</th>
          <th>轉換結果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>HTTP handler</td>
          <td>HTTP request</td>
          <td>command</td>
      </tr>
      <tr>
          <td>WebSocket router</td>
          <td>client message</td>
          <td>command</td>
      </tr>
      <tr>
          <td>callback receiver</td>
          <td>external callback</td>
          <td>domain event</td>
      </tr>
      <tr>
          <td>worker</td>
          <td>timer 或 queue item</td>
          <td>command/event</td>
      </tr>
  </tbody>
</table>
<p>例如 HTTP adapter：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">HTTPNotificationHandler</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">usecase</span> <span class="o">*</span><span class="nx">application</span><span class="p">.</span><span class="nx">CreateNotificationUsecase</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">now</span>     <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="nx">HTTPNotificationHandler</span><span class="p">)</span> <span class="nf">Create</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="kd">var</span> <span class="nx">req</span> <span class="nx">createNotificationRequest</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">NewDecoder</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">).</span><span class="nf">Decode</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">req</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">,</span> <span class="s">&#34;invalid_json&#34;</span><span class="p">,</span> <span class="s">&#34;request body must be valid JSON&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">cmd</span> <span class="o">:=</span> <span class="nx">application</span><span class="p">.</span><span class="nx">CreateNotificationCommand</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>        <span class="nx">req</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span>     <span class="nx">req</span><span class="p">.</span><span class="nx">Topic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span>     <span class="nx">req</span><span class="p">.</span><span class="nx">Title</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">h</span><span class="p">.</span><span class="nf">now</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">h</span><span class="p">.</span><span class="nx">usecase</span><span class="p">.</span><span class="nf">Execute</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nf">Context</span><span class="p">(),</span> <span class="nx">cmd</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="nf">writeUsecaseError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusCreated</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>HTTP adapter 知道 JSON、status code、request body。usecase 不知道這些協定細節。</p>
<h2 id="判讀outbound-adapter-實作-application-port">【判讀】outbound adapter 實作 application port</h2>
<p>outbound adapter 的核心責任是實作 application 定義的 port。application 說「我需要儲存 notification」，adapter 決定用 memory、SQLite 或其他技術完成。</p>
<p>application 定義 port：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kn">package</span> <span class="nx">application</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kd">type</span> <span class="nx">NotificationRepository</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">notification</span> <span class="nx">domain</span><span class="p">.</span><span class="nx">Notification</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nf">FindByID</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">domain</span><span class="p">.</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>memory adapter 實作：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">package</span> <span class="nx">memory</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kd">type</span> <span class="nx">NotificationRepository</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">mu</span>            <span class="nx">sync</span><span class="p">.</span><span class="nx">RWMutex</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">notifications</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">domain</span><span class="p">.</span><span class="nx">Notification</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">NotificationRepository</span><span class="p">)</span> <span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">notification</span> <span class="nx">domain</span><span class="p">.</span><span class="nx">Notification</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Lock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Unlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">notifications</span><span class="p">[</span><span class="nx">notification</span><span class="p">.</span><span class="nx">ID</span><span class="p">]</span> <span class="p">=</span> <span class="nx">notification</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">NotificationRepository</span><span class="p">)</span> <span class="nf">FindByID</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">domain</span><span class="p">.</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RLock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RUnlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="nx">notification</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">notifications</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">return</span> <span class="nx">notification</span><span class="p">,</span> <span class="nx">ok</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>adapter import application 不一定必要，因為 Go implicit interface 不要求顯式宣告。只要 method set 符合，組裝時就能傳給 usecase。</p>
<h2 id="策略usecase-是遷移中心">【策略】usecase 是遷移中心</h2>
<p>usecase 的核心角色是協調 domain 規則與 ports。它不處理 HTTP，也不操作具體資料庫。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">package</span> <span class="nx">application</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kd">type</span> <span class="nx">CreateNotificationUsecase</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">repository</span> <span class="nx">NotificationRepository</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">eventLog</span>   <span class="nx">EventLog</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="kd">func</span> <span class="nf">NewCreateNotificationUsecase</span><span class="p">(</span><span class="nx">repository</span> <span class="nx">NotificationRepository</span><span class="p">,</span> <span class="nx">eventLog</span> <span class="nx">EventLog</span><span class="p">)</span> <span class="o">*</span><span class="nx">CreateNotificationUsecase</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">return</span> <span class="o">&amp;</span><span class="nx">CreateNotificationUsecase</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">repository</span><span class="p">:</span> <span class="nx">repository</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">eventLog</span><span class="p">:</span>   <span class="nx">eventLog</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">u</span> <span class="o">*</span><span class="nx">CreateNotificationUsecase</span><span class="p">)</span> <span class="nf">Execute</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">cmd</span> <span class="nx">CreateNotificationCommand</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">notification</span> <span class="o">:=</span> <span class="nx">domain</span><span class="p">.</span><span class="nx">Notification</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>        <span class="nx">cmd</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span>     <span class="nx">cmd</span><span class="p">.</span><span class="nx">Topic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span>     <span class="nx">cmd</span><span class="p">.</span><span class="nx">Title</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">cmd</span><span class="p">.</span><span class="nx">CreatedAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">u</span><span class="p">.</span><span class="nx">repository</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">notification</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;save notification: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="nx">event</span> <span class="o">:=</span> <span class="nx">domain</span><span class="p">.</span><span class="nf">NewNotificationCreated</span><span class="p">(</span><span class="nx">notification</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">u</span><span class="p">.</span><span class="nx">eventLog</span><span class="p">.</span><span class="nf">Append</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;append event: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">
</span></span><span class="line"><span class="ln">32</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 usecase 只依賴 domain 與 ports。HTTP handler、WebSocket router、memory repository、[event <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>](/go/backend/knowledge-cards/event-log) implementation 都在外面。</p>
<h2 id="執行組裝放在-main-或-composition-root">【執行】組裝放在 main 或 composition root</h2>
<p>composition root 的核心責任是建立 concrete implementation，並把它們接到 usecase 與 adapter。Go 專案常把這件事放在 <code>main.go</code> 或 <code>cmd/.../main.go</code>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">notificationRepo</span> <span class="o">:=</span> <span class="nx">memory</span><span class="p">.</span><span class="nf">NewNotificationRepository</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">eventLog</span> <span class="o">:=</span> <span class="nx">memory</span><span class="p">.</span><span class="nf">NewEventLog</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">createNotification</span> <span class="o">:=</span> <span class="nx">application</span><span class="p">.</span><span class="nf">NewCreateNotificationUsecase</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">notificationRepo</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">eventLog</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">handler</span> <span class="o">:=</span> <span class="nx">httpadapter</span><span class="p">.</span><span class="nf">NewNotificationHandler</span><span class="p">(</span><span class="nx">createNotification</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Now</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">mux</span> <span class="o">:=</span> <span class="nx">http</span><span class="p">.</span><span class="nf">NewServeMux</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">mux</span><span class="p">.</span><span class="nf">HandleFunc</span><span class="p">(</span><span class="s">&#34;POST /notifications&#34;</span><span class="p">,</span> <span class="nx">handler</span><span class="p">.</span><span class="nx">Create</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">server</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">http</span><span class="p">.</span><span class="nx">Server</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="nx">Addr</span><span class="p">:</span>    <span class="s">&#34;:8080&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="nx">Handler</span><span class="p">:</span> <span class="nx">mux</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">server</span><span class="p">.</span><span class="nf">ListenAndServe</span><span class="p">();</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="nx">log</span><span class="p">.</span><span class="nf">Fatal</span><span class="p">(</span><span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>main 可以知道所有具體實作，因為它負責組裝。這不違反依賴方向；問題是 application 或 domain 不能反過來 import main、HTTP adapter 或 memory adapter。</p>
<h2 id="策略新功能先走新架構">【策略】新功能先走新架構</h2>
<p>漸進式遷移的核心策略是新功能先走新邊界，舊功能在被修改時再搬。一次性大重構風險高，容易同時改壞行為與結構。</p>
<p>建議做法：</p>
<ul>
<li>新 endpoint 直接建立 command/usecase。</li>
<li>新 repository 先定義小 port。</li>
<li>新 event flow 先走 <code>DomainEvent</code> 與 processor。</li>
<li>舊 handler 只有在新增需求或修 bug 時才拆。</li>
<li>保留舊路徑測試，搬移完成再刪掉。</li>
</ul>
<p>這樣可以讓新架構逐步長出來，而不是一次強迫整個專案符合模板。</p>
<h2 id="執行從-callback-ingestion-開始切">【執行】從 callback ingestion 開始切</h2>
<p>以外部 callback 進入事件系統為例，application usecase 可以叫 <code>IngestExternalEvent</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">package</span> <span class="nx">application</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kd">type</span> <span class="nx">EventLog</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nf">Append</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">domain</span><span class="p">.</span><span class="nx">Event</span><span class="p">)</span> <span class="kt">error</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="kd">type</span> <span class="nx">EventProcessor</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nf">Process</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">domain</span><span class="p">.</span><span class="nx">Event</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="kd">type</span> <span class="nx">IngestExternalEvent</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">eventLog</span>  <span class="nx">EventLog</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">processor</span> <span class="nx">EventProcessor</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">u</span> <span class="o">*</span><span class="nx">IngestExternalEvent</span><span class="p">)</span> <span class="nf">Execute</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">domain</span><span class="p">.</span><span class="nx">Event</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">event</span><span class="p">.</span><span class="nf">Validate</span><span class="p">();</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;validate event: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">u</span><span class="p">.</span><span class="nx">eventLog</span><span class="p">.</span><span class="nf">Append</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;append event: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">u</span><span class="p">.</span><span class="nx">processor</span><span class="p">.</span><span class="nf">Process</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;process event: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>callback adapter 只負責 raw input 轉 domain event：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="nx">CallbackHandler</span><span class="p">)</span> <span class="nf">ServeHTTP</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="kd">var</span> <span class="nx">raw</span> <span class="nx">RawNotificationCallback</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">NewDecoder</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">).</span><span class="nf">Decode</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">raw</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">,</span> <span class="s">&#34;invalid_json&#34;</span><span class="p">,</span> <span class="s">&#34;invalid JSON&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">event</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">NormalizeNotificationCallback</span><span class="p">(</span><span class="nx">raw</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">,</span> <span class="s">&#34;invalid_payload&#34;</span><span class="p">,</span> <span class="s">&#34;invalid callback payload&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">h</span><span class="p">.</span><span class="nx">ingest</span><span class="p">.</span><span class="nf">Execute</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nf">Context</span><span class="p">(),</span> <span class="nx">event</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusInternalServerError</span><span class="p">,</span> <span class="s">&#34;ingest_failed&#34;</span><span class="p">,</span> <span class="s">&#34;event ingest failed&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusAccepted</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個切法讓 callback 格式停在 adapter，event log 與 processor 行為停在 application。</p>
<h2 id="判讀websocket-adapter-也是-inbound-adapter">【判讀】WebSocket adapter 也是 inbound adapter</h2>
<p>WebSocket adapter 的核心責任是把 client message 轉成 command。它不應直接知道 repository 或 event log implementation。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">WebSocketAdapter</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">router</span> <span class="o">*</span><span class="nx">MessageRouter</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="kd">type</span> <span class="nx">MessageRouter</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">subscriptions</span> <span class="nx">application</span><span class="p">.</span><span class="nx">SubscriptionUsecase</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>router 可以呼叫 application usecase：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">MessageRouter</span><span class="p">)</span> <span class="nf">handleSubscribeTopic</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">clientID</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">req</span> <span class="nx">SubscribeTopicRequest</span><span class="p">)</span> <span class="nx">ServerMessage</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">cmd</span> <span class="o">:=</span> <span class="nx">application</span><span class="p">.</span><span class="nx">SubscribeTopicCommand</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">ClientID</span><span class="p">:</span>       <span class="nx">clientID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span>          <span class="nx">req</span><span class="p">.</span><span class="nx">Topic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">IncludeHistory</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">IncludeHistory</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">subscriptions</span><span class="p">.</span><span class="nf">SubscribeTopic</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">cmd</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">return</span> <span class="nf">ErrorMessage</span><span class="p">(</span><span class="s">&#34;&#34;</span><span class="p">,</span> <span class="s">&#34;subscribe_failed&#34;</span><span class="p">,</span> <span class="s">&#34;topic subscription failed&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">return</span> <span class="nf">OKMessage</span><span class="p">(</span><span class="s">&#34;&#34;</span><span class="p">,</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;topic&#34;</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">Topic</span><span class="p">})</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這和 HTTP handler 的方向相同：adapter 處理協定，application 處理行為。</p>
<h2 id="執行驗證-import-direction">【執行】驗證 import direction</h2>
<p>架構邊界的核心驗證是 import direction。即使沒有工具，也可以用簡單規則檢查：</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">domain       不 import application、transport、storage
</span></span><span class="line"><span class="ln">2</span><span class="cl">application  可以 import domain，不 import transport/storage implementation
</span></span><span class="line"><span class="ln">3</span><span class="cl">transport    可以 import application/domain
</span></span><span class="line"><span class="ln">4</span><span class="cl">storage      可以 import application/domain
</span></span><span class="line"><span class="ln">5</span><span class="cl">cmd/main     可以 import 所有 adapter 與 application 做組裝</span></span></code></pre></div><p>可以用 <code>go list</code> 觀察 package 依賴：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">go list -deps ./...</span></span></code></pre></div><p>也可以在 review 時檢查：如果 <code>domain/job</code> import 了 <code>net/http</code>，幾乎一定是邊界錯了；如果 <code>application</code> import 了 <code>storage/memory</code>，則 usecase 已經依賴 implementation。</p>
<h2 id="執行usecase-test-與-adapter-integration-test-分工">【執行】usecase test 與 adapter integration test 分工</h2>
<p>測試分工的核心原則是 usecase 測規則，adapter 測協定轉換與組裝。不要只靠端到端測試保護所有行為。</p>
<p>usecase test：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestIngestExternalEventAppendsAndProcesses</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">eventLog</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">fakeEventLog</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">processor</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">fakeEventProcessor</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">usecase</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">application</span><span class="p">.</span><span class="nx">IngestExternalEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">eventLog</span><span class="p">:</span>  <span class="nx">eventLog</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">processor</span><span class="p">:</span> <span class="nx">processor</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">event</span> <span class="o">:=</span> <span class="nf">validDomainEvent</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">usecase</span><span class="p">.</span><span class="nf">Execute</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">(),</span> <span class="nx">event</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;ingest event: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">eventLog</span><span class="p">.</span><span class="nx">appended</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">1</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;appended events = %d, want 1&#34;</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">eventLog</span><span class="p">.</span><span class="nx">appended</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">processor</span><span class="p">.</span><span class="nx">processed</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">1</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;processed events = %d, want 1&#34;</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">processor</span><span class="p">.</span><span class="nx">processed</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>adapter integration test 則可以用 <code>httptest</code> 驗證 request/response 與 usecase fake 是否被呼叫。兩種測試分工清楚，失敗時才知道是規則錯還是協定轉換錯。</p>
<h2 id="重構步驟">重構步驟</h2>
<p>逐步遷移到 ports/adapters，可以按這個順序：</p>
<ol>
<li>先找一條最痛的功能路徑，例如新增 notification 或 ingest external event。</li>
<li>把 handler/router 中的規則抽成 command/usecase。</li>
<li>在 application 定義 repository、event log、publisher port。</li>
<li>讓現有 memory store 或 publisher 實作 port。</li>
<li>main 組裝 concrete adapter 與 usecase。</li>
<li>新功能只走新路徑。</li>
<li>舊功能被修改時，逐步搬到同樣邊界。</li>
<li>用 import direction review 防止反向依賴。</li>
</ol>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="檢查一遷移從單一邊界開始">檢查一：遷移從單一邊界開始</h3>
<p>ports/adapters 的價值是依賴方向。若沒有先拆 usecase 與 port，只是搬資料夾，複雜度會上升但邊界不會變清楚。</p>
<h3 id="檢查二application-依賴-port">檢查二：application 依賴 port</h3>
<p>application 應依賴 port，不依賴 memory、SQLite 或 database adapter。若 application import storage，依賴方向已經反了。</p>
<h3 id="檢查三業務規則留在-application-或-domain">檢查三：業務規則留在 application 或 domain</h3>
<p>adapter 可以驗證輸入格式，但業務規則應該在 usecase 或 domain。否則 HTTP 與 WebSocket 會各自複製規則。</p>
<h3 id="檢查四port-跟著使用端分散">檢查四：port 跟著使用端分散</h3>
<p>port 應靠近使用端。把所有 interface 集中到一個大型 package，常會讓依賴重新糾纏在一起。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理 ports/adapters 的依賴方向；分散式系統、資料庫與平台 wiring，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/" data-link-title="模組七：跨節點與平台整合" data-link-desc="把單一 Go 服務延伸到資料庫、queue、跨節點 WebSocket、可觀測性與部署平台">Go 進階：跨節點與平台整合</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">Go 進階：資料庫 transaction 與 schema migration</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Go 進階：Durable queue、outbox 與 idempotency</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">Go 進階：Kubernetes、systemd 與 load balancer 合約</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 handler、repository、event 與 composition root 的遷移路線；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">Go：把 handler 邏輯拆成可測單元</a></li>
<li><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
<li><a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go：如何新增 repository port</a></li>
<li><a href="/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">Go：composition root 與依賴組裝</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>ports/adapters 遷移的重點是控制依賴方向：adapter 處理外部技術，application 定義 usecase 與 ports，domain 保存核心語意。Go 專案可以漸進式遷移，新功能先走清楚邊界，舊功能在修改時再搬。架構的價值在於測試更直接、替換更容易、核心規則不被外部技術綁住。</p>
]]></content:encoded></item><item><title>6.7 Go 常見服務場景總覽</title><link>https://tarrragon.github.io/blog/go/06-practical/service-scenarios/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/06-practical/service-scenarios/</guid><description>&lt;p>這一章先整理 Go 常見會被用到的服務場景。對剛從 PHP 或 Python 轉來的讀者來說，先知道 Go 最常做哪些事，會更容易理解前面那些語言特性為什麼重要。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>看出 Go 常見的服務落點&lt;/li>
&lt;li>理解每種場景通常需要哪些核心能力&lt;/li>
&lt;li>判斷哪些場景值得進一步用 Go 實作&lt;/li>
&lt;li>把後續的實戰章節放回正確脈絡&lt;/li>
&lt;li>分辨即時服務、背景 worker 與 API service 的不同責任&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察go-很常出現在服務邊界">【觀察】Go 很常出現在服務邊界&lt;/h2>
&lt;p>Go 最常出現在需要長時間運行、協調 I/O、維持清楚邊界的服務層：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>場景&lt;/th>
 &lt;th>Go 常扮演的角色&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> service&lt;/td>
 &lt;td>長連線、事件傳遞、訂閱與廣播&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>background worker&lt;/td>
 &lt;td>背景處理、批次同步、事件消費&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>API gateway&lt;/td>
 &lt;td>路由、聚合、驗證與轉送&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>notification system&lt;/td>
 &lt;td>推播、排程與重試&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>event processor&lt;/td>
 &lt;td>事件解碼、去重、派送與狀態更新&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這些場景共通點都是：工作量大多是 I/O 與協調，CPU 單點重運算通常只占一小部分。&lt;/p>
&lt;h2 id="判讀即時服務需要穩定的生命週期">【判讀】即時服務需要穩定的生命週期&lt;/h2>
&lt;p>WebSocket、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/sse/" data-link-title="Server-Sent Events (SSE)" data-link-desc="說明 SSE 如何透過 HTTP 長連線向 client 單向推送事件">SSE&lt;/a> 或其他長連線服務，通常需要處理：&lt;/p>
&lt;ul>
&lt;li>連線建立與關閉&lt;/li>
&lt;li>heartbeat&lt;/li>
&lt;li>事件路由&lt;/li>
&lt;li>慢 client&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>這些都和 goroutine、channel、context、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a> 直接相關，所以 Go 很自然會出現在這裡。&lt;/p>
&lt;h2 id="判讀背景-worker-需要明確的停止條件">【判讀】背景 worker 需要明確的停止條件&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer&lt;/a>、cron worker、event relay 或同步流程，通常都需要：&lt;/p>
&lt;ul>
&lt;li>可取消&lt;/li>
&lt;li>可重試&lt;/li>
&lt;li>可觀測&lt;/li>
&lt;li>可限流&lt;/li>
&lt;/ul>
&lt;p>這類工作很適合 Go，因為 runtime、context 與標準庫已經把這些邊界鋪好了。&lt;/p>
&lt;h2 id="策略api-聚合與服務編排">【策略】API 聚合與服務編排&lt;/h2>
&lt;p>有些 Go 服務的主要角色是協調與整合：&lt;/p>
&lt;ul>
&lt;li>把多個下游資料聚合成一個 response&lt;/li>
&lt;li>在多個服務之間轉送 command&lt;/li>
&lt;li>在進入 domain 前先做 request normalization&lt;/li>
&lt;/ul>
&lt;p>這類工作通常會出現在微服務環境裡，Go 的清楚邊界與簡單部署會很有價值。&lt;/p>
&lt;h2 id="小結">小結&lt;/h2>
&lt;p>Go 的常見場景很少是「單純做一個頁面」，更多是即時、背景、事件、聚合與服務邊界。先知道 Go 常被放在哪裡，後面的實戰章節就會更容易理解。&lt;/p></description><content:encoded><![CDATA[<p>這一章先整理 Go 常見會被用到的服務場景。對剛從 PHP 或 Python 轉來的讀者來說，先知道 Go 最常做哪些事，會更容易理解前面那些語言特性為什麼重要。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>看出 Go 常見的服務落點</li>
<li>理解每種場景通常需要哪些核心能力</li>
<li>判斷哪些場景值得進一步用 Go 實作</li>
<li>把後續的實戰章節放回正確脈絡</li>
<li>分辨即時服務、背景 worker 與 API service 的不同責任</li>
</ol>
<hr>
<h2 id="觀察go-很常出現在服務邊界">【觀察】Go 很常出現在服務邊界</h2>
<p>Go 最常出現在需要長時間運行、協調 I/O、維持清楚邊界的服務層：</p>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>Go 常扮演的角色</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> service</td>
          <td>長連線、事件傳遞、訂閱與廣播</td>
      </tr>
      <tr>
          <td>background worker</td>
          <td>背景處理、批次同步、事件消費</td>
      </tr>
      <tr>
          <td>API gateway</td>
          <td>路由、聚合、驗證與轉送</td>
      </tr>
      <tr>
          <td>notification system</td>
          <td>推播、排程與重試</td>
      </tr>
      <tr>
          <td>event processor</td>
          <td>事件解碼、去重、派送與狀態更新</td>
      </tr>
  </tbody>
</table>
<p>這些場景共通點都是：工作量大多是 I/O 與協調，CPU 單點重運算通常只占一小部分。</p>
<h2 id="判讀即時服務需要穩定的生命週期">【判讀】即時服務需要穩定的生命週期</h2>
<p>WebSocket、<a href="/blog/backend/knowledge-cards/sse/" data-link-title="Server-Sent Events (SSE)" data-link-desc="說明 SSE 如何透過 HTTP 長連線向 client 單向推送事件">SSE</a> 或其他長連線服務，通常需要處理：</p>
<ul>
<li>連線建立與關閉</li>
<li>heartbeat</li>
<li>事件路由</li>
<li>慢 client</li>
<li><a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a></li>
</ul>
<p>這些都和 goroutine、channel、context、<a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a> 直接相關，所以 Go 很自然會出現在這裡。</p>
<h2 id="判讀背景-worker-需要明確的停止條件">【判讀】背景 worker 需要明確的停止條件</h2>
<p><a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a>、cron worker、event relay 或同步流程，通常都需要：</p>
<ul>
<li>可取消</li>
<li>可重試</li>
<li>可觀測</li>
<li>可限流</li>
</ul>
<p>這類工作很適合 Go，因為 runtime、context 與標準庫已經把這些邊界鋪好了。</p>
<h2 id="策略api-聚合與服務編排">【策略】API 聚合與服務編排</h2>
<p>有些 Go 服務的主要角色是協調與整合：</p>
<ul>
<li>把多個下游資料聚合成一個 response</li>
<li>在多個服務之間轉送 command</li>
<li>在進入 domain 前先做 request normalization</li>
</ul>
<p>這類工作通常會出現在微服務環境裡，Go 的清楚邊界與簡單部署會很有價值。</p>
<h2 id="小結">小結</h2>
<p>Go 的常見場景很少是「單純做一個頁面」，更多是即時、背景、事件、聚合與服務邊界。先知道 Go 常被放在哪裡，後面的實戰章節就會更容易理解。</p>
]]></content:encoded></item><item><title>1.7 從入口程式看應用啟動流程</title><link>https://tarrragon.github.io/blog/go/01-basics/main-flow/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/01-basics/main-flow/</guid><description>&lt;p>入口程式是 Go 應用的系統地圖。它不一定包含最多細節，但應該讓你知道 process 如何初始化、哪些 goroutine 會啟動、HTTP endpoint 如何註冊，以及程式如何關閉。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>用啟動流程理解 Go 應用結構&lt;/li>
&lt;li>看懂 channel 與元件之間的資料流&lt;/li>
&lt;li>理解 &lt;code>context.WithCancel&lt;/code> 在關閉流程中的角色&lt;/li>
&lt;li>判斷新增功能應該接在哪個生命週期位置&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察入口流程分成五段">【觀察】入口流程分成五段&lt;/h2>
&lt;p>入口程式的核心責任是揭露應用如何啟動，而不是承載所有實作細節。一個稍具規模的 &lt;code>main()&lt;/code> 可以切成五個區塊：&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>Runtime 與日誌設定&lt;/td>
 &lt;td>設定記憶體限制、初始化 &lt;code>slog&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>環境設定&lt;/td>
 &lt;td>讀取設定檔、環境變數與 port&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>元件組裝&lt;/td>
 &lt;td>建立 repository、worker、service 或 server&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>背景工作&lt;/td>
 &lt;td>啟動 worker、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer&lt;/a> 或定時任務&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>對外介面&lt;/td>
 &lt;td>註冊 CLI command、HTTP endpoint 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> route&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這種切法讓入口同時保留完整脈絡，又不把所有實作細節塞進 &lt;code>main()&lt;/code>。&lt;/p>
&lt;h2 id="判讀main-的價值是揭露依賴關係">【判讀】&lt;code>main()&lt;/code> 的價值是揭露依賴關係&lt;/h2>
&lt;p>&lt;code>main()&lt;/code> 的核心價值是讓依賴關係可見。Go 專案常把依賴直接組裝在 &lt;code>main()&lt;/code>，好處是維護者能直接看到應用骨架：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">events&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="nx">Event&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">128&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nx">notifications&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="nx">Notification&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">128&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>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="nx">repo&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewNotificationRepository&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="nx">worker&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewWorker&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">repo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">events&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">notifications&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="nx">server&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewHTTPServer&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">repo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">notifications&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式揭露一個重要事實：&lt;code>repo&lt;/code> 負責保存共享狀態，&lt;code>worker&lt;/code> 負責處理背景事件，&lt;code>server&lt;/code> 負責提供 HTTP 入口。資料如何流動，不需要先查框架設定就能看懂。&lt;/p>
&lt;h2 id="策略用生命週期判斷功能應該放哪裡">【策略】用生命週期判斷功能應該放哪裡&lt;/h2>
&lt;p>新增功能的核心判斷是：先確認它屬於哪一種生命週期，再決定接入位置。新增功能時，先判斷它屬於哪一種生命週期：&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>新 HTTP endpoint&lt;/td>
 &lt;td>入口程式註冊 route，實作獨立 handler&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>新背景事件來源&lt;/td>
 &lt;td>新增 channel、worker 或 queue consumer&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>新即時訊息 action&lt;/td>
 &lt;td>message router 或連線管理元件&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>新狀態欄位&lt;/td>
 &lt;td>repository 更新與 model 擴展&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>新診斷能力&lt;/td>
 &lt;td>條件註冊 endpoint 或 &lt;code>slog&lt;/code> 欄位&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這個判斷可以避免把功能塞進錯誤元件，造成後續難測與難改。&lt;/p>
&lt;h2 id="執行完整啟動路徑">【執行】完整啟動路徑&lt;/h2>
&lt;p>啟動路徑的核心用途是提供除錯地圖。一個有背景工作與 HTTP 介面的 Go 應用，啟動後的主要路徑可能如下：&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">main()
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> ├─ setup logger / runtime
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> ├─ create channels
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> ├─ create repository / worker / server
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> ├─ context.WithCancel()
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> ├─ go worker.Run(ctx)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> ├─ go startPeriodicSync(ctx)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> ├─ go server.Run(ctx)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> ├─ register /health
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> ├─ register /ws
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> ├─ register /events
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> ├─ go waitForShutdown(cancel, ...)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> └─ http.Server.ListenAndServe()&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這份路徑也是除錯清單。當應用沒有產生預期輸出時，可以依序確認：輸入來源是否產生資料、worker 是否處理資料、對外介面是否有 client 或呼叫者、狀態資料是否被正確更新。&lt;/p></description><content:encoded><![CDATA[<p>入口程式是 Go 應用的系統地圖。它不一定包含最多細節，但應該讓你知道 process 如何初始化、哪些 goroutine 會啟動、HTTP endpoint 如何註冊，以及程式如何關閉。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>用啟動流程理解 Go 應用結構</li>
<li>看懂 channel 與元件之間的資料流</li>
<li>理解 <code>context.WithCancel</code> 在關閉流程中的角色</li>
<li>判斷新增功能應該接在哪個生命週期位置</li>
</ol>
<hr>
<h2 id="觀察入口流程分成五段">【觀察】入口流程分成五段</h2>
<p>入口程式的核心責任是揭露應用如何啟動，而不是承載所有實作細節。一個稍具規模的 <code>main()</code> 可以切成五個區塊：</p>
<table>
  <thead>
      <tr>
          <th>區塊</th>
          <th>責任</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Runtime 與日誌設定</td>
          <td>設定記憶體限制、初始化 <code>slog</code></td>
      </tr>
      <tr>
          <td>環境設定</td>
          <td>讀取設定檔、環境變數與 port</td>
      </tr>
      <tr>
          <td>元件組裝</td>
          <td>建立 repository、worker、service 或 server</td>
      </tr>
      <tr>
          <td>背景工作</td>
          <td>啟動 worker、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a> 或定時任務</td>
      </tr>
      <tr>
          <td>對外介面</td>
          <td>註冊 CLI command、HTTP endpoint 或 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> route</td>
      </tr>
  </tbody>
</table>
<p>這種切法讓入口同時保留完整脈絡，又不把所有實作細節塞進 <code>main()</code>。</p>
<h2 id="判讀main-的價值是揭露依賴關係">【判讀】<code>main()</code> 的價值是揭露依賴關係</h2>
<p><code>main()</code> 的核心價值是讓依賴關係可見。Go 專案常把依賴直接組裝在 <code>main()</code>，好處是維護者能直接看到應用骨架：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">events</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">Event</span><span class="p">,</span> <span class="mi">128</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">notifications</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">Notification</span><span class="p">,</span> <span class="mi">128</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nx">repo</span> <span class="o">:=</span> <span class="nf">NewNotificationRepository</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nx">worker</span> <span class="o">:=</span> <span class="nf">NewWorker</span><span class="p">(</span><span class="nx">repo</span><span class="p">,</span> <span class="nx">events</span><span class="p">,</span> <span class="nx">notifications</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nx">server</span> <span class="o">:=</span> <span class="nf">NewHTTPServer</span><span class="p">(</span><span class="nx">repo</span><span class="p">,</span> <span class="nx">notifications</span><span class="p">)</span></span></span></code></pre></div><p>這段程式揭露一個重要事實：<code>repo</code> 負責保存共享狀態，<code>worker</code> 負責處理背景事件，<code>server</code> 負責提供 HTTP 入口。資料如何流動，不需要先查框架設定就能看懂。</p>
<h2 id="策略用生命週期判斷功能應該放哪裡">【策略】用生命週期判斷功能應該放哪裡</h2>
<p>新增功能的核心判斷是：先確認它屬於哪一種生命週期，再決定接入位置。新增功能時，先判斷它屬於哪一種生命週期：</p>
<table>
  <thead>
      <tr>
          <th>新功能類型</th>
          <th>接入位置</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>新 HTTP endpoint</td>
          <td>入口程式註冊 route，實作獨立 handler</td>
      </tr>
      <tr>
          <td>新背景事件來源</td>
          <td>新增 channel、worker 或 queue consumer</td>
      </tr>
      <tr>
          <td>新即時訊息 action</td>
          <td>message router 或連線管理元件</td>
      </tr>
      <tr>
          <td>新狀態欄位</td>
          <td>repository 更新與 model 擴展</td>
      </tr>
      <tr>
          <td>新診斷能力</td>
          <td>條件註冊 endpoint 或 <code>slog</code> 欄位</td>
      </tr>
  </tbody>
</table>
<p>這個判斷可以避免把功能塞進錯誤元件，造成後續難測與難改。</p>
<h2 id="執行完整啟動路徑">【執行】完整啟動路徑</h2>
<p>啟動路徑的核心用途是提供除錯地圖。一個有背景工作與 HTTP 介面的 Go 應用，啟動後的主要路徑可能如下：</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">main()
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  ├─ setup logger / runtime
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  ├─ create channels
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  ├─ create repository / worker / server
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  ├─ context.WithCancel()
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  ├─ go worker.Run(ctx)
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  ├─ go startPeriodicSync(ctx)
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  ├─ go server.Run(ctx)
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  ├─ register /health
</span></span><span class="line"><span class="ln">10</span><span class="cl">  ├─ register /ws
</span></span><span class="line"><span class="ln">11</span><span class="cl">  ├─ register /events
</span></span><span class="line"><span class="ln">12</span><span class="cl">  ├─ go waitForShutdown(cancel, ...)
</span></span><span class="line"><span class="ln">13</span><span class="cl">  └─ http.Server.ListenAndServe()</span></span></code></pre></div><p>這份路徑也是除錯清單。當應用沒有產生預期輸出時，可以依序確認：輸入來源是否產生資料、worker 是否處理資料、對外介面是否有 client 或呼叫者、狀態資料是否被正確更新。</p>
]]></content:encoded></item><item><title>7.7 composition root 與依賴組裝</title><link>https://tarrragon.github.io/blog/go/07-refactoring/composition-root/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/07-refactoring/composition-root/</guid><description>&lt;p>composition root 的核心責任是集中建立具體依賴。domain 與 application 應依賴 port；&lt;code>main&lt;/code> 或啟動層負責讀取 config、建立 adapter、組裝 usecase、註冊 handler 與啟動 server。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>理解 composition root 為什麼要集中在啟動層&lt;/li>
&lt;li>分辨 port、adapter 與 usecase 的組裝責任&lt;/li>
&lt;li>用 typed config 讓 wiring 依賴可讀、可測、可替換&lt;/li>
&lt;li>看懂哪些依賴應在 &lt;code>main&lt;/code> 組裝，哪些不該散在 handler 裡&lt;/li>
&lt;li>讓啟動層只負責「把系統接起來」，不負責業務規則&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察composition-root-是整個應用的接線板">【觀察】composition root 是整個應用的接線板&lt;/h2>
&lt;p>composition root 的核心用途是把具體依賴集中在一個地方建立。這個地方通常是 &lt;code>main()&lt;/code>、&lt;code>cmd/.../main.go&lt;/code> 或啟動層 package。&lt;/p>
&lt;p>當讀者打開入口程式時，應該能直接看到：&lt;/p>
&lt;ul>
&lt;li>config 從哪裡來&lt;/li>
&lt;li>repository 怎麼建立&lt;/li>
&lt;li>publisher / worker / server 怎麼串起來&lt;/li>
&lt;li>哪些 dependency 是 mockable port&lt;/li>
&lt;li>哪些是明確的外部 adapter&lt;/li>
&lt;/ul>
&lt;p>這種集中式 wiring 的好處是：&lt;/p>
&lt;ul>
&lt;li>依賴方向清楚&lt;/li>
&lt;li>測試替身好替換&lt;/li>
&lt;li>啟動問題容易定位&lt;/li>
&lt;li>不會把建構邏輯散落到各個 handler 或 usecase&lt;/li>
&lt;/ul>
&lt;h2 id="判讀dependency-injection-的重點是方向">【判讀】dependency injection 的重點是方向&lt;/h2>
&lt;p>Go 的依賴注入通常不需要框架。真正的重點是：高層只依賴 port，低層在入口被組裝進來。&lt;/p>
&lt;p>例如：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">App&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">jobs&lt;/span> &lt;span class="nx">JobRepository&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">log&lt;/span> &lt;span class="nx">EventLog&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;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">NewApp&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">jobs&lt;/span> &lt;span class="nx">JobRepository&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">log&lt;/span> &lt;span class="nx">EventLog&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">App&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">App&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">jobs&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">jobs&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">log&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">log&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>main()&lt;/code> 負責建立具體實作，再傳給 &lt;code>NewApp&lt;/code>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">main&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">cfg&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">LoadConfig&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">repo&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewSQLJobRepository&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">cfg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">DatabaseDSN&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="nx">eventLog&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewRedisEventLog&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">cfg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">RedisAddr&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">app&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewApp&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">repo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">eventLog&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">server&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewHTTPServer&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">app&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="nx">log&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">server&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ListenAndServe&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;code>App&lt;/code> 不知道 SQL 或 Redis 是怎麼接的。&lt;/p>
&lt;h2 id="策略typed-config-先收斂設定再進行組裝">【策略】typed config 先收斂設定，再進行組裝&lt;/h2>
&lt;p>composition root 會變亂，通常是因為設定沒有先整理成型別清楚的 config。把環境變數、flag 與預設值先集中讀成結構體，wiring 會清楚很多。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Config&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">HTTPAddr&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">DatabaseDSN&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">RedisAddr&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>load config 的責任是把外部輸入變成可預期的程式設定，而不是在每個 adapter 初始化時各自讀環境變數。&lt;/p>
&lt;h2 id="執行建立-adapter-後再注入-usecase">【執行】建立 adapter 後再注入 usecase&lt;/h2>
&lt;p>常見的組裝順序是：&lt;/p>
&lt;ol>
&lt;li>讀 config。&lt;/li>
&lt;li>建立 logger / &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics&lt;/a> / tracer。&lt;/li>
&lt;li>建立 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database&lt;/a> / cache / &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker&lt;/a> client。&lt;/li>
&lt;li>建立 repository 與 service。&lt;/li>
&lt;li>建立 handler 或 server。&lt;/li>
&lt;li>啟動背景 worker 與 HTTP server。&lt;/li>
&lt;/ol>
&lt;p>這樣做可以讓初始化失敗在入口層就被看見，不會等到請求進來才爆。&lt;/p>
&lt;h2 id="判讀組裝邏輯應集中在入口層">【判讀】組裝邏輯應集中在入口層&lt;/h2>
&lt;p>如果 handler 自己 new repository、new client、new worker，就會出現這些問題：&lt;/p>
&lt;ul>
&lt;li>測試無法替換依賴&lt;/li>
&lt;li>生命週期很難控制&lt;/li>
&lt;li>每個 request 都可能建立不必要的資源&lt;/li>
&lt;li>啟動路徑與請求路徑混在一起&lt;/li>
&lt;/ul>
&lt;p>handler 應該只接收已組裝好的依賴，專心處理輸入和回應。&lt;/p>
&lt;h2 id="延伸backend-教材負責具體外部服務語意">【延伸】Backend 教材負責具體外部服務語意&lt;/h2>
&lt;p>Go 章節只需要知道依賴怎麼接，真正的外部服務語意留給 Backend 教材：&lt;/p>
&lt;ul>
&lt;li>database client 建立、pool 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction&lt;/a> 語意&lt;/li>
&lt;li>Redis client、pipeline 與 cache 邊界&lt;/li>
&lt;li>broker connection、[durable &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a>](/go/backend/knowledge-cards/durable-queue) 與重試&lt;/li>
&lt;li>platform secret、runtime limit 與部署環境&lt;/li>
&lt;/ul>
&lt;p>Go 的 composition root 不需要重複教這些技術，只要把它們正確接上即可。&lt;/p></description><content:encoded><![CDATA[<p>composition root 的核心責任是集中建立具體依賴。domain 與 application 應依賴 port；<code>main</code> 或啟動層負責讀取 config、建立 adapter、組裝 usecase、註冊 handler 與啟動 server。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>理解 composition root 為什麼要集中在啟動層</li>
<li>分辨 port、adapter 與 usecase 的組裝責任</li>
<li>用 typed config 讓 wiring 依賴可讀、可測、可替換</li>
<li>看懂哪些依賴應在 <code>main</code> 組裝，哪些不該散在 handler 裡</li>
<li>讓啟動層只負責「把系統接起來」，不負責業務規則</li>
</ol>
<hr>
<h2 id="觀察composition-root-是整個應用的接線板">【觀察】composition root 是整個應用的接線板</h2>
<p>composition root 的核心用途是把具體依賴集中在一個地方建立。這個地方通常是 <code>main()</code>、<code>cmd/.../main.go</code> 或啟動層 package。</p>
<p>當讀者打開入口程式時，應該能直接看到：</p>
<ul>
<li>config 從哪裡來</li>
<li>repository 怎麼建立</li>
<li>publisher / worker / server 怎麼串起來</li>
<li>哪些 dependency 是 mockable port</li>
<li>哪些是明確的外部 adapter</li>
</ul>
<p>這種集中式 wiring 的好處是：</p>
<ul>
<li>依賴方向清楚</li>
<li>測試替身好替換</li>
<li>啟動問題容易定位</li>
<li>不會把建構邏輯散落到各個 handler 或 usecase</li>
</ul>
<h2 id="判讀dependency-injection-的重點是方向">【判讀】dependency injection 的重點是方向</h2>
<p>Go 的依賴注入通常不需要框架。真正的重點是：高層只依賴 port，低層在入口被組裝進來。</p>
<p>例如：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">App</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">jobs</span> <span class="nx">JobRepository</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">log</span>  <span class="nx">EventLog</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="kd">func</span> <span class="nf">NewApp</span><span class="p">(</span><span class="nx">jobs</span> <span class="nx">JobRepository</span><span class="p">,</span> <span class="nx">log</span> <span class="nx">EventLog</span><span class="p">)</span> <span class="o">*</span><span class="nx">App</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="k">return</span> <span class="o">&amp;</span><span class="nx">App</span><span class="p">{</span><span class="nx">jobs</span><span class="p">:</span> <span class="nx">jobs</span><span class="p">,</span> <span class="nx">log</span><span class="p">:</span> <span class="nx">log</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>main()</code> 負責建立具體實作，再傳給 <code>NewApp</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">cfg</span> <span class="o">:=</span> <span class="nf">LoadConfig</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">repo</span> <span class="o">:=</span> <span class="nf">NewSQLJobRepository</span><span class="p">(</span><span class="nx">cfg</span><span class="p">.</span><span class="nx">DatabaseDSN</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">eventLog</span> <span class="o">:=</span> <span class="nf">NewRedisEventLog</span><span class="p">(</span><span class="nx">cfg</span><span class="p">.</span><span class="nx">RedisAddr</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">app</span> <span class="o">:=</span> <span class="nf">NewApp</span><span class="p">(</span><span class="nx">repo</span><span class="p">,</span> <span class="nx">eventLog</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">server</span> <span class="o">:=</span> <span class="nf">NewHTTPServer</span><span class="p">(</span><span class="nx">app</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">log</span><span class="p">.</span><span class="nf">Fatal</span><span class="p">(</span><span class="nx">server</span><span class="p">.</span><span class="nf">ListenAndServe</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>這裡沒有框架，但依賴方向已經清楚：<code>App</code> 不知道 SQL 或 Redis 是怎麼接的。</p>
<h2 id="策略typed-config-先收斂設定再進行組裝">【策略】typed config 先收斂設定，再進行組裝</h2>
<p>composition root 會變亂，通常是因為設定沒有先整理成型別清楚的 config。把環境變數、flag 與預設值先集中讀成結構體，wiring 會清楚很多。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">Config</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">HTTPAddr</span>   <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">DatabaseDSN</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">RedisAddr</span>  <span class="kt">string</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>load config 的責任是把外部輸入變成可預期的程式設定，而不是在每個 adapter 初始化時各自讀環境變數。</p>
<h2 id="執行建立-adapter-後再注入-usecase">【執行】建立 adapter 後再注入 usecase</h2>
<p>常見的組裝順序是：</p>
<ol>
<li>讀 config。</li>
<li>建立 logger / <a href="/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics</a> / tracer。</li>
<li>建立 <a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database</a> / cache / <a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a> client。</li>
<li>建立 repository 與 service。</li>
<li>建立 handler 或 server。</li>
<li>啟動背景 worker 與 HTTP server。</li>
</ol>
<p>這樣做可以讓初始化失敗在入口層就被看見，不會等到請求進來才爆。</p>
<h2 id="判讀組裝邏輯應集中在入口層">【判讀】組裝邏輯應集中在入口層</h2>
<p>如果 handler 自己 new repository、new client、new worker，就會出現這些問題：</p>
<ul>
<li>測試無法替換依賴</li>
<li>生命週期很難控制</li>
<li>每個 request 都可能建立不必要的資源</li>
<li>啟動路徑與請求路徑混在一起</li>
</ul>
<p>handler 應該只接收已組裝好的依賴，專心處理輸入和回應。</p>
<h2 id="延伸backend-教材負責具體外部服務語意">【延伸】Backend 教材負責具體外部服務語意</h2>
<p>Go 章節只需要知道依賴怎麼接，真正的外部服務語意留給 Backend 教材：</p>
<ul>
<li>database client 建立、pool 與 <a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a> 語意</li>
<li>Redis client、pipeline 與 cache 邊界</li>
<li>broker connection、[durable <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a>](/go/backend/knowledge-cards/durable-queue) 與重試</li>
<li>platform secret、runtime limit 與部署環境</li>
</ul>
<p>Go 的 composition root 不需要重複教這些技術，只要把它們正確接上即可。</p>
<h2 id="與-backend-教材的分工">與 Backend 教材的分工</h2>
<p>本章處理 Go 程式如何組裝依賴。資料庫連線池、Redis client、broker connection、container secret 與平台設定會放在 Backend 對應模組；Go 章節只保留「誰依賴誰」與「在哪裡組裝」的設計。</p>
]]></content:encoded></item><item><title>0.8 Deterministic vs Fuzzy Engineering：軟體設計典範的位移</title><link>https://tarrragon.github.io/blog/llm/00-foundations/deterministic-vs-fuzzy-engineering/</link><pubDate>Thu, 14 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/00-foundations/deterministic-vs-fuzzy-engineering/</guid><description>&lt;p>LLM 進到軟體工程的最大影響、不是「多了一個 API 可以呼叫」、而是軟體設計典範本身的位移（見 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/deterministic-vs-fuzzy/" data-link-title="Deterministic vs Fuzzy engineering" data-link-desc="LLM 軟體 vs 傳統軟體在資料 / 邏輯 / 行為一致性 / 實驗成本四維度的典範差異、決定哪段該包 guardrail">deterministic-vs-fuzzy&lt;/a> 卡）。傳統軟體建立在 deterministic 假設上——同樣的 input 永遠對應同樣的 output、邏輯靠人類寫定、行為可以靠 test 鎖住。LLM 軟體則建立在 fuzzy 假設上——同樣的 input 在不同溫度、不同 sampling 下會給不同 output、邏輯是模型自己推、行為只能用統計方式驗證。&lt;/p>
&lt;p>這個位移影響的不只是「在某段程式裡呼叫 LLM」、而是整套設計思維：怎麼處理資料、怎麼定義「正確」、怎麼分解任務、怎麼版本控制、怎麼測試、怎麼除錯。本章把這個典範位移寫成跨應用都成立的心智模型、讓你在後續模組（特別是 &lt;a href="https://tarrragon.github.io/blog/llm/04-applications/" data-link-title="模組四：LLM 應用層原理" data-link-desc="Prompt 技術光譜、RAG、tool use、agent、應用層協議、人機協作、multi-agent、workflow 編排、eval 設計：跨工具不變的概念地圖">模組四 LLM 應用層&lt;/a>）讀到 RAG、agent、workflow pattern 時、知道自己在跟哪個典範打交道、該套哪一邊的設計直覺。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>讀完本章後你能：&lt;/p>
&lt;ol>
&lt;li>區分一段程式碼是 deterministic 還是 fuzzy。&lt;/li>
&lt;li>列出兩個典範在四個維度（資料、邏輯、分解、實驗成本）的差異。&lt;/li>
&lt;li>判斷一個系統的哪段該 deterministic、哪段該 fuzzy。&lt;/li>
&lt;li>設計 fuzzy 邊界的 guardrail（schema / validator / HITL）。&lt;/li>
&lt;li>看到一個失敗案例、能定位是「典範用錯」還是「實作問題」。&lt;/li>
&lt;/ol>
&lt;h2 id="兩個典範的對照">兩個典範的對照&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>Deterministic 軟體&lt;/th>
 &lt;th>Fuzzy 軟體&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>資料形狀&lt;/td>
 &lt;td>結構化（JSON、DB row、form 欄位）&lt;/td>
 &lt;td>半結構化 / 非結構化（自由文字、圖像、音訊）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>邏輯來源&lt;/td>
 &lt;td>人類寫死規則&lt;/td>
 &lt;td>模型推論、依 prompt + context 浮動&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>行為一致性&lt;/td>
 &lt;td>同 input → 同 output&lt;/td>
 &lt;td>同 input → 分佈、需 sample 多次才看見平均行為&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>分解原則&lt;/td>
 &lt;td>按職責 / 模組（monolith / microservice）&lt;/td>
 &lt;td>按角色 / agent（manager 思維：誰負責什麼任務）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>測試方式&lt;/td>
 &lt;td>unit test、integration test、覆蓋率&lt;/td>
 &lt;td>eval、judge、distribution-level metric&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>除錯&lt;/td>
 &lt;td>step debugger、log、stack trace&lt;/td>
 &lt;td>trace、prompt diff、token-level inspection&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>版本控制&lt;/td>
 &lt;td>code diff 是行為差異的完整來源&lt;/td>
 &lt;td>code diff + prompt diff + model version 三者&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>實驗成本&lt;/td>
 &lt;td>高（改 code 要 review、可能影響穩定性）&lt;/td>
 &lt;td>低（改 prompt 即可、推翻重做便宜）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>失敗模式&lt;/td>
 &lt;td>crash、wrong value、type error&lt;/td>
 &lt;td>hallucination、tone drift、partial completion&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表是後續所有判讀的骨架。看到一段程式時、用這幾個維度自問「這段在哪個典範」、設計直覺自然分開。&lt;/p>
&lt;h2 id="為什麼這個位移是典範級不是只是換工具">為什麼這個位移是典範級、不是只是換工具&lt;/h2>
&lt;p>很多人把 LLM 當「多了一個 API」、結果是把 LLM 塞進 deterministic 設計框架裡、然後因為它「不夠 deterministic」而 frustrated。這個 framing 錯了。LLM 不是 deterministic 工具的下一代、是另一條工具線、需要另一套設計直覺。&lt;/p>
&lt;p>幾個容易踩的混淆：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>把 LLM 行為當 bug 修&lt;/strong>：模型輸出不穩定、想用更多 &lt;code>if&lt;/code> 把它「夾」回固定行為。這條路會走到死巷——當 prompt 越夾越窄、模型反而開始失去原有能力。正確方向是讓邊界本身可以容忍變化（schema validation + retry、distribution metric、HITL）。&lt;/li>
&lt;li>&lt;strong>用 deterministic 的 test 思維測 LLM&lt;/strong>：寫了一個「input X 應該得到 output Y」的單元測試、期望 byte-exact match。LLM 行為是分佈、即使 temperature=0、prompt brittleness 也讓單次測試結果不穩。Fuzzy 系統的測試是「在 N 次採樣中、output 落在期望範圍內的比例」、或「分佈級別 metric」、不是「精確等於某 string」。&lt;/li>
&lt;li>&lt;strong>用 deterministic 的 code review 審 LLM-generated code&lt;/strong>：要求 generated code 完全符合 style guide、結果耗在 nitpick 而不是行為正確性。LLM 生成是 fuzzy 過程、review 焦點該是「功能對 + 安全 + 可讀」、style 交給 linter / formatter 後處理。&lt;/li>
&lt;/ul>
&lt;p>典範位移的真正意涵：&lt;strong>設計時就承認 fuzziness 存在、並圍繞它設計&lt;/strong>、不是假裝它不存在。&lt;/p></description><content:encoded><![CDATA[<p>LLM 進到軟體工程的最大影響、不是「多了一個 API 可以呼叫」、而是軟體設計典範本身的位移（見 <a href="/blog/llm/knowledge-cards/deterministic-vs-fuzzy/" data-link-title="Deterministic vs Fuzzy engineering" data-link-desc="LLM 軟體 vs 傳統軟體在資料 / 邏輯 / 行為一致性 / 實驗成本四維度的典範差異、決定哪段該包 guardrail">deterministic-vs-fuzzy</a> 卡）。傳統軟體建立在 deterministic 假設上——同樣的 input 永遠對應同樣的 output、邏輯靠人類寫定、行為可以靠 test 鎖住。LLM 軟體則建立在 fuzzy 假設上——同樣的 input 在不同溫度、不同 sampling 下會給不同 output、邏輯是模型自己推、行為只能用統計方式驗證。</p>
<p>這個位移影響的不只是「在某段程式裡呼叫 LLM」、而是整套設計思維：怎麼處理資料、怎麼定義「正確」、怎麼分解任務、怎麼版本控制、怎麼測試、怎麼除錯。本章把這個典範位移寫成跨應用都成立的心智模型、讓你在後續模組（特別是 <a href="/blog/llm/04-applications/" data-link-title="模組四：LLM 應用層原理" data-link-desc="Prompt 技術光譜、RAG、tool use、agent、應用層協議、人機協作、multi-agent、workflow 編排、eval 設計：跨工具不變的概念地圖">模組四 LLM 應用層</a>）讀到 RAG、agent、workflow pattern 時、知道自己在跟哪個典範打交道、該套哪一邊的設計直覺。</p>
<h2 id="本章目標">本章目標</h2>
<p>讀完本章後你能：</p>
<ol>
<li>區分一段程式碼是 deterministic 還是 fuzzy。</li>
<li>列出兩個典範在四個維度（資料、邏輯、分解、實驗成本）的差異。</li>
<li>判斷一個系統的哪段該 deterministic、哪段該 fuzzy。</li>
<li>設計 fuzzy 邊界的 guardrail（schema / validator / HITL）。</li>
<li>看到一個失敗案例、能定位是「典範用錯」還是「實作問題」。</li>
</ol>
<h2 id="兩個典範的對照">兩個典範的對照</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Deterministic 軟體</th>
          <th>Fuzzy 軟體</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>資料形狀</td>
          <td>結構化（JSON、DB row、form 欄位）</td>
          <td>半結構化 / 非結構化（自由文字、圖像、音訊）</td>
      </tr>
      <tr>
          <td>邏輯來源</td>
          <td>人類寫死規則</td>
          <td>模型推論、依 prompt + context 浮動</td>
      </tr>
      <tr>
          <td>行為一致性</td>
          <td>同 input → 同 output</td>
          <td>同 input → 分佈、需 sample 多次才看見平均行為</td>
      </tr>
      <tr>
          <td>分解原則</td>
          <td>按職責 / 模組（monolith / microservice）</td>
          <td>按角色 / agent（manager 思維：誰負責什麼任務）</td>
      </tr>
      <tr>
          <td>測試方式</td>
          <td>unit test、integration test、覆蓋率</td>
          <td>eval、judge、distribution-level metric</td>
      </tr>
      <tr>
          <td>除錯</td>
          <td>step debugger、log、stack trace</td>
          <td>trace、prompt diff、token-level inspection</td>
      </tr>
      <tr>
          <td>版本控制</td>
          <td>code diff 是行為差異的完整來源</td>
          <td>code diff + prompt diff + model version 三者</td>
      </tr>
      <tr>
          <td>實驗成本</td>
          <td>高（改 code 要 review、可能影響穩定性）</td>
          <td>低（改 prompt 即可、推翻重做便宜）</td>
      </tr>
      <tr>
          <td>失敗模式</td>
          <td>crash、wrong value、type error</td>
          <td>hallucination、tone drift、partial completion</td>
      </tr>
  </tbody>
</table>
<p>這張表是後續所有判讀的骨架。看到一段程式時、用這幾個維度自問「這段在哪個典範」、設計直覺自然分開。</p>
<h2 id="為什麼這個位移是典範級不是只是換工具">為什麼這個位移是典範級、不是只是換工具</h2>
<p>很多人把 LLM 當「多了一個 API」、結果是把 LLM 塞進 deterministic 設計框架裡、然後因為它「不夠 deterministic」而 frustrated。這個 framing 錯了。LLM 不是 deterministic 工具的下一代、是另一條工具線、需要另一套設計直覺。</p>
<p>幾個容易踩的混淆：</p>
<ul>
<li><strong>把 LLM 行為當 bug 修</strong>：模型輸出不穩定、想用更多 <code>if</code> 把它「夾」回固定行為。這條路會走到死巷——當 prompt 越夾越窄、模型反而開始失去原有能力。正確方向是讓邊界本身可以容忍變化（schema validation + retry、distribution metric、HITL）。</li>
<li><strong>用 deterministic 的 test 思維測 LLM</strong>：寫了一個「input X 應該得到 output Y」的單元測試、期望 byte-exact match。LLM 行為是分佈、即使 temperature=0、prompt brittleness 也讓單次測試結果不穩。Fuzzy 系統的測試是「在 N 次採樣中、output 落在期望範圍內的比例」、或「分佈級別 metric」、不是「精確等於某 string」。</li>
<li><strong>用 deterministic 的 code review 審 LLM-generated code</strong>：要求 generated code 完全符合 style guide、結果耗在 nitpick 而不是行為正確性。LLM 生成是 fuzzy 過程、review 焦點該是「功能對 + 安全 + 可讀」、style 交給 linter / formatter 後處理。</li>
</ul>
<p>典範位移的真正意涵：<strong>設計時就承認 fuzziness 存在、並圍繞它設計</strong>、不是假裝它不存在。</p>
<h2 id="哪段該-deterministic哪段該-fuzzy">哪段該 Deterministic、哪段該 Fuzzy</h2>
<p>一個系統幾乎不會「全 deterministic」或「全 fuzzy」、實務上是混合。判讀「哪段該哪個」的決策框架：</p>
<table>
  <thead>
      <tr>
          <th>屬性</th>
          <th>偏 deterministic</th>
          <th>偏 fuzzy</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>行為定義</td>
          <td>規則可窮舉</td>
          <td>規則太多 / 邊界模糊</td>
      </tr>
      <tr>
          <td>失敗代價</td>
          <td>高（金錢、安全、不可逆）</td>
          <td>低（可 retry、可 fallback）</td>
      </tr>
      <tr>
          <td>解釋需求</td>
          <td>必須能解釋為什麼做這個決定</td>
          <td>解釋是 nice-to-have</td>
      </tr>
      <tr>
          <td>一致性需求</td>
          <td>必須 byte-exact 重現（auditing、test）</td>
          <td>統計上一致即可</td>
      </tr>
      <tr>
          <td>資料形狀</td>
          <td>結構化</td>
          <td>自由文字 / 多模態</td>
      </tr>
      <tr>
          <td>變化頻率</td>
          <td>規則穩定、長期不變</td>
          <td>需求 / 領域知識 / 用戶輸入快速變化</td>
      </tr>
      <tr>
          <td>邊界條件</td>
          <td>邊界清楚（valid / invalid 兩段式）</td>
          <td>邊界連續（差不多好 / 還行 / 不夠好）</td>
      </tr>
  </tbody>
</table>
<p>實務上一個 production LLM 應用的常見組合：</p>
<ul>
<li><strong>使用者輸入解析</strong>：偏 fuzzy（LLM 解意圖、parse 自由文字）。</li>
<li><strong>資料庫查詢 / 更新</strong>：偏 deterministic（SQL、API、schema validation）。</li>
<li><strong>業務規則檢查</strong>（如「能否退款」「能否變更地址」）：偏 deterministic（policy as code）。</li>
<li><strong>回應草稿生成</strong>：偏 fuzzy（LLM 寫 email、考慮語氣）。</li>
<li><strong>發送 / 寫入動作</strong>：偏 deterministic（API call、template render）。</li>
</ul>
<p>這個混合不是隨機、是按上述決策框架推出來的。LLM 強在「理解模糊輸入」跟「生成有風格的輸出」、其餘部分能 deterministic 就 deterministic。</p>
<h3 id="反模式典範用錯的訊號">反模式：典範用錯的訊號</h3>
<ul>
<li><strong>Deterministic 的需求硬用 fuzzy 解</strong>：例如用 LLM 算稅金、然後用 retry + LLM judge 校驗。這條路的成本跟錯誤率都遠高於直接寫 deterministic 規則。判讀訊號：能用 30 行 code 寫死的規則、不要 LLM。</li>
<li><strong>Fuzzy 的需求硬用 deterministic 解</strong>：例如用 regex 解析自由文字客服訊息、然後維護一個越來越長的 case list。判讀訊號：規則 list 每週都在加新 case、加完還是漏、就該換 fuzzy。</li>
<li><strong>邊界用錯</strong>：把 deterministic 的部分塞進 prompt（如「請計算 9.32 × 47 並退款」）、或把 fuzzy 的部分塞進 code（如 <code>if user_intent == &quot;refund&quot;</code>）。前者讓 LLM 出算術錯、後者讓 code 漏 case。判讀訊號：prompt 在做算術 / 字串解析、或 code 在做意圖分類、就該重切。</li>
</ul>
<h2 id="fuzzy-邊界的-guardrail-設計">Fuzzy 邊界的 Guardrail 設計</h2>
<p>承認 fuzziness 存在後、設計重點轉成「邊界要怎麼包」。Guardrail 是 deterministic 包 fuzzy 的設計模式、防止 fuzzy 行為溢出到不該影響的地方。</p>
<p>四種常見 guardrail：</p>
<h3 id="schema-validation">Schema validation</h3>
<p>LLM 輸出被強制符合某個 schema（JSON schema、Pydantic model、TypeScript type）。不符合就 retry 或 fallback。</p>
<p>適用：LLM 結果要直接餵給下游 deterministic 系統（API、DB、UI）。</p>
<p>實作位置：LLM call 之後、下游 system 之前。</p>
<p>失敗模式：schema 對了但語意錯（structurally valid、semantically wrong）——這層 guardrail 接不住、要加 semantic check。</p>
<h3 id="output-validator">Output validator</h3>
<p>對 LLM 輸出跑語意驗證、不是只看 schema。例：生成的 email 不能包含未經授權的折扣承諾、生成的 code 不能呼叫 deprecated API。</p>
<p>適用：LLM 輸出有「該做 / 不該做」的清單。</p>
<p>實作位置：LLM call 之後、deliver 之前。可以是 deterministic check（regex、AST 分析）、可以是另一個 LLM judge（見 <a href="/blog/llm/04-applications/llm-as-judge/" data-link-title="4.21 LLM-as-Judge 評估方法" data-link-desc="LLM 評估 LLM 的 production eval 方法：rubric design、pairwise / direct scoring、三大 bias 緩解、跟 trace 串接的閉環、calibration">4.21 LLM-as-Judge</a>）。</p>
<p>失敗模式：validator 自己 hallucinate（如果是 LLM judge）、或漏 case（如果是 deterministic check）。混用兩種比較穩。</p>
<h3 id="action-gating">Action gating</h3>
<p>LLM 想做高代價動作前、強制走人類確認或外部驗證。例：寫 production DB 前要 human approval、發 email 前要 dry-run 給內部 review、執行 shell 前要看到 diff。</p>
<p>適用：副作用範圍大、失敗不可逆。對應 <a href="/blog/llm/04-applications/agent-architecture/" data-link-title="4.4 Agent 架構原理" data-link-desc="Agent loop 結構、失敗模式、什麼任務適合 vs 不適合、跟人類審查的協作模型">4.4 agent 架構</a> 的 step-by-step approval / HITL 協作模型。</p>
<p>實作位置：tool layer、不是 prompt layer。Prompt 「請小心」是不夠的、靠 tool 本身不執行才有保證。</p>
<p>失敗模式：人類疲勞（rubber-stamp approval）、確認流程變橡皮圖章。設計時要讓 high-risk 跟 low-risk 動作走不同 gate、不要全部要人類確認、否則人類會關掉腦袋。</p>
<h3 id="distribution-monitoring">Distribution monitoring</h3>
<p>不在 single call 層擋、而是看 LLM 行為的分佈。例：每天客服回應的「拒絕率」「退款承諾率」、跑 alert；新 prompt 上線後追 token 用量、語氣 polarity、user satisfaction 的 baseline 漂移。</p>
<p>適用：行為層面的 silent drift（個別 call 看不出問題、加總起來偏掉）。</p>
<p>實作位置：production observability、trace pipeline（見 <a href="/blog/llm/04-applications/llm-tracing-and-observability/" data-link-title="4.20 LLM tracing 與 observability" data-link-desc="OpenTelemetry GenAI semantic conventions、結構化 span 設計、cost / latency 監控、failure debug 流程、跟 LLM-as-judge eval 的串接">4.20 LLM tracing</a>）。</p>
<p>失敗模式：baseline 沒先建、新 prompt 上線後不知道「正常範圍」是什麼、alert 無基準。</p>
<h3 id="四種-guardrail-怎麼選">四種 guardrail 怎麼選</h3>
<p>順序通常是：schema validation 最便宜先上、output validator 看內容風險再加、action gating 看不可逆性決定、distribution monitoring 是長期經營必備。</p>
<p>混用比例：一個成熟的 production LLM 應用通常四種都有、但分擔不同 risk class。輕量 query 只走 schema、會寫資料的走 schema + validator + gating、會影響多人的走全套加 monitoring。</p>
<h2 id="實驗成本的位移">實驗成本的位移</h2>
<p>Deterministic 軟體的實驗成本高、改 code 要 PR review、要跑 CI、要考慮回退、所以團隊文化是「想清楚再寫」。Fuzzy 軟體的實驗成本低——改 prompt 一行、跑兩個 case、就能看新行為——所以更接近「快速試、不行就丟」。</p>
<p>這個位移對工程師的工作方式有實質影響：</p>
<ul>
<li><strong>Throw-away code 更可接受</strong>：原本「寫了就要維護」、現在「先試、不行就重來」。</li>
<li><strong>Prompt 是 source、但生命週期不一樣</strong>：跟 code 一樣 version control（見 <a href="/blog/llm/04-applications/artifact-management/" data-link-title="4.10 衍生產物管理原理：什麼進 git、什麼不該" data-link-desc="LLM 應用的 source / derived / external 三類產物對應 git / build cache / registry、與 production 部署的 reproducibility / cost / share 取捨">4.10 衍生產物管理</a>）、但 iteration 速度比 code 快一個量級。</li>
<li><strong>Eval 比 unit test 重要</strong>：unit test 鎖行為、但 fuzzy 行為本來就會變、eval 看「行為分佈是否在期望範圍」才是有用的測試。</li>
<li><strong>失敗的歸因分層</strong>：壞掉時要問「是 prompt 問題、model 問題、context 問題、tool 問題、還是 deterministic glue 的 bug」——deterministic 軟體的歸因比較單一、fuzzy 軟體要分這幾層查。</li>
</ul>
<p>這個位移是雙面刃。便宜實驗讓 iteration 快、但也讓 prompt / config / 行為快速分裂、production 跑著的東西跟 git 上看到的東西可能不一致。Mitigation 是 prompt template 上 version control、prompt diff 進 CI、production behavior 進 distribution monitoring。</p>
<h2 id="跟-agent--workflow-設計的關係">跟 Agent / Workflow 設計的關係</h2>
<p>Agent 跟 multi-call workflow 是「fuzzy 軟體」最複雜的型態。<a href="/blog/llm/04-applications/agent-architecture/" data-link-title="4.4 Agent 架構原理" data-link-desc="Agent loop 結構、失敗模式、什麼任務適合 vs 不適合、跟人類審查的協作模型">4.4 agent 架構</a> 列出 agent 的三大失敗模式（context drift / goal drift / tool misread）、本質上都是 fuzzy 行為在多步累積後溢出 guardrail。</p>
<p>這個 framing 對 agent 設計的啟示：</p>
<ul>
<li><strong>Loop 的每一步都是一個 fuzzy 邊界</strong>：每步都要決定 schema / validator / gating / monitoring 的組合。</li>
<li><strong>越多步累積、越需要 deterministic checkpoint</strong>：「跑 10 步 fuzzy 推理、最後一步寫 DB」是高風險、要在中間插 deterministic verification。</li>
<li><strong>Termination 是 deterministic 邊界</strong>：靠模型自己說「完成了」是純 fuzzy、容易失控（見 <a href="/blog/llm/04-applications/agent-architecture/" data-link-title="4.4 Agent 架構原理" data-link-desc="Agent loop 結構、失敗模式、什麼任務適合 vs 不適合、跟人類審查的協作模型">4.4 termination 條件</a>）。混用 step cap、cost cap、external validation 是 deterministic guardrail 包 fuzzy loop 的標準做法。</li>
</ul>
<h2 id="何時過時--何時不過時">何時過時 / 何時不過時</h2>
<p><strong>不會過時的部分</strong>：</p>
<ul>
<li>兩個典範的四維對照（資料、邏輯、行為一致性、實驗成本）。</li>
<li>「哪段該 deterministic / 哪段該 fuzzy」的決策框架。</li>
<li>四種 guardrail 的分類跟組合原則。</li>
<li>Fuzzy 邊界要包 deterministic、不是反過來的設計直覺。</li>
</ul>
<p><strong>會變的部分</strong>：</p>
<ul>
<li>具體 schema 工具（Pydantic、Zod、各家 framework 的 typed output API）。</li>
<li>具體 LLM-as-judge 平台跟方法（見 <a href="/blog/llm/04-applications/llm-as-judge/" data-link-title="4.21 LLM-as-Judge 評估方法" data-link-desc="LLM 評估 LLM 的 production eval 方法：rubric design、pairwise / direct scoring、三大 bias 緩解、跟 trace 串接的閉環、calibration">4.21</a>）。</li>
<li>各家 framework 的 guardrail SDK（隨工具世代換）。</li>
<li>Fuzzy / deterministic 的邊界位置會隨模型能力移動——模型越強、能 fuzzy 處理的範圍越大、但「該包 guardrail」的原則不變。</li>
</ul>
<h2 id="下一章">下一章</h2>
<p>下一章：<a href="/blog/llm/01-local-llm-services/" data-link-title="模組一：本地 LLM 服務的安裝與應用" data-link-desc="Ollama、LM Studio、llama.cpp 的安裝與差異、VS Code &#43; Continue.dev 整合、模型選型與期望管理">模組一 本地 LLM 服務</a> 進入工具層、或跳到 <a href="/blog/llm/04-applications/" data-link-title="模組四：LLM 應用層原理" data-link-desc="Prompt 技術光譜、RAG、tool use、agent、應用層協議、人機協作、multi-agent、workflow 編排、eval 設計：跨工具不變的概念地圖">模組四 LLM 應用層</a> 看這個典範怎麼落到 RAG / agent / workflow 設計。Agent 設計怎麼把 fuzzy / deterministic 邊界體現在 loop 結構上見 <a href="/blog/llm/04-applications/agent-architecture/" data-link-title="4.4 Agent 架構原理" data-link-desc="Agent loop 結構、失敗模式、什麼任務適合 vs 不適合、跟人類審查的協作模型">4.4 agent 架構</a>、人類介入點的設計選擇見 <a href="/blog/llm/04-applications/human-ai-collaboration/" data-link-title="4.5 人機協作拓樸：何時人介入、怎麼介入" data-link-desc="Centaur vs Cyborg 工作模式、jagged frontier、HITL 三種觸發時機（pre-act / mid-stream / post-hoc）、確認流程的設計避免橡皮圖章化">4.5 人機協作拓樸</a>、跨多 call workflow 的 fuzzy 邊界設計見 <a href="/blog/llm/04-applications/workflow-patterns/" data-link-title="4.7 Workflow 編排模式" data-link-desc="Pipeline / router / parallel / reflection：多 LLM call 組合的四種基本模式與退化條件">4.7 workflow 編排模式</a>。</p>
]]></content:encoded></item><item><title>4.8 Multi-Agent 拓樸：flat / hierarchical / agent-as-tool</title><link>https://tarrragon.github.io/blog/llm/04-applications/multi-agent-topology/</link><pubDate>Thu, 14 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/04-applications/multi-agent-topology/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/llm/04-applications/workflow-patterns/" data-link-title="4.7 Workflow 編排模式" data-link-desc="Pipeline / router / parallel / reflection：多 LLM call 組合的四種基本模式與退化條件">4.7 workflow patterns&lt;/a> 寫的是「多次 LLM call 怎麼組合」、四個基本模式（pipeline / router / parallel / &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/reflection/" data-link-title="Reflection / Self-critique" data-link-desc="要求模型先輸出一版、再 critique 自己、再修改的 prompting / workflow 模式、有自身失敗模式">reflection&lt;/a>）解的是 single-thread 多 call 問題。當問題進一步複雜——需要平行的多個專業化角色、需要跨產品的 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/agent/" data-link-title="LLM Agent" data-link-desc="把控制流交給 LLM 的應用模式：自主決策、跨多步呼叫工具、人類角色從主導變監督">agent&lt;/a> 重用、需要 agent 之間互相呼叫——就進入 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/multi-agent-system/" data-link-title="Multi-agent system" data-link-desc="多個 LLM agent 協作的系統、跟 multi-call workflow 的差異在控制流跟責任邊界、三種拓樸 flat / hierarchical / agent-as-tool">multi-agent system&lt;/a> 的領域。&lt;/p>
&lt;p>本章寫的是 multi-agent 系統的&lt;strong>拓樸結構&lt;/strong>：何時值得從多 call 走到多 agent、flat 跟 hierarchical 兩種拓樸的差異、&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/agent-as-tool/" data-link-title="Agent-as-Tool" data-link-desc="把一個專責 agent 包成可被另一個 agent 呼叫的 tool，形成跨 agent 的責任邊界">agent-as-tool&lt;/a> 的 MCP 視角、specialization 跟 orchestration overhead 的核心 trade-off。具體 framework（CrewAI、AutoGen、LangGraph 多 agent 等）半年一個世代、本章不寫具體 API。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>讀完本章後你能：&lt;/p>
&lt;ol>
&lt;li>判斷一個系統該停在 multi-call workflow 還是進入 multi-agent。&lt;/li>
&lt;li>區分 flat / hierarchical / agent-as-tool 三種拓樸、各自的適用場景。&lt;/li>
&lt;li>估算 specialization gain vs orchestration overhead 的 trade-off。&lt;/li>
&lt;li>識別 multi-agent 特有的失敗模式（循環依賴、責任歸屬模糊、context 重複傳遞）。&lt;/li>
&lt;li>把 agent-as-tool 對應回 MCP / function calling 的協議設計。&lt;/li>
&lt;/ol>
&lt;h2 id="從-multi-call-走到-multi-agent-的判讀">從 Multi-Call 走到 Multi-Agent 的判讀&lt;/h2>
&lt;p>Multi-agent 跟 multi-call 不是「agent 數量多寡」的差別、是控制流跟責任邊界的差別。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>Multi-call workflow&lt;/th>
 &lt;th>Multi-agent system&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>控制流&lt;/td>
 &lt;td>主程式編排、每 call 是 step&lt;/td>
 &lt;td>Agent 自己決定下一步、可能呼叫其他 agent&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>角色&lt;/td>
 &lt;td>Step 跟 step 之間沒有「身份」、就是函數&lt;/td>
 &lt;td>每個 agent 有 role / 專業 / 工具集&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Context&lt;/td>
 &lt;td>主程式傳 context、step 不擁有 context&lt;/td>
 &lt;td>Agent 自帶 memory、有「自己知道的事」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>重用&lt;/td>
 &lt;td>Step 是函數、容易 import 重用&lt;/td>
 &lt;td>Agent 是黑盒、跨系統重用要透過協議&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>失敗歸屬&lt;/td>
 &lt;td>Step 失敗、主程式接&lt;/td>
 &lt;td>Agent 失敗、可能 cascading 影響別的 agent&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>判讀「該走 multi-agent」的四條件（&lt;strong>任一不滿足、就留在 multi-call&lt;/strong>）：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>角色差異顯著&lt;/strong>：不同 step 要不同 prompt / model / tool / memory。任一條件同質就退回 multi-call、硬拆成多 agent 只是換個名字、orchestration overhead 純增。&lt;/li>
&lt;li>&lt;strong>跨產品重用&lt;/strong>：同一個 agent 要被多團隊 / 多場景使用。單一 user / 單一場景的話、寫成函數比 agent 簡單。&lt;/li>
&lt;li>&lt;strong>真正平行 / 動態協作&lt;/strong>：多個 agent 各做自己的事最後合併、或哪些 agent 參與是 query-dependent。控制流可寫死、step 順序固定時、multi-call pipeline 已足夠。&lt;/li>
&lt;li>&lt;strong>團隊熟悉度足&lt;/strong>：multi-agent 失敗模式比 multi-call 多、debug 比較難。團隊還在學階段、debug 容易性 &amp;gt; 靈活性、先 stick to multi-call。&lt;/li>
&lt;/ul>
&lt;p>「先 multi-call、不夠再 multi-agent」是合理預設姿勢。Multi-agent 是「特定問題的解法」、不是「更高級的設計」。對應 &lt;a href="https://tarrragon.github.io/blog/llm/04-applications/agent-architecture/" data-link-title="4.4 Agent 架構原理" data-link-desc="Agent loop 結構、失敗模式、什麼任務適合 vs 不適合、跟人類審查的協作模型">4.4 agent 架構&lt;/a> 的「先 single-call、不夠再 agent」反射、層級往上類似。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/llm/04-applications/workflow-patterns/" data-link-title="4.7 Workflow 編排模式" data-link-desc="Pipeline / router / parallel / reflection：多 LLM call 組合的四種基本模式與退化條件">4.7 workflow patterns</a> 寫的是「多次 LLM call 怎麼組合」、四個基本模式（pipeline / router / parallel / <a href="/blog/llm/knowledge-cards/reflection/" data-link-title="Reflection / Self-critique" data-link-desc="要求模型先輸出一版、再 critique 自己、再修改的 prompting / workflow 模式、有自身失敗模式">reflection</a>）解的是 single-thread 多 call 問題。當問題進一步複雜——需要平行的多個專業化角色、需要跨產品的 <a href="/blog/llm/knowledge-cards/agent/" data-link-title="LLM Agent" data-link-desc="把控制流交給 LLM 的應用模式：自主決策、跨多步呼叫工具、人類角色從主導變監督">agent</a> 重用、需要 agent 之間互相呼叫——就進入 <a href="/blog/llm/knowledge-cards/multi-agent-system/" data-link-title="Multi-agent system" data-link-desc="多個 LLM agent 協作的系統、跟 multi-call workflow 的差異在控制流跟責任邊界、三種拓樸 flat / hierarchical / agent-as-tool">multi-agent system</a> 的領域。</p>
<p>本章寫的是 multi-agent 系統的<strong>拓樸結構</strong>：何時值得從多 call 走到多 agent、flat 跟 hierarchical 兩種拓樸的差異、<a href="/blog/llm/knowledge-cards/agent-as-tool/" data-link-title="Agent-as-Tool" data-link-desc="把一個專責 agent 包成可被另一個 agent 呼叫的 tool，形成跨 agent 的責任邊界">agent-as-tool</a> 的 MCP 視角、specialization 跟 orchestration overhead 的核心 trade-off。具體 framework（CrewAI、AutoGen、LangGraph 多 agent 等）半年一個世代、本章不寫具體 API。</p>
<h2 id="本章目標">本章目標</h2>
<p>讀完本章後你能：</p>
<ol>
<li>判斷一個系統該停在 multi-call workflow 還是進入 multi-agent。</li>
<li>區分 flat / hierarchical / agent-as-tool 三種拓樸、各自的適用場景。</li>
<li>估算 specialization gain vs orchestration overhead 的 trade-off。</li>
<li>識別 multi-agent 特有的失敗模式（循環依賴、責任歸屬模糊、context 重複傳遞）。</li>
<li>把 agent-as-tool 對應回 MCP / function calling 的協議設計。</li>
</ol>
<h2 id="從-multi-call-走到-multi-agent-的判讀">從 Multi-Call 走到 Multi-Agent 的判讀</h2>
<p>Multi-agent 跟 multi-call 不是「agent 數量多寡」的差別、是控制流跟責任邊界的差別。</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Multi-call workflow</th>
          <th>Multi-agent system</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>控制流</td>
          <td>主程式編排、每 call 是 step</td>
          <td>Agent 自己決定下一步、可能呼叫其他 agent</td>
      </tr>
      <tr>
          <td>角色</td>
          <td>Step 跟 step 之間沒有「身份」、就是函數</td>
          <td>每個 agent 有 role / 專業 / 工具集</td>
      </tr>
      <tr>
          <td>Context</td>
          <td>主程式傳 context、step 不擁有 context</td>
          <td>Agent 自帶 memory、有「自己知道的事」</td>
      </tr>
      <tr>
          <td>重用</td>
          <td>Step 是函數、容易 import 重用</td>
          <td>Agent 是黑盒、跨系統重用要透過協議</td>
      </tr>
      <tr>
          <td>失敗歸屬</td>
          <td>Step 失敗、主程式接</td>
          <td>Agent 失敗、可能 cascading 影響別的 agent</td>
      </tr>
  </tbody>
</table>
<p>判讀「該走 multi-agent」的四條件（<strong>任一不滿足、就留在 multi-call</strong>）：</p>
<ul>
<li><strong>角色差異顯著</strong>：不同 step 要不同 prompt / model / tool / memory。任一條件同質就退回 multi-call、硬拆成多 agent 只是換個名字、orchestration overhead 純增。</li>
<li><strong>跨產品重用</strong>：同一個 agent 要被多團隊 / 多場景使用。單一 user / 單一場景的話、寫成函數比 agent 簡單。</li>
<li><strong>真正平行 / 動態協作</strong>：多個 agent 各做自己的事最後合併、或哪些 agent 參與是 query-dependent。控制流可寫死、step 順序固定時、multi-call pipeline 已足夠。</li>
<li><strong>團隊熟悉度足</strong>：multi-agent 失敗模式比 multi-call 多、debug 比較難。團隊還在學階段、debug 容易性 &gt; 靈活性、先 stick to multi-call。</li>
</ul>
<p>「先 multi-call、不夠再 multi-agent」是合理預設姿勢。Multi-agent 是「特定問題的解法」、不是「更高級的設計」。對應 <a href="/blog/llm/04-applications/agent-architecture/" data-link-title="4.4 Agent 架構原理" data-link-desc="Agent loop 結構、失敗模式、什麼任務適合 vs 不適合、跟人類審查的協作模型">4.4 agent 架構</a> 的「先 single-call、不夠再 agent」反射、層級往上類似。</p>
<h2 id="三種拓樸">三種拓樸</h2>
<p>Multi-agent 的拓樸結構決定 agent 之間怎麼通訊、誰決定誰做什麼。三種主流拓樸各有適用場景。</p>
<h3 id="flat-拓樸all-to-all">Flat 拓樸：all-to-all</h3>
<p>所有 agent 同層級、可以互相呼叫、沒有固定 orchestrator。</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">       Agent A ─────── Agent B
</span></span><span class="line"><span class="ln">2</span><span class="cl">         │  ╲          ╱  │
</span></span><span class="line"><span class="ln">3</span><span class="cl">         │   ╲        ╱   │
</span></span><span class="line"><span class="ln">4</span><span class="cl">         │    ╲      ╱    │
</span></span><span class="line"><span class="ln">5</span><span class="cl">       Agent C ─────── Agent D</span></span></code></pre></div><ul>
<li><strong>適用</strong>：agent 之間平等、任務需要動態協商（agent A 想知道 X、問 B 跟 D、再決定）。</li>
<li><strong>典型場景</strong>：研究型多 agent debate、模擬多個利害關係人協商。</li>
<li><strong>失敗模式</strong>：
<ul>
<li><strong>N² 通訊複雜度</strong>：agent 多了之後、通訊路徑潛在 N²、實務常較稀疏但難預測、cost / latency 上限不可控。</li>
<li><strong>無權威仲裁</strong>：兩個 agent 意見衝突、沒有第三方決定、容易死鎖。</li>
<li><strong>責任歸屬模糊</strong>：最終結果是誰決定的不清楚、debug 困難。</li>
</ul>
</li>
<li><strong>規模限制</strong>：實務上 flat 拓樸超過 5–6 個 agent 就難維護、不推薦大規模。</li>
</ul>
<h3 id="hierarchical-拓樸orchestrator--specialists">Hierarchical 拓樸：orchestrator + specialists</h3>
<p>一個 orchestrator agent 對外、底下若干 specialist agent、orchestrator 決定 dispatch 給誰、合併結果回 user。</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">              User
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">                │
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">          ┌─────────────┐
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">          │ Orchestrator │
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">          └──┬──┬──┬──┬─┘
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">             │  │  │  │
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        ┌────┘  │  │  └────┐
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">   Specialist  │  │   Specialist
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">       A    Spec  Spec      D
</span></span><span class="line"><span class="ln">10</span><span class="cl">             B    C</span></span></code></pre></div><ul>
<li><strong>適用</strong>：對 user 要單一介面、底下 agent 專業化、orchestrator 知道每個 specialist 的 capability。</li>
<li><strong>典型場景</strong>：智慧家庭中央控制（user 對 orchestrator 說話、orchestrator 派給 climate / security / energy agent）、複雜客服系統（orchestrator 派給 product / refund / billing 不同 specialist）。</li>
<li><strong>失敗模式</strong>：
<ul>
<li><strong>Orchestrator 變單點瓶頸</strong>：所有請求過 orchestrator、它的 prompt / model 限制整個系統能力。</li>
<li><strong>Specialist 之間訊息傳遞要過 orchestrator</strong>：增加 latency、容易丟細節。</li>
<li><strong>Orchestrator 不知道何時該派誰</strong>：需要動態描述 specialist capability、複雜 query 容易 dispatch 錯。</li>
</ul>
</li>
<li><strong>變體</strong>：multi-level hierarchy（orchestrator 下面還有 sub-orchestrator），實務上 2 層夠用、3 層以上 overhead 大於 specialization gain。</li>
</ul>
<h3 id="agent-as-toolagent-互通就是-tool-call">Agent-as-Tool：agent 互通就是 tool call</h3>
<p>把每個 agent 包成「另一個 agent 的 tool」、agent A 呼叫 agent B 跟呼叫 weather API 在介面上一樣——都是 tool call。</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">Agent A
</span></span><span class="line"><span class="ln">2</span><span class="cl">  ├── tool: weather_api
</span></span><span class="line"><span class="ln">3</span><span class="cl">  ├── tool: database_query
</span></span><span class="line"><span class="ln">4</span><span class="cl">  └── tool: agent_B  ←── 內部其實是另一個 agent loop
</span></span><span class="line"><span class="ln">5</span><span class="cl">                            └── 它也有自己的 tools
</span></span><span class="line"><span class="ln">6</span><span class="cl">                                ├── tool: code_executor
</span></span><span class="line"><span class="ln">7</span><span class="cl">                                └── tool: agent_C</span></span></code></pre></div><ul>
<li><strong>適用</strong>：agent 之間有清楚的「誰呼叫誰」、不是平等協商；想透過標準協議（function calling / MCP）讓 agent 跨系統重用。</li>
<li><strong>典型場景</strong>：<a href="/blog/llm/knowledge-cards/mcp/" data-link-title="MCP（Model Context Protocol）" data-link-desc="LLM application ↔ 外部 tool server 之間的標準化協議、複用 OpenAI 相容 API 的成功模式">MCP</a> 的 tool primitive 視角下、<a href="/blog/llm/knowledge-cards/agent-as-tool/" data-link-title="Agent-as-Tool" data-link-desc="把一個專責 agent 包成可被另一個 agent 呼叫的 tool，形成跨 agent 的責任邊界">agent-as-tool</a> 可以包成 MCP server 暴露、client agent 把它當 tool 用。跨組織 agent 互通常走這個模式。注意 MCP 還有 resources / prompts 另外兩類 primitive、不是所有 MCP server 都是 agent-as-tool。</li>
<li><strong>跟 hierarchical 的關係</strong>：agent-as-tool 是 hierarchical 的一個實作策略——orchestrator 把 specialist agent 當 tool。差異在於：hierarchical 可能是同進程內的緊耦合、agent-as-tool 走標準協議、跨進程 / 跨組織 / 可替換。</li>
<li><strong>失敗模式</strong>：
<ul>
<li><strong>協議的 schema 太薄</strong>：agent 跟 agent 之間的 input/output 用 string 傳、丟結構資訊、下游難解析。</li>
<li><strong>Cascading failure</strong>：下游 agent 失敗、上游 agent 不知道為什麼失敗、誤判繼續。</li>
<li><strong>重複 context 傳遞</strong>：每次呼叫都要重新 brief 一次下游 agent、token cost 爆。緩解：下游 agent 自帶 session memory（見 <a href="/blog/llm/04-applications/agent-memory-architecture/" data-link-title="4.19 Agent memory 分層架構" data-link-desc="Agent 在 context window 之外管理長期狀態的設計：working / short-term / long-term episodic / semantic / procedural 五個層次、寫入時機、retrieval 設計、失敗模式">4.19 agent memory architecture</a>）。</li>
</ul>
</li>
</ul>
<h3 id="三種拓樸的選擇">三種拓樸的選擇</h3>
<table>
  <thead>
      <tr>
          <th>場景特性</th>
          <th>推薦拓樸</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>2–4 個 agent、需要動態協商</td>
          <td>Flat</td>
      </tr>
      <tr>
          <td>多個專業 agent、單一對外介面</td>
          <td>Hierarchical</td>
      </tr>
      <tr>
          <td>跨組織 / 跨進程 / 標準化重用</td>
          <td>Agent-as-tool</td>
      </tr>
      <tr>
          <td>大規模（10+ agents）、固定協作模式</td>
          <td>Hierarchical 多層</td>
      </tr>
      <tr>
          <td>想簡單開始</td>
          <td>Hierarchical 兩層</td>
      </tr>
  </tbody>
</table>
<p>教材建議的組合：對外是 hierarchical（單一 orchestrator）、orchestrator 內部跟 specialist 通訊走 agent-as-tool 協議（如 MCP tool primitive）、specialist 之間用 flat 模式平等溝通。實務上組合方式因團隊跟產品差異很大、這只是一個合理起點。</p>
<h2 id="specialization-gain-vs-orchestration-overhead">Specialization Gain vs Orchestration Overhead</h2>
<p>Multi-agent 的核心 trade-off 是<strong>專業化收益跟協調成本的拉鋸</strong>。</p>
<h3 id="specialization-gain把-agent-拆細的好處">Specialization gain：把 agent 拆細的好處</h3>
<ul>
<li><strong>單一責任</strong>：每個 agent prompt 短、focus 清楚、debugging 容易。</li>
<li><strong>獨立優化</strong>：每個 agent 可以用不同 model（具體 routing 思路屬於 <a href="/blog/llm/04-applications/workflow-patterns/" data-link-title="4.7 Workflow 編排模式" data-link-desc="Pipeline / router / parallel / reflection：多 LLM call 組合的四種基本模式與退化條件">4.7 workflow patterns</a> router 模式）、不同 prompt、獨立 eval。</li>
<li><strong>重用</strong>：同一個 specialist 跨多個系統用、攤平訓練 / 設計成本。</li>
<li><strong>平行</strong>：獨立 agent 可平行跑、latency 降。</li>
</ul>
<h3 id="orchestration-overhead拆細的成本">Orchestration overhead：拆細的成本</h3>
<ul>
<li><strong>Context 傳遞成本</strong>：每個 agent 要被 brief、context 重複傳、token 累積。</li>
<li><strong>Latency 累積</strong>：每跳一個 agent 加一個 LLM call 的 latency、跨 agent chain 跟 reflection / multi-step retrieval 一樣會累積。</li>
<li><strong>失敗模式多</strong>：每個 agent 自己會 drift、agent 之間也會誤判、debug 比 single agent 難。</li>
<li><strong>責任歸屬</strong>：bug 出現時、定位是哪個 agent 跑偏要看完整 trace。</li>
</ul>
<h3 id="何時-specialization-划算">何時 specialization 划算</h3>
<table>
  <thead>
      <tr>
          <th>條件</th>
          <th>Specialization 划算？</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Agent 之間 role 差異顯著</td>
          <td>划算</td>
      </tr>
      <tr>
          <td>Agent 之間 role 同質</td>
          <td>不划算</td>
      </tr>
      <tr>
          <td>重用機會多（多產品 / 多場景）</td>
          <td>划算</td>
      </tr>
      <tr>
          <td>單一場景 / 單一團隊</td>
          <td>不划算</td>
      </tr>
      <tr>
          <td>每個 sub-task 各自有客觀 eval</td>
          <td>划算</td>
      </tr>
      <tr>
          <td>Sub-task 無法獨立評估</td>
          <td>不划算（debugging 困難）</td>
      </tr>
      <tr>
          <td>Latency 容忍度高（後台 batch）</td>
          <td>划算</td>
      </tr>
      <tr>
          <td>即時 chatbot</td>
          <td>不划算（orchestration latency 殺死 UX）</td>
      </tr>
  </tbody>
</table>
<p>兩個容易低估的條件展開：</p>
<ul>
<li><strong>「sub-task 無法獨立評估」為何讓 debugging 困難</strong>：當 specialist agent 出問題、若沒有 component-level eval、要從 final output 倒推到「哪個 agent 跑偏」要看完整 trace + 人工讀。Single agent 失敗只需查一個 agent 的 trace、multi-agent 失敗要查 N 個、且 cascading failure 讓 root cause 模糊。要配 sub-task 客觀 eval（如 <a href="/blog/llm/knowledge-cards/retrieval-recall/" data-link-title="Retrieval Recall" data-link-desc="衡量 RAG 檢索是否把應該命中的文件或 chunk 放進 top-k 結果，是 component-level eval 的核心指標">retrieval recall</a>、抽取 accuracy）才能秒抓問題層、不然 specialization 換來的是更貴的 debug。</li>
<li><strong>「orchestration latency 殺死 UX」的量級</strong>：每跳一個 agent 加一個 LLM call（雲端旗艦 ~1-3s）。Hierarchical 三層、user query 到回應走 3+ 次 LLM、累積 3-10s。即時 chatbot 的 latency budget 通常 &lt; 3s、multi-agent 容易超標。Workaround：specialist 換小 model、或某些 step 改 deterministic、或退回 single agent + multi-step prompt。</li>
</ul>
<h3 id="先粗再細的演化路徑">「先粗、再細」的演化路徑</h3>
<p>實務多採演化路徑、不是一開始就設計多 agent：</p>
<ol>
<li><strong>Single agent 開始</strong>：把整個任務塞一個 agent、看跑得起來嗎。</li>
<li><strong>發現某子任務 systematic 失敗</strong>：那個子任務拆出來、變成 specialist agent。</li>
<li><strong>更多子任務需要拆</strong>：演化成 hierarchical。</li>
<li><strong>要跨產品重用</strong>：把某個 specialist 包成 agent-as-tool（透過 MCP）。</li>
</ol>
<p>這條路徑的好處是<strong>每一步都有具體痛點驅動拆分</strong>、不是「為了 multi-agent 而 multi-agent」。</p>
<h2 id="multi-agent-特有的失敗模式">Multi-Agent 特有的失敗模式</h2>
<p>除了單 agent 共通的失敗（context drift / goal drift / tool misread、見 <a href="/blog/llm/04-applications/agent-architecture/" data-link-title="4.4 Agent 架構原理" data-link-desc="Agent loop 結構、失敗模式、什麼任務適合 vs 不適合、跟人類審查的協作模型">4.4</a>）、multi-agent 系統有自己特有的失敗模式：</p>
<h3 id="循環依賴">循環依賴</h3>
<p>循環依賴是 agent 呼叫圖在執行期才形成 cycle、靜態 declaration 抓不出來、結果無限執行。例：Agent A 呼叫 B、B 呼叫 C、C 又呼叫 A、形成 cycle。</p>
<p>緩解：</p>
<ul>
<li>Call stack 監測、深度超過 N 強制中止。</li>
<li>Agent 設計時明確 declare 它會呼叫哪些下游 agent、靜態 check 不出 cycle。</li>
<li>Cycle 的合法用例（如 negotiation）要明確設停止條件。</li>
</ul>
<h3 id="責任歸屬模糊">責任歸屬模糊</h3>
<p>責任歸屬模糊是 multi-agent 的 cascading 結構讓 final output 的「哪個 agent 出錯」可能跨多個 agent 累積、debug 時不知道從哪查。</p>
<p>緩解：</p>
<ul>
<li>強制 trace 全部 agent call（見 <a href="/blog/llm/04-applications/llm-tracing-and-observability/" data-link-title="4.20 LLM tracing 與 observability" data-link-desc="OpenTelemetry GenAI semantic conventions、結構化 span 設計、cost / latency 監控、failure debug 流程、跟 LLM-as-judge eval 的串接">4.20 LLM tracing</a>）。</li>
<li>每個 agent 明確 declare 它對 final output 的貢獻範圍。</li>
<li>Error 用結構化、明確標出 raised by 哪個 agent。</li>
</ul>
<h3 id="context-重複傳遞">Context 重複傳遞</h3>
<p>Context 重複傳遞是 agent-as-tool 介面下、上游每次呼叫下游都要重新 brief 一遍、缺乏跨 call 的狀態保留、累積成 token cost 跟 latency 雙重浪費。</p>
<p>緩解：</p>
<ul>
<li>Specialist agent 自帶 session memory、不用每次 brief（見 <a href="/blog/llm/04-applications/agent-memory-architecture/" data-link-title="4.19 Agent memory 分層架構" data-link-desc="Agent 在 context window 之外管理長期狀態的設計：working / short-term / long-term episodic / semantic / procedural 五個層次、寫入時機、retrieval 設計、失敗模式">4.19 agent memory architecture</a>）。</li>
<li>共享 context（global state、reference passing）取代複製。</li>
<li>Agent-as-tool 協議設計時、輸入 schema 包含「已 brief 過、跳過 intro」flag。</li>
</ul>
<h3 id="orchestrator-成為單點認知瓶頸">Orchestrator 成為單點認知瓶頸</h3>
<p>Orchestrator 是 hierarchical 拓樸的核心、要理解所有 specialist 跟分派邏輯、它的 prompt / capability 限制整個系統上限。換 specialist 容易（介面標準）、換 orchestrator 牽動所有 routing 邏輯（耦合深）。</p>
<p>緩解：</p>
<ul>
<li>Orchestrator 的 dispatch 邏輯外部化（不寫在 prompt 內、寫在 deterministic routing rule）。</li>
<li>Specialist 自己 declare capability（用 OpenAPI / MCP schema）、orchestrator 動態讀、不寫死。</li>
</ul>
<h3 id="agent-之間互相-hallucinate">Agent 之間互相 hallucinate</h3>
<p>Agent 之間互相 hallucinate 是 agent 介面信任假設失效——上游 agent 給的 input 被視為「可信」、下游沒驗證就執行、hallucinated 內容沿著 agent chain 層層放大。</p>
<p>緩解：</p>
<ul>
<li>Agent 之間互通也要走 schema validation（見 <a href="/blog/llm/00-foundations/deterministic-vs-fuzzy-engineering/" data-link-title="0.8 Deterministic vs Fuzzy Engineering：軟體設計典範的位移" data-link-desc="傳統 deterministic 軟體跟 fuzzy LLM 軟體在資料、邏輯、分解、實驗成本四個維度的根本差異、以及哪段該 deterministic、哪段該 fuzzy 的決策框架">0.8 fuzzy engineering</a> guardrail 段）。</li>
<li>Critical path 加 deterministic check、不只靠 LLM 自評。</li>
</ul>
<h2 id="跟-mcp--function-calling-的協議對應">跟 MCP / Function Calling 的協議對應</h2>
<p><a href="/blog/llm/04-applications/application-protocols/" data-link-title="4.6 應用層協議：function calling / structured output / MCP" data-link-desc="三個常被混為一談的概念：模型能力、sampling 約束、server 協議，三者的層級差異與組合方式">4.6 應用層協議</a> 寫 function calling / structured output / MCP 的層級差異。Multi-agent 拓樸的 agent-as-tool 模式直接對應 MCP：</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">Agent-as-tool 在 MCP 視角下的展開：
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl">Client Agent
</span></span><span class="line"><span class="ln">4</span><span class="cl">  ├── MCP client
</span></span><span class="line"><span class="ln">5</span><span class="cl">  │     ↓ stdio / SSE / HTTP
</span></span><span class="line"><span class="ln">6</span><span class="cl">  │   MCP server #1 ← 包了一個 specialist agent
</span></span><span class="line"><span class="ln">7</span><span class="cl">  │   MCP server #2 ← 包了另一個 specialist agent
</span></span><span class="line"><span class="ln">8</span><span class="cl">  │   MCP server #3 ← 包了一個外部 service
</span></span><span class="line"><span class="ln">9</span><span class="cl">  └── 對 client agent 來說、三者介面一致、都是 tool</span></span></code></pre></div><p>這個 framing 的價值：<strong>目前 agent 跨組織重用的主要工程問題是 agent-as-tool 協議普及度</strong>——MCP 是當前的主流選項。當業界對協議 schema 達成共識（無論是 MCP 還是後續演化的標準）、agent-as-tool 拓樸的工程成本會大幅下降。</p>
<p>判讀訊號：自家 agent 想暴露給其他團隊用、預設選 MCP server 包裝、不要設計 proprietary protocol。</p>
<h2 id="何時過時--何時不過時">何時過時 / 何時不過時</h2>
<p><strong>不會過時的部分</strong>：</p>
<ul>
<li>Multi-call vs multi-agent 的判讀框架（控制流 / 角色 / context / 重用 / 失敗歸屬五維度）。</li>
<li>Flat / hierarchical / agent-as-tool 三種拓樸的結構分類。</li>
<li>Specialization gain vs orchestration overhead 的 trade-off。</li>
<li>「先粗、再細」的演化路徑反射。</li>
<li>Multi-agent 特有的五類失敗模式跟緩解。</li>
<li>Agent-as-tool 對應 MCP 的 framing。</li>
</ul>
<p><strong>會變的部分</strong>：</p>
<ul>
<li>具體 multi-agent framework（CrewAI / AutoGen / LangGraph multi-agent 等會持續演化）。</li>
<li>MCP server 生態的成熟度（普及度會大幅影響 agent-as-tool 的工程成本）。</li>
<li>各家 framework 對 multi-agent 失敗模式的 handling 工具（debugging / tracing tooling）。</li>
</ul>
<h2 id="下一章">下一章</h2>
<p>下一章：<a href="/blog/llm/04-applications/production-resource-planning/" data-link-title="4.9 Production 部署的資源評估原理" data-link-desc="從本地單 user 到 production multi-tenant：concurrent users、cost model、observability、SLA、capacity planning 的設計取捨">4.9 Production 部署資源評估</a>、把多 LLM call / 多 agent 系統的 cost / latency / capacity 落到具體 production 評估。Multi-agent 跟 multi-call 的對比基礎見 <a href="/blog/llm/04-applications/workflow-patterns/" data-link-title="4.7 Workflow 編排模式" data-link-desc="Pipeline / router / parallel / reflection：多 LLM call 組合的四種基本模式與退化條件">4.7 workflow patterns</a>、agent 自身的失敗模式見 <a href="/blog/llm/04-applications/agent-architecture/" data-link-title="4.4 Agent 架構原理" data-link-desc="Agent loop 結構、失敗模式、什麼任務適合 vs 不適合、跟人類審查的協作模型">4.4 agent 架構</a>、MCP 協議層討論見 <a href="/blog/llm/04-applications/application-protocols/" data-link-title="4.6 應用層協議：function calling / structured output / MCP" data-link-desc="三個常被混為一談的概念：模型能力、sampling 約束、server 協議，三者的層級差異與組合方式">4.6 應用層協議</a>。</p>
]]></content:encoded></item><item><title>6.8 高併發下的 Redis 與 SQL 使用原則</title><link>https://tarrragon.github.io/blog/go/06-practical/data-access-boundaries/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/06-practical/data-access-boundaries/</guid><description>&lt;p>這一章從 Go 服務的角度整理資料存取原則。重點在於：當併發增加時，Go 端要用明確邊界使用 Redis 或 SQL，讓下游維持可承受的請求節奏。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>理解高併發下最常見的資料存取風險&lt;/li>
&lt;li>區分 Redis 與 SQL 各自適合的角色&lt;/li>
&lt;li>用 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">connection pool&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a> 與批次策略控制壓力&lt;/li>
&lt;li>避免 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cache-stampede/" data-link-title="Cache Stampede" data-link-desc="說明快取同時失效時大量 request 如何壓垮正式來源">cache stampede&lt;/a> 與慢查詢連鎖&lt;/li>
&lt;li>在 Go 服務內設計可控的下游存取邊界&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察go-端要先控住請求節奏">【觀察】Go 端要先控住請求節奏&lt;/h2>
&lt;p>高併發時，資料存取風險通常來自請求節奏超過下游承受能力。你可以有很多 goroutine，但 Redis 與 SQL 不會因為 goroutine 多就自動變快。&lt;/p>
&lt;p>Go 端通常要先做的是：&lt;/p>
&lt;ul>
&lt;li>限制同時對下游發出的請求數&lt;/li>
&lt;li>設定明確 timeout&lt;/li>
&lt;li>避免無限 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out&lt;/a>&lt;/li>
&lt;li>在壓力過高時拒絕新工作&lt;/li>
&lt;/ul>
&lt;h2 id="判讀redis-適合快取狀態與短生命週期資料">【判讀】Redis 適合快取、狀態與短生命週期資料&lt;/h2>
&lt;p>Redis 在 Go 服務裡常見用途包括：&lt;/p>
&lt;ul>
&lt;li>cache&lt;/li>
&lt;li>session&lt;/li>
&lt;li>counter&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/rate-limit/" data-link-title="Rate Limit" data-link-desc="說明限流如何保護服務入口、下游依賴與租戶公平性">rate limit&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a> key&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> / stream&lt;/li>
&lt;/ul>
&lt;p>Go 端使用 Redis 時要注意：&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>熱 key&lt;/td>
 &lt;td>單點壓力過大&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>cache &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cache-hit-miss/" data-link-title="Cache Hit / Miss" data-link-desc="說明快取命中與未命中如何影響讀取成本與下游壓力">miss&lt;/a> 擁塞&lt;/td>
 &lt;td>大量 goroutine 同時打到後端&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>pipeline 太大&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a> 與記憶體壓力增加&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>缺少 timeout&lt;/td>
 &lt;td>慢 request 會堆積成連鎖問題&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="判讀sql-適合正式狀態與一致性資料">【判讀】SQL 適合正式狀態與一致性資料&lt;/h2>
&lt;p>SQL 在 Go 服務裡通常承接的是：&lt;/p>
&lt;ul>
&lt;li>最終狀態&lt;/li>
&lt;li>查詢&lt;/li>
&lt;li>交易&lt;/li>
&lt;li>可追蹤資料&lt;/li>
&lt;/ul>
&lt;p>Go 端最重要的原則是共用 &lt;code>*sql.DB&lt;/code>，讓 connection pool 真正發揮作用，並讓每個 query 都有 context 與 timeout。&lt;/p>
&lt;p>需要特別注意的是：&lt;/p>
&lt;ul>
&lt;li>太高的同時連線數會壓垮資料庫&lt;/li>
&lt;li>太長的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction&lt;/a> 會卡住連線池&lt;/li>
&lt;li>慢查詢會把 goroutine 一起拖住&lt;/li>
&lt;/ul>
&lt;h2 id="策略go-端要用邊界保護下游">【策略】Go 端要用邊界保護下游&lt;/h2>
&lt;p>高併發下的資料存取，通常要搭配以下做法：&lt;/p>
&lt;ul>
&lt;li>&lt;code>sql.DB&lt;/code> 與 Redis client 長期共用&lt;/li>
&lt;li>所有操作都帶 &lt;code>context&lt;/code>&lt;/li>
&lt;li>用 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">worker pool&lt;/a> 或 semaphore 控制同時請求數&lt;/li>
&lt;li>對 cache miss 做去重或保護&lt;/li>
&lt;li>對寫入高峰做批次或排隊&lt;/li>
&lt;/ul>
&lt;p>這些做法是讓高併發系統能長時間穩定運行的基本條件。&lt;/p></description><content:encoded><![CDATA[<p>這一章從 Go 服務的角度整理資料存取原則。重點在於：當併發增加時，Go 端要用明確邊界使用 Redis 或 SQL，讓下游維持可承受的請求節奏。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>理解高併發下最常見的資料存取風險</li>
<li>區分 Redis 與 SQL 各自適合的角色</li>
<li>用 <a href="/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">connection pool</a>、<a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a> 與批次策略控制壓力</li>
<li>避免 <a href="/blog/backend/knowledge-cards/cache-stampede/" data-link-title="Cache Stampede" data-link-desc="說明快取同時失效時大量 request 如何壓垮正式來源">cache stampede</a> 與慢查詢連鎖</li>
<li>在 Go 服務內設計可控的下游存取邊界</li>
</ol>
<hr>
<h2 id="觀察go-端要先控住請求節奏">【觀察】Go 端要先控住請求節奏</h2>
<p>高併發時，資料存取風險通常來自請求節奏超過下游承受能力。你可以有很多 goroutine，但 Redis 與 SQL 不會因為 goroutine 多就自動變快。</p>
<p>Go 端通常要先做的是：</p>
<ul>
<li>限制同時對下游發出的請求數</li>
<li>設定明確 timeout</li>
<li>避免無限 <a href="/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out</a></li>
<li>在壓力過高時拒絕新工作</li>
</ul>
<h2 id="判讀redis-適合快取狀態與短生命週期資料">【判讀】Redis 適合快取、狀態與短生命週期資料</h2>
<p>Redis 在 Go 服務裡常見用途包括：</p>
<ul>
<li>cache</li>
<li>session</li>
<li>counter</li>
<li><a href="/blog/backend/knowledge-cards/rate-limit/" data-link-title="Rate Limit" data-link-desc="說明限流如何保護服務入口、下游依賴與租戶公平性">rate limit</a></li>
<li><a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> key</li>
<li><a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> / stream</li>
</ul>
<p>Go 端使用 Redis 時要注意：</p>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>熱 key</td>
          <td>單點壓力過大</td>
      </tr>
      <tr>
          <td>cache <a href="/blog/backend/knowledge-cards/cache-hit-miss/" data-link-title="Cache Hit / Miss" data-link-desc="說明快取命中與未命中如何影響讀取成本與下游壓力">miss</a> 擁塞</td>
          <td>大量 goroutine 同時打到後端</td>
      </tr>
      <tr>
          <td>pipeline 太大</td>
          <td><a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 與記憶體壓力增加</td>
      </tr>
      <tr>
          <td>缺少 timeout</td>
          <td>慢 request 會堆積成連鎖問題</td>
      </tr>
  </tbody>
</table>
<h2 id="判讀sql-適合正式狀態與一致性資料">【判讀】SQL 適合正式狀態與一致性資料</h2>
<p>SQL 在 Go 服務裡通常承接的是：</p>
<ul>
<li>最終狀態</li>
<li>查詢</li>
<li>交易</li>
<li>可追蹤資料</li>
</ul>
<p>Go 端最重要的原則是共用 <code>*sql.DB</code>，讓 connection pool 真正發揮作用，並讓每個 query 都有 context 與 timeout。</p>
<p>需要特別注意的是：</p>
<ul>
<li>太高的同時連線數會壓垮資料庫</li>
<li>太長的 <a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a> 會卡住連線池</li>
<li>慢查詢會把 goroutine 一起拖住</li>
</ul>
<h2 id="策略go-端要用邊界保護下游">【策略】Go 端要用邊界保護下游</h2>
<p>高併發下的資料存取，通常要搭配以下做法：</p>
<ul>
<li><code>sql.DB</code> 與 Redis client 長期共用</li>
<li>所有操作都帶 <code>context</code></li>
<li>用 <a href="/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">worker pool</a> 或 semaphore 控制同時請求數</li>
<li>對 cache miss 做去重或保護</li>
<li>對寫入高峰做批次或排隊</li>
</ul>
<p>這些做法是讓高併發系統能長時間穩定運行的基本條件。</p>
]]></content:encoded></item><item><title>3.10 標準庫如何支撐服務型 Go</title><link>https://tarrragon.github.io/blog/go/03-stdlib/service-support/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/03-stdlib/service-support/</guid><description>&lt;p>Go 標準庫的服務價值在於它直接提供 HTTP、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a>、取消、日誌與資源管理的基本能力。這一章把前面學過的工具串成服務底座，讓讀者理解標準庫如何支撐後端程式，而不只是個別 API 的使用方式。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>看出標準庫為什麼是 Go 服務的底座&lt;/li>
&lt;li>把 &lt;code>context&lt;/code>、&lt;code>net/http&lt;/code>、&lt;code>log/slog&lt;/code>、&lt;code>defer&lt;/code> 與 &lt;code>time&lt;/code> 串成一個服務模型&lt;/li>
&lt;li>理解為什麼這些工具會讓服務更可維護&lt;/li>
&lt;li>把標準庫能力轉成實際服務邊界&lt;/li>
&lt;li>知道何時標準庫已足夠，何時才需要外部框架&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察標準庫本身就能做服務">【觀察】標準庫本身就能做服務&lt;/h2>
&lt;p>Go 的標準庫已經包含服務程式需要的主要基礎能力。&lt;code>net/http&lt;/code> 可以直接建立服務，&lt;code>context&lt;/code> 可以控制取消與 timeout，&lt;code>log/slog&lt;/code> 可以支援結構化日誌，&lt;code>defer&lt;/code> 可以整理資源釋放，&lt;code>time&lt;/code> 可以處理期限與排程。&lt;/p>
&lt;p>這些能力拼在一起，就是一個後端服務最基本的底盤。&lt;/p>
&lt;h2 id="判讀context-是服務生命週期的中心">【判讀】context 是服務生命週期的中心&lt;/h2>
&lt;p>在服務型 Go 裡，&lt;code>context&lt;/code> 是請求、取消與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline&lt;/a> 的共同語言。當 handler、worker、DB、Redis 都接受 context 時，整個流程就能在同一個生命週期邊界內運作；缺少 context 的長時間流程會讓取消與逾時難以傳遞。&lt;/p>
&lt;h2 id="判讀nethttp-讓入口保持簡單">【判讀】net/http 讓入口保持簡單&lt;/h2>
&lt;p>&lt;code>net/http&lt;/code> 的 handler 模型很薄，這是優點。它讓你能快速建立路由、驗證 request、回傳 response，而不需要先學一大套框架約定。對服務型 Go 來說，這種簡單性會直接降低協作成本。&lt;/p>
&lt;h2 id="策略log-與-defer-讓邊界更完整">【策略】log 與 defer 讓邊界更完整&lt;/h2>
&lt;p>&lt;code>log/slog&lt;/code> 提供結構化日誌，讓高併發服務的診斷更容易；&lt;code>defer&lt;/code> 則讓 close、unlock、cancel 等收尾操作更安全。這兩個工具都是 Go 在長時間運行服務中很重要的可靠性支撐。&lt;/p>
&lt;h2 id="小結">小結&lt;/h2>
&lt;p>標準庫是 Go 成為服務語言的核心原因之一。當你把 &lt;code>context&lt;/code>、&lt;code>net/http&lt;/code>、&lt;code>log/slog&lt;/code>、&lt;code>defer&lt;/code> 與 &lt;code>time&lt;/code> 看成一組工具時，就更容易理解 Go 為什麼適合做後端服務。&lt;/p></description><content:encoded><![CDATA[<p>Go 標準庫的服務價值在於它直接提供 HTTP、<a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a>、取消、日誌與資源管理的基本能力。這一章把前面學過的工具串成服務底座，讓讀者理解標準庫如何支撐後端程式，而不只是個別 API 的使用方式。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>看出標準庫為什麼是 Go 服務的底座</li>
<li>把 <code>context</code>、<code>net/http</code>、<code>log/slog</code>、<code>defer</code> 與 <code>time</code> 串成一個服務模型</li>
<li>理解為什麼這些工具會讓服務更可維護</li>
<li>把標準庫能力轉成實際服務邊界</li>
<li>知道何時標準庫已足夠，何時才需要外部框架</li>
</ol>
<hr>
<h2 id="觀察標準庫本身就能做服務">【觀察】標準庫本身就能做服務</h2>
<p>Go 的標準庫已經包含服務程式需要的主要基礎能力。<code>net/http</code> 可以直接建立服務，<code>context</code> 可以控制取消與 timeout，<code>log/slog</code> 可以支援結構化日誌，<code>defer</code> 可以整理資源釋放，<code>time</code> 可以處理期限與排程。</p>
<p>這些能力拼在一起，就是一個後端服務最基本的底盤。</p>
<h2 id="判讀context-是服務生命週期的中心">【判讀】context 是服務生命週期的中心</h2>
<p>在服務型 Go 裡，<code>context</code> 是請求、取消與 <a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline</a> 的共同語言。當 handler、worker、DB、Redis 都接受 context 時，整個流程就能在同一個生命週期邊界內運作；缺少 context 的長時間流程會讓取消與逾時難以傳遞。</p>
<h2 id="判讀nethttp-讓入口保持簡單">【判讀】net/http 讓入口保持簡單</h2>
<p><code>net/http</code> 的 handler 模型很薄，這是優點。它讓你能快速建立路由、驗證 request、回傳 response，而不需要先學一大套框架約定。對服務型 Go 來說，這種簡單性會直接降低協作成本。</p>
<h2 id="策略log-與-defer-讓邊界更完整">【策略】log 與 defer 讓邊界更完整</h2>
<p><code>log/slog</code> 提供結構化日誌，讓高併發服務的診斷更容易；<code>defer</code> 則讓 close、unlock、cancel 等收尾操作更安全。這兩個工具都是 Go 在長時間運行服務中很重要的可靠性支撐。</p>
<h2 id="小結">小結</h2>
<p>標準庫是 Go 成為服務語言的核心原因之一。當你把 <code>context</code>、<code>net/http</code>、<code>log/slog</code>、<code>defer</code> 與 <code>time</code> 看成一組工具時，就更容易理解 Go 為什麼適合做後端服務。</p>
]]></content:encoded></item><item><title>DragonflyDB shared-nothing 多核架構：用 scale-up 取代 Redis Cluster</title><link>https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/dragonflydb/shared-nothing-multicore-architecture/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/dragonflydb/shared-nothing-multicore-architecture/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/dragonflydb/" data-link-title="DragonflyDB" data-link-desc="高效能 Redis / Memcached 相容替代、多核架構">DragonflyDB&lt;/a> overview 的 implementation-layer deep article。選型層（為何選 DragonflyDB、BSL 授權、相容度）見 overview；本文只處理「決定用 DragonflyDB 後，多核架構怎麼用、相容邊界在哪」。命令實機驗證於 dragonfly df-v1.39.0（&lt;code>redis_version:7.4.0&lt;/code>）、最後檢查日 2026-06-16；效能數字以 &lt;a href="https://www.dragonflydb.io/">DragonflyDB 官方 benchmark&lt;/a> 為準。&lt;/p>&lt;/blockquote>
&lt;h2 id="scale-up-還是-scale-out一個架構賭注">scale-up 還是 scale-out：一個架構賭注&lt;/h2>
&lt;p>把一台 32 核機器交給 Redis，Redis 的主執行緒只用得到其中一核處理命令——要榨乾這台機器，你得在同一台上跑好幾個 Redis 進程、組成 Cluster、用 hash slot 把 key 分片。多核利用變成了一個分散式系統問題（&lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">cluster re-sharding&lt;/a>、cross-slot transaction、hash tag 治理全都來了）。&lt;/p>
&lt;p>DragonflyDB 賭的是相反方向：一個進程、thread-per-core、shared-nothing，讓單機在不分片的情況下用滿所有核。它的論點是——多數「需要 Redis Cluster」的場景，真正的需求是吞吐與記憶體，不是跨機器分散；如果單機就能撐到那個規模，Cluster 的複雜度就不必付。實機可以看到這個架構：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">redis-cli INFO server &lt;span class="p">|&lt;/span> grep -E &lt;span class="s2">&amp;#34;thread_count|redis_version|dragonfly_version&amp;#34;&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"># thread_count:8 ← 自動對齊 CPU 核數&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1"># redis_version:7.4.0 ← 對 client 裝成 Redis 7.4&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># dragonfly_version:df-v1.39.0&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>thread_count:8&lt;/code> 在一個進程內，不是 8 個 Redis 進程組 Cluster。這就是賭注的核心：把 Redis Cluster 的水平分片，收進單一進程的垂直多核。理解 DragonflyDB 就是理解這個賭注成立的條件與它撞牆的地方。&lt;/p>
&lt;p>對高吞吐單機 workload，這個賭注有現成的對照。&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/snap-gcp-keydb-cross-cloud/" data-link-title="9.C35 Snap：GCP &amp;#43; KeyDB 在 multi-cloud 架構下的低延遲快取" data-link-desc="Snap 用 GCP 上的 KeyDB cluster 減少跨 cloud cache 延遲、用 TPU 訓練廣告推薦模型">Snap 在 multi-cloud 用 KeyDB&lt;/a>（Redis 的 multi-threaded fork、單實例吞吐提升 5-10x）撐超高吞吐 cache，正是「不想為了多核去組 Cluster」的同一類需求；DragonflyDB 是這條路線更激進的版本（從零用 C++ 重寫、不是在 Redis 上加 thread）。&lt;/p>
&lt;h2 id="核心概念thread-per-core-與-shared-nothing">核心概念：thread-per-core 與 shared-nothing&lt;/h2>
&lt;p>DragonflyDB 的多核不是「多個執行緒搶同一份資料」，而是把資料切給各個執行緒、彼此不共享——這是它能線性擴展到多核的關鍵。&lt;/p>
&lt;p>&lt;strong>thread-per-core + 資料分區&lt;/strong>。每個 thread 綁一個核，keyspace 被 hash 切成多個 slice，每個 slice 只由一個 thread 擁有。一個命令進來，被路由到擁有該 key 的 thread 處理。因為一個 key 只有一個 thread 碰，單 key 操作不需要鎖——這消除了 Redis 多執行緒方案最大的開銷（lock contention）。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/02-cache-redis/vendors/dragonflydb/" data-link-title="DragonflyDB" data-link-desc="高效能 Redis / Memcached 相容替代、多核架構">DragonflyDB</a> overview 的 implementation-layer deep article。選型層（為何選 DragonflyDB、BSL 授權、相容度）見 overview；本文只處理「決定用 DragonflyDB 後，多核架構怎麼用、相容邊界在哪」。命令實機驗證於 dragonfly df-v1.39.0（<code>redis_version:7.4.0</code>）、最後檢查日 2026-06-16；效能數字以 <a href="https://www.dragonflydb.io/">DragonflyDB 官方 benchmark</a> 為準。</p></blockquote>
<h2 id="scale-up-還是-scale-out一個架構賭注">scale-up 還是 scale-out：一個架構賭注</h2>
<p>把一台 32 核機器交給 Redis，Redis 的主執行緒只用得到其中一核處理命令——要榨乾這台機器，你得在同一台上跑好幾個 Redis 進程、組成 Cluster、用 hash slot 把 key 分片。多核利用變成了一個分散式系統問題（<a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">cluster re-sharding</a>、cross-slot transaction、hash tag 治理全都來了）。</p>
<p>DragonflyDB 賭的是相反方向：一個進程、thread-per-core、shared-nothing，讓單機在不分片的情況下用滿所有核。它的論點是——多數「需要 Redis Cluster」的場景，真正的需求是吞吐與記憶體，不是跨機器分散；如果單機就能撐到那個規模，Cluster 的複雜度就不必付。實機可以看到這個架構：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">redis-cli INFO server <span class="p">|</span> grep -E <span class="s2">&#34;thread_count|redis_version|dragonfly_version&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># thread_count:8               ← 自動對齊 CPU 核數</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># redis_version:7.4.0          ← 對 client 裝成 Redis 7.4</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># dragonfly_version:df-v1.39.0</span></span></span></code></pre></div><p><code>thread_count:8</code> 在一個進程內，不是 8 個 Redis 進程組 Cluster。這就是賭注的核心：把 Redis Cluster 的水平分片，收進單一進程的垂直多核。理解 DragonflyDB 就是理解這個賭注成立的條件與它撞牆的地方。</p>
<p>對高吞吐單機 workload，這個賭注有現成的對照。<a href="/blog/backend/09-performance-capacity/cases/snap-gcp-keydb-cross-cloud/" data-link-title="9.C35 Snap：GCP &#43; KeyDB 在 multi-cloud 架構下的低延遲快取" data-link-desc="Snap 用 GCP 上的 KeyDB cluster 減少跨 cloud cache 延遲、用 TPU 訓練廣告推薦模型">Snap 在 multi-cloud 用 KeyDB</a>（Redis 的 multi-threaded fork、單實例吞吐提升 5-10x）撐超高吞吐 cache，正是「不想為了多核去組 Cluster」的同一類需求；DragonflyDB 是這條路線更激進的版本（從零用 C++ 重寫、不是在 Redis 上加 thread）。</p>
<h2 id="核心概念thread-per-core-與-shared-nothing">核心概念：thread-per-core 與 shared-nothing</h2>
<p>DragonflyDB 的多核不是「多個執行緒搶同一份資料」，而是把資料切給各個執行緒、彼此不共享——這是它能線性擴展到多核的關鍵。</p>
<p><strong>thread-per-core + 資料分區</strong>。每個 thread 綁一個核，keyspace 被 hash 切成多個 slice，每個 slice 只由一個 thread 擁有。一個命令進來，被路由到擁有該 key 的 thread 處理。因為一個 key 只有一個 thread 碰，單 key 操作不需要鎖——這消除了 Redis 多執行緒方案最大的開銷（lock contention）。</p>
<p><strong>dashtable 取代 Redis 的 dict</strong>。DragonflyDB 用自製的 dashtable（一種 hash table）取代 Redis 的 dictionary，記憶體佈局更緊湊、resize 時不需要像 Redis 那樣漸進式 rehash 全表，同樣的 dataset 通常比 Redis 省 20-40% 記憶體（依資料形狀，以官方 benchmark 為準）。</p>
<p><strong>fork-less snapshot</strong>。Redis 的持久化靠 <code>fork()</code>，大記憶體下會凍結主執行緒並讓記憶體接近翻倍（見 <a href="/blog/backend/02-cache-redis/vendors/redis/persistence-fork-latency/" data-link-title="Redis 持久化與 fork latency：AOF、RDB 與那一次卡住整個 cluster 的 fork" data-link-desc="Redis 的 RDB save 與 AOF rewrite 都靠一次 fork()，而 fork 在大記憶體實例上會凍結主執行緒數百毫秒、複製分頁讓記憶體逼近翻倍。本文展開 AOF / RDB 的機制與 fsync 取捨、copy-on-write 的記憶體放大、5 個把持久化寫成延遲尖峰與資料遺失的 production 踩坑，以及 cache 場景到底要不要持久化的邊界">Redis persistence deep article</a>）。DragonflyDB 不用 fork——它用自己的快照演算法在不複製整個進程的前提下做一致性快照，大記憶體場景不付 fork 的延遲尖峰與記憶體翻倍代價。這是它對「fork 是 Redis 結構性瓶頸」這個痛點的直接回答。</p>
<p><strong>多執行緒的代價：沒有 Redis Cluster mode</strong>。資料分區在單進程內，DragonflyDB 不提供 Redis Cluster mode（它的哲學是單機撐大、不跨機器分片）。這個取捨決定了它的相容邊界與容量天花板，是後面踩坑的根源。</p>
<h2 id="配置多核與持久化的設定路徑">配置：多核與持久化的設定路徑</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">docker run -d --name dragonfly -p 6379:6379 <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  docker.dragonflydb.io/dragonflydb/dragonfly <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>    --threads <span class="m">8</span> <span class="se">\ </span>             <span class="c1"># thread 數、預設等於 CPU 核數（一般不需手動設）</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    --maxmemory 4gb <span class="se">\ </span>         <span class="c1"># 記憶體上限、行為類似 Redis maxmemory</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    --cache_mode <span class="nb">true</span> <span class="se">\ </span>       <span class="c1"># 純 cache 模式：記憶體滿時自動 evict（類似 allkeys-lru）</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    --snapshot_cron <span class="s2">&#34;0 3 * * *&#34;</span> <span class="c1"># fork-less snapshot 排程（cron 格式、這裡每天 3 點）</span></span></span></code></pre></div><p>調校判讀：</p>
<ul>
<li><code>--threads</code> 預設對齊 CPU 核數，多數情況不需手動設；設小於核數會浪費核，設大於核數沒有意義</li>
<li><code>--cache_mode true</code> 讓 DragonflyDB 在記憶體滿時自動淘汰（純 cache 行為）；不開則記憶體滿時拒絕寫入（類似 Redis noeviction）</li>
<li><code>--maxmemory</code> 留 headroom，但因為 fork-less，headroom 不需要像 Redis 留那麼多給 fork copy-on-write</li>
<li>snapshot 用 <code>--snapshot_cron</code> 排程，fork-less 機制讓大記憶體快照不產生延遲尖峰</li>
</ul>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1client-配-cluster-mode連不上">Case 1：client 配 Cluster mode、連不上</h3>
<p><strong>徵兆</strong>：從 Redis Cluster 遷來，application 的 client library 還配著 cluster mode，連 DragonflyDB 報錯或 hang，<code>CLUSTER</code> 相關命令行為不如預期。</p>
<p><strong>根因</strong>：DragonflyDB 不提供 Redis Cluster mode（單進程多核、不跨機器分片）。cluster-aware client 會嘗試 <code>CLUSTER SLOTS</code> 之類的拓樸發現，跟 standalone 的 DragonflyDB 對不上。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>client 改回 standalone 配置（不要 cluster mode）</li>
<li>評估原本用 Cluster 的理由：若是為了多核吞吐，DragonflyDB 單進程多核已涵蓋，不需要 cluster mode</li>
<li>若原本用 Cluster 是為了超過單機的容量 / 跨機器分散，DragonflyDB 的 scale-up 模型撐不住，該留在 <a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Redis Cluster</a></li>
<li>確認 application 沒有依賴 cluster-specific 行為（hash tag 的跨 slot 語意等）</li>
</ol>
<h3 id="case-2某些-redis-命令--module-不支援">Case 2：某些 Redis 命令 / module 不支援</h3>
<p><strong>徵兆</strong>：核心 SET/GET/HASH 等正常，但某個命令報 <code>unknown command</code> 或行為跟 Redis 不同，特別是 module 命令（RedisJSON / RedisSearch）與部分冷門命令。</p>
<p><strong>根因</strong>：DragonflyDB 相容大多數 Redis 命令但不是 100%；它宣稱相容 <code>redis_version:7.4.0</code>，但部分 module、部分冷門命令、部分 Lua 行為有差異。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>遷移前盤點 application 用到的命令，對照 DragonflyDB 的 API 相容清單（官方 docs）</li>
<li>module 重度依賴（RedisJSON / RedisSearch）要特別確認——DragonflyDB 的 module 生態比 Redis 淺</li>
<li>Lua script 行為差異要實測，不要假設跟 Redis 完全一致</li>
<li>相容性是遷移的主要風險，跟 <a href="/blog/backend/02-cache-redis/vendors/valkey/redis-compatibility-and-io-threads/" data-link-title="Valkey 相容性驗證與 io-threads 調校：drop-in 切換與多執行緒的實機判讀" data-link-desc="Valkey 跟 Redis 100% 相容這句話要怎麼驗證、切換才敢上線。本文用 INFO server 的雙版本回報拆解相容性的真實邊界、展開 Valkey 8 的 io-threads 多執行緒調校、5 個把 drop-in 切換或執行緒配置寫成事故的 production 踩坑，以及相容性撞牆該怎麼判斷的邊界">Valkey 的相容性驗證</a>同理但 DragonflyDB 邊界更寬（重寫而非 fork）</li>
</ol>
<h3 id="case-3thread-沒對齊核數多核優勢沒發揮">Case 3：thread 沒對齊核數、多核優勢沒發揮</h3>
<p><strong>徵兆</strong>：吞吐沒有達到預期、CPU 使用率不均（部分核閒置），<code>thread_count</code> 跟機器核數對不上。</p>
<p><strong>根因</strong>：<code>--threads</code> 被手動設成小於 CPU 核數，或容器的 CPU limit 限制了實際可用核數，DragonflyDB 沒能用滿所有核。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><code>redis-cli INFO server | grep thread_count</code> 確認 thread 數對齊實體核數</li>
<li>容器環境確認 CPU limit 沒有卡住 DragonflyDB 的核數（cgroup CPU quota）</li>
<li>不要手動把 <code>--threads</code> 設小，預設對齊核數就是最佳</li>
<li>吞吐沒到預期也可能是 workload 本身（大命令、網路 RTT），用 <a href="/blog/backend/02-cache-redis/vendors/redis/connection-pipeline-latency/" data-link-title="Redis 連線與 pipeline：RTT 稅、連線池與一次往返打包多命令" data-link-desc="Redis 單命令通常微秒級執行，但 application 端量到的延遲是毫秒級——差距全在網路往返（RTT）。pipelining 的本質不是『批次發命令』，是把 N 次 RTT 壓成 1 次。本文展開 RTT 會計、連線池配置、pipeline 與 MULTI 的差異、5 個把連線與往返寫成延遲與正確性問題的 production 踩坑，以及連線模型撞牆的邊界">連線 / pipeline</a> 的 RTT 分析交叉判斷</li>
</ol>
<h3 id="case-4跨-partition-的多-key-操作有額外成本">Case 4：跨 partition 的多 key 操作有額外成本</h3>
<p><strong>徵兆</strong>：大量多 key 命令（MGET 跨很多 key、跨 key 的 Lua）的延遲比預期高，單 key 操作則很快。</p>
<p><strong>根因</strong>：shared-nothing 下 key 分散在不同 thread，多 key 操作要跨 thread 協調——單 key 免鎖的好處在多 key 跨 partition 時要付協調成本。這跟 Redis Cluster 的 cross-slot 是類似的本質（資料分散的代價），只是發生在單進程內。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>高頻的多 key 操作盡量讓 key 落在同 partition（DragonflyDB 的 key 分布規則）</li>
<li>評估能否用單 key 結構（hash）取代多個 key 的聚合</li>
<li>跨 partition 協調是分區架構的固有成本，不是 bug，量大時要設計繞過</li>
<li>對照 <a href="/blog/backend/02-cache-redis/vendors/redis/connection-pipeline-latency/" data-link-title="Redis 連線與 pipeline：RTT 稅、連線池與一次往返打包多命令" data-link-desc="Redis 單命令通常微秒級執行，但 application 端量到的延遲是毫秒級——差距全在網路往返（RTT）。pipelining 的本質不是『批次發命令』，是把 N 次 RTT 壓成 1 次。本文展開 RTT 會計、連線池配置、pipeline 與 MULTI 的差異、5 個把連線與往返寫成延遲與正確性問題的 production 踩坑，以及連線模型撞牆的邊界">Redis Cluster 的 cross-slot 限制</a>，兩者都是「資料分散換吞吐」的代價</li>
</ol>
<h3 id="case-5bsl-授權踩到商業使用限制">Case 5：BSL 授權踩到商業使用限制</h3>
<p><strong>徵兆</strong>：準備把 DragonflyDB 包成對外的 managed service 提供給客戶，法務 review 卡關。</p>
<p><strong>根因</strong>：DragonflyDB 用 BSL（Business Source License），商業使用受限——具體限制是不可把 DragonflyDB 當成 managed service 對外提供（4 年後該版本轉 Apache 2.0）。內部使用無限制，但 SaaS 對外提供 DragonflyDB 即服務受限。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>內部使用（多數企業場景）無限制，直接用</li>
<li>要把 DragonflyDB 當 managed service 對外賣，聯絡 DragonflyDB 取得商業 license</li>
<li>開源合規敏感（公部門 / 企業 OSI 政策）走 OSI 認可的 <a href="/blog/backend/02-cache-redis/vendors/valkey/" data-link-title="Valkey" data-link-desc="Redis fork、Linux Foundation 託管、BSD 授權">Valkey</a>（BSD）</li>
<li>授權法律解讀諮詢法務，不要憑技術判斷</li>
</ol>
<h2 id="capacity--cost-邊界">Capacity / cost 邊界</h2>
<p>DragonflyDB 的容量判讀，核心在 scale-up 的天花板與多核效率：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>健康區間</th>
          <th>警戒與動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>thread_count</code></td>
          <td>= CPU 實體核數</td>
          <td>&lt; 核數 → 沒用滿多核、查 &ndash;threads / cgroup</td>
      </tr>
      <tr>
          <td>單機吞吐</td>
          <td>遠高於單 Redis 進程</td>
          <td>撞單機網路 / CPU 上限 → scale-up 到頂</td>
      </tr>
      <tr>
          <td>記憶體效率</td>
          <td>比 Redis 省 20-40%（依形狀）</td>
          <td>以官方 benchmark + 自己量為準</td>
      </tr>
      <tr>
          <td>snapshot 延遲尖峰</td>
          <td>接近 0（fork-less）</td>
          <td>有尖峰 → 確認用的是 DragonflyDB 快照不是相容路徑</td>
      </tr>
      <tr>
          <td>單機容量 / 跨 AZ 需求</td>
          <td>單機 + replica 撐得住</td>
          <td>超單機 / 要跨機器分散 → DragonflyDB 撐不住</td>
      </tr>
  </tbody>
</table>
<p>撞牆後的路由判斷：</p>
<ul>
<li><strong>超過單機容量、需要跨機器分散</strong>：DragonflyDB 的 scale-up 賭注在這裡輸——它沒有 Cluster mode。要跨機器分片走 <a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Redis / Valkey Cluster</a>。</li>
<li><strong>需要 OSI 認可開源授權</strong>：BSL 不是 OSI 認可，合規敏感走 <a href="/blog/backend/02-cache-redis/vendors/valkey/" data-link-title="Valkey" data-link-desc="Redis fork、Linux Foundation 託管、BSD 授權">Valkey</a>（BSD）。</li>
<li><strong>不想自管</strong>：DragonflyDB 目前沒有 fully managed offering（無 ElastiCache for Dragonfly），必須自管。要 managed 走 <a href="/blog/backend/02-cache-redis/vendors/aws-elasticache/" data-link-title="AWS ElastiCache" data-link-desc="AWS managed Redis / Valkey / Memcached">ElastiCache</a>（Redis / Valkey / Memcached）。</li>
<li><strong>跨 AZ / 跨 region HA</strong>：DragonflyDB 有 replica 模式（primary-replica）跨 AZ 可行，但跨 region 需自建——大規模跨區走 managed 的 Global Datastore。</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<p>DragonflyDB 的定位是「Redis 相容 + 激進多核」，它在 Redis 相容服務的光譜上有明確座標：</p>
<ul>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/valkey/" data-link-title="Valkey" data-link-desc="Redis fork、Linux Foundation 託管、BSD 授權">Valkey</a></strong>：兩者都打「Redis 相容 + 更好的多核」，但 Valkey 是 fork（同源、最高相容、漸進加 thread），DragonflyDB 是 C++ 重寫（相容核心但架構激進、多核更徹底）。相容度要極致選 Valkey，多核吞吐要極致選 DragonflyDB。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/keydb/" data-link-title="KeyDB" data-link-desc="Redis multi-threaded fork、active-replication、Snap 採用">KeyDB</a> / Garnet</strong>：KeyDB 是 Redis 的 multi-threaded fork（<a href="/blog/backend/09-performance-capacity/cases/snap-gcp-keydb-cross-cloud/" data-link-title="9.C35 Snap：GCP &#43; KeyDB 在 multi-cloud 架構下的低延遲快取" data-link-desc="Snap 用 GCP 上的 KeyDB cluster 減少跨 cloud cache 延遲、用 TPU 訓練廣告推薦模型">Snap 採用</a>、Snap 收購後相對停滯）；Garnet 是 Microsoft 的研究型高吞吐 store（生態淺）。DragonflyDB 是這個「高吞吐 Redis 替代」群裡商業化最積極、生態最活躍的。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/vendors/redis/cluster-resharding/" data-link-title="Redis Cluster Re-sharding：source = target，但 topology 重劃的 5 段流程" data-link-desc="Redis cluster re-sharding 是 5 type migration 漏類實證 — source / target 同 cluster、無 schema / paradigm 差、但 16384 slot 重分配是核心；本文涵蓋 4 種 re-sharding driver、slot migration 機制、redis-cli --cluster rebalance / reshard 工具、5 個 production 踩雷（cluster busy / replica lag / client cache stale / cross-slot transaction / monitor gap）">Redis Cluster re-sharding</a></strong>：如果你的 Redis Cluster re-sharding 頻繁觸發、運維負擔重，DragonflyDB 的 scale-up 模型可能用單機取代整個 Cluster——這是評估遷移的主要動機。</li>
<li><strong>跟 <a href="/blog/backend/02-cache-redis/cases/shopify-write-through-cache-at-scale/" data-link-title="2.C5 Shopify：Write-through Cache 在高讀流量的實作" data-link-desc="read-heavy 服務如何轉向 write-through 快取模型。">Shopify write-through</a></strong>：write-through 在 DragonflyDB 上行為一致，但單進程多核能承接比單 Redis 進程更大的 throughput，是 read-heavy + write-through 場景的 scale-up 選項。</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/02-cache-redis/vendors/dragonflydb/" data-link-title="DragonflyDB" data-link-desc="高效能 Redis / Memcached 相容替代、多核架構">DragonflyDB</a></li>
<li>對照 vendor：<a href="/blog/backend/02-cache-redis/vendors/valkey/redis-compatibility-and-io-threads/" data-link-title="Valkey 相容性驗證與 io-threads 調校：drop-in 切換與多執行緒的實機判讀" data-link-desc="Valkey 跟 Redis 100% 相容這句話要怎麼驗證、切換才敢上線。本文用 INFO server 的雙版本回報拆解相容性的真實邊界、展開 Valkey 8 的 io-threads 多執行緒調校、5 個把 drop-in 切換或執行緒配置寫成事故的 production 踩坑，以及相容性撞牆該怎麼判斷的邊界">Valkey 相容性與 io-threads</a>、<a href="/blog/backend/02-cache-redis/vendors/redis/persistence-fork-latency/" data-link-title="Redis 持久化與 fork latency：AOF、RDB 與那一次卡住整個 cluster 的 fork" data-link-desc="Redis 的 RDB save 與 AOF rewrite 都靠一次 fork()，而 fork 在大記憶體實例上會凍結主執行緒數百毫秒、複製分頁讓記憶體逼近翻倍。本文展開 AOF / RDB 的機制與 fsync 取捨、copy-on-write 的記憶體放大、5 個把持久化寫成延遲尖峰與資料遺失的 production 踩坑，以及 cache 場景到底要不要持久化的邊界">Redis persistence 與 fork latency</a>（fork-less 對照的痛點）</li>
<li>相關 migration：<a href="/blog/backend/02-cache-redis/vendors/redis/migrate-to-dragonflydb/" data-link-title="Redis → DragonflyDB：drop-in 相容下的容量躍升 &#43; 5 個踩雷" data-link-desc="DragonflyDB 號稱 Redis drop-in 替代、單機 throughput 25x、記憶體效率 30% 提升；遷移流程簡單但有 5 個 production 踩雷（RDB 版本差 / Lua 腳本不全支援 / Pub-Sub fanout 行為差異 / Cluster mode 兼容度 / Modules 不支援）、跟 Sentinel / Cluster 模式對位">Redis → DragonflyDB</a></li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章寫作方法論</a></li>
</ul>
]]></content:encoded></item><item><title>Filter 與 Source 的抽象層錯位</title><link>https://tarrragon.github.io/blog/report/view-layer-filter-vs-source-layer/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/view-layer-filter-vs-source-layer/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>Filter 必須跟它過濾的資料源在同一層運作。&lt;/strong> 把 filter 寫在視覺層（querySelector + show/hide）、把 source 留在資料層分批產出（paginated fetch / streaming / lazy iterator）— 兩層的「一筆」定義不一致、filter 看不到 source 還沒產出的東西、結果跟使用者意圖之間有語意縫。&lt;/p>
&lt;p>更廣義的說法：&lt;strong>stream 操作（filter / sort / count / transform / search）必須跟 stream 的 materialization 同層或更上游&lt;/strong>。在下游做 stream 操作、操作的對象是已經 materialize 的 subset、不是完整的 stream。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼層錯位產生語意縫">為什麼層錯位產生語意縫&lt;/h2>
&lt;h3 id="一筆在不同層有不同定義">「一筆」在不同層有不同定義&lt;/h3>
&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>Source 產出的一筆 record&lt;/td>
 &lt;td>全部、或還沒產出的下一批&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>渲染層&lt;/td>
 &lt;td>已 render 進 DOM 的一筆&lt;/td>
 &lt;td>= 已 fetch 並 render 過的子集&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>視覺層&lt;/td>
 &lt;td>螢幕上看得見的一筆&lt;/td>
 &lt;td>= render 層之中沒被 hide 的子集&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Filter 寫在視覺層、它的「過濾全部」≡「過濾螢幕上看得見的全部」≡「過濾已 fetch 已 render 的子集」。&lt;strong>離資料層的真實全集差兩層&lt;/strong>。使用者意圖（「給我所有 title 含 X 的結果」）對應的是資料層的全集、不是視覺層的子集。&lt;/p>
&lt;h3 id="silent-失敗的條件">Silent 失敗的條件&lt;/h3>
&lt;p>層錯位不會在「filter 子集裡有命中」的情境下被發現。它只在以下條件下顯露：&lt;/p>
&lt;ol>
&lt;li>已 materialize 的子集裡剛好沒命中&lt;/li>
&lt;li>但完整 stream 裡有命中、只是還沒 materialize&lt;/li>
&lt;li>使用者沒有訊號知道「還有沒抓的」&lt;/li>
&lt;/ol>
&lt;p>三個條件同時滿足、使用者看到「filter 後是空的」、誤以為是「沒有命中」、放棄。&lt;/p>
&lt;h3 id="為什麼這個-bug-容易寫出來">為什麼這個 bug 容易寫出來&lt;/h3>
&lt;p>視覺層 filter 是寫起來最簡單的版本：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">items&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">forEach&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">style&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">display&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">dataset&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">title&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">includes&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">query&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">?&lt;/span> &lt;span class="s1">&amp;#39;&amp;#39;&lt;/span> &lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;none&amp;#39;&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="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>5 行解決、看起來能用、第一輪測試（手動輸入 query → 看到 filter 生效）會通過。&lt;strong>「能用」的訊號出現太早、掩蓋了語意缺口&lt;/strong>。&lt;/p>
&lt;p>這是 &lt;a href="../ease-of-writing-vs-intent-alignment/">#67 寫作便利度跟意圖對齊反相關&lt;/a> 在「filter × source」情境的具體展現 — 容易寫的位置（已 materialize 的 view 層）跟對齊意圖的位置（source 層）方向相反。&lt;/p>
&lt;hr>
&lt;h2 id="哪些-source-形狀有層錯位風險">哪些 source 形狀有層錯位風險&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Source 型態&lt;/th>
 &lt;th>是否有層錯位風險&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>一次性 fetch、靜態陣列&lt;/td>
 &lt;td>否（沒有 subset）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Paginated fetch（load more / cursor）&lt;/td>
 &lt;td>是 — 本次任務的 case&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Streaming（SSE / WebSocket）&lt;/td>
 &lt;td>視 server 是否限額&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Lazy iterator + take(N) / break&lt;/td>
 &lt;td>是&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Cached + revalidate&lt;/td>
 &lt;td>是（cache vs fresh 兩 dataset）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>四類 source 共用同個結構：&lt;strong>source 分批 / 限額 / 延遲 materialize、filter 在下游 → silent 缺口&lt;/strong>。詳細形狀分析見 &lt;a href="../data-source-shape-defines-feature-shape/">#63 資料源的形狀決定 feature 的形狀&lt;/a>。&lt;/p>
&lt;hr>
&lt;h2 id="這次任務的實際情境">這次任務的實際情境&lt;/h2>
&lt;h3 id="觀察">觀察&lt;/h3>
&lt;p>搜尋頁實作 title / content filter：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// pagefind 分批 load (load more 按鈕)
&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="kr">const&lt;/span> &lt;span class="nx">results&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">pagefind&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">search&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">query&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="nx">results&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">results&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">slice&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">start&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">start&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="mi">10&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">forEach&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="nx">container&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">render&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">)));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="c1">// 我們在 view 層 post-filter
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">function&lt;/span> &lt;span class="nx">applyFilter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">scope&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelectorAll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.result&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">forEach&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">hidden&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="o">!&lt;/span>&lt;span class="nx">matchesScope&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">scope&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="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>跑出來的問題：使用者選 title-only filter、第二批 8 筆全部 title 不含 query → 點 &amp;ldquo;load more&amp;rdquo; 後畫面閃了一下、新增的 8 筆全 hidden、使用者看到的內容沒變。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>Filter 必須跟它過濾的資料源在同一層運作。</strong> 把 filter 寫在視覺層（querySelector + show/hide）、把 source 留在資料層分批產出（paginated fetch / streaming / lazy iterator）— 兩層的「一筆」定義不一致、filter 看不到 source 還沒產出的東西、結果跟使用者意圖之間有語意縫。</p>
<p>更廣義的說法：<strong>stream 操作（filter / sort / count / transform / search）必須跟 stream 的 materialization 同層或更上游</strong>。在下游做 stream 操作、操作的對象是已經 materialize 的 subset、不是完整的 stream。</p>
<hr>
<h2 id="為什麼層錯位產生語意縫">為什麼層錯位產生語意縫</h2>
<h3 id="一筆在不同層有不同定義">「一筆」在不同層有不同定義</h3>
<table>
  <thead>
      <tr>
          <th>層</th>
          <th>「一筆」是什麼</th>
          <th>邊界</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>資料層</td>
          <td>Source 產出的一筆 record</td>
          <td>全部、或還沒產出的下一批</td>
      </tr>
      <tr>
          <td>渲染層</td>
          <td>已 render 進 DOM 的一筆</td>
          <td>= 已 fetch 並 render 過的子集</td>
      </tr>
      <tr>
          <td>視覺層</td>
          <td>螢幕上看得見的一筆</td>
          <td>= render 層之中沒被 hide 的子集</td>
      </tr>
  </tbody>
</table>
<p>Filter 寫在視覺層、它的「過濾全部」≡「過濾螢幕上看得見的全部」≡「過濾已 fetch 已 render 的子集」。<strong>離資料層的真實全集差兩層</strong>。使用者意圖（「給我所有 title 含 X 的結果」）對應的是資料層的全集、不是視覺層的子集。</p>
<h3 id="silent-失敗的條件">Silent 失敗的條件</h3>
<p>層錯位不會在「filter 子集裡有命中」的情境下被發現。它只在以下條件下顯露：</p>
<ol>
<li>已 materialize 的子集裡剛好沒命中</li>
<li>但完整 stream 裡有命中、只是還沒 materialize</li>
<li>使用者沒有訊號知道「還有沒抓的」</li>
</ol>
<p>三個條件同時滿足、使用者看到「filter 後是空的」、誤以為是「沒有命中」、放棄。</p>
<h3 id="為什麼這個-bug-容易寫出來">為什麼這個 bug 容易寫出來</h3>
<p>視覺層 filter 是寫起來最簡單的版本：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">items</span><span class="p">.</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">el</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">el</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">display</span> <span class="o">=</span> <span class="nx">el</span><span class="p">.</span><span class="nx">dataset</span><span class="p">.</span><span class="nx">title</span><span class="p">.</span><span class="nx">includes</span><span class="p">(</span><span class="nx">query</span><span class="p">)</span> <span class="o">?</span> <span class="s1">&#39;&#39;</span> <span class="o">:</span> <span class="s1">&#39;none&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>5 行解決、看起來能用、第一輪測試（手動輸入 query → 看到 filter 生效）會通過。<strong>「能用」的訊號出現太早、掩蓋了語意缺口</strong>。</p>
<p>這是 <a href="../ease-of-writing-vs-intent-alignment/">#67 寫作便利度跟意圖對齊反相關</a> 在「filter × source」情境的具體展現 — 容易寫的位置（已 materialize 的 view 層）跟對齊意圖的位置（source 層）方向相反。</p>
<hr>
<h2 id="哪些-source-形狀有層錯位風險">哪些 source 形狀有層錯位風險</h2>
<table>
  <thead>
      <tr>
          <th>Source 型態</th>
          <th>是否有層錯位風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>一次性 fetch、靜態陣列</td>
          <td>否（沒有 subset）</td>
      </tr>
      <tr>
          <td>Paginated fetch（load more / cursor）</td>
          <td>是 — 本次任務的 case</td>
      </tr>
      <tr>
          <td>Streaming（SSE / WebSocket）</td>
          <td>視 server 是否限額</td>
      </tr>
      <tr>
          <td>Lazy iterator + take(N) / break</td>
          <td>是</td>
      </tr>
      <tr>
          <td>Cached + revalidate</td>
          <td>是（cache vs fresh 兩 dataset）</td>
      </tr>
  </tbody>
</table>
<p>四類 source 共用同個結構：<strong>source 分批 / 限額 / 延遲 materialize、filter 在下游 → silent 缺口</strong>。詳細形狀分析見 <a href="../data-source-shape-defines-feature-shape/">#63 資料源的形狀決定 feature 的形狀</a>。</p>
<hr>
<h2 id="這次任務的實際情境">這次任務的實際情境</h2>
<h3 id="觀察">觀察</h3>
<p>搜尋頁實作 title / content filter：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// pagefind 分批 load (load more 按鈕)
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kr">const</span> <span class="nx">results</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">pagefind</span><span class="p">.</span><span class="nx">search</span><span class="p">(</span><span class="nx">query</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="nx">results</span><span class="p">.</span><span class="nx">results</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="nx">start</span><span class="p">,</span> <span class="nx">start</span> <span class="o">+</span> <span class="mi">10</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">r</span> <span class="p">=&gt;</span> <span class="nx">container</span><span class="p">.</span><span class="nx">append</span><span class="p">(</span><span class="nx">render</span><span class="p">(</span><span class="nx">r</span><span class="p">)));</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1">// 我們在 view 層 post-filter
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span><span class="kd">function</span> <span class="nx">applyFilter</span><span class="p">(</span><span class="nx">scope</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.result&#39;</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">el</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">el</span><span class="p">.</span><span class="nx">hidden</span> <span class="o">=</span> <span class="o">!</span><span class="nx">matchesScope</span><span class="p">(</span><span class="nx">el</span><span class="p">,</span> <span class="nx">scope</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <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>跑出來的問題：使用者選 title-only filter、第二批 8 筆全部 title 不含 query → 點 &ldquo;load more&rdquo; 後畫面閃了一下、新增的 8 筆全 hidden、使用者看到的內容沒變。</p>
<h3 id="判讀">判讀</h3>
<p>問題的根因不在「畫面閃」這個視覺現象、而在 filter 的層級錯位：</p>
<table>
  <thead>
      <tr>
          <th>使用者意圖</th>
          <th>filter 實際對應</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>「title 符合的」</td>
          <td>「已載入 + title 符合的」</td>
      </tr>
      <tr>
          <td>「全部結果」</td>
          <td>「已載入的全部」</td>
      </tr>
  </tbody>
</table>
<p>兩個定義在一般狀況看起來一樣（已載入子集裡有命中）、稀疏 case 暴露縫。</p>
<h3 id="執行解法選擇">執行（解法選擇）</h3>
<p>解法選擇展開見 <a href="../filter-source-composition-strategies/">#59 Filter × Source 合成策略五選一</a> — A 推進 query / B 自動續抓 / C 預先 index / D 誠實 UX / E 明示縮小。本文聚焦「先識別這是層錯位、不是 UI bug」 — 識別錯了、後續解法都會在錯誤的層上補救。</p>
<hr>
<h2 id="內在屬性比較filter-該放哪一層">內在屬性比較：filter 該放哪一層</h2>
<table>
  <thead>
      <tr>
          <th>層</th>
          <th>看到的範圍</th>
          <th>跟使用者意圖的距離</th>
          <th>寫作成本</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>視覺層</td>
          <td>已 render 的子集</td>
          <td>最遠（差兩層）</td>
          <td>最低</td>
      </tr>
      <tr>
          <td>渲染層</td>
          <td>已 fetch 的子集</td>
          <td>中（差一層）</td>
          <td>低</td>
      </tr>
      <tr>
          <td>資料層 (源頭)</td>
          <td>完整 dataset</td>
          <td>最近</td>
          <td>中-高</td>
      </tr>
      <tr>
          <td>Source 之外</td>
          <td>重 query</td>
          <td>最近 + 最新</td>
          <td>高（query 重設計）</td>
      </tr>
  </tbody>
</table>
<p>「寫作成本最低」跟「跟意圖最近」是反相關 — 這個反相關本身是 <a href="../ease-of-writing-vs-intent-alignment/">#67</a> 的核心命題、本卡是它在 filter × source 情境的展開。</p>
<hr>
<h2 id="識別層錯位的三問">識別層錯位的三問</h2>
<p>寫 filter / sort / count / transform 之前自問：</p>
<h3 id="1-這個操作的對象是什麼層的一筆">1. 這個操作的「對象」是什麼層的「一筆」？</h3>
<p>如果寫在 view 層、對象是「螢幕上的元素」 — 那源頭如果分批、就有缺口。</p>
<h3 id="2-source-是一次給完整-dataset還是分批--限額">2. Source 是「一次給完整 dataset」還是「分批 / 限額」？</h3>
<p>對照前面「哪些 source 形狀有層錯位風險」表 — 任何分批 / 限額 / streaming / cached source 都有風險。一次性 fetch 或靜態陣列才安全。</p>
<h3 id="3-沒命中與還沒-materialize對使用者要不要區分">3. 「沒命中」與「還沒 materialize」對使用者要不要區分？</h3>
<p>要區分 → filter 必須在 source 層或自動續抓、否則使用者無法判斷。
不區分（可接受「在已載入範圍內找」這個語意） → view 層 filter 加誠實 UX。</p>
<p>三問跑完才寫 filter — 跳過任一問就可能掉進層錯位。</p>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的行動</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>即將寫 <code>elements.forEach(el =&gt; el.hidden = !matches(el))</code></td>
          <td>停 — 確認 source 是不是分批的；是 → 推到資料層</td>
      </tr>
      <tr>
          <td>Source 是 <code>pagefind.search()</code> / <code>paginatedFetch()</code> / <code>for await</code> 但 filter 在 forEach</td>
          <td>是 — 重看「filter 該放哪一層」</td>
      </tr>
      <tr>
          <td>不確定 source 真實 cardinality 跟分批機制</td>
          <td>用 <a href="../playwright-early-in-loop/">#11 playwright</a> 量 live source 的回傳數量</td>
      </tr>
      <tr>
          <td>Filter 後可能 0 筆但 source 還有未載入</td>
          <td>必須補「自動續抓」或「誠實掃描範圍 UX」</td>
      </tr>
      <tr>
          <td>「Load more」「Show next」按鈕存在、且有 filter</td>
          <td>評估：filter 跟 load more 的 quota 是否同層</td>
      </tr>
      <tr>
          <td>內心 OS：「先做出來、晚點補資料層」</td>
          <td>停 — 補不回來、會 ship 進 production silent 失敗</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：filter / sort / count / transform 是 stream operation、必須跟 stream 的 materialization 同層或更上游。寫在下游 = 操作 subset 而不是 stream、語意縫是必然、不是偶發 bug。</p>
]]></content:encoded></item><item><title>Filter × Source 的合成策略五選一</title><link>https://tarrragon.github.io/blog/report/filter-source-composition-strategies/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/filter-source-composition-strategies/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>Filter 跟分批 source 的合成有五種策略、各自機會成本不同&lt;/strong>。沒有絕對最佳 — 選哪個取決於三個變數：&lt;/p>
&lt;ol>
&lt;li>Source 是否支援 server-side filter（capabilities）&lt;/li>
&lt;li>Match 密度（稀疏 vs 密集）&lt;/li>
&lt;li>UX 容忍度（要不要誠實顯示「掃描範圍」）&lt;/li>
&lt;/ol>
&lt;p>本文是 #55 &lt;a href="../view-layer-filter-vs-source-layer/">Filter 與 Source 的層錯位&lt;/a> 的解法展開、列出五個合理選項與適用情境。&lt;/p>
&lt;hr>
&lt;h2 id="五策略對照表">五策略對照表&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>策略&lt;/th>
 &lt;th>一句話&lt;/th>
 &lt;th>對 source 的需求&lt;/th>
 &lt;th>對 UX 的影響&lt;/th>
 &lt;th>工程量&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>A&lt;/td>
 &lt;td>把 filter 推進 source 的 query&lt;/td>
 &lt;td>必須支援該 filter 條件&lt;/td>
 &lt;td>透明（無感）&lt;/td>
 &lt;td>中-高&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>B&lt;/td>
 &lt;td>自動續抓直到湊滿 N 個 match&lt;/td>
 &lt;td>任何分批 source&lt;/td>
 &lt;td>透明（稍慢）&lt;/td>
 &lt;td>中&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>C&lt;/td>
 &lt;td>預先建獨立 index（每種 mode 一份）&lt;/td>
 &lt;td>能控 source 的 build pipeline&lt;/td>
 &lt;td>透明（最快）&lt;/td>
 &lt;td>高&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>D&lt;/td>
 &lt;td>誠實 UX 顯示「已掃 N / 命中 K」&lt;/td>
 &lt;td>任何 source&lt;/td>
 &lt;td>顯眼（多按鈕）&lt;/td>
 &lt;td>低&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>E&lt;/td>
 &lt;td>接受「filter 範圍 = 已載入」、不承諾 source 全集&lt;/td>
 &lt;td>任何 source&lt;/td>
 &lt;td>隱性語意縮小&lt;/td>
 &lt;td>最低&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="五策略一句話總覽">五策略一句話總覽&lt;/h2>
&lt;p>每個策略各自一張獨立 pattern 卡片、本卡只給總覽與選擇規則。&lt;/p>
&lt;h3 id="策略-a推進-query">策略 A：推進 query&lt;/h3>
&lt;p>把 filter 條件變成 source 的 query 參數、source 端就回符合的。最優、無層錯位 — 但要 source 支援。詳見 &lt;a href="../pattern-query-side-pushdown/">#61 Pattern：推進 query&lt;/a>。&lt;/p>
&lt;h3 id="策略-b自動續抓直到湊滿">策略 B：自動續抓直到湊滿&lt;/h3>
&lt;p>抓一批 → filter → 不夠再抓 → 湊滿 N 個或 source 結束。需要上限保護避免拉爆。詳見 &lt;a href="../pattern-fetch-until-quota/">#60 Pattern：自動續抓&lt;/a>。&lt;/p>
&lt;h3 id="策略-c預先建獨立-index">策略 C：預先建獨立 index&lt;/h3>
&lt;p>Build time 為每種 filter mode 各建一份 source、runtime 切 mode = 切 source。前提是能控 build、mode 有限。詳見 &lt;a href="../pattern-multiple-indexes/">#65 Pattern：多 index&lt;/a>。&lt;/p>
&lt;h3 id="策略-d誠實進度-ux">策略 D：誠實進度 UX&lt;/h3>
&lt;p>保留 view 層 filter、UI 顯示「已掃 N / 命中 K / 共 M」三數字 + 「再掃一批」、使用者手動觸發續抓。詳見 &lt;a href="../pattern-honest-progress-ui/">#62 Pattern：誠實進度 UX&lt;/a>。&lt;/p>
&lt;h3 id="策略-e明示語意縮小">策略 E：明示語意縮小&lt;/h3>
&lt;p>明示告訴使用者「filter 範圍 = 已載入、不承諾全集」、不假裝是全集 filter。比 D 顯眼度低、但成本最低。詳見 &lt;a href="../pattern-explicit-semantic-narrowing/">#66 Pattern：明示語意縮小&lt;/a>。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>D 跟 E 都是 subset 上做、差別&lt;/strong>：D 用三數字持續顯示掃描範圍、E 用文字一次性告知。silent 縮小（既不三數字、也不告知）= 反模式、撞回 &lt;a href="../view-layer-filter-vs-source-layer/">#55 層錯位&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="選擇規則決定矩陣">選擇規則：決定矩陣&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>條件&lt;/th>
 &lt;th>建議策略&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Source 支援 server-side filter&lt;/td>
 &lt;td>A（最優）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Source 不支援、match 密度高、自動較好&lt;/td>
 &lt;td>B&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Source 不支援、能控 build、mode 有限&lt;/td>
 &lt;td>C&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Source 不支援、稀疏、要避免拉爆&lt;/td>
 &lt;td>D&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>原型期、不解決完美&lt;/td>
 &lt;td>E（明示語意縮小）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Source 一次性給完、無分批&lt;/td>
 &lt;td>view 層 filter 直接寫&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="多策略並用">多策略並用&lt;/h2>
&lt;p>實務上常見組合：&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>Filter 跟分批 source 的合成有五種策略、各自機會成本不同</strong>。沒有絕對最佳 — 選哪個取決於三個變數：</p>
<ol>
<li>Source 是否支援 server-side filter（capabilities）</li>
<li>Match 密度（稀疏 vs 密集）</li>
<li>UX 容忍度（要不要誠實顯示「掃描範圍」）</li>
</ol>
<p>本文是 #55 <a href="../view-layer-filter-vs-source-layer/">Filter 與 Source 的層錯位</a> 的解法展開、列出五個合理選項與適用情境。</p>
<hr>
<h2 id="五策略對照表">五策略對照表</h2>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>一句話</th>
          <th>對 source 的需求</th>
          <th>對 UX 的影響</th>
          <th>工程量</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>A</td>
          <td>把 filter 推進 source 的 query</td>
          <td>必須支援該 filter 條件</td>
          <td>透明（無感）</td>
          <td>中-高</td>
      </tr>
      <tr>
          <td>B</td>
          <td>自動續抓直到湊滿 N 個 match</td>
          <td>任何分批 source</td>
          <td>透明（稍慢）</td>
          <td>中</td>
      </tr>
      <tr>
          <td>C</td>
          <td>預先建獨立 index（每種 mode 一份）</td>
          <td>能控 source 的 build pipeline</td>
          <td>透明（最快）</td>
          <td>高</td>
      </tr>
      <tr>
          <td>D</td>
          <td>誠實 UX 顯示「已掃 N / 命中 K」</td>
          <td>任何 source</td>
          <td>顯眼（多按鈕）</td>
          <td>低</td>
      </tr>
      <tr>
          <td>E</td>
          <td>接受「filter 範圍 = 已載入」、不承諾 source 全集</td>
          <td>任何 source</td>
          <td>隱性語意縮小</td>
          <td>最低</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="五策略一句話總覽">五策略一句話總覽</h2>
<p>每個策略各自一張獨立 pattern 卡片、本卡只給總覽與選擇規則。</p>
<h3 id="策略-a推進-query">策略 A：推進 query</h3>
<p>把 filter 條件變成 source 的 query 參數、source 端就回符合的。最優、無層錯位 — 但要 source 支援。詳見 <a href="../pattern-query-side-pushdown/">#61 Pattern：推進 query</a>。</p>
<h3 id="策略-b自動續抓直到湊滿">策略 B：自動續抓直到湊滿</h3>
<p>抓一批 → filter → 不夠再抓 → 湊滿 N 個或 source 結束。需要上限保護避免拉爆。詳見 <a href="../pattern-fetch-until-quota/">#60 Pattern：自動續抓</a>。</p>
<h3 id="策略-c預先建獨立-index">策略 C：預先建獨立 index</h3>
<p>Build time 為每種 filter mode 各建一份 source、runtime 切 mode = 切 source。前提是能控 build、mode 有限。詳見 <a href="../pattern-multiple-indexes/">#65 Pattern：多 index</a>。</p>
<h3 id="策略-d誠實進度-ux">策略 D：誠實進度 UX</h3>
<p>保留 view 層 filter、UI 顯示「已掃 N / 命中 K / 共 M」三數字 + 「再掃一批」、使用者手動觸發續抓。詳見 <a href="../pattern-honest-progress-ui/">#62 Pattern：誠實進度 UX</a>。</p>
<h3 id="策略-e明示語意縮小">策略 E：明示語意縮小</h3>
<p>明示告訴使用者「filter 範圍 = 已載入、不承諾全集」、不假裝是全集 filter。比 D 顯眼度低、但成本最低。詳見 <a href="../pattern-explicit-semantic-narrowing/">#66 Pattern：明示語意縮小</a>。</p>
<blockquote>
<p><strong>D 跟 E 都是 subset 上做、差別</strong>：D 用三數字持續顯示掃描範圍、E 用文字一次性告知。silent 縮小（既不三數字、也不告知）= 反模式、撞回 <a href="../view-layer-filter-vs-source-layer/">#55 層錯位</a>。</p></blockquote>
<hr>
<h2 id="選擇規則決定矩陣">選擇規則：決定矩陣</h2>
<table>
  <thead>
      <tr>
          <th>條件</th>
          <th>建議策略</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Source 支援 server-side filter</td>
          <td>A（最優）</td>
      </tr>
      <tr>
          <td>Source 不支援、match 密度高、自動較好</td>
          <td>B</td>
      </tr>
      <tr>
          <td>Source 不支援、能控 build、mode 有限</td>
          <td>C</td>
      </tr>
      <tr>
          <td>Source 不支援、稀疏、要避免拉爆</td>
          <td>D</td>
      </tr>
      <tr>
          <td>原型期、不解決完美</td>
          <td>E（明示語意縮小）</td>
      </tr>
      <tr>
          <td>Source 一次性給完、無分批</td>
          <td>view 層 filter 直接寫</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="多策略並用">多策略並用</h2>
<p>實務上常見組合：</p>
<ul>
<li><strong>A + D fallback</strong>：query 推進失敗（如使用者用 source 不支援的條件）→ fallback 到 D</li>
<li><strong>B + 上限 → D</strong>：自動續抓到上限後切 D（顯示「已掃 N 筆、再掃？」）</li>
<li><strong>C + B 補強</strong>：預先 index 解一般 case、B 解 index 沒覆蓋的組合</li>
</ul>
<p>並用通常比單選有效、但複雜度也最高。詳細的疊加判準（解不同層 / 沒副作用衝突 / 增量成本可接受）見 <a href="../main-strategy-plus-supplementary/">#75 主策略 + 補強策略</a> — 本表的「並用」就是 #75 的具體展現。</p>
<p>「先 ship 哪個策略、哪個下輪」見 <a href="../incremental-shipping-criteria/">#76 分批 ship 準則</a> — 例如 D（UX）通常先 ship、A/C（結構）下輪。</p>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該選的策略起點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Source 是 SQL / ES / pagefind 且 filter 條件已索引</td>
          <td>A</td>
      </tr>
      <tr>
          <td>Source 是 pagefind 且 filter 是「title vs content」</td>
          <td>C（重 index 兩份）</td>
      </tr>
      <tr>
          <td>Source 不支援、預期 match 密集、要無感</td>
          <td>B</td>
      </tr>
      <tr>
          <td>工程量限制、能接受顯眼 UX</td>
          <td>D</td>
      </tr>
      <tr>
          <td>原型 / MVP、能接受語意縮小但要明示</td>
          <td>E（含語意聲明）</td>
      </tr>
      <tr>
          <td>使用者意圖明確要「全部命中」、source 不支援、match 稀疏</td>
          <td>A 或 C 重設計、不要 B（會拉爆）</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：Filter × Source 沒有最佳解、只有「對齊三變數（capabilities / 密度 / UX）的取捨」。識別三變數、選對策略 → 比寫漂亮的程式重要。</p>
<p>跟 <a href="../external-component-collaboration-layers/">#45 跟外部組件合作的四層次</a> 同構：A 推進 query ≈ 公共介面層（最穩定）、C 多 index ≈ 邊界層（build pipeline 控制）、B 自動續抓 ≈ 邊界 DOM 層（client 補足）、D / E 誠實或縮小 ≈ 內部結構層（接受限制）。兩個原則的選擇順序都是「離 source 公共介面越近、合作越穩」。</p>
]]></content:encoded></item><item><title>資料源的形狀決定 feature 的形狀</title><link>https://tarrragon.github.io/blog/report/data-source-shape-defines-feature-shape/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/data-source-shape-defines-feature-shape/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>Feature 的設計受資料源的形狀約束、不能憑 UI 想要的形狀去倒推&lt;/strong>。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>資料源形狀&lt;/th>
 &lt;th>對 feature 的硬約束&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>一次性 fetch（靜態 / API 全集）&lt;/td>
 &lt;td>Filter / sort / count 都安全可在任意層做&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>分批 fetch（pagination）&lt;/td>
 &lt;td>Filter / sort 必須跟 source 同層&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Streaming（SSE / iterator）&lt;/td>
 &lt;td>結果可能無上限、count 是不確定值&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Cached + revalidate&lt;/td>
 &lt;td>兩個 dataset 並存、要決定哪個 winning&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>憑 UI 倒推資料層 =「我希望畫面這樣呈現、所以資料層應該這樣」 → 多半會在錯誤的層做錯誤的操作（見 #55 &lt;a href="../view-layer-filter-vs-source-layer/">層錯位&lt;/a>）。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼會憑-ui-倒推">為什麼會憑 UI 倒推&lt;/h2>
&lt;h3 id="ui-設計通常先動">UI 設計通常先動&lt;/h3>
&lt;p>設計師畫 wireframe、PM 描述體驗、執行者看到的是「畫面該長什麼樣」 — 資料層的限制不在 wireframe 裡。&lt;/p>
&lt;h3 id="ui-形狀對資料層假設過強">UI 形狀對資料層假設過強&lt;/h3>
&lt;p>UI 上「filter 拉桿」這個元件、隱含假設「資料能立即過濾」 — 但如果資料是分批 fetch、立即過濾在資料層不成立。執行者按 UI 寫 → view 層 post-filter → 撞上層錯位。&lt;/p>
&lt;h3 id="能用訊號早於對齊資料形狀">「能用」訊號早於「對齊資料形狀」&lt;/h3>
&lt;p>寫完 view 層 filter、手動測一次能用、覺得對 — 但能用的範圍是「已載入子集」、不是「完整 dataset」。資料形狀的限制要刻意對照才看得到。&lt;/p>
&lt;hr>
&lt;h2 id="多面向資料源形狀的不同類型">多面向：資料源形狀的不同類型&lt;/h2>
&lt;h3 id="形狀-1一次性給完整-dataset">形狀 1：一次性給完整 dataset&lt;/h3>
&lt;p>範例：靜態 JSON、SSR 完整渲染、API 一次回全集（&amp;lt; 1MB）。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Feature 設計&lt;/th>
 &lt;th>安全與否&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>任意層 filter&lt;/td>
 &lt;td>安全&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>任意層 sort&lt;/td>
 &lt;td>安全&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Count&lt;/td>
 &lt;td>安全&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Pagination&lt;/td>
 &lt;td>不需要&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這類 source 是「最寬容」的、UI 想怎麼設計都行。&lt;/p>
&lt;h3 id="形狀-2分批-fetchpagination">形狀 2：分批 fetch（pagination）&lt;/h3>
&lt;p>範例：pagefind、infinite scroll、cursor-based API。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Feature 設計&lt;/th>
 &lt;th>限制&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Filter&lt;/td>
 &lt;td>必須跟 source 同層（A）或自動續抓（B）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sort&lt;/td>
 &lt;td>必須是 server-side sort、不能 client 重排&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Count&lt;/td>
 &lt;td>通常需要 source 提供 total（pagefind 有）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>「跳到最後一頁」&lt;/td>
 &lt;td>需要 cursor / offset 支援&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>UI 設計時要避開：「立即 filter」「立即 sort」「Show all」 — 這些假設 dataset 已 materialize。&lt;/p>
&lt;h3 id="形狀-3streaming--async-iterator">形狀 3：Streaming / async iterator&lt;/h3>
&lt;p>範例：SSE、WebSocket push、async iterator from generator、log tail。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Feature 設計&lt;/th>
 &lt;th>限制&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Filter&lt;/td>
 &lt;td>可在 stream 裡做（透明）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sort&lt;/td>
 &lt;td>不能 — stream 沒終點、無法 sort&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Count&lt;/td>
 &lt;td>「目前累計」、不是「總數」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>進度條&lt;/td>
 &lt;td>只能顯示「已收 N 筆」、不能 % progress&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>UI 設計時要避開：「sort by 任意欄位」「總共 X 筆」「進度條 50%」 — 這些假設有限終點。&lt;/p>
&lt;h3 id="形狀-4cached--revalidate">形狀 4：Cached + revalidate&lt;/h3>
&lt;p>範例：service worker cache、SWR、HTTP cache、IndexedDB cache。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>Feature 的設計受資料源的形狀約束、不能憑 UI 想要的形狀去倒推</strong>。</p>
<table>
  <thead>
      <tr>
          <th>資料源形狀</th>
          <th>對 feature 的硬約束</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>一次性 fetch（靜態 / API 全集）</td>
          <td>Filter / sort / count 都安全可在任意層做</td>
      </tr>
      <tr>
          <td>分批 fetch（pagination）</td>
          <td>Filter / sort 必須跟 source 同層</td>
      </tr>
      <tr>
          <td>Streaming（SSE / iterator）</td>
          <td>結果可能無上限、count 是不確定值</td>
      </tr>
      <tr>
          <td>Cached + revalidate</td>
          <td>兩個 dataset 並存、要決定哪個 winning</td>
      </tr>
  </tbody>
</table>
<p>憑 UI 倒推資料層 =「我希望畫面這樣呈現、所以資料層應該這樣」 → 多半會在錯誤的層做錯誤的操作（見 #55 <a href="../view-layer-filter-vs-source-layer/">層錯位</a>）。</p>
<hr>
<h2 id="為什麼會憑-ui-倒推">為什麼會憑 UI 倒推</h2>
<h3 id="ui-設計通常先動">UI 設計通常先動</h3>
<p>設計師畫 wireframe、PM 描述體驗、執行者看到的是「畫面該長什麼樣」 — 資料層的限制不在 wireframe 裡。</p>
<h3 id="ui-形狀對資料層假設過強">UI 形狀對資料層假設過強</h3>
<p>UI 上「filter 拉桿」這個元件、隱含假設「資料能立即過濾」 — 但如果資料是分批 fetch、立即過濾在資料層不成立。執行者按 UI 寫 → view 層 post-filter → 撞上層錯位。</p>
<h3 id="能用訊號早於對齊資料形狀">「能用」訊號早於「對齊資料形狀」</h3>
<p>寫完 view 層 filter、手動測一次能用、覺得對 — 但能用的範圍是「已載入子集」、不是「完整 dataset」。資料形狀的限制要刻意對照才看得到。</p>
<hr>
<h2 id="多面向資料源形狀的不同類型">多面向：資料源形狀的不同類型</h2>
<h3 id="形狀-1一次性給完整-dataset">形狀 1：一次性給完整 dataset</h3>
<p>範例：靜態 JSON、SSR 完整渲染、API 一次回全集（&lt; 1MB）。</p>
<table>
  <thead>
      <tr>
          <th>Feature 設計</th>
          <th>安全與否</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>任意層 filter</td>
          <td>安全</td>
      </tr>
      <tr>
          <td>任意層 sort</td>
          <td>安全</td>
      </tr>
      <tr>
          <td>Count</td>
          <td>安全</td>
      </tr>
      <tr>
          <td>Pagination</td>
          <td>不需要</td>
      </tr>
  </tbody>
</table>
<p>這類 source 是「最寬容」的、UI 想怎麼設計都行。</p>
<h3 id="形狀-2分批-fetchpagination">形狀 2：分批 fetch（pagination）</h3>
<p>範例：pagefind、infinite scroll、cursor-based API。</p>
<table>
  <thead>
      <tr>
          <th>Feature 設計</th>
          <th>限制</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Filter</td>
          <td>必須跟 source 同層（A）或自動續抓（B）</td>
      </tr>
      <tr>
          <td>Sort</td>
          <td>必須是 server-side sort、不能 client 重排</td>
      </tr>
      <tr>
          <td>Count</td>
          <td>通常需要 source 提供 total（pagefind 有）</td>
      </tr>
      <tr>
          <td>「跳到最後一頁」</td>
          <td>需要 cursor / offset 支援</td>
      </tr>
  </tbody>
</table>
<p>UI 設計時要避開：「立即 filter」「立即 sort」「Show all」 — 這些假設 dataset 已 materialize。</p>
<h3 id="形狀-3streaming--async-iterator">形狀 3：Streaming / async iterator</h3>
<p>範例：SSE、WebSocket push、async iterator from generator、log tail。</p>
<table>
  <thead>
      <tr>
          <th>Feature 設計</th>
          <th>限制</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Filter</td>
          <td>可在 stream 裡做（透明）</td>
      </tr>
      <tr>
          <td>Sort</td>
          <td>不能 — stream 沒終點、無法 sort</td>
      </tr>
      <tr>
          <td>Count</td>
          <td>「目前累計」、不是「總數」</td>
      </tr>
      <tr>
          <td>進度條</td>
          <td>只能顯示「已收 N 筆」、不能 % progress</td>
      </tr>
  </tbody>
</table>
<p>UI 設計時要避開：「sort by 任意欄位」「總共 X 筆」「進度條 50%」 — 這些假設有限終點。</p>
<h3 id="形狀-4cached--revalidate">形狀 4：Cached + revalidate</h3>
<p>範例：service worker cache、SWR、HTTP cache、IndexedDB cache。</p>
<table>
  <thead>
      <tr>
          <th>Feature 設計</th>
          <th>限制</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Filter</td>
          <td>哪個 dataset 在 filter？cache 還是 fresh？</td>
      </tr>
      <tr>
          <td>「最新狀態」訊號</td>
          <td>需要 UI 區分 stale vs fresh</td>
      </tr>
      <tr>
          <td>衝突處理</td>
          <td>Cache 跟 fresh 結果不同時、誰 winning？</td>
      </tr>
  </tbody>
</table>
<p>UI 設計時要決定：cache-first（快但 stale）還是 fresh-first（慢但新）。Filter 跟其他操作要對齊這個選擇。</p>
<hr>
<h2 id="形狀識別的-protocol">形狀識別的 protocol</h2>
<p>拿到一個 source（API、SDK、library）、用以下兩問判斷它是哪個形狀：</p>
<h3 id="問-1是否一次給完整-dataset">問 1：是否一次給完整 dataset？</h3>
<table>
  <thead>
      <tr>
          <th>答案</th>
          <th>形狀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>是</td>
          <td>形狀 1（一次性）— 安全</td>
      </tr>
      <tr>
          <td>否</td>
          <td>形狀 2 / 3 / 4 — 進問 2</td>
      </tr>
  </tbody>
</table>
<p>判讀依據：API 是否有 <code>pagination</code> / <code>cursor</code> / <code>nextPage</code> / <code>loadMore</code> / <code>for await</code> / <code>subscribe</code> 等概念？有就是「不一次給完」。</p>
<h3 id="問-2分批的觸發機制是什麼">問 2：分批的觸發機制是什麼？</h3>
<table>
  <thead>
      <tr>
          <th>機制</th>
          <th>形狀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>客戶端要求下一頁（pull）</td>
          <td>形狀 2（paginated）</td>
      </tr>
      <tr>
          <td>伺服端推（push）、可能無終點</td>
          <td>形狀 3（streaming）</td>
      </tr>
      <tr>
          <td>預先給一份（cache）+ 之後重抓（fresh）</td>
          <td>形狀 4（cached + revalidate）</td>
      </tr>
  </tbody>
</table>
<p>判讀依據：SDK doc / API spec 的「資料更新方式」段落。讀不到就跑 spike：手動觸發、看是 pull 還是 push、有沒有 cache。</p>
<p>兩問跑完、形狀已知 → 寫 feature 之前能評估「資料形狀對 feature 設計的硬約束」。</p>
<hr>
<h2 id="形狀混合疊加">形狀混合（疊加）</h2>
<p>實務上、source 常常是多個形狀疊加。常見組合：</p>
<h3 id="組合-1cached--paginated">組合 1：Cached + Paginated</h3>





<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">[Server paginated API]
</span></span><span class="line"><span class="ln">2</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln">3</span><span class="cl">[Client cache layer (e.g. SWR)]
</span></span><span class="line"><span class="ln">4</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln">5</span><span class="cl">[UI 拿 cache + 分批 fetch fresh]</span></span></code></pre></div><ul>
<li>形狀 4（cached）+ 形狀 2（paginated）疊加</li>
<li>Filter 要決定：在 cache 上還是 fresh 上？fresh 是分批的、又有層錯位？</li>
</ul>
<h3 id="組合-2streaming--buffered">組合 2：Streaming + Buffered</h3>





<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">[Server SSE push]
</span></span><span class="line"><span class="ln">2</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln">3</span><span class="cl">[Client buffer N events]
</span></span><span class="line"><span class="ln">4</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln">5</span><span class="cl">[UI 從 buffer 取]</span></span></code></pre></div><ul>
<li>形狀 3（streaming）+ 內部 buffer 限額</li>
<li>Filter 要看：在 stream 入口還是 buffer 出口？buffer 滿了怎麼處理舊事件？</li>
</ul>
<h3 id="組合-3lazy-iterator--taken">組合 3：Lazy iterator + take(N)</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">def</span> <span class="nf">stream</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">for</span> <span class="n">chunk</span> <span class="ow">in</span> <span class="n">remote_paginated</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="k">yield from</span> <span class="n">chunk</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nb">list</span><span class="p">(</span><span class="n">itertools</span><span class="o">.</span><span class="n">islice</span><span class="p">(</span><span class="n">stream</span><span class="p">(),</span> <span class="mi">100</span><span class="p">))</span>  <span class="c1"># 限額 100</span></span></span></code></pre></div><ul>
<li>形狀 2（paginated）+ 用 take 限額 → 行為像形狀 1（一次給完）但只給前 100</li>
<li>Filter 全集還是 100 個 subset？</li>
</ul>
<p>混合形狀的 filter 要分別處理每一層的層錯位、不是當成單一形狀。</p>
<hr>
<h2 id="形狀的可改造性">形狀的可改造性</h2>
<p>形狀不只決定 feature 設計、還決定「策略可選範圍」。可改造性分三類：</p>
<table>
  <thead>
      <tr>
          <th>類別</th>
          <th>例子</th>
          <th>對策略選擇的影響</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>你控的 source</td>
          <td>自家 build pipeline、自家 API</td>
          <td>全部策略可選（A 重 index、C 多 index、改 schema 都行）</td>
      </tr>
      <tr>
          <td>你不控但能要求</td>
          <td>同公司其他團隊、open source vendor</td>
          <td>部分可選（提 issue / PR、等回覆）</td>
      </tr>
      <tr>
          <td>完全不可控</td>
          <td>第三方 API、legacy black box</td>
          <td>只剩 B / D / E（client-side 解）</td>
      </tr>
  </tbody>
</table>
<p>評估可改造性、跟 #59 五策略的選擇配套：</p>
<ul>
<li>全可控 → A（推進 query）或 C（多 index）通常最優</li>
<li>半可控 → B 短期解 + 長期等可改造</li>
<li>不可控 → 接受 D / E、不要硬撞 A / C</li>
</ul>
<hr>
<h2 id="寫-feature-前的形狀對照表">寫 feature 前的形狀對照表</h2>
<p>寫第一行之前、先填這張表：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>答案</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Source 是什麼形狀（1-4）</td>
          <td>?</td>
      </tr>
      <tr>
          <td>Total cardinality 是多少</td>
          <td>?（10? 1萬? 10萬?）</td>
      </tr>
      <tr>
          <td>是否分批 / 限額 / streaming</td>
          <td>?</td>
      </tr>
      <tr>
          <td>Source 支援哪些 filter / sort</td>
          <td>?</td>
      </tr>
      <tr>
          <td>Cache 策略（如果有）</td>
          <td>?</td>
      </tr>
      <tr>
          <td>Match 密度預期</td>
          <td>?（密集 / 中等 / 稀疏）</td>
      </tr>
  </tbody>
</table>
<p>填完後評估：UI 設計需求跟資料形狀有沒有衝突？衝突就重設計 UI、或調整資料層、或退到誠實 UX（D）。</p>
<hr>
<h2 id="設計取捨ui-還是-source-先服從">設計取捨：UI 還是 Source 先服從</h2>
<h3 id="aui-服從-source-形狀推薦">A：UI 服從 source 形狀（推薦）</h3>
<ul>
<li><strong>機制</strong>：先看 source 給什麼形狀、UI 設計成「這個形狀能呈現的」</li>
<li><strong>適合</strong>：source 已存在（vendor library、legacy API、無法改）</li>
<li><strong>代價</strong>：UI 可能比設計理想中簡單</li>
</ul>
<h3 id="bsource-服從-ui-需求重設計-source">B：Source 服從 UI 需求（重設計 source）</h3>
<ul>
<li><strong>機制</strong>：UI 設計理想化、為了支援 UI、改 source（重 index、加欄位、換 SDK）</li>
<li><strong>跟 A 的取捨</strong>：B 工程量大、但 UX 上限高</li>
<li><strong>B 才合理的情境</strong>：source 能控、改 source 的成本 &lt; 長期 UX 收益</li>
</ul>
<h3 id="c兩邊妥協用誠實-ux-補縫">C：兩邊妥協、用誠實 UX 補縫</h3>
<ul>
<li><strong>機制</strong>：UI 設計理想、source 不重做、用 #62 誠實進度 UX 把資料形狀的限制告訴使用者</li>
<li><strong>跟 A 的取捨</strong>：C 比 A 顯眼、比 B 工程量小、是常見的中間方案</li>
<li><strong>C 才合理的情境</strong>：使用者能接受顯眼的「掃描範圍」UX</li>
</ul>
<h3 id="dui-假裝-source-形狀符合反模式">D：UI 假裝 source 形狀符合（反模式）</h3>
<ul>
<li><strong>為什麼是反模式</strong>：UI 暗示的能力跟資料層實際能力不符、使用者基於錯誤訊號決策</li>
<li><strong>看起來吸引人的原因</strong>：UI 設計可以理想化、不用看資料層限制、設計師跟工程師都輕鬆</li>
<li><strong>實際發生的代價</strong>：撞上 <a href="../view-layer-filter-vs-source-layer/">#55 層錯位</a>、長期維護負擔大（每次 source 升級都要重 patch）、使用者信任損失</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的行動</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>拿到 wireframe 開始實作前、沒看過資料源 API doc</td>
          <td>先看 — 確認資料形狀</td>
      </tr>
      <tr>
          <td>UI 含「立即 filter」「sort by 任意欄位」但 source 是分批的</td>
          <td>衝突 — 重設計 UI 或重 index source</td>
      </tr>
      <tr>
          <td>UI 顯示 progress bar 但 source 是 streaming</td>
          <td>衝突 — 改成「已收 N 筆」、不寫 %</td>
      </tr>
      <tr>
          <td>Cache 策略沒設定就開始寫 feature</td>
          <td>先設定 — cache-first / fresh-first</td>
      </tr>
      <tr>
          <td>內心 OS：「資料層之後處理、先把 UI 寫出來」</td>
          <td>停 — 形狀對照表先填</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：資料源的形狀是 feature 的硬約束。UI 設計可以理想化、但實作要看 source 給什麼。憑 UI 倒推資料層的實作 = 在錯誤的層解錯誤的問題、最終產生層錯位類 bug。</p>
<p>「形狀的可改造性」三類跟 <a href="../external-component-customization/">#1 在外部組件上加客製功能</a> 共骨：兩者都是「先看你能改什麼、再決定怎麼客製」。#1 講的是 UI 客製、本卡講的是資料層客製、共同精神是「客製從邊界往中心做、不要倒推」。</p>
]]></content:encoded></item><item><title>Feature 操作要跟 Source 同層合成</title><link>https://tarrragon.github.io/blog/report/compose-feature-at-source-layer/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/compose-feature-at-source-layer/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>Stream 操作（filter / sort / count / transform / search）必須跟 stream 的 materialization 同層或更上游合成。&lt;/strong> 在下游合成 = 操作的對象是 subset、不是 stream。&lt;/p>
&lt;p>這是 #55 &lt;a href="../view-layer-filter-vs-source-layer/">Filter 與 Source 的層錯位&lt;/a> 的抽象升級 — 不限於「視覺層 vs 資料層」、適用任何分層系統（前端 / 後端 / 演算法管線 / 資料庫）。&lt;/p>
&lt;hr>
&lt;h2 id="抽象結構">抽象結構&lt;/h2>





&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">[Stream Source]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> ↓ (materialize 部分)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">[Subset L1]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> ↓ (再 materialize)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">[Subset L2]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> ↓ ...&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Stream 操作要套在哪一層、決定它「過濾的範圍」是什麼：&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>Stream Source&lt;/td>
 &lt;td>完整 stream&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Subset L1&lt;/td>
 &lt;td>L1 子集&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Subset L2&lt;/td>
 &lt;td>L1 的子集的子集&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>使用者 / 呼叫者通常想要的是「完整 stream 的操作結果」、不是「下游 subset 的結果」。在下游做 = 跟意圖不對齊。&lt;/p>
&lt;hr>
&lt;h2 id="多面向跨領域的同個結構">多面向：跨領域的同個結構&lt;/h2>
&lt;h3 id="領域-1前端-ui55-的-case">領域 1：前端 UI（#55 的 case）&lt;/h3>
&lt;ul>
&lt;li>Stream：完整搜尋結果集&lt;/li>
&lt;li>Materialize：pagefind 分批 fetch&lt;/li>
&lt;li>Subset：已載入的 result&lt;/li>
&lt;li>錯誤合成：在 view 層 filter（subset 上做）&lt;/li>
&lt;/ul>
&lt;h3 id="領域-2後端-api--middleware">領域 2：後端 API + middleware&lt;/h3>





&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">[Database query result] ← stream source
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">[ORM materialize as objects] ← L1 subset (lazy load 部分欄位)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">[API response] ← L2 subset (pagination 後)
&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">[Middleware filter] ← 錯誤位置 — 已是 subset 了&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Middleware 過濾「pagination 後的回應」 — 漏掉沒在這頁的符合項。應該推進 ORM query。&lt;/p>
&lt;h3 id="領域-3演算法管線">領域 3：演算法管線&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">pipeline&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">chunk&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">load_chunks&lt;/span>&lt;span class="p">():&lt;/span> &lt;span class="c1"># stream source&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">item&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">chunk&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="c1"># L1&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">processed&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">transform&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">item&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># L2&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">yield&lt;/span> &lt;span class="n">processed&lt;/span> &lt;span class="c1"># L3&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"># 錯誤合成&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="n">results&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">list&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">pipeline&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">filtered&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">x&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">x&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">results&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">matches&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">x&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="c1"># ↑ 如果上游有 take(N) 或 break、filtered 對的是 subset&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>對例：filter 推到 transform 之前 / 之內。&lt;/p>
&lt;h3 id="領域-4資料庫--materialized-view">領域 4：資料庫 + materialized view&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 錯誤：在 view 上 filter
&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="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">materialized_view&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">x&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- ↑ materialized_view 可能是 partial / stale
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 對例：filter 推進原表
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">source_table&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">x&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 或 view 重建時 filter 已加進去&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="領域-5map--reduce">領域 5：Map / Reduce&lt;/h3>





&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">[shards] → [map output partial] → [reduce]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> [post-reduce filter] ← 錯位&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Filter 應該在 map 階段（per-shard）或 reduce 內、不是 reduce 後。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>Stream 操作（filter / sort / count / transform / search）必須跟 stream 的 materialization 同層或更上游合成。</strong> 在下游合成 = 操作的對象是 subset、不是 stream。</p>
<p>這是 #55 <a href="../view-layer-filter-vs-source-layer/">Filter 與 Source 的層錯位</a> 的抽象升級 — 不限於「視覺層 vs 資料層」、適用任何分層系統（前端 / 後端 / 演算法管線 / 資料庫）。</p>
<hr>
<h2 id="抽象結構">抽象結構</h2>





<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">[Stream Source]
</span></span><span class="line"><span class="ln">2</span><span class="cl">   ↓ (materialize 部分)
</span></span><span class="line"><span class="ln">3</span><span class="cl">[Subset L1]
</span></span><span class="line"><span class="ln">4</span><span class="cl">   ↓ (再 materialize)
</span></span><span class="line"><span class="ln">5</span><span class="cl">[Subset L2]
</span></span><span class="line"><span class="ln">6</span><span class="cl">   ↓ ...</span></span></code></pre></div><p>Stream 操作要套在哪一層、決定它「過濾的範圍」是什麼：</p>
<table>
  <thead>
      <tr>
          <th>套在哪一層</th>
          <th>操作範圍</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Stream Source</td>
          <td>完整 stream</td>
      </tr>
      <tr>
          <td>Subset L1</td>
          <td>L1 子集</td>
      </tr>
      <tr>
          <td>Subset L2</td>
          <td>L1 的子集的子集</td>
      </tr>
  </tbody>
</table>
<p>使用者 / 呼叫者通常想要的是「完整 stream 的操作結果」、不是「下游 subset 的結果」。在下游做 = 跟意圖不對齊。</p>
<hr>
<h2 id="多面向跨領域的同個結構">多面向：跨領域的同個結構</h2>
<h3 id="領域-1前端-ui55-的-case">領域 1：前端 UI（#55 的 case）</h3>
<ul>
<li>Stream：完整搜尋結果集</li>
<li>Materialize：pagefind 分批 fetch</li>
<li>Subset：已載入的 result</li>
<li>錯誤合成：在 view 層 filter（subset 上做）</li>
</ul>
<h3 id="領域-2後端-api--middleware">領域 2：後端 API + middleware</h3>





<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">[Database query result]  ← stream source
</span></span><span class="line"><span class="ln">2</span><span class="cl">  ↓
</span></span><span class="line"><span class="ln">3</span><span class="cl">[ORM materialize as objects]  ← L1 subset (lazy load 部分欄位)
</span></span><span class="line"><span class="ln">4</span><span class="cl">  ↓
</span></span><span class="line"><span class="ln">5</span><span class="cl">[API response]  ← L2 subset (pagination 後)
</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">[Middleware filter]  ← 錯誤位置 — 已是 subset 了</span></span></code></pre></div><p>Middleware 過濾「pagination 後的回應」 — 漏掉沒在這頁的符合項。應該推進 ORM query。</p>
<h3 id="領域-3演算法管線">領域 3：演算法管線</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">def</span> <span class="nf">pipeline</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">for</span> <span class="n">chunk</span> <span class="ow">in</span> <span class="n">load_chunks</span><span class="p">():</span>       <span class="c1"># stream source</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">for</span> <span class="n">item</span> <span class="ow">in</span> <span class="n">chunk</span><span class="p">:</span>             <span class="c1"># L1</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">            <span class="n">processed</span> <span class="o">=</span> <span class="n">transform</span><span class="p">(</span><span class="n">item</span><span class="p">)</span> <span class="c1"># L2</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="k">yield</span> <span class="n">processed</span>             <span class="c1"># L3</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"># 錯誤合成</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">results</span> <span class="o">=</span> <span class="nb">list</span><span class="p">(</span><span class="n">pipeline</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">filtered</span> <span class="o">=</span> <span class="p">[</span><span class="n">x</span> <span class="k">for</span> <span class="n">x</span> <span class="ow">in</span> <span class="n">results</span> <span class="k">if</span> <span class="n">matches</span><span class="p">(</span><span class="n">x</span><span class="p">)]</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"># ↑ 如果上游有 take(N) 或 break、filtered 對的是 subset</span></span></span></code></pre></div><p>對例：filter 推到 transform 之前 / 之內。</p>
<h3 id="領域-4資料庫--materialized-view">領域 4：資料庫 + materialized view</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">-- 錯誤：在 view 上 filter
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">materialized_view</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">x</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- ↑ materialized_view 可能是 partial / stale
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- 對例：filter 推進原表
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">source_table</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">x</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="c1">-- 或 view 重建時 filter 已加進去</span></span></span></code></pre></div><h3 id="領域-5map--reduce">領域 5：Map / Reduce</h3>





<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">[shards] → [map output partial] → [reduce]
</span></span><span class="line"><span class="ln">2</span><span class="cl">                                       ↓
</span></span><span class="line"><span class="ln">3</span><span class="cl">                                  [post-reduce filter]  ← 錯位</span></span></code></pre></div><p>Filter 應該在 map 階段（per-shard）或 reduce 內、不是 reduce 後。</p>
<p><strong>五個領域共用結構</strong>：在 materialization 下游做 stream 操作 → silent 缺口。</p>
<hr>
<h2 id="同層合成的具體做法">同層合成的具體做法</h2>
<h3 id="做法-1把操作推進-source-query">做法 1：把操作推進 source query</h3>
<p>最直接 — source 端就回符合的、根本沒 subset。</p>
<p>對應 #61 <a href="../pattern-query-side-pushdown/">Pattern：推進 query</a>。</p>
<h3 id="做法-2在-materialization-過程中合成">做法 2：在 materialization 過程中合成</h3>
<p>如果 source 是 lazy stream、操作放進 stream 而不是事後：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 對例：filter 放進 stream</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">def</span> <span class="nf">filtered_pipeline</span><span class="p">(</span><span class="n">predicate</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">for</span> <span class="n">chunk</span> <span class="ow">in</span> <span class="n">load_chunks</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="k">for</span> <span class="n">item</span> <span class="ow">in</span> <span class="n">chunk</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">            <span class="k">if</span> <span class="n">predicate</span><span class="p">(</span><span class="n">item</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">                <span class="k">yield</span> <span class="n">item</span></span></span></code></pre></div><p>每筆 materialize 時就 filter、不累積到 subset 後再做。</p>
<h3 id="做法-3自動續抓直到湊滿">做法 3：自動續抓直到湊滿</h3>
<p>當 source 不能改、且 materialization 是分批 — 用 loop 把分批變透明。</p>
<p>對應 #60 <a href="../pattern-fetch-until-quota/">Pattern：自動續抓</a>。</p>
<h3 id="做法-4明示降級到-subset-操作">做法 4：明示降級到 subset 操作</h3>
<p>不能同層合成 → 顯式告訴呼叫者「我只在 subset 上做」、而不是假裝在 stream 上做。</p>
<p>對應 #62 <a href="../pattern-honest-progress-ui/">Pattern：誠實進度 UX</a>。</p>
<hr>
<h2 id="為什麼這個原則跨領域通用資訊可見範圍">為什麼這個原則跨領域通用：資訊可見範圍</h2>
<p>五個領域共用結構不是巧合。底層命題是<strong>資訊論的問題、不是工程問題</strong>：</p>
<blockquote>
<p>一個操作能「看見」的範圍、就是它能正確套用的範圍。把操作放在看不見完整 stream 的位置 = 操作對部分資訊運算 = 結果不能宣稱對完整資訊。</p></blockquote>
<p>「合成位置」就是「資訊可見範圍」的代名詞。同層或上游的位置看得到完整 stream、下游位置只看得到 subset。這跟「stream 是什麼樣的資料」「系統是哪個語言寫的」「框架是 React 還是 Vue」都無關 — 只跟「看得到什麼」有關。</p>
<p>所以這個原則：</p>
<ul>
<li>不是「前端 bug」 — 後端、演算法、DB、map-reduce、分散式系統都會遇到</li>
<li>不是「特定技術 stack 問題」 — 任何分層架構都適用</li>
<li>不是「pagefind 特定問題」 — 任何「分批 materialize」的 source 都會引發</li>
</ul>
<p>把它當「資訊可見範圍」原則來理解、能應用到任何「stream 操作 + 分層 materialization」的情境。</p>
<hr>
<h2 id="上推push-down在不同領域的代價">上推（push down）在不同領域的代價</h2>
<p>把操作從下游推到上游 = 改變誰負責執行操作。每個領域的「上推」代價不同：</p>
<table>
  <thead>
      <tr>
          <th>領域</th>
          <th>上推 = 在哪裡做</th>
          <th>代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>前端 UI</td>
          <td>推到 fetch 層 / source query</td>
          <td>重設計 fetcher、可能改 API contract</td>
      </tr>
      <tr>
          <td>後端 middleware</td>
          <td>推到 ORM query / SQL WHERE</td>
          <td>改 query、可能要加 index</td>
      </tr>
      <tr>
          <td>演算法管線</td>
          <td>推到 stream stage 內</td>
          <td>重排 pipeline、可能影響其他 stage</td>
      </tr>
      <tr>
          <td>資料庫</td>
          <td>推到原表 query / 重建 view</td>
          <td>重 build view、影響其他依賴 view 的 query</td>
      </tr>
      <tr>
          <td>Map-reduce</td>
          <td>推到 map 階段或 reduce 內</td>
          <td>改 mapper / reducer 邏輯</td>
      </tr>
  </tbody>
</table>
<p>代價評估決定「能不能上推」：</p>
<ul>
<li>代價 &lt; 缺口的維護成本 → 上推</li>
<li>代價 &gt; 缺口的維護成本 → 退到 explicit 縮小（#66）+ 接受</li>
<li>代價 ≈ 缺口的維護成本 → 看其他因素（短期 vs 長期、團隊熟悉度）</li>
</ul>
<hr>
<h2 id="常見誤判以為自己在-source-層實際在-subset-層">常見誤判：以為自己在 source 層、實際在 subset 層</h2>
<p>每個領域都有「看起來是 source 但實際是 subset」的陷阱：</p>
<table>
  <thead>
      <tr>
          <th>領域</th>
          <th>看起來是 source、實際是 subset</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>前端</td>
          <td><code>Array.from(document.querySelectorAll(...))</code> 看起來是「全部元素」、實際是「已 render 的元素」</td>
      </tr>
      <tr>
          <td>後端 ORM</td>
          <td><code>User.all()</code> 看起來是「所有 user」、實際是 lazy load + memory 限制</td>
      </tr>
      <tr>
          <td>演算法</td>
          <td><code>list(generator)</code> 看起來是「materialize 全部」、實際 generator 上游可能 lazy / take(N)</td>
      </tr>
      <tr>
          <td>資料庫</td>
          <td><code>SELECT * FROM materialized_view</code> 看起來是查表、實際 view 可能 stale / partial</td>
      </tr>
      <tr>
          <td>分散式 cache</td>
          <td><code>cache.get_all()</code> 看起來是「cache 全集」、實際是 single-node subset</td>
      </tr>
  </tbody>
</table>
<p>這些誤判共用結構：<strong>API 命名暗示「全集」、實際是 subset</strong>。寫之前要看「這個 API 的真實 cardinality 是什麼」、不是看名字。</p>
<hr>
<h2 id="跟-63-形狀原則的關係">跟 #63 形狀原則的關係</h2>
<p><a href="../data-source-shape-defines-feature-shape/">#63 資料源的形狀決定 feature 的形狀</a> 講「形狀是硬約束」 — 本文講「在硬約束下、操作該放哪一層」。</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>#63</th>
          <th>本文</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>焦點</td>
          <td>形狀如何約束 feature 設計</td>
          <td>操作如何跟 stream 合成</td>
      </tr>
      <tr>
          <td>階段</td>
          <td>設計 / 規劃</td>
          <td>實作 / 架構</td>
      </tr>
      <tr>
          <td>結論</td>
          <td>不要憑 UI 倒推資料層</td>
          <td>操作要同層或更上游</td>
      </tr>
  </tbody>
</table>
<p>兩者互補：#63 是 high-level 設計原則、本文是 implementation 指引。</p>
<hr>
<h2 id="設計取捨操作合成的位置">設計取捨：操作合成的位置</h2>
<p>四種、跟 #59 <a href="../filter-source-composition-strategies/">策略五選一</a> 對應但更抽象。</p>
<h3 id="a合成在-source">A：合成在 source</h3>
<p>最近 stream、無 silent 缺口。對應 #61 推進 query。</p>
<h3 id="b合成在-materialization-過程中">B：合成在 materialization 過程中</h3>
<p>Stream 處理時就做、不累積到 subset 後。對應 #60 自動續抓 + 在 loop 內 filter。</p>
<h3 id="c合成在-subset但顯式">C：合成在 subset、但顯式</h3>
<p>明示語意縮小、用誠實 UX 告訴呼叫者範圍。對應 #62。</p>
<h3 id="d合成在-subset隱式反模式">D：合成在 subset、隱式（反模式）</h3>
<ul>
<li><strong>為什麼是反模式</strong>：silent 失敗、跟意圖有縫、違反「資訊可見範圍 = 操作正確套用範圍」的本質</li>
<li><strong>看起來吸引人的原因</strong>：寫起來最快、用現成 subset、不用追上游、5 行解決</li>
<li><strong>實際發生的代價</strong>：跨情境 silent bug、使用者基於錯結果決策、debug 時定位困難（因為錯位的位置不會報錯）</li>
</ul>
<p>選擇順序：<strong>A → B → C → 不要 D</strong>。</p>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>寫 <code>.filter()</code> / <code>.sort()</code> / <code>.count()</code> 在已 materialize 的 subset 上</td>
          <td>確認 source 是不是 stream / 分批；是 → 推到上游</td>
      </tr>
      <tr>
          <td>跨多層的系統、操作出現在最下游</td>
          <td>評估能不能上推</td>
      </tr>
      <tr>
          <td>「能用、但沒覆蓋邊界 case」的功能</td>
          <td>多半是合成位置錯了</td>
      </tr>
      <tr>
          <td>Map-reduce / pipeline / middleware 鏈路裡、filter 在最後一層</td>
          <td>推進到 stage 內</td>
      </tr>
      <tr>
          <td>內心 OS：「在最後 filter 比較容易寫」</td>
          <td>是訊號 — 容易寫的位置通常是錯位的位置</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：Stream 操作的合成位置決定它的語意。同層或更上游 = 操作 stream、跟意圖對齊。下游 = 操作 subset、跟意圖有縫。這個原則跨前端 / 後端 / 演算法 / 資料庫 / 分散式系統通用 — 不是「前端 vs 後端」的問題、是「合成位置 vs materialization 位置」的問題。</p>
<p>跟其他抽象層原則的關係：</p>
<ul>
<li>跟 <a href="../minimum-necessary-scope-is-sanity-defense/">#43 最小必要範圍是 sanity 防線</a>：兩者共用「邊界選對 vs 選錯」的精神 — #43 講範圍從窄到寬、本卡講合成從上游到下游；錯方向都是 silent 失敗</li>
<li>跟 <a href="../single-source-of-truth/">#44 Single Source of Truth</a>：兩者共用「值的住址唯一」精神 — SSOT 是「定義位置唯一」、本卡是「操作位置正確」；操作不在 source 層 = 等於建了個第二定義（subset 上的「filter 結果」）跟 stream 全集競爭</li>
<li>跟 <a href="../two-occurrence-threshold/">#42 2 次門檻</a>：發現合成位置錯時、不要試「同層補丁」三次以上、第 2 次失敗就退一層找根因</li>
</ul>
]]></content:encoded></item><item><title>URL 是 stateful UI 的儲存層 — 哪些 state 該寫進 URL</title><link>https://tarrragon.github.io/blog/report/url-as-state-container/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/url-as-state-container/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;blockquote>
&lt;p>State 的儲存層決定它的特性 — 可分享 / 可恢復 / 可導航 的 state 該寫進 URL、不寫進 = silent 把這些特性犧牲掉。&lt;/p>&lt;/blockquote>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>儲存層&lt;/th>
 &lt;th>可分享&lt;/th>
 &lt;th>可 reload 恢復&lt;/th>
 &lt;th>可 back/forward 導航&lt;/th>
 &lt;th>跨 tab 同步&lt;/th>
 &lt;th>跨 device 同步&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>In-memory&lt;/td>
 &lt;td>否&lt;/td>
 &lt;td>否&lt;/td>
 &lt;td>否&lt;/td>
 &lt;td>否&lt;/td>
 &lt;td>否&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>URL&lt;/td>
 &lt;td>是&lt;/td>
 &lt;td>是&lt;/td>
 &lt;td>是&lt;/td>
 &lt;td>部分（同 URL）&lt;/td>
 &lt;td>部分（複製連結）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>sessionStorage&lt;/td>
 &lt;td>否&lt;/td>
 &lt;td>是&lt;/td>
 &lt;td>否&lt;/td>
 &lt;td>否&lt;/td>
 &lt;td>否&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>localStorage&lt;/td>
 &lt;td>否&lt;/td>
 &lt;td>是&lt;/td>
 &lt;td>否&lt;/td>
 &lt;td>是（同 origin）&lt;/td>
 &lt;td>否&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Server&lt;/td>
 &lt;td>是&lt;/td>
 &lt;td>是&lt;/td>
 &lt;td>否&lt;/td>
 &lt;td>是&lt;/td>
 &lt;td>是&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>寫 stateful UI 時、每個 state 的儲存位置是個設計選擇 — 不選 = 預設用 in-memory = 預設犧牲所有上面五個特性。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼-url-容易被忽略">為什麼 URL 容易被忽略&lt;/h2>
&lt;h3 id="url-是隱形維度">URL 是隱形維度&lt;/h3>
&lt;p>In-memory state 在 React useState / Vue ref / vanilla 變數裡 — 寫起來最便利、是「預設位置」。URL state 需要 &lt;code>URLSearchParams&lt;/code> + &lt;code>history.pushState&lt;/code> + &lt;code>popstate&lt;/code> listener、寫起來成本高。&lt;/p>
&lt;p>&lt;a href="../ease-of-writing-vs-intent-alignment/">#67 寫作便利度跟意圖對齊反相關&lt;/a> 直接解釋為什麼：URL state 是「對齊使用者期望」的位置（使用者預期 URL 包含 state、能分享）、in-memory 是「便利位置」。預設便利、要刻意才走對齊。&lt;/p>
&lt;h3 id="沒寫-url-state-的失敗訊號是-silent">沒寫 URL state 的失敗訊號是 silent&lt;/h3>
&lt;p>使用者打開搜尋頁、輸入「pagefind」、選擇 title-only filter、看到結果。這時：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>複製 URL 分享給朋友&lt;/strong> → 朋友打開看到空白搜尋框（query 不在 URL）&lt;/li>
&lt;li>&lt;strong>重整頁面&lt;/strong> → 自己也看到空白搜尋框&lt;/li>
&lt;li>&lt;strong>點 back&lt;/strong> → browser back 跳離搜尋頁、不是回到「沒 filter 的同個搜尋」&lt;/li>
&lt;/ul>
&lt;p>這三個動作沒有 error、沒有崩潰、就是「state 不見了」。使用者通常以為「網站就這樣」、不會 report bug。Silent 失敗 = 維護者永遠不知道有問題。&lt;/p>
&lt;p>對照 &lt;a href="../view-layer-filter-vs-source-layer/">#55 Filter × Source 層錯位&lt;/a> — 都是 silent 失敗、都是「該存在的東西不在」。&lt;/p>
&lt;hr>
&lt;h2 id="state-該寫進-url-的判準">State 該寫進 URL 的判準&lt;/h2>
&lt;h3 id="三問">三問&lt;/h3>
&lt;ol>
&lt;li>&lt;strong>使用者會分享這個 state 嗎&lt;/strong>？— 是 → URL（複製連結即帶 state）&lt;/li>
&lt;li>&lt;strong>使用者 reload 後預期 state 還在嗎&lt;/strong>？— 是 → URL 或 sessionStorage&lt;/li>
&lt;li>&lt;strong>使用者期望 browser back/forward 在 state 之間導航嗎&lt;/strong>？— 是 → URL&lt;/li>
&lt;/ol>
&lt;p>任一個「是」 → URL。&lt;/p>
&lt;h3 id="反向判準什麼不該寫進-url">反向判準：什麼不該寫進 URL&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>State 類型&lt;/th>
 &lt;th>為什麼不該寫進 URL&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Scroll position&lt;/td>
 &lt;td>頻繁變動破壞 history、且每個瀏覽器自己管&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Focus / hover state&lt;/td>
 &lt;td>Ephemeral、跟使用者操作直接綁定、寫進 URL 沒意義&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Form 編輯中的暫存值&lt;/td>
 &lt;td>使用者沒提交、不該被分享&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>敏感資訊（token / 密碼）&lt;/td>
 &lt;td>URL 進 history / referer header / log、安全性問題&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>高頻 polling 結果&lt;/td>
 &lt;td>每秒變、history 爆炸&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>內部 component state（折疊 / 展開動畫進度）&lt;/td>
 &lt;td>跟 UI 細節綁、不是使用者意圖&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="多面向常見-ui-元素的-url-state-對照">多面向：常見 UI 元素的 URL state 對照&lt;/h2>
&lt;h3 id="面向-1search-filter這次任務的-case">面向 1：Search filter（這次任務的 case）&lt;/h3>





&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">Query string、scope filter、type filter、tag filter
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">→ 都該進 URL：使用者會分享「我搜什麼 + 怎麼篩」&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>範例 URL：&lt;code>/search/?q=pagefind&amp;amp;scope=title&amp;amp;type=post&amp;amp;tag=js&lt;/code>&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<blockquote>
<p>State 的儲存層決定它的特性 — 可分享 / 可恢復 / 可導航 的 state 該寫進 URL、不寫進 = silent 把這些特性犧牲掉。</p></blockquote>
<table>
  <thead>
      <tr>
          <th>儲存層</th>
          <th>可分享</th>
          <th>可 reload 恢復</th>
          <th>可 back/forward 導航</th>
          <th>跨 tab 同步</th>
          <th>跨 device 同步</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>In-memory</td>
          <td>否</td>
          <td>否</td>
          <td>否</td>
          <td>否</td>
          <td>否</td>
      </tr>
      <tr>
          <td>URL</td>
          <td>是</td>
          <td>是</td>
          <td>是</td>
          <td>部分（同 URL）</td>
          <td>部分（複製連結）</td>
      </tr>
      <tr>
          <td>sessionStorage</td>
          <td>否</td>
          <td>是</td>
          <td>否</td>
          <td>否</td>
          <td>否</td>
      </tr>
      <tr>
          <td>localStorage</td>
          <td>否</td>
          <td>是</td>
          <td>否</td>
          <td>是（同 origin）</td>
          <td>否</td>
      </tr>
      <tr>
          <td>Server</td>
          <td>是</td>
          <td>是</td>
          <td>否</td>
          <td>是</td>
          <td>是</td>
      </tr>
  </tbody>
</table>
<p>寫 stateful UI 時、每個 state 的儲存位置是個設計選擇 — 不選 = 預設用 in-memory = 預設犧牲所有上面五個特性。</p>
<hr>
<h2 id="為什麼-url-容易被忽略">為什麼 URL 容易被忽略</h2>
<h3 id="url-是隱形維度">URL 是隱形維度</h3>
<p>In-memory state 在 React useState / Vue ref / vanilla 變數裡 — 寫起來最便利、是「預設位置」。URL state 需要 <code>URLSearchParams</code> + <code>history.pushState</code> + <code>popstate</code> listener、寫起來成本高。</p>
<p><a href="../ease-of-writing-vs-intent-alignment/">#67 寫作便利度跟意圖對齊反相關</a> 直接解釋為什麼：URL state 是「對齊使用者期望」的位置（使用者預期 URL 包含 state、能分享）、in-memory 是「便利位置」。預設便利、要刻意才走對齊。</p>
<h3 id="沒寫-url-state-的失敗訊號是-silent">沒寫 URL state 的失敗訊號是 silent</h3>
<p>使用者打開搜尋頁、輸入「pagefind」、選擇 title-only filter、看到結果。這時：</p>
<ul>
<li><strong>複製 URL 分享給朋友</strong> → 朋友打開看到空白搜尋框（query 不在 URL）</li>
<li><strong>重整頁面</strong> → 自己也看到空白搜尋框</li>
<li><strong>點 back</strong> → browser back 跳離搜尋頁、不是回到「沒 filter 的同個搜尋」</li>
</ul>
<p>這三個動作沒有 error、沒有崩潰、就是「state 不見了」。使用者通常以為「網站就這樣」、不會 report bug。Silent 失敗 = 維護者永遠不知道有問題。</p>
<p>對照 <a href="../view-layer-filter-vs-source-layer/">#55 Filter × Source 層錯位</a> — 都是 silent 失敗、都是「該存在的東西不在」。</p>
<hr>
<h2 id="state-該寫進-url-的判準">State 該寫進 URL 的判準</h2>
<h3 id="三問">三問</h3>
<ol>
<li><strong>使用者會分享這個 state 嗎</strong>？— 是 → URL（複製連結即帶 state）</li>
<li><strong>使用者 reload 後預期 state 還在嗎</strong>？— 是 → URL 或 sessionStorage</li>
<li><strong>使用者期望 browser back/forward 在 state 之間導航嗎</strong>？— 是 → URL</li>
</ol>
<p>任一個「是」 → URL。</p>
<h3 id="反向判準什麼不該寫進-url">反向判準：什麼不該寫進 URL</h3>
<table>
  <thead>
      <tr>
          <th>State 類型</th>
          <th>為什麼不該寫進 URL</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Scroll position</td>
          <td>頻繁變動破壞 history、且每個瀏覽器自己管</td>
      </tr>
      <tr>
          <td>Focus / hover state</td>
          <td>Ephemeral、跟使用者操作直接綁定、寫進 URL 沒意義</td>
      </tr>
      <tr>
          <td>Form 編輯中的暫存值</td>
          <td>使用者沒提交、不該被分享</td>
      </tr>
      <tr>
          <td>敏感資訊（token / 密碼）</td>
          <td>URL 進 history / referer header / log、安全性問題</td>
      </tr>
      <tr>
          <td>高頻 polling 結果</td>
          <td>每秒變、history 爆炸</td>
      </tr>
      <tr>
          <td>內部 component state（折疊 / 展開動畫進度）</td>
          <td>跟 UI 細節綁、不是使用者意圖</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="多面向常見-ui-元素的-url-state-對照">多面向：常見 UI 元素的 URL state 對照</h2>
<h3 id="面向-1search-filter這次任務的-case">面向 1：Search filter（這次任務的 case）</h3>





<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">Query string、scope filter、type filter、tag filter
</span></span><span class="line"><span class="ln">2</span><span class="cl">→ 都該進 URL：使用者會分享「我搜什麼 + 怎麼篩」</span></span></code></pre></div><p>範例 URL：<code>/search/?q=pagefind&amp;scope=title&amp;type=post&amp;tag=js</code></p>
<h3 id="面向-2tab--step-navigation">面向 2：Tab / step navigation</h3>





<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">Active tab、wizard step
</span></span><span class="line"><span class="ln">2</span><span class="cl">→ 該進 URL：分享 = 直接打開該 tab/step</span></span></code></pre></div><p>範例：<code>/settings/?tab=notifications</code>、<code>/checkout/?step=payment</code></p>
<h3 id="面向-3sort--pagination">面向 3：Sort / pagination</h3>





<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">排序欄位、頁碼
</span></span><span class="line"><span class="ln">2</span><span class="cl">→ 該進 URL：分享 = 朋友看到同樣排序的同一頁</span></span></code></pre></div><p>範例：<code>/posts/?sort=date_desc&amp;page=3</code></p>
<h3 id="面向-4modal--drawer-開合">面向 4：Modal / drawer 開合</h3>





<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">看情境：
</span></span><span class="line"><span class="ln">2</span><span class="cl">- 重要 modal（圖片預覽、編輯對話框）→ URL（可分享 / back 關閉）
</span></span><span class="line"><span class="ln">3</span><span class="cl">- 純 UX 提示 modal（welcome tour）→ in-memory（不該分享）</span></span></code></pre></div><h3 id="面向-5theme--ui-preference">面向 5：Theme / UI preference</h3>





<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">Dark mode、字型大小
</span></span><span class="line"><span class="ln">2</span><span class="cl">→ localStorage（跨 session 但不分享、跟 device 綁）
</span></span><span class="line"><span class="ln">3</span><span class="cl">不進 URL（不會「分享你的 dark mode 設定」）</span></span></code></pre></div><hr>
<h2 id="url-state-的實作模式">URL state 的實作模式</h2>
<h3 id="讀載入時從-url-同步到-component-state">讀：載入時從 URL 同步到 component state</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">function</span> <span class="nx">getInitialState</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kr">const</span> <span class="nx">params</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">URLSearchParams</span><span class="p">(</span><span class="nx">location</span><span class="p">.</span><span class="nx">search</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="k">return</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">query</span><span class="o">:</span> <span class="nx">params</span><span class="p">.</span><span class="nx">get</span><span class="p">(</span><span class="s1">&#39;q&#39;</span><span class="p">)</span> <span class="o">||</span> <span class="s1">&#39;&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">scope</span><span class="o">:</span> <span class="nx">params</span><span class="p">.</span><span class="nx">get</span><span class="p">(</span><span class="s1">&#39;scope&#39;</span><span class="p">)</span> <span class="o">||</span> <span class="s1">&#39;all&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">type</span><span class="o">:</span> <span class="nx">params</span><span class="p">.</span><span class="nx">get</span><span class="p">(</span><span class="s1">&#39;type&#39;</span><span class="p">)</span> <span class="o">||</span> <span class="kc">null</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="p">};</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="kr">const</span> <span class="nx">initialState</span> <span class="o">=</span> <span class="nx">getInitialState</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">// component 用 initialState 初始化
</span></span></span></code></pre></div><h3 id="寫state-變動時同步到-url">寫：state 變動時同步到 URL</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">function</span> <span class="nx">syncUrl</span><span class="p">(</span><span class="nx">state</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kr">const</span> <span class="nx">params</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">URLSearchParams</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">state</span><span class="p">.</span><span class="nx">query</span><span class="p">)</span> <span class="nx">params</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="s1">&#39;q&#39;</span><span class="p">,</span> <span class="nx">state</span><span class="p">.</span><span class="nx">query</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">state</span><span class="p">.</span><span class="nx">scope</span> <span class="o">&amp;&amp;</span> <span class="nx">state</span><span class="p">.</span><span class="nx">scope</span> <span class="o">!==</span> <span class="s1">&#39;all&#39;</span><span class="p">)</span> <span class="nx">params</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="s1">&#39;scope&#39;</span><span class="p">,</span> <span class="nx">state</span><span class="p">.</span><span class="nx">scope</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">state</span><span class="p">.</span><span class="nx">type</span><span class="p">)</span> <span class="nx">params</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="s1">&#39;type&#39;</span><span class="p">,</span> <span class="nx">state</span><span class="p">.</span><span class="nx">type</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="kr">const</span> <span class="nx">url</span> <span class="o">=</span> <span class="sb">`</span><span class="si">${</span><span class="nx">location</span><span class="p">.</span><span class="nx">pathname</span><span class="si">}${</span><span class="nx">params</span><span class="p">.</span><span class="nx">toString</span><span class="p">()</span> <span class="o">?</span> <span class="s1">&#39;?&#39;</span> <span class="o">+</span> <span class="nx">params</span><span class="p">.</span><span class="nx">toString</span><span class="p">()</span> <span class="o">:</span> <span class="s1">&#39;&#39;</span><span class="si">}</span><span class="sb">`</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="nx">history</span><span class="p">.</span><span class="nx">replaceState</span><span class="p">(</span><span class="kc">null</span><span class="p">,</span> <span class="s1">&#39;&#39;</span><span class="p">,</span> <span class="nx">url</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">// 每次 state 變動觸發
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="nx">onStateChange</span><span class="p">((</span><span class="nx">newState</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="nx">syncUrl</span><span class="p">(</span><span class="nx">newState</span><span class="p">));</span></span></span></code></pre></div><p>選擇 <code>replaceState</code> vs <code>pushState</code>：</p>
<ul>
<li><code>replaceState</code>：每次 state 變動覆蓋當前 history entry — back/forward 跳過中間狀態</li>
<li><code>pushState</code>：每次 state 變動加新 history entry — back 回到上一個 state</li>
</ul>
<p>通常 search filter / sort / pagination 用 <code>replaceState</code>（typing 太快、不該每個字符一個 history entry）；tab / step 用 <code>pushState</code>（每個 step 該 back 回上一個）。</p>
<h3 id="雙向聽-popstate-處理-backforward">雙向：聽 popstate 處理 back/forward</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">window</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;popstate&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kr">const</span> <span class="nx">state</span> <span class="o">=</span> <span class="nx">getInitialState</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">applyStateToUI</span><span class="p">(</span><span class="nx">state</span><span class="p">);</span>  <span class="c1">// back/forward 後、把 state 套回 UI
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="p">});</span></span></span></code></pre></div><p>沒 listen popstate = back/forward 不會觸發 UI 更新、URL 跟 UI 不同步。</p>
<hr>
<h2 id="不該套用本原則的情境">不該套用本原則的情境</h2>
<p>「URL 是 state 儲存層」原則在「公開可分享的 UI」成立、但有合理例外：</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>為什麼不該套用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>內部 admin 工具</td>
          <td>不分享、不公開、URL persistence ROI 低</td>
      </tr>
      <tr>
          <td>Single-page wizard 強制流程</td>
          <td>不該允許 deep link 跳關卡（業務規則需要照順序走）</td>
      </tr>
      <tr>
          <td>一次性確認對話框</td>
          <td>不該被 back 回來、不該分享</td>
      </tr>
      <tr>
          <td>開發中的 prototype</td>
          <td>還沒穩定的 UI、不該固化 URL contract</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="跟其他抽象層原則的關係">跟其他抽象層原則的關係</h2>
<table>
  <thead>
      <tr>
          <th>原則</th>
          <th>跟本卡的關係</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="../single-source-of-truth/">#44 SSOT</a></td>
          <td>URL 是 state 的 SSOT 候選 — 選對位置 = 一處可改、不選 = 多源 drift</td>
      </tr>
      <tr>
          <td><a href="../ease-of-writing-vs-intent-alignment/">#67 寫作便利度跟意圖對齊反相關</a></td>
          <td>In-memory state 是便利位置、URL state 是對齊（使用者預期）位置</td>
      </tr>
      <tr>
          <td><a href="../view-layer-filter-vs-source-layer/">#55 Filter × Source 層錯位</a></td>
          <td>都是 silent 失敗結構 — state 該在的位置不在、使用者沒訊號</td>
      </tr>
      <tr>
          <td><a href="../visual-completion-vs-functional-completion/">#56 視覺完成 ≠ 功能完成</a></td>
          <td>URL state 沒做 = 「畫面對了但 reload 後不見」是同類功能缺口</td>
      </tr>
      <tr>
          <td><a href="../pattern-explicit-semantic-narrowing/">#66 明示語意縮小</a></td>
          <td>「URL 不持久化」如果是設計選擇、要明示（「重整會清除狀態」hint）</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="對應的實作篇">對應的實作篇</h2>
<ul>
<li>搜尋頁的 scope filter URL persistence — Phase 1+2 修完後 retrospective Checkpoint 1 才發現遺漏（#68 dogfooding）</li>
<li>任何 search / list / dashboard UI — 都該檢視 URL state coverage</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>寫互動 UI 但沒寫 URL 同步</td>
          <td>跑三問、確認該不該寫進 URL</td>
      </tr>
      <tr>
          <td>使用者 report「我分享連結給朋友、他看不到我看到的」</td>
          <td>URL state 缺漏的 silent 訊號顯現</td>
      </tr>
      <tr>
          <td><code>replaceState</code> 跟 <code>pushState</code> 沒區分、所有 state 變動用同一個</td>
          <td>評估：哪些是 history entry 該被記、哪些不該</td>
      </tr>
      <tr>
          <td>沒 listen <code>popstate</code></td>
          <td>back/forward 會 silent 失效、補 listener</td>
      </tr>
      <tr>
          <td>URL 變超長、含 ephemeral state</td>
          <td>過度寫進 URL、用反向判準砍掉不該寫的</td>
      </tr>
      <tr>
          <td>內心 OS：「state 用 useState 就好、URL 之後再說」</td>
          <td>「之後再說」= <a href="../ease-of-writing-vs-intent-alignment/">#67 reformer 謊言</a>、補不回來</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：URL 是 stateful UI 的隱形儲存層。沒寫 URL state = silent 犧牲分享 / 恢復 / 導航三個 UX 特性。寫之前跑三問（分享？reload？back/forward？）、任一個是 → URL。</p>
]]></content:encoded></item><item><title>搜尋引擎的匹配模式跟使用者預期的對齊</title><link>https://tarrragon.github.io/blog/report/search-engine-matching-mode-mismatch/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/search-engine-matching-mode-mismatch/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>搜尋引擎的「匹配模式」是個經常被忽略的維度&lt;/strong> — 工具的預設行為跟使用者的 mental model 不對齊時、產生 silent 失敗：使用者打字、看不到預期結果、誤以為「沒有」、不會 report bug。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>匹配模式&lt;/th>
 &lt;th>例：query「pre」會匹配&lt;/th>
 &lt;th>典型來源&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Exact&lt;/td>
 &lt;td>&lt;code>pre&lt;/code>（不含「pre」這個 token）&lt;/td>
 &lt;td>DB &lt;code>=&lt;/code> 比較&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Prefix&lt;/td>
 &lt;td>&lt;code>pre&lt;/code>、&lt;code>prefix&lt;/code>、&lt;code>prefetch&lt;/code>、&lt;code>presence&lt;/code>&lt;/td>
 &lt;td>Pagefind / Lunr 預設&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Substring&lt;/td>
 &lt;td>上面 + &lt;code>backpressure&lt;/code>、&lt;code>SuperPress&lt;/code>&lt;/td>
 &lt;td>DB &lt;code>LIKE '%pre%'&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Fuzzy&lt;/td>
 &lt;td>上面 + &lt;code>prv&lt;/code>、&lt;code>pre1&lt;/code>（編輯距離）&lt;/td>
 &lt;td>Algolia、TypeSense&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Semantic&lt;/td>
 &lt;td>上面 + &lt;code>before&lt;/code>、&lt;code>prior&lt;/code>（語意相近）&lt;/td>
 &lt;td>Vector search / LLM&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>使用者被 Google / 桌面搜尋訓練、預期 &lt;strong>substring 或更高層級&lt;/strong>。預設拿到 prefix 的 site search → 「pre」找不到 backpressure → 看起來像 bug 但其實是 capability 落差。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼預設是-prefix">為什麼預設是 prefix&lt;/h2>
&lt;p>Static site search engines（Pagefind / Lunr / MiniSearch 預設）選 prefix matching 的原因：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>因素&lt;/th>
 &lt;th>Prefix&lt;/th>
 &lt;th>Substring&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Index size&lt;/td>
 &lt;td>O(N)&lt;/td>
 &lt;td>O(N²)（要 index 所有後綴）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Query speed&lt;/td>
 &lt;td>快（trie）&lt;/td>
 &lt;td>慢（全掃 substring）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>跨語言支援&lt;/td>
 &lt;td>容易&lt;/td>
 &lt;td>中文 / CJK 邊界不明確&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Build time&lt;/td>
 &lt;td>快&lt;/td>
 &lt;td>慢&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>對 static site（沒 server）、index 是要下載到 client 的 — substring index 可能 5-10x 大、unacceptable。Pagefind / Lunr 選 prefix 是「對齊 size constraint」、不是「對齊使用者意圖」。&lt;/p>
&lt;p>這是個典型的 &lt;a href="../ease-of-writing-vs-intent-alignment/">#67 寫作便利度跟意圖對齊反相關&lt;/a> — 工具預設是「實作便利位置」、不是「使用者意圖位置」。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼這個-gap-是-silent">為什麼這個 gap 是 silent&lt;/h2>
&lt;p>跟 &lt;a href="../view-layer-filter-vs-source-layer/">#55 Filter × Source 層錯位&lt;/a> 共用結構：使用者打字看到結果列表、結果不空、看起來「有東西」、不會懷疑 engine 沒在做完整 search。&lt;/p>
&lt;p>silent 失敗的條件：&lt;/p>
&lt;ol>
&lt;li>Prefix matching 對某些 query 仍能回到結果（排版上看起來「有用」）&lt;/li>
&lt;li>使用者不知道「沒看到的還有什麼」&lt;/li>
&lt;li>只有當 query 剛好不是任何 token 的 prefix、才會 0 結果（極少見、這時才會懷疑）&lt;/li>
&lt;/ol>
&lt;p>對照 &lt;a href="../view-layer-filter-vs-source-layer/">#55 silent 失敗條件&lt;/a> 三條件 — 完全一樣的結構：「有部分結果掩蓋了缺口」。&lt;/p>
&lt;hr>
&lt;h2 id="多面向跨工具的匹配模式對照">多面向：跨工具的匹配模式對照&lt;/h2>
&lt;h3 id="前端-client-side-search">前端 client-side search&lt;/h3>
&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>Pagefind v1.5&lt;/td>
 &lt;td>Word-prefix&lt;/td>
 &lt;td>Exact only（&lt;code>useExact&lt;/code>）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Lunr&lt;/td>
 &lt;td>Stem + prefix&lt;/td>
 &lt;td>Wildcard（&lt;code>q+'*'&lt;/code>）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>MiniSearch&lt;/td>
 &lt;td>Prefix&lt;/td>
 &lt;td>Substring（&lt;code>prefix: false, fuzzy: 0.2&lt;/code>）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>FlexSearch&lt;/td>
 &lt;td>Token-based&lt;/td>
 &lt;td>多種 tokenizer（含 ngram）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Fuse.js&lt;/td>
 &lt;td>Fuzzy&lt;/td>
 &lt;td>可關掉 fuzzy 變 substring&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="backend--db">Backend / DB&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>工具&lt;/th>
 &lt;th>匹配模式&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>SQL &lt;code>=&lt;/code>&lt;/td>
 &lt;td>Exact&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>SQL &lt;code>LIKE '%X%'&lt;/code>&lt;/td>
 &lt;td>Substring（O(n) scan）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>SQL FULLTEXT&lt;/td>
 &lt;td>Token + stem + (有時 prefix)&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>ElasticSearch&lt;/td>
 &lt;td>配置：term / match / wildcard / fuzzy / regexp&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>PostgreSQL trigram&lt;/td>
 &lt;td>Substring + similarity&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Vector DB（Pinecone 等）&lt;/td>
 &lt;td>Semantic&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="命令列--ide-搜尋">命令列 / IDE 搜尋&lt;/h3>
&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>grep&lt;/code>&lt;/td>
 &lt;td>Substring（regex）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>rg&lt;/code>&lt;/td>
 &lt;td>Substring（smart-case + regex）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Vim &lt;code>/&lt;/code>&lt;/td>
 &lt;td>Regex&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>VSCode 搜尋&lt;/td>
 &lt;td>Substring（含 fuzzy file search）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>共通結構&lt;/strong>：每個工具預設不同、使用者帶著舊工具的 expectation 來、不對齊時 silent 失敗。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>搜尋引擎的「匹配模式」是個經常被忽略的維度</strong> — 工具的預設行為跟使用者的 mental model 不對齊時、產生 silent 失敗：使用者打字、看不到預期結果、誤以為「沒有」、不會 report bug。</p>
<table>
  <thead>
      <tr>
          <th>匹配模式</th>
          <th>例：query「pre」會匹配</th>
          <th>典型來源</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Exact</td>
          <td><code>pre</code>（不含「pre」這個 token）</td>
          <td>DB <code>=</code> 比較</td>
      </tr>
      <tr>
          <td>Prefix</td>
          <td><code>pre</code>、<code>prefix</code>、<code>prefetch</code>、<code>presence</code></td>
          <td>Pagefind / Lunr 預設</td>
      </tr>
      <tr>
          <td>Substring</td>
          <td>上面 + <code>backpressure</code>、<code>SuperPress</code></td>
          <td>DB <code>LIKE '%pre%'</code></td>
      </tr>
      <tr>
          <td>Fuzzy</td>
          <td>上面 + <code>prv</code>、<code>pre1</code>（編輯距離）</td>
          <td>Algolia、TypeSense</td>
      </tr>
      <tr>
          <td>Semantic</td>
          <td>上面 + <code>before</code>、<code>prior</code>（語意相近）</td>
          <td>Vector search / LLM</td>
      </tr>
  </tbody>
</table>
<p>使用者被 Google / 桌面搜尋訓練、預期 <strong>substring 或更高層級</strong>。預設拿到 prefix 的 site search → 「pre」找不到 backpressure → 看起來像 bug 但其實是 capability 落差。</p>
<hr>
<h2 id="為什麼預設是-prefix">為什麼預設是 prefix</h2>
<p>Static site search engines（Pagefind / Lunr / MiniSearch 預設）選 prefix matching 的原因：</p>
<table>
  <thead>
      <tr>
          <th>因素</th>
          <th>Prefix</th>
          <th>Substring</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Index size</td>
          <td>O(N)</td>
          <td>O(N²)（要 index 所有後綴）</td>
      </tr>
      <tr>
          <td>Query speed</td>
          <td>快（trie）</td>
          <td>慢（全掃 substring）</td>
      </tr>
      <tr>
          <td>跨語言支援</td>
          <td>容易</td>
          <td>中文 / CJK 邊界不明確</td>
      </tr>
      <tr>
          <td>Build time</td>
          <td>快</td>
          <td>慢</td>
      </tr>
  </tbody>
</table>
<p>對 static site（沒 server）、index 是要下載到 client 的 — substring index 可能 5-10x 大、unacceptable。Pagefind / Lunr 選 prefix 是「對齊 size constraint」、不是「對齊使用者意圖」。</p>
<p>這是個典型的 <a href="../ease-of-writing-vs-intent-alignment/">#67 寫作便利度跟意圖對齊反相關</a> — 工具預設是「實作便利位置」、不是「使用者意圖位置」。</p>
<hr>
<h2 id="為什麼這個-gap-是-silent">為什麼這個 gap 是 silent</h2>
<p>跟 <a href="../view-layer-filter-vs-source-layer/">#55 Filter × Source 層錯位</a> 共用結構：使用者打字看到結果列表、結果不空、看起來「有東西」、不會懷疑 engine 沒在做完整 search。</p>
<p>silent 失敗的條件：</p>
<ol>
<li>Prefix matching 對某些 query 仍能回到結果（排版上看起來「有用」）</li>
<li>使用者不知道「沒看到的還有什麼」</li>
<li>只有當 query 剛好不是任何 token 的 prefix、才會 0 結果（極少見、這時才會懷疑）</li>
</ol>
<p>對照 <a href="../view-layer-filter-vs-source-layer/">#55 silent 失敗條件</a> 三條件 — 完全一樣的結構：「有部分結果掩蓋了缺口」。</p>
<hr>
<h2 id="多面向跨工具的匹配模式對照">多面向：跨工具的匹配模式對照</h2>
<h3 id="前端-client-side-search">前端 client-side search</h3>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>預設匹配模式</th>
          <th>可調整為</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Pagefind v1.5</td>
          <td>Word-prefix</td>
          <td>Exact only（<code>useExact</code>）</td>
      </tr>
      <tr>
          <td>Lunr</td>
          <td>Stem + prefix</td>
          <td>Wildcard（<code>q+'*'</code>）</td>
      </tr>
      <tr>
          <td>MiniSearch</td>
          <td>Prefix</td>
          <td>Substring（<code>prefix: false, fuzzy: 0.2</code>）</td>
      </tr>
      <tr>
          <td>FlexSearch</td>
          <td>Token-based</td>
          <td>多種 tokenizer（含 ngram）</td>
      </tr>
      <tr>
          <td>Fuse.js</td>
          <td>Fuzzy</td>
          <td>可關掉 fuzzy 變 substring</td>
      </tr>
  </tbody>
</table>
<h3 id="backend--db">Backend / DB</h3>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>匹配模式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>SQL <code>=</code></td>
          <td>Exact</td>
      </tr>
      <tr>
          <td>SQL <code>LIKE '%X%'</code></td>
          <td>Substring（O(n) scan）</td>
      </tr>
      <tr>
          <td>SQL FULLTEXT</td>
          <td>Token + stem + (有時 prefix)</td>
      </tr>
      <tr>
          <td>ElasticSearch</td>
          <td>配置：term / match / wildcard / fuzzy / regexp</td>
      </tr>
      <tr>
          <td>PostgreSQL trigram</td>
          <td>Substring + similarity</td>
      </tr>
      <tr>
          <td>Vector DB（Pinecone 等）</td>
          <td>Semantic</td>
      </tr>
  </tbody>
</table>
<h3 id="命令列--ide-搜尋">命令列 / IDE 搜尋</h3>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>預設</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>grep</code></td>
          <td>Substring（regex）</td>
      </tr>
      <tr>
          <td><code>rg</code></td>
          <td>Substring（smart-case + regex）</td>
      </tr>
      <tr>
          <td>Vim <code>/</code></td>
          <td>Regex</td>
      </tr>
      <tr>
          <td>VSCode 搜尋</td>
          <td>Substring（含 fuzzy file search）</td>
      </tr>
  </tbody>
</table>
<p><strong>共通結構</strong>：每個工具預設不同、使用者帶著舊工具的 expectation 來、不對齊時 silent 失敗。</p>
<hr>
<h2 id="識別三問">識別三問</h2>
<p>寫之前 / debug 時、自問：</p>
<h3 id="1-這個工具的預設匹配模式是什麼">1. 這個工具的預設匹配模式是什麼？</h3>
<p>讀 docs、不要假設。Pagefind docs 寫 &ldquo;Pagefind matches by word prefix&rdquo;。Lunr 文件寫 &ldquo;Lunr does prefix matching by default&rdquo;。 預設不是直覺。</p>
<h3 id="2-使用者預期哪種匹配模式">2. 使用者預期哪種匹配模式？</h3>
<p>使用者被別的工具訓練。使用者基數越大、越接近 Google substring + fuzzy 預期。</p>
<table>
  <thead>
      <tr>
          <th>使用者類型</th>
          <th>預期匹配模式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>一般使用者（被 Google 訓練）</td>
          <td>Substring + fuzzy + semantic</td>
      </tr>
      <tr>
          <td>開發者（用 grep / IDE）</td>
          <td>Substring + regex</td>
      </tr>
      <tr>
          <td>資料庫使用者（寫 SQL）</td>
          <td>看你給的 hint</td>
      </tr>
      <tr>
          <td>命令列重度使用者</td>
          <td>預設 regex</td>
      </tr>
  </tbody>
</table>
<h3 id="3-gap-多大是否-silent">3. Gap 多大？是否 silent？</h3>
<p>工具預設 vs 使用者預期不一致時、評估「使用者會在多少 case 中遇到不一致」。</p>
<ul>
<li>Prefix vs Substring：使用者只要打詞中間部分就 silent 失敗、頻率高</li>
<li>Prefix vs Fuzzy：使用者打錯字才會發現、頻率低</li>
<li>Substring vs Semantic：使用者用同義詞才會發現、頻率中</li>
</ul>
<p>頻率高的 gap 必須有對策。</p>
<hr>
<h2 id="五種對策跟-59-filter--source-五策略-同構">五種對策（跟 <a href="../filter-source-composition-strategies/">#59 Filter × Source 五策略</a> 同構）</h2>
<h3 id="a選用支援目標匹配模式的引擎">A：選用支援目標匹配模式的引擎</h3>
<p>Pagefind 不支援 substring → 換 MiniSearch / FlexSearch。Lunr 不支援 fuzzy → 換 FlexSearch / Fuse.js。</p>
<ul>
<li><strong>適合</strong>：早期決策、index size 不是 bottleneck、能接受工程量</li>
<li><strong>代價</strong>：換引擎成本（API 不同、index 重建、UI 重整合）</li>
</ul>
<h3 id="b在-build-time-pre-tokenize增加替代-token">B：在 build time pre-tokenize、增加替代 token</h3>
<p>在 build pipeline 拆字、把 <code>backpressure</code> 加進 search index 的多個 token：<code>back</code> + <code>pressure</code> + <code>backpressure</code> + <code>back-pressure</code>。Pagefind 透過 <code>data-pagefind-meta</code> 或多份 hidden text 注入。</p>
<ul>
<li><strong>適合</strong>：少量已知關鍵詞 / 跨語言邊界（中文）/ 能控 build pipeline</li>
<li><strong>代價</strong>：手動標記、index 變大、新詞要加進清單</li>
</ul>
<h3 id="cclient-side-fallback-substring-search">C：Client-side fallback substring search</h3>
<p>Pagefind 找不到時、fetch 一份頁面 metadata（title + slug）、做 client-side substring filter。</p>
<ul>
<li><strong>適合</strong>：頁面數量 &lt; 10000、可接受第二層延遲</li>
<li><strong>代價</strong>：需要額外 fetch + 客戶端 substring scan、兩種 result UI 整合</li>
</ul>
<h3 id="dux-hint-明示匹配模式">D：UX hint 明示匹配模式</h3>
<p>把限制告訴使用者：「搜尋為前綴匹配、想找 X 請打 Y」。對應 <a href="../pattern-explicit-semantic-narrowing/">#66 明示語意縮小</a>。</p>
<ul>
<li><strong>適合</strong>：成本最低、只需文字 hint</li>
<li><strong>代價</strong>：使用者要學新規則、不對齊 Google expectation</li>
</ul>
<h3 id="e接受限制不告知">E：接受限制（不告知）</h3>
<p>不做任何處理、silent 接受。這是反模式（同 <a href="../view-layer-filter-vs-source-layer/">#55 silent 失敗</a>）— 使用者誤以為「沒有相關內容」、放棄。</p>
<hr>
<h2 id="跟其他抽象層原則的關係">跟其他抽象層原則的關係</h2>
<table>
  <thead>
      <tr>
          <th>原則</th>
          <th>跟本卡的關係</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="../view-layer-filter-vs-source-layer/">#55 Filter × Source 層錯位</a></td>
          <td>都是「使用者意圖跟工具實際行為的 silent gap」、本卡是 matching 維度的展現</td>
      </tr>
      <tr>
          <td><a href="../data-source-shape-defines-feature-shape/">#63 資料源的形狀</a></td>
          <td>形狀是 source 的 capability 維度、本卡是「matching mode」這個 capability 維度</td>
      </tr>
      <tr>
          <td><a href="../ease-of-writing-vs-intent-alignment/">#67 寫作便利度跟意圖對齊反相關</a></td>
          <td>工具預設是實作便利、使用者預期是 mental model 對齊、反相關</td>
      </tr>
      <tr>
          <td><a href="../verification-timeline-checkpoints/">#68 驗收的時間軸</a> Checkpoint 1</td>
          <td>「source capabilities 是否對齊使用者預期」屬意圖完整集 — 容易跳過</td>
      </tr>
      <tr>
          <td><a href="../external-trigger-for-high-roi-work/">#72 高 ROI 無外部觸發</a></td>
          <td>「讀 search engine docs 確認 matching mode」沒便利路徑、容易跳過</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="對應的實作篇">對應的實作篇</h2>
<ul>
<li>本 blog 搜尋頁的 Pagefind prefix-match 限制（這次 user 報的 case）</li>
<li>任何用 client-side search 的 SPA / 靜態站</li>
<li>內部 admin tool 的 search box（往往用 SQL <code>LIKE</code> 的 substring、跟使用者 Google 預期反方向）</li>
<li>ElasticSearch 配置時 term vs match query 的選擇</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>寫 search feature、沒讀工具的 matching mode docs</td>
          <td>跑識別三問、確認預設</td>
      </tr>
      <tr>
          <td>使用者報「我搜 X 找不到、但是有 X」</td>
          <td>多半是 matching mode gap、不是 bug</td>
      </tr>
      <tr>
          <td>使用者打字、結果列表 0 筆、但確實有相關內容</td>
          <td>不對齊的訊號明顯、需要對策</td>
      </tr>
      <tr>
          <td>Search 跨多種使用者（Google trained / dev / DB user）</td>
          <td>Mental model 異質、選擇性高（A/B + C 組合通常需要）</td>
      </tr>
      <tr>
          <td>工具 docs 寫「matches by word prefix」這類字眼</td>
          <td>警訊 — 預設不是 substring</td>
      </tr>
      <tr>
          <td>Pagefind / Lunr / 任何 static site search</td>
          <td>預設 prefix、要主動評估是否符合需求</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：搜尋引擎的匹配模式是個容易被忽略的 capability 維度。工具預設多半是 prefix（為了 index size）、使用者預期多半是 substring 或更高（被 Google 訓練）。沒對齊 = silent 失敗：使用者誤以為內容不存在、不會 report bug。Checkpoint 1 列「使用者意圖完整集」要包含「使用者打字行為的預期」。</p>
]]></content:encoded></item><item><title>Build-time 預處理 vs Runtime 計算的光譜：何時把成本前置</title><link>https://tarrragon.github.io/blog/report/build-time-vs-runtime-computation-spectrum/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/build-time-vs-runtime-computation-spectrum/</guid><description>&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>Runtime 成本&lt;/th>
 &lt;th>儲存成本&lt;/th>
 &lt;th>Freshness&lt;/th>
 &lt;th>適合&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>Pure build-time&lt;/strong>&lt;/td>
 &lt;td>高（pipeline + 一次計算全部）&lt;/td>
 &lt;td>~0&lt;/td>
 &lt;td>高（存 N 種預算結果）&lt;/td>
 &lt;td>差（每次 build 才 refresh）&lt;/td>
 &lt;td>高頻 query、少變動、closed-set&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Hybrid hot-path&lt;/strong>&lt;/td>
 &lt;td>中（預算 top X%）&lt;/td>
 &lt;td>低（hot 命中 0、cold runtime）&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>中（hot stale 風險）&lt;/td>
 &lt;td>長尾分布、可分 hot/cold&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Pure runtime&lt;/strong>&lt;/td>
 &lt;td>0&lt;/td>
 &lt;td>高（per query）&lt;/td>
 &lt;td>0&lt;/td>
 &lt;td>即時&lt;/td>
 &lt;td>低頻、高變動、open-ended query&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>光譜兩端都有合理場景、不是「build-time 永遠贏」。&lt;strong>選哪個位置依四軸：query 頻率 / dataset 大小 / freshness 需求 / build pipeline 複雜度&lt;/strong>。&lt;/p>
&lt;hr>
&lt;h2 id="build-time-的三個成本維度">build-time 的三個成本維度&lt;/h2>
&lt;p>直覺反應：「能 precompute 就 precompute、runtime 0 最好」。但這個直覺漏了三個維度：&lt;/p>
&lt;h3 id="1-freshness-成本">1. Freshness 成本&lt;/h3>
&lt;p>Build-time 結果是 build 那一刻的 snapshot。dataset 改變後、結果直到下次 build 才 refresh。&lt;/p>
&lt;ul>
&lt;li>&lt;strong>適合 build-time&lt;/strong>：靜態 / 慢變的內容（blog post、product catalog）&lt;/li>
&lt;li>&lt;strong>不適合 build-time&lt;/strong>：頻繁更新（user posts、live data、search index over user content）&lt;/li>
&lt;/ul>
&lt;h3 id="2-儲存成本">2. 儲存成本&lt;/h3>
&lt;p>Precompute N 種 query 的結果 = 存 N 份。當 query 是 open-ended（任意組合 filter / sort / search term）、N 是組合爆炸。&lt;/p>
&lt;ul>
&lt;li>&lt;strong>適合 build-time&lt;/strong>：closed-set（fixed list of routes、pre-defined search terms）&lt;/li>
&lt;li>&lt;strong>不適合 build-time&lt;/strong>：open-ended（任意 user input）&lt;/li>
&lt;/ul>
&lt;h3 id="3-pipeline-複雜度">3. Pipeline 複雜度&lt;/h3>
&lt;p>Build-time 計算需要 build pipeline 配合 — 加一條規則 = 加一份 artifact、需要 CI 跑、版本管理、deployment 同步。&lt;/p>
&lt;ul>
&lt;li>&lt;strong>適合 build-time&lt;/strong>：已有 build pipeline、加一條規則便宜&lt;/li>
&lt;li>&lt;strong>不適合 build-time&lt;/strong>：純 dynamic system、加 build step = 引入新 infrastructure&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="四軸判準">四軸判準&lt;/h2>
&lt;p>評估某個計算該放哪一端：&lt;/p>
&lt;h3 id="軸-1query-頻率">軸 1：Query 頻率&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>高頻&lt;/strong>（同一 query 每秒被 call N 次）→ build-time 划算（一次算、N 次受益）&lt;/li>
&lt;li>&lt;strong>低頻&lt;/strong>（query 多樣、每個 query 唯一）→ runtime 划算（precompute 全部 = 浪費）&lt;/li>
&lt;/ul>
&lt;h3 id="軸-2dataset-大小">軸 2：Dataset 大小&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>小 dataset&lt;/strong> → 兩端都可以、依其他軸&lt;/li>
&lt;li>&lt;strong>大 dataset&lt;/strong> → build-time 的儲存成本爆炸、傾向 runtime / hybrid&lt;/li>
&lt;li>&lt;strong>超大&lt;/strong> → 幾乎強制 runtime（即使 hot path 也 partial precompute）&lt;/li>
&lt;/ul>
&lt;h3 id="軸-3freshness-需求">軸 3：Freshness 需求&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>可接受 stale&lt;/strong>（小時 / 天） → build-time 可行&lt;/li>
&lt;li>&lt;strong>要近即時&lt;/strong>（分鐘級） → runtime 或 hybrid + invalidation&lt;/li>
&lt;li>&lt;strong>強即時&lt;/strong>（秒級） → 強制 runtime&lt;/li>
&lt;/ul>
&lt;h3 id="軸-4build-pipeline-複雜度">軸 4：Build pipeline 複雜度&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>既有 pipeline 成熟&lt;/strong> → 加 build-time step 便宜&lt;/li>
&lt;li>&lt;strong>沒 pipeline 或脆弱&lt;/strong> → runtime 更實際（不引入新 infrastructure）&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="三段光譜的實例對照">三段光譜的實例對照&lt;/h2>
&lt;h3 id="pure-build-time">Pure build-time&lt;/h3>
&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;/td>
 &lt;td>Hugo / Jekyll generate HTML&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Search index&lt;/td>
 &lt;td>Pagefind build-time index、Algolia indexer&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Image 變體&lt;/td>
 &lt;td>sharp / imagemin pre-generate sizes&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Route table&lt;/td>
 &lt;td>Compile-time routes（Next.js static export）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>ML model&lt;/td>
 &lt;td>Train once、serve trained weights&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sitemap / RSS&lt;/td>
 &lt;td>Build-time generate&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="hybrid-hot-path">Hybrid hot-path&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>領域&lt;/th>
 &lt;th>例子&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Cache (Redis)&lt;/td>
 &lt;td>Hot keys precompute、cold keys runtime + write-back&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CDN&lt;/td>
 &lt;td>Hot routes cached、cold routes hit origin&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>LLM RAG&lt;/td>
 &lt;td>Hot embeddings precompute、cold runtime embed&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Search autocomplete&lt;/td>
 &lt;td>Top N suggestions precompute、tail runtime&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Image responsive&lt;/td>
 &lt;td>Hot sizes precompute、edge cases runtime resize&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="pure-runtime">Pure runtime&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>領域&lt;/th>
 &lt;th>例子&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Live search over user data&lt;/td>
 &lt;td>每 query 掃 DB&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>User-specific compute（dashboard、recommendation）&lt;/td>
 &lt;td>每 user 每次 reload 算&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Real-time analytics&lt;/td>
 &lt;td>per-event 處理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Open-ended NLP query&lt;/td>
 &lt;td>LLM call per query&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Crypto / hash signature&lt;/td>
 &lt;td>Per-request 算&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="兩極之間的決策當不確定該選哪端">兩極之間的決策：當不確定該選哪端&lt;/h2>
&lt;h3 id="步驟-1列query-frequency--dataset-size象限">步驟 1：列「query frequency × dataset size」象限&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>&lt;/th>
 &lt;th>小 dataset&lt;/th>
 &lt;th>大 dataset&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>高頻 query&lt;/strong>&lt;/td>
 &lt;td>Build-time&lt;/td>
 &lt;td>Hybrid（hot precompute）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>低頻 query&lt;/strong>&lt;/td>
 &lt;td>兩端都可&lt;/td>
 &lt;td>Runtime&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="步驟-2套-freshness-限制">步驟 2：套 freshness 限制&lt;/h3>
&lt;p>如果 freshness 需求高、把 build-time 列從候選移除（除非有 incremental build / invalidation 機制）。&lt;/p></description><content:encoded><![CDATA[<h2 id="結論">結論</h2>
<p>計算放哪裡有光譜、不是二元：</p>
<table>
  <thead>
      <tr>
          <th>位置</th>
          <th>預付成本</th>
          <th>Runtime 成本</th>
          <th>儲存成本</th>
          <th>Freshness</th>
          <th>適合</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Pure build-time</strong></td>
          <td>高（pipeline + 一次計算全部）</td>
          <td>~0</td>
          <td>高（存 N 種預算結果）</td>
          <td>差（每次 build 才 refresh）</td>
          <td>高頻 query、少變動、closed-set</td>
      </tr>
      <tr>
          <td><strong>Hybrid hot-path</strong></td>
          <td>中（預算 top X%）</td>
          <td>低（hot 命中 0、cold runtime）</td>
          <td>中</td>
          <td>中（hot stale 風險）</td>
          <td>長尾分布、可分 hot/cold</td>
      </tr>
      <tr>
          <td><strong>Pure runtime</strong></td>
          <td>0</td>
          <td>高（per query）</td>
          <td>0</td>
          <td>即時</td>
          <td>低頻、高變動、open-ended query</td>
      </tr>
  </tbody>
</table>
<p>光譜兩端都有合理場景、不是「build-time 永遠贏」。<strong>選哪個位置依四軸：query 頻率 / dataset 大小 / freshness 需求 / build pipeline 複雜度</strong>。</p>
<hr>
<h2 id="build-time-的三個成本維度">build-time 的三個成本維度</h2>
<p>直覺反應：「能 precompute 就 precompute、runtime 0 最好」。但這個直覺漏了三個維度：</p>
<h3 id="1-freshness-成本">1. Freshness 成本</h3>
<p>Build-time 結果是 build 那一刻的 snapshot。dataset 改變後、結果直到下次 build 才 refresh。</p>
<ul>
<li><strong>適合 build-time</strong>：靜態 / 慢變的內容（blog post、product catalog）</li>
<li><strong>不適合 build-time</strong>：頻繁更新（user posts、live data、search index over user content）</li>
</ul>
<h3 id="2-儲存成本">2. 儲存成本</h3>
<p>Precompute N 種 query 的結果 = 存 N 份。當 query 是 open-ended（任意組合 filter / sort / search term）、N 是組合爆炸。</p>
<ul>
<li><strong>適合 build-time</strong>：closed-set（fixed list of routes、pre-defined search terms）</li>
<li><strong>不適合 build-time</strong>：open-ended（任意 user input）</li>
</ul>
<h3 id="3-pipeline-複雜度">3. Pipeline 複雜度</h3>
<p>Build-time 計算需要 build pipeline 配合 — 加一條規則 = 加一份 artifact、需要 CI 跑、版本管理、deployment 同步。</p>
<ul>
<li><strong>適合 build-time</strong>：已有 build pipeline、加一條規則便宜</li>
<li><strong>不適合 build-time</strong>：純 dynamic system、加 build step = 引入新 infrastructure</li>
</ul>
<hr>
<h2 id="四軸判準">四軸判準</h2>
<p>評估某個計算該放哪一端：</p>
<h3 id="軸-1query-頻率">軸 1：Query 頻率</h3>
<ul>
<li><strong>高頻</strong>（同一 query 每秒被 call N 次）→ build-time 划算（一次算、N 次受益）</li>
<li><strong>低頻</strong>（query 多樣、每個 query 唯一）→ runtime 划算（precompute 全部 = 浪費）</li>
</ul>
<h3 id="軸-2dataset-大小">軸 2：Dataset 大小</h3>
<ul>
<li><strong>小 dataset</strong> → 兩端都可以、依其他軸</li>
<li><strong>大 dataset</strong> → build-time 的儲存成本爆炸、傾向 runtime / hybrid</li>
<li><strong>超大</strong> → 幾乎強制 runtime（即使 hot path 也 partial precompute）</li>
</ul>
<h3 id="軸-3freshness-需求">軸 3：Freshness 需求</h3>
<ul>
<li><strong>可接受 stale</strong>（小時 / 天） → build-time 可行</li>
<li><strong>要近即時</strong>（分鐘級） → runtime 或 hybrid + invalidation</li>
<li><strong>強即時</strong>（秒級） → 強制 runtime</li>
</ul>
<h3 id="軸-4build-pipeline-複雜度">軸 4：Build pipeline 複雜度</h3>
<ul>
<li><strong>既有 pipeline 成熟</strong> → 加 build-time step 便宜</li>
<li><strong>沒 pipeline 或脆弱</strong> → runtime 更實際（不引入新 infrastructure）</li>
</ul>
<hr>
<h2 id="三段光譜的實例對照">三段光譜的實例對照</h2>
<h3 id="pure-build-time">Pure build-time</h3>
<table>
  <thead>
      <tr>
          <th>領域</th>
          <th>例子</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>靜態網站</td>
          <td>Hugo / Jekyll generate HTML</td>
      </tr>
      <tr>
          <td>Search index</td>
          <td>Pagefind build-time index、Algolia indexer</td>
      </tr>
      <tr>
          <td>Image 變體</td>
          <td>sharp / imagemin pre-generate sizes</td>
      </tr>
      <tr>
          <td>Route table</td>
          <td>Compile-time routes（Next.js static export）</td>
      </tr>
      <tr>
          <td>ML model</td>
          <td>Train once、serve trained weights</td>
      </tr>
      <tr>
          <td>Sitemap / RSS</td>
          <td>Build-time generate</td>
      </tr>
  </tbody>
</table>
<h3 id="hybrid-hot-path">Hybrid hot-path</h3>
<table>
  <thead>
      <tr>
          <th>領域</th>
          <th>例子</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Cache (Redis)</td>
          <td>Hot keys precompute、cold keys runtime + write-back</td>
      </tr>
      <tr>
          <td>CDN</td>
          <td>Hot routes cached、cold routes hit origin</td>
      </tr>
      <tr>
          <td>LLM RAG</td>
          <td>Hot embeddings precompute、cold runtime embed</td>
      </tr>
      <tr>
          <td>Search autocomplete</td>
          <td>Top N suggestions precompute、tail runtime</td>
      </tr>
      <tr>
          <td>Image responsive</td>
          <td>Hot sizes precompute、edge cases runtime resize</td>
      </tr>
  </tbody>
</table>
<h3 id="pure-runtime">Pure runtime</h3>
<table>
  <thead>
      <tr>
          <th>領域</th>
          <th>例子</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Live search over user data</td>
          <td>每 query 掃 DB</td>
      </tr>
      <tr>
          <td>User-specific compute（dashboard、recommendation）</td>
          <td>每 user 每次 reload 算</td>
      </tr>
      <tr>
          <td>Real-time analytics</td>
          <td>per-event 處理</td>
      </tr>
      <tr>
          <td>Open-ended NLP query</td>
          <td>LLM call per query</td>
      </tr>
      <tr>
          <td>Crypto / hash signature</td>
          <td>Per-request 算</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="兩極之間的決策當不確定該選哪端">兩極之間的決策：當不確定該選哪端</h2>
<h3 id="步驟-1列query-frequency--dataset-size象限">步驟 1：列「query frequency × dataset size」象限</h3>
<table>
  <thead>
      <tr>
          <th></th>
          <th>小 dataset</th>
          <th>大 dataset</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>高頻 query</strong></td>
          <td>Build-time</td>
          <td>Hybrid（hot precompute）</td>
      </tr>
      <tr>
          <td><strong>低頻 query</strong></td>
          <td>兩端都可</td>
          <td>Runtime</td>
      </tr>
  </tbody>
</table>
<h3 id="步驟-2套-freshness-限制">步驟 2：套 freshness 限制</h3>
<p>如果 freshness 需求高、把 build-time 列從候選移除（除非有 incremental build / invalidation 機制）。</p>
<h3 id="步驟-3看-build-pipeline-cost">步驟 3：看 build pipeline cost</h3>
<p>如果 build-time 成本（新 step、新 artifact、新 deploy 流程）大於 runtime 成本（per query CPU）、選 runtime。</p>
<h3 id="步驟-4留-escape-hatch">步驟 4：留 escape hatch</h3>
<p>選了一端不代表永遠 — 設計 invalidation hook / runtime fallback、未來能重新平衡。</p>
<hr>
<h2 id="反模式">反模式</h2>
<table>
  <thead>
      <tr>
          <th>反模式</th>
          <th>後果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>「能 precompute 就 precompute」當預設</td>
          <td>freshness / 儲存爆炸</td>
      </tr>
      <tr>
          <td>「runtime 比較動態」當預設</td>
          <td>高頻 query 浪費 CPU</td>
      </tr>
      <tr>
          <td>Build-time 沒留 invalidation hook</td>
          <td>dataset 改了無法 refresh</td>
      </tr>
      <tr>
          <td>Hybrid 沒明示 hot 邊界</td>
          <td>運作不穩、cold path 突然爆量</td>
      </tr>
      <tr>
          <td>把 freshness 假設成「不變」</td>
          <td>真實 dataset 會變、blowup</td>
      </tr>
      <tr>
          <td>Pre-build 全部 + runtime 又再算一次</td>
          <td>雙倍成本、無增益</td>
      </tr>
      <tr>
          <td>「先 runtime、之後 optimize 成 build-time」當口號</td>
          <td>optimize 那次永遠不發生（<a href="../external-trigger-for-high-roi-work/">#72</a>）</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="何時是兩端都不對要重思-problem">何時是「兩端都不對、要重思 problem」</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Build-time 結果 stale、runtime 又太慢</td>
          <td>Hybrid + invalidation 設計</td>
      </tr>
      <tr>
          <td>Hybrid hot 一直 miss、cold path 是常態</td>
          <td>重排 hot 邊界、可能整個翻成 pure runtime</td>
      </tr>
      <tr>
          <td>Open-ended query 試圖 build-time</td>
          <td>Reformulate problem、可能要分 query class</td>
      </tr>
      <tr>
          <td>加了 invalidation 後 build pipeline 太複雜</td>
          <td>改成 runtime + cache、別再強行 build-time</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="跟其他抽象層原則的關係">跟其他抽象層原則的關係</h2>
<table>
  <thead>
      <tr>
          <th>原則</th>
          <th>關係</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="../capability-gap-three-layer-escalation/">#86 Capability gap 三層階梯</a></td>
          <td>L3 structural rebuild 通常是「動 build-time 計算」、本卡是 L3 內部的具體取捨</td>
      </tr>
      <tr>
          <td><a href="../filter-source-composition-strategies/">#59 五策略選擇矩陣</a></td>
          <td>A 推進 query（runtime）vs C 預先建 index（build-time）vs B 自動續抓（hybrid）— 五策略的本卡映射</td>
      </tr>
      <tr>
          <td><a href="../main-strategy-plus-supplementary/">#75 主策略 + 補強疊加</a></td>
          <td>Hybrid hot-path 是 build-time + runtime 疊加的具體 case</td>
      </tr>
      <tr>
          <td><a href="../minimum-necessary-scope-is-sanity-defense/">#43 最小必要範圍</a></td>
          <td>Hybrid 的 hot 邊界 = 最小必要範圍、有證據再擴張</td>
      </tr>
      <tr>
          <td><a href="../search-engine-matching-mode-mismatch/">#73 search 匹配模式</a></td>
          <td>Build-time tokenize（B 策略）vs client-side fallback（C 策略）就是本卡兩極的具體 case</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>「能 precompute 就 precompute」沒列軸</td>
          <td>套四軸（頻率 / 大小 / freshness / pipeline）</td>
      </tr>
      <tr>
          <td>Build-time artifact 越來越大</td>
          <td>檢查 query frequency 分布、可能該移到 hybrid</td>
      </tr>
      <tr>
          <td>Runtime 計算成本爆</td>
          <td>找 hot path、考慮 hybrid</td>
      </tr>
      <tr>
          <td>Freshness 抱怨</td>
          <td>Build-time 已不適用、改 hybrid + invalidation</td>
      </tr>
      <tr>
          <td>加了 build step 後 deploy 變慢</td>
          <td>Build pipeline 成本不可忽略、評估是否仍划算</td>
      </tr>
      <tr>
          <td>Hybrid 邊界從沒重新 review</td>
          <td>hot / cold 比例會漂移、定期重 baseline</td>
      </tr>
  </tbody>
</table>
<p><strong>核心</strong>：Build-time vs runtime 是光譜、不是二元 — 中間 hybrid 段是多數實務情境的最佳位置。<strong>「能 precompute 就 precompute」是便利驅動（<a href="../ease-of-writing-vs-intent-alignment/">#67</a>）的口號</strong>、實際要套四軸（頻率 / 大小 / freshness / pipeline）才知道該放哪。</p>
]]></content:encoded></item><item><title>Engine 不可調時、把 transformation 移到外層</title><link>https://tarrragon.github.io/blog/report/transformation-at-outer-layer-when-engine-closed/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/transformation-at-outer-layer-when-engine-closed/</guid><description>&lt;h2 id="結論">結論&lt;/h2>
&lt;p>當底層 engine（search engine、LLM、DB、compiler、framework）&lt;strong>沒開放某能力的客製 API&lt;/strong>、不該硬改 engine 內部、改在 engine 的&lt;strong>輸入層 / 外層做 transformation&lt;/strong>：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Engine 限制&lt;/th>
 &lt;th>外層 transformation&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Search engine 只 prefix match&lt;/td>
 &lt;td>Build-time emit suffix tokens（&amp;ldquo;backpressure&amp;rdquo; → 加 hidden &amp;ldquo;pressure&amp;rdquo;）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>LLM 不會 CoT reasoning&lt;/td>
 &lt;td>Prompt 層加「請逐步推理」instruction&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>DB 不能 query JSON 內欄位&lt;/td>
 &lt;td>預先 denormalize 成獨立 column&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Compiler 不可調 lowering&lt;/td>
 &lt;td>Source-level macro 展開&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Framework 沒 hook 點&lt;/td>
 &lt;td>Wrapper component / proxy&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>API rate limit 不能調&lt;/td>
 &lt;td>Client-side batching / queueing&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>核心原則&lt;/strong>：engine 不開放 = 不要硬改 engine、改改你給 engine 的輸入。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼移到外層是合理-escape">為什麼移到外層是合理 escape&lt;/h2>
&lt;p>直覺反應遇到 engine 限制是「fork engine」「升級到能調的 engine」「換 engine」 — 但這三條都成本爆炸：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Fork engine&lt;/strong>：維護 fork 成本（merge upstream changes、bug fix back-port）&lt;/li>
&lt;li>&lt;strong>升級&lt;/strong>：可能需要等好幾版、可能 breaking change、可能根本沒得升&lt;/li>
&lt;li>&lt;strong>換 engine&lt;/strong>：data migration、API 重寫、config 重學&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>外層 transformation&lt;/strong> 跳過這三條：&lt;/p>
&lt;ul>
&lt;li>不動 engine 內部&lt;/li>
&lt;li>用 engine 既有的合法 API（input、metadata、wrapper）&lt;/li>
&lt;li>升級 engine 時 transformation 通常仍兼容&lt;/li>
&lt;li>換 engine 時也常能直接搬&lt;/li>
&lt;/ul>
&lt;p>代價：&lt;/p>
&lt;ul>
&lt;li>多一層 indirection&lt;/li>
&lt;li>需要維護 transformation 邏輯&lt;/li>
&lt;li>可能有 leak（transformation 不完美、edge case 露出 engine 限制）&lt;/li>
&lt;/ul>
&lt;p>通常代價遠小於三條「動 engine」路線。&lt;/p>
&lt;hr>
&lt;h2 id="五個跨領域實例">五個跨領域實例&lt;/h2>
&lt;h3 id="1-searchsuffix-token-injection">1. Search：suffix token injection&lt;/h3>
&lt;p>Pagefind / 多數 build-time search engine 只 prefix match。要支援 substring，build-time emit 額外 hidden tokens：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-html" data-lang="html">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c">&amp;lt;!-- 原文 --&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">p&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>backpressure handling&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">p&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c">&amp;lt;!-- transformation 後 --&amp;gt;&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">&amp;lt;&lt;/span>&lt;span class="nt">p&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>backpressure handling&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">p&lt;/span>&lt;span class="p">&amp;gt;&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">&amp;lt;&lt;/span>&lt;span class="nt">span&lt;/span> &lt;span class="na">hidden&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>pressure handling&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">span&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>grep &amp;quot;pre&amp;quot;&lt;/code> → matches &lt;code>pressure&lt;/code> prefix → finds page。&lt;/p>
&lt;h3 id="2-llmprompt-level-chain-of-thought">2. LLM：prompt-level chain-of-thought&lt;/h3>
&lt;p>LLM 不會自動 CoT。在 prompt 層加：&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">請先列出已知資訊、然後推理步驟、最後給出結論。&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Engine 沒變、輸入變了、行為變了。&lt;/p>
&lt;h3 id="3-dbdenormalized-columns">3. DB：denormalized columns&lt;/h3>
&lt;p>PostgreSQL JSON column query 慢、但:&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">events&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ADD&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">COLUMN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">user_id_extracted&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">UUID&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">GENERATED&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ALWAYS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">data&lt;/span>&lt;span class="o">-&amp;gt;&amp;gt;&lt;/span>&lt;span class="s1">&amp;#39;user_id&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">STORED&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INDEX&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">events&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">user_id_extracted&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Engine 沒變、schema 加了 generated column、query 走 index、變快。&lt;/p></description><content:encoded><![CDATA[<h2 id="結論">結論</h2>
<p>當底層 engine（search engine、LLM、DB、compiler、framework）<strong>沒開放某能力的客製 API</strong>、不該硬改 engine 內部、改在 engine 的<strong>輸入層 / 外層做 transformation</strong>：</p>
<table>
  <thead>
      <tr>
          <th>Engine 限制</th>
          <th>外層 transformation</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Search engine 只 prefix match</td>
          <td>Build-time emit suffix tokens（&ldquo;backpressure&rdquo; → 加 hidden &ldquo;pressure&rdquo;）</td>
      </tr>
      <tr>
          <td>LLM 不會 CoT reasoning</td>
          <td>Prompt 層加「請逐步推理」instruction</td>
      </tr>
      <tr>
          <td>DB 不能 query JSON 內欄位</td>
          <td>預先 denormalize 成獨立 column</td>
      </tr>
      <tr>
          <td>Compiler 不可調 lowering</td>
          <td>Source-level macro 展開</td>
      </tr>
      <tr>
          <td>Framework 沒 hook 點</td>
          <td>Wrapper component / proxy</td>
      </tr>
      <tr>
          <td>API rate limit 不能調</td>
          <td>Client-side batching / queueing</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：engine 不開放 = 不要硬改 engine、改改你給 engine 的輸入。</p>
<hr>
<h2 id="為什麼移到外層是合理-escape">為什麼移到外層是合理 escape</h2>
<p>直覺反應遇到 engine 限制是「fork engine」「升級到能調的 engine」「換 engine」 — 但這三條都成本爆炸：</p>
<ul>
<li><strong>Fork engine</strong>：維護 fork 成本（merge upstream changes、bug fix back-port）</li>
<li><strong>升級</strong>：可能需要等好幾版、可能 breaking change、可能根本沒得升</li>
<li><strong>換 engine</strong>：data migration、API 重寫、config 重學</li>
</ul>
<p><strong>外層 transformation</strong> 跳過這三條：</p>
<ul>
<li>不動 engine 內部</li>
<li>用 engine 既有的合法 API（input、metadata、wrapper）</li>
<li>升級 engine 時 transformation 通常仍兼容</li>
<li>換 engine 時也常能直接搬</li>
</ul>
<p>代價：</p>
<ul>
<li>多一層 indirection</li>
<li>需要維護 transformation 邏輯</li>
<li>可能有 leak（transformation 不完美、edge case 露出 engine 限制）</li>
</ul>
<p>通常代價遠小於三條「動 engine」路線。</p>
<hr>
<h2 id="五個跨領域實例">五個跨領域實例</h2>
<h3 id="1-searchsuffix-token-injection">1. Search：suffix token injection</h3>
<p>Pagefind / 多數 build-time search engine 只 prefix match。要支援 substring，build-time emit 額外 hidden tokens：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="c">&lt;!-- 原文 --&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">&lt;</span><span class="nt">p</span><span class="p">&gt;</span>backpressure handling<span class="p">&lt;/</span><span class="nt">p</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c">&lt;!-- transformation 後 --&gt;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">&lt;</span><span class="nt">p</span><span class="p">&gt;</span>backpressure handling<span class="p">&lt;/</span><span class="nt">p</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">&lt;</span><span class="nt">span</span> <span class="na">hidden</span><span class="p">&gt;</span>pressure handling<span class="p">&lt;/</span><span class="nt">span</span><span class="p">&gt;</span></span></span></code></pre></div><p><code>grep &quot;pre&quot;</code> → matches <code>pressure</code> prefix → finds page。</p>
<h3 id="2-llmprompt-level-chain-of-thought">2. LLM：prompt-level chain-of-thought</h3>
<p>LLM 不會自動 CoT。在 prompt 層加：</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">請先列出已知資訊、然後推理步驟、最後給出結論。</span></span></code></pre></div><p>Engine 沒變、輸入變了、行為變了。</p>
<h3 id="3-dbdenormalized-columns">3. DB：denormalized columns</h3>
<p>PostgreSQL JSON column query 慢、但:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">events</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="k">COLUMN</span><span class="w"> </span><span class="n">user_id_extracted</span><span class="w"> </span><span class="n">UUID</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">  </span><span class="k">GENERATED</span><span class="w"> </span><span class="n">ALWAYS</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="p">(</span><span class="k">data</span><span class="o">-&gt;&gt;</span><span class="s1">&#39;user_id&#39;</span><span class="p">)</span><span class="w"> </span><span class="n">STORED</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">events</span><span class="p">(</span><span class="n">user_id_extracted</span><span class="p">);</span></span></span></code></pre></div><p>Engine 沒變、schema 加了 generated column、query 走 index、變快。</p>
<h3 id="4-compilersource-level-macros">4. Compiler：source-level macros</h3>
<p>C 語言沒泛型、用 <code>#define</code> macro 模擬：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-c" data-lang="c"><span class="line"><span class="ln">1</span><span class="cl"><span class="cp">#define SWAP(a, b, T) do { T tmp = a; a = b; b = tmp; } while(0)</span></span></span></code></pre></div><p>Compiler 沒變、source 經 preprocessor transformation、行為變了。</p>
<h3 id="5-frameworkwrapper-component">5. Framework：wrapper component</h3>
<p>React 沒 onAttach lifecycle、用 wrapper:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-jsx" data-lang="jsx"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">function</span> <span class="nx">withMount</span><span class="p">(</span><span class="nx">Component</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">return</span> <span class="p">(</span><span class="nx">props</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">useEffect</span><span class="p">(()</span> <span class="p">=&gt;</span> <span class="p">{</span> <span class="nx">props</span><span class="p">.</span><span class="nx">onMount</span><span class="o">?</span><span class="p">.();</span> <span class="p">},</span> <span class="p">[]);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="k">return</span> <span class="p">&lt;</span><span class="nt">Component</span> <span class="p">{</span><span class="na">...props</span><span class="p">}</span> <span class="p">/&gt;;</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 class="p">}</span></span></span></code></pre></div><p>React 沒變、加一層 wrapper、行為加上了。</p>
<hr>
<h2 id="input-跟-output-transformation-是不同-pattern">Input 跟 Output transformation 是不同 pattern</h2>
<p>外層 transformation 不是單一 pattern、要區分 <strong>input transformation</strong>（改 engine 看到的）跟 <strong>output transformation</strong>（改 engine 給出的）：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Input transformation</th>
          <th>Output transformation</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>動的位置</td>
          <td>Engine 之前（給 engine 的輸入）</td>
          <td>Engine 之後（engine 的輸出）</td>
      </tr>
      <tr>
          <td>Engine 行為</td>
          <td>改變（看到不同輸入、做出不同事）</td>
          <td>不變（後處理而已）</td>
      </tr>
      <tr>
          <td>副作用</td>
          <td>可能改變 ranking / 內部狀態</td>
          <td>純後加工、對 engine 透明</td>
      </tr>
      <tr>
          <td>失敗模式</td>
          <td>Input 錯誤 → engine 整個跑錯</td>
          <td>Output 處理錯 → 個別 case 不對</td>
      </tr>
      <tr>
          <td>適合</td>
          <td>改變 engine 「能看到什麼」</td>
          <td>補強 engine 「沒給的東西」</td>
      </tr>
  </tbody>
</table>
<h3 id="例對照">例對照</h3>
<p><strong>Search</strong>：</p>
<ul>
<li>Input：build-time 加 suffix tokens（engine 索引時看到 hidden &ldquo;pressure&rdquo;）</li>
<li>Output：result 出來後 client-side substring scan（engine 結果照常、額外加 fallback）</li>
</ul>
<p><strong>LLM</strong>：</p>
<ul>
<li>Input：prompt 加 &ldquo;請逐步推理&rdquo;（engine 看到不同 prompt）</li>
<li>Output：parse output 後 extract structured data（engine 輸出照常、後處理）</li>
</ul>
<p><strong>DB</strong>：</p>
<ul>
<li>Input：generated column（engine 索引時看到 denormalized column）</li>
<li>Output：query 結果後處理 / aggregation（engine 結果照常、應用層加工）</li>
</ul>
<h3 id="何時選哪個">何時選哪個</h3>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>選</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>想改變 engine 結果的「內容覆蓋」</td>
          <td>Input</td>
      </tr>
      <tr>
          <td>Engine 不該被改、純補強 output</td>
          <td>Output</td>
      </tr>
      <tr>
          <td>副作用衝突風險高（如 search ranking）</td>
          <td>Output（更安全）</td>
      </tr>
      <tr>
          <td>需要 engine 配合（index size、build cost）</td>
          <td>Input（更徹底）</td>
      </tr>
  </tbody>
</table>
<p><strong>多數實作 case 兩者疊加</strong>：input 解 80%（更徹底）、output 補剩下 20%（catch input 漏的）。</p>
<hr>
<h2 id="何時不該用外層-transformation">何時不該用外層 transformation</h2>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>為什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Engine 開放了 API、有官方解</td>
          <td>用官方、別自己 transformation</td>
      </tr>
      <tr>
          <td>Transformation 跟 engine 行為衝突（例：injected tokens 影響 ranking）</td>
          <td>副作用大、考慮其他路</td>
      </tr>
      <tr>
          <td>Transformation 邏輯比 engine 還複雜</td>
          <td>可能該換 engine 了</td>
      </tr>
      <tr>
          <td>Transformation 永遠 catch 不全 edge case</td>
          <td>用了會誤導、不如顯式說「不支援」（<a href="../capability-gap-three-layer-escalation/">#86 L1</a>）</td>
      </tr>
      <tr>
          <td>Engine 升級會破壞 transformation</td>
          <td>維護成本長期高</td>
      </tr>
  </tbody>
</table>
<p>五類共通：<strong>transformation 的成本 / 風險 &gt; 動 engine 的成本</strong>。其他情境外層 transformation 是首選。</p>
<hr>
<h2 id="跟-45-外部組件合作四層的關係">跟 #45 外部組件合作四層的關係</h2>
<p><a href="../external-component-collaboration-layers/">#45</a> 講「離公共介面越近越穩」、本卡是這條原則的具體展開：</p>
<table>
  <thead>
      <tr>
          <th>#45 層次</th>
          <th>本卡對應</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>公共介面層（最穩）</td>
          <td>Engine 開放的 API</td>
      </tr>
      <tr>
          <td>邊界層</td>
          <td><strong>外層 transformation</strong>（本卡焦點）</td>
      </tr>
      <tr>
          <td>內部結構層</td>
          <td>Engine 內部、不該動</td>
      </tr>
      <tr>
          <td>客戶端層</td>
          <td>Wrapper / proxy</td>
      </tr>
  </tbody>
</table>
<p><strong>外層 transformation 是邊界層的具體技法</strong> — 在 engine 公共介面外、做 input / output transformation。</p>
<hr>
<h2 id="反模式">反模式</h2>
<table>
  <thead>
      <tr>
          <th>反模式</th>
          <th>後果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>沒先試外層、直接 fork engine</td>
          <td>維護成本爆炸</td>
      </tr>
      <tr>
          <td>Transformation 寫得太聰明、catch 不全 case</td>
          <td>看似 work、暗藏 silent failure</td>
      </tr>
      <tr>
          <td>Transformation 跟 engine 預設行為衝突</td>
          <td>結果不可預期</td>
      </tr>
      <tr>
          <td>把 transformation 寫在 engine code 裡（混入）</td>
          <td>該升級 engine 時 transformation 跟著動、失去隔離價值</td>
      </tr>
      <tr>
          <td>Engine 升級後不重 review transformation</td>
          <td>可能新版已支援、舊 transformation 變累贅</td>
      </tr>
      <tr>
          <td>Transformation 沒文件、只有 implicit comment</td>
          <td>後人不懂為什麼 / 不敢碰</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="跟其他抽象層原則的關係">跟其他抽象層原則的關係</h2>
<table>
  <thead>
      <tr>
          <th>原則</th>
          <th>關係</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="../external-component-collaboration-layers/">#45 外部組件合作四層</a></td>
          <td>本卡是「邊界層」的具體技法</td>
      </tr>
      <tr>
          <td><a href="../capability-gap-three-layer-escalation/">#86 Capability gap 三層階梯</a></td>
          <td>外層 transformation 多是 L2 augmenting computation 的實作方式</td>
      </tr>
      <tr>
          <td><a href="../build-time-vs-runtime-computation-spectrum/">#87 Build-time vs Runtime</a></td>
          <td>Transformation 可放 build-time（suffix token）或 runtime（query rewrite）</td>
      </tr>
      <tr>
          <td><a href="../search-engine-matching-mode-mismatch/">#73 search 匹配模式</a></td>
          <td>search engine prefix-only 限制是本卡 case 1 的具體場景</td>
      </tr>
      <tr>
          <td><a href="../literal-interception-vs-behavioral-refinement/">#82 字面攔截 vs 行為精煉</a></td>
          <td>Transformation 是字面層 catch、適合 hook 自動化（build step）</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>「engine 不支援 X、所以 X 不能做」</td>
          <td>檢查能不能在外層做 transformation</td>
      </tr>
      <tr>
          <td>「我們需要 fork 這個 lib」</td>
          <td>先試外層、多數情況夠</td>
      </tr>
      <tr>
          <td>「等 upstream 加 feature」</td>
          <td>多半永遠等不到、外層先解</td>
      </tr>
      <tr>
          <td>「這個 hack 太醜、要改 engine」</td>
          <td>醜不是換工具的理由、看實際 ROI</td>
      </tr>
      <tr>
          <td>Transformation 寫了沒文件</td>
          <td>補 why、否則後人會誤拆</td>
      </tr>
      <tr>
          <td>同一 engine 累積 ≥ 3 種 transformation</td>
          <td>可能該換 engine 了</td>
      </tr>
      <tr>
          <td>升級 engine 後 transformation 沒測</td>
          <td>可能新版 native 支援、舊 transformation 多餘</td>
      </tr>
  </tbody>
</table>
<p><strong>核心</strong>：Engine 限制不等於 capability 限制 — engine 沒開放的能力、通常可在 engine 的輸入 / 輸出層做 transformation 補上。<strong>「engine 不支援」是表象、「我沒思考外層解」是根因</strong>。</p>
]]></content:encoded></item><item><title>Go 教材核心術語</title><link>https://tarrragon.github.io/blog/go/glossary/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/glossary/</guid><description>&lt;p>本頁整理 Go 入門篇與進階篇反覆使用的詞彙。核心目的是讓同一個概念在不同章節中保持同一種意思。&lt;/p>
&lt;p>Go 教材中的術語應先服務可讀性：詞彙要幫助工程師判斷責任邊界，而不是把簡單程式包裝成複雜架構。小程式可以只有 &lt;code>main.go&lt;/code>，服務變大後才逐步引入 event、repository、port、adapter、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection&lt;/a> 等詞彙。&lt;/p>
&lt;h2 id="輸入與行為">輸入與行為&lt;/h2>
&lt;h3 id="action">Action&lt;/h3>
&lt;p>&lt;code>action&lt;/code> 表示 client 對服務提出的意圖。它通常來自 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> message、HTTP request 或 CLI input，還沒有完成驗證、授權或業務規則套用。&lt;/p>
&lt;p>例如 &lt;code>subscribe_topic&lt;/code> 可以是 WebSocket action，代表 client 想訂閱某個 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a>。它進入系統後，router 會先解析 payload，再交給 usecase 或 subscription manager。&lt;/p>
&lt;p>延伸閱讀：&lt;a href="https://tarrragon.github.io/blog/go/06-practical/new-websocket-action/" data-link-title="6.1 如何新增一個即時訊息 action" data-link-desc="修改 client message、路由與 handler">如何新增一個 WebSocket action&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/subscription-routing/" data-link-title="2.3 訂閱模型與訊息路由" data-link-desc="將 client action 對應到主題訂閱狀態">訂閱模型與訊息路由&lt;/a>。&lt;/p>
&lt;h3 id="command">Command&lt;/h3>
&lt;p>&lt;code>command&lt;/code> 表示 application layer 接受的行為輸入。它已經脫離 HTTP JSON、WebSocket frame 或 CLI flag 的外部格式，變成 usecase 可以理解的資料。&lt;/p>
&lt;p>例如 &lt;code>CreateNotificationCommand&lt;/code> 可以由 HTTP handler、WebSocket router 或背景 worker 建立。handler 負責把 request DTO 轉成 command，usecase 負責處理 command 的規則。&lt;/p>
&lt;p>延伸閱讀：&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">把 handler 邏輯拆成可測單元&lt;/a>。&lt;/p>
&lt;h3 id="usecase">Usecase&lt;/h3>
&lt;p>&lt;code>usecase&lt;/code> 表示一個 application 行為。它負責協調 validation、repository、event publisher、clock、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction&lt;/a> 等能力，並保留「這件事如何完成」的規則。&lt;/p>
&lt;p>usecase 的重點是行為邊界，不是資料夾名稱。小型程式可以先用函式表達 usecase；當 handler、worker、WebSocket action 都需要共用同一套規則時，再把 usecase 抽出來。&lt;/p>
&lt;p>延伸閱讀：&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">把 handler 邏輯拆成可測單元&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/hexagonal-migration/" data-link-title="7.6 逐步遷移到 ports/adapters 架構" data-link-desc="用 ports 與 adapters 控制 Go 服務的依賴方向">逐步遷移到 ports/adapters 架構&lt;/a>。&lt;/p>
&lt;h2 id="事件系統">事件系統&lt;/h2>
&lt;h3 id="domain-event">Domain Event&lt;/h3>
&lt;p>&lt;code>domain event&lt;/code> 表示系統承認已經發生的內部事實。它和 action 不同：action 是請求，domain event 是經過系統語意整理後的事實。&lt;/p>
&lt;p>例如 &lt;code>notification.created&lt;/code> 可以表示通知已被建立。這個事件可以來自 HTTP request、WebSocket action、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> message 或 background worker，但進入 processor 前應先被 normalize 成同一種內部模型。&lt;/p>
&lt;p>延伸閱讀：&lt;a href="https://tarrragon.github.io/blog/go/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">如何新增一種事件類型&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/component-boundaries/" data-link-title="4.1 事件來源、處理流程與狀態邊界" data-link-desc="分辨事件來源、事件融合、處理流程、狀態真相與推送邊界">事件來源、處理流程與狀態邊界&lt;/a>。&lt;/p>
&lt;h3 id="domainevent">DomainEvent&lt;/h3>
&lt;p>&lt;code>DomainEvent&lt;/code> 是範例程式中用來承載 domain event 的 Go 型別。它通常包含事件類型、來源、主體、發生時間、接收時間與 payload。&lt;/p>
&lt;p>名稱使用 PascalCase 是因為它是 Go 型別；概念說明時使用 &lt;code>domain event&lt;/code>，程式碼型別使用 &lt;code>DomainEvent&lt;/code>。&lt;/p>
&lt;h3 id="event-envelope">Event Envelope&lt;/h3>
&lt;p>&lt;code>event envelope&lt;/code> 是事件外層的穩定欄位集合。它通常描述 event id、event type、source、subject、occurred time、received time、schema version、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/correlation-id/" data-link-title="Correlation ID" data-link-desc="說明跨事件或跨服務的關聯識別碼如何支援排障">correlation id&lt;/a> 與 payload。&lt;/p></description><content:encoded><![CDATA[<p>本頁整理 Go 入門篇與進階篇反覆使用的詞彙。核心目的是讓同一個概念在不同章節中保持同一種意思。</p>
<p>Go 教材中的術語應先服務可讀性：詞彙要幫助工程師判斷責任邊界，而不是把簡單程式包裝成複雜架構。小程式可以只有 <code>main.go</code>，服務變大後才逐步引入 event、repository、port、adapter、<a href="/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection</a> 等詞彙。</p>
<h2 id="輸入與行為">輸入與行為</h2>
<h3 id="action">Action</h3>
<p><code>action</code> 表示 client 對服務提出的意圖。它通常來自 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> message、HTTP request 或 CLI input，還沒有完成驗證、授權或業務規則套用。</p>
<p>例如 <code>subscribe_topic</code> 可以是 WebSocket action，代表 client 想訂閱某個 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a>。它進入系統後，router 會先解析 payload，再交給 usecase 或 subscription manager。</p>
<p>延伸閱讀：<a href="/blog/go/06-practical/new-websocket-action/" data-link-title="6.1 如何新增一個即時訊息 action" data-link-desc="修改 client message、路由與 handler">如何新增一個 WebSocket action</a>、<a href="/blog/go-advanced/02-networking-websocket/subscription-routing/" data-link-title="2.3 訂閱模型與訊息路由" data-link-desc="將 client action 對應到主題訂閱狀態">訂閱模型與訊息路由</a>。</p>
<h3 id="command">Command</h3>
<p><code>command</code> 表示 application layer 接受的行為輸入。它已經脫離 HTTP JSON、WebSocket frame 或 CLI flag 的外部格式，變成 usecase 可以理解的資料。</p>
<p>例如 <code>CreateNotificationCommand</code> 可以由 HTTP handler、WebSocket router 或背景 worker 建立。handler 負責把 request DTO 轉成 command，usecase 負責處理 command 的規則。</p>
<p>延伸閱讀：<a href="/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">把 handler 邏輯拆成可測單元</a>。</p>
<h3 id="usecase">Usecase</h3>
<p><code>usecase</code> 表示一個 application 行為。它負責協調 validation、repository、event publisher、clock、<a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a> 等能力，並保留「這件事如何完成」的規則。</p>
<p>usecase 的重點是行為邊界，不是資料夾名稱。小型程式可以先用函式表達 usecase；當 handler、worker、WebSocket action 都需要共用同一套規則時，再把 usecase 抽出來。</p>
<p>延伸閱讀：<a href="/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">把 handler 邏輯拆成可測單元</a>、<a href="/blog/go/07-refactoring/hexagonal-migration/" data-link-title="7.6 逐步遷移到 ports/adapters 架構" data-link-desc="用 ports 與 adapters 控制 Go 服務的依賴方向">逐步遷移到 ports/adapters 架構</a>。</p>
<h2 id="事件系統">事件系統</h2>
<h3 id="domain-event">Domain Event</h3>
<p><code>domain event</code> 表示系統承認已經發生的內部事實。它和 action 不同：action 是請求，domain event 是經過系統語意整理後的事實。</p>
<p>例如 <code>notification.created</code> 可以表示通知已被建立。這個事件可以來自 HTTP request、WebSocket action、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> message 或 background worker，但進入 processor 前應先被 normalize 成同一種內部模型。</p>
<p>延伸閱讀：<a href="/blog/go/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">如何新增一種事件類型</a>、<a href="/blog/go-advanced/04-architecture-boundaries/component-boundaries/" data-link-title="4.1 事件來源、處理流程與狀態邊界" data-link-desc="分辨事件來源、事件融合、處理流程、狀態真相與推送邊界">事件來源、處理流程與狀態邊界</a>。</p>
<h3 id="domainevent">DomainEvent</h3>
<p><code>DomainEvent</code> 是範例程式中用來承載 domain event 的 Go 型別。它通常包含事件類型、來源、主體、發生時間、接收時間與 payload。</p>
<p>名稱使用 PascalCase 是因為它是 Go 型別；概念說明時使用 <code>domain event</code>，程式碼型別使用 <code>DomainEvent</code>。</p>
<h3 id="event-envelope">Event Envelope</h3>
<p><code>event envelope</code> 是事件外層的穩定欄位集合。它通常描述 event id、event type、source、subject、occurred time、received time、schema version、<a href="/blog/backend/knowledge-cards/correlation-id/" data-link-title="Correlation ID" data-link-desc="說明跨事件或跨服務的關聯識別碼如何支援排障">correlation id</a> 與 payload。</p>
<p>envelope 的價值是讓不同事件共享同一種路由、去重、記錄與觀測方式。payload 則保留每種事件自己的資料內容。</p>
<p>延伸閱讀：<a href="/blog/go/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">如何新增一種事件類型</a>、<a href="/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">多來源 event 融合</a>。</p>
<h3 id="event-log">Event Log</h3>
<p><code>event log</code> 表示記錄 domain event 的事實紀錄。它用來追蹤系統承認過哪些事件，重點是事件語意、順序、去重與後續查詢。</p>
<p>[event <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>](/go/backend/knowledge-cards/event-log) 和 structured log 的用途不同。structured log 服務操作診斷，event log 服務業務事實追蹤；兩者可以共享 <code>trace_id</code>、<code>event_id</code>、<code>subject_id</code> 等欄位，但不應互相取代。</p>
<p>延伸閱讀：<a href="/blog/go/06-practical/structured-recording/" data-link-title="6.5 如何新增結構化記錄欄位" data-link-desc="區分 operational log、domain event log 與狀態資料">如何新增結構化記錄欄位</a>、<a href="/blog/go-advanced/06-production-operations/log-fields/" data-link-title="6.3 結構化日誌欄位設計" data-link-desc="讓 log 可 grep、可聚合、可追蹤">結構化日誌欄位設計</a>。</p>
<h3 id="event-store">Event Store</h3>
<p><code>event store</code> 是具備持久化、排序、replay、schema 演進與 transaction 語意的事件儲存。它比 event log 承擔更高的資料一致性責任。</p>
<p>教材中的 event log 先用來建立事件記錄概念；當系統需要 replay、跨節點處理或以事件歷史作為狀態來源時，才需要討論 event store。</p>
<p>延伸閱讀：<a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Durable queue、outbox 與 idempotency</a>。</p>
<h3 id="event-sourcing">Event Sourcing</h3>
<p><code>event sourcing</code> 表示以事件歷史作為狀態真相來源。系統透過事件序列重建狀態，事件歷史本身就是真相。</p>
<p>保留 event log 不等於採用 event sourcing。許多服務會記錄 domain event 作為審計或整合用途，但 <a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth</a> 仍然是資料庫中的 current state。</p>
<p>延伸閱讀：<a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Source of Truth：狀態邊界</a>。</p>
<h3 id="dedup-key">Dedup Key</h3>
<p><code>dedup key</code> 表示用 domain 語意判斷兩筆事件是否是同一件事的 key。它通常由 subject kind、subject id、event type、外部序號或時間窗口組成。</p>
<p>dedup key 的重點是「同一件事」，不是「同一份 bytes」。raw payload hash 可以偵測完全相同的輸入，但無法處理不同來源描述同一個 domain fact 的情境。</p>
<p>延伸閱讀：<a href="/blog/go/07-refactoring/dedup-refactor/" data-link-title="7.3 事件去重邏輯的重構策略" data-link-desc="保留語義鍵並降低重複流程">事件去重邏輯的重構策略</a>、<a href="/blog/go-advanced/04-architecture-boundaries/dedup-key/" data-link-title="4.2 事件去重與語義鍵設計" data-link-desc="用 entity ID、event type、來源語意與時間窗口建立去重鍵">事件去重與語義鍵設計</a>。</p>
<h3 id="idempotency-key">Idempotency Key</h3>
<p><code>idempotency key</code> 表示外部呼叫或重試流程用來安全重複執行的 key。它常出現在 HTTP request、queue message 或 outbox publish 流程中。</p>
<p><a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> key 和 dedup key 的責任不同。idempotency key 保護同一次操作的重試；dedup key 保護 domain 層面上同一件事的重複描述。</p>
<p>延伸閱讀：<a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Durable queue、outbox 與 idempotency</a>。</p>
<h2 id="狀態與資料模型">狀態與資料模型</h2>
<h3 id="repository">Repository</h3>
<p><code>repository</code> 表示狀態或資料存取的邊界。它負責保存與讀取某一類資料，並讓外部呼叫者不需要知道資料目前存在 memory、<a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database</a> 或遠端服務。</p>
<p>repository 的核心價值是權責集中。當狀態轉移、copy boundary、transaction 或查詢模型開始變複雜時，把資料能力集中在 repository 會比讓 handler 直接操作 map 更穩定。</p>
<p>延伸閱讀：<a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">如何新增 repository port</a>、<a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">狀態管理的安全邊界</a>。</p>
<h3 id="repository-port">Repository Port</h3>
<p><code>repository port</code> 表示 application layer 需要的資料能力介面。它由 usecase 的需求定義，而不是由資料庫表格或具體儲存技術定義。</p>
<p>例如 usecase 只需要 <code>Save</code> 和 <code>FindByID</code>，port 就只暴露這兩個方法。memory repository、SQL repository 或 test fake 都可以實作同一個 port。</p>
<p>延伸閱讀：<a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">如何新增 repository port</a>、<a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">資料庫 transaction 與 schema migration</a>。</p>
<h3 id="state-owner">State Owner</h3>
<p><code>state owner</code> 表示擁有某份可變狀態寫入權的元件。它可以是 mutex 保護的 repository，也可以是單一 goroutine 持有狀態並透過 channel 接收 command。</p>
<p>state owner 的重點是只有一個地方能決定狀態如何改變。其他元件應送入 command 或 event，而不是直接修改內部 map、slice 或 pointer。</p>
<p>延伸閱讀：<a href="/blog/go-advanced/01-concurrency-patterns/shared-state/" data-link-title="1.4 共享狀態與複製邊界" data-link-desc="用 lock 與 copy 保護長期服務的狀態資料">共享狀態與複製邊界</a>、<a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Source of Truth：狀態邊界</a>。</p>
<h3 id="source-of-truth">Source of Truth</h3>
<p><code>source of truth</code> 表示狀態轉移的寫入權責，是系統承認「誰能決定目前狀態」的邊界。</p>
<p>小型服務的 source of truth 可能是 memory repository；加入資料庫後，source of truth 仍然包含 application 的狀態規則、transaction 邊界與持久化資料。</p>
<p>延伸閱讀：<a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Source of Truth：狀態邊界</a>。</p>
<h3 id="projection--read-model">Projection / Read Model</h3>
<p><code>projection</code> 或 <code>read model</code> 表示為讀取需求整理出的資料模型。它可以來自 domain state、event history 或其他來源，目標是讓查詢、列表、即時推送或 UI 顯示更直接。</p>
<p>projection 可以提升讀取效率與簡化 response 組裝，但它不應反過來成為狀態真相。狀態規則仍然應由 repository、state owner 或 usecase 控制。</p>
<p>延伸閱讀：<a href="/blog/go/06-practical/state-fields/" data-link-title="6.3 如何擴展狀態投影欄位" data-link-desc="更新狀態模型、repository 與 API 輸出">如何擴展狀態資料欄位</a>、<a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Source of Truth：狀態邊界</a>。</p>
<h3 id="response-view">Response View</h3>
<p><code>response view</code> 表示對外輸出的資料形狀。它負責 JSON tag、<code>omitempty</code>、顯示文字、相容性欄位與 API contract。</p>
<p>response view 的核心責任是翻譯內部資料給外部使用者。顯示文字、前端 badge、API 版本相容欄位通常應放在 response view，而不是混進 domain state。</p>
<p>延伸閱讀：<a href="/blog/go/06-practical/state-fields/" data-link-title="6.3 如何擴展狀態投影欄位" data-link-desc="更新狀態模型、repository 與 API 輸出">如何擴展狀態資料欄位</a>。</p>
<h3 id="dto">DTO</h3>
<p><code>DTO</code> 表示資料傳輸形狀。它常用於 HTTP request、HTTP response、queue message、WebSocket payload 或外部 API client。</p>
<p>DTO 的責任是描述邊界格式。它可以有 JSON tag、相容性欄位與外部命名慣例，但不應直接取代 domain model、repository model 或 command。</p>
<p>延伸閱讀：<a href="/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">把 handler 邏輯拆成可測單元</a>、<a href="/blog/go/07-refactoring/domain-packages/" data-link-title="7.5 以 domain 重新整理 package" data-link-desc="讓 account、job、event、workflow 這類領域邊界在目錄中可見">以 domain 重新整理 package</a>。</p>
<h3 id="copy-boundary">Copy Boundary</h3>
<p><code>copy boundary</code> 表示回傳或接收 slice、map、pointer 時用複製保護狀態所有權的邊界。它防止呼叫端透過引用修改 repository 內部資料。</p>
<p>Go 的 slice、map 與 pointer 都可能共享底層資料，所以 repository 回傳資料時要判斷是否需要 shallow copy 或 deep copy。資料量大時，可以改用分頁、projection 或 snapshot cache 來控制成本。</p>
<p>延伸閱讀：<a href="/blog/go/02-types-data/pointers-copy/" data-link-title="2.5 指標與資料複製邊界" data-link-desc="理解指標、slice 與共享狀態的防護策略">指標與資料複製邊界</a>、<a href="/blog/go-advanced/01-concurrency-patterns/shared-state/" data-link-title="1.4 共享狀態與複製邊界" data-link-desc="用 lock 與 copy 保護長期服務的狀態資料">共享狀態與複製邊界</a>。</p>
<h2 id="架構邊界">架構邊界</h2>
<h3 id="port">Port</h3>
<p><code>port</code> 表示 application 依賴的能力介面。它描述「我需要什麼能力」，例如儲存通知、發布事件、讀取外部資料或取得現在時間。</p>
<p>Go 的 port 通常是小 interface。它應由使用方定義，讓 application 可以依賴抽象能力，而不是依賴具體資料庫、message <a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a> 或 <a href="/blog/backend/knowledge-cards/http-client/" data-link-title="HTTP Client" data-link-desc="說明服務呼叫外部 HTTP 依賴時需要管理 timeout、連線與重試">HTTP client</a>。</p>
<p>延伸閱讀：<a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">用 interface 隔離外部依賴</a>、<a href="/blog/go/07-refactoring/hexagonal-migration/" data-link-title="7.6 逐步遷移到 ports/adapters 架構" data-link-desc="用 ports 與 adapters 控制 Go 服務的依賴方向">逐步遷移到 ports/adapters 架構</a>。</p>
<h3 id="adapter">Adapter</h3>
<p><code>adapter</code> 表示把外部技術或協定接到 application port 的實作或轉換層。它可以是 HTTP handler、WebSocket router、SQL repository、queue <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a> 或 external API client。</p>
<p>adapter 的核心責任是翻譯邊界格式。application 不應知道 HTTP body、SQL row、queue message 或 WebSocket frame 的細節。</p>
<p>延伸閱讀：<a href="/blog/go/07-refactoring/hexagonal-migration/" data-link-title="7.6 逐步遷移到 ports/adapters 架構" data-link-desc="用 ports 與 adapters 控制 Go 服務的依賴方向">逐步遷移到 ports/adapters 架構</a>。</p>
<h3 id="inbound-adapter">Inbound Adapter</h3>
<p><code>inbound adapter</code> 表示把外部輸入轉成 application command 或 domain event 的 adapter。HTTP handler、WebSocket router、CLI command、queue consumer 都可以是 inbound adapter。</p>
<p>inbound adapter 通常負責 parsing、基本 validation、身份資訊提取與錯誤轉換。行為規則應交給 usecase 或 processor。</p>
<p>延伸閱讀：<a href="/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">把 handler 邏輯拆成可測單元</a>、<a href="/blog/go-advanced/02-networking-websocket/read-write-pump/" data-link-title="2.1 read pump / write pump 模式" data-link-desc="分離 WebSocket 讀取、寫入與心跳">read pump / write pump 模式</a>。</p>
<h3 id="outbound-adapter">Outbound Adapter</h3>
<p><code>outbound adapter</code> 表示實作 application port 並連接外部系統的 adapter。SQL repository、Redis cache、message publisher、email sender、external API client 都屬於這類。</p>
<p>outbound adapter 的重點是隔離技術細節。usecase 依賴 port；adapter 承擔 retry、serialization、connection、<a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a> 與外部錯誤轉換。</p>
<p>延伸閱讀：<a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">如何新增 repository port</a>、<a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">資料庫 transaction 與 schema migration</a>。</p>
<h3 id="normalizer">Normalizer</h3>
<p><code>normalizer</code> 表示把 raw input 轉成內部模型的元件。它常出現在事件系統中，負責把 HTTP callback、queue message 或外部 API response 轉成 <code>DomainEvent</code>。</p>
<p>normalizer 的責任是建立內部一致性。不同來源可以有不同 raw format，但進入 processor 前應變成一致的 domain event。</p>
<p>延伸閱讀：<a href="/blog/go-advanced/04-architecture-boundaries/component-boundaries/" data-link-title="4.1 事件來源、處理流程與狀態邊界" data-link-desc="分辨事件來源、事件融合、處理流程、狀態真相與推送邊界">事件來源、處理流程與狀態邊界</a>。</p>
<h3 id="processor">Processor</h3>
<p><code>processor</code> 表示處理 domain event 或 background job 的元件。它負責套用規則、去重、更新狀態、寫入 event log 或呼叫 publisher。</p>
<p>processor 應處理已經 normalize 的資料。它不應依賴 HTTP request body、WebSocket frame 或 queue message 的原始格式。</p>
<p>延伸閱讀：<a href="/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">如何新增背景工作流程</a>、<a href="/blog/go-advanced/04-architecture-boundaries/component-boundaries/" data-link-title="4.1 事件來源、處理流程與狀態邊界" data-link-desc="分辨事件來源、事件融合、處理流程、狀態真相與推送邊界">事件來源、處理流程與狀態邊界</a>。</p>
<h2 id="工具與靜態分析">工具與靜態分析</h2>
<h3 id="ast-walker">AST Walker</h3>
<p><code>AST walker</code> 表示用 visitor pattern 對解析後的抽象語法樹做 DFS 走訪，並在每個節點上套用規則或收集資訊的處理方式。在 Go 中，<code>ast.Walk</code> 是 <code>go/ast</code> 與 <code>goldmark/ast</code> 都採用的 API 慣例：傳入 root node 跟 walker 函式，framework 負責遞迴下降、把 entering / exiting 時機告訴你。</p>
<p>walker 的責任是<strong>把樹的走訪邏輯外部化</strong>，讓規則本身只關心「這個節點我要做什麼」。多條規則可以共用同一次走訪；複雜的 context（例如父節點是什麼、目前在哪個 heading 層級）由 walker 狀態機累積，不污染 rule 程式碼。Block vs inline 節點的判讀是 walker 用法最常見的陷阱 — 對 inline 節點呼叫 <code>Lines()</code> 會 panic，因此需要走上去找最近的 block 節點再取位置。</p>
<p>延伸閱讀：<a href="/blog/go/09-tooling-and-analysis/goldmark-ast-basics/" data-link-title="9.2 第三方 parser 整合：goldmark AST 入門" data-link-desc="用 goldmark 把 markdown 解析成 AST，掌握 ast.Walk visitor 模式、block 與 inline 節點的判讀、byte offset 如何定位到行號">goldmark AST 入門</a>、<a href="/blog/posts/%E4%BB%80%E9%BA%BC%E6%98%AF-ast-%E5%BE%9E%E5%AD%97%E4%B8%B2%E5%88%B0%E8%AA%9E%E6%B3%95%E6%A8%B9%E7%9A%84%E8%A6%96%E8%A7%92%E8%BD%89%E6%8F%9B/" data-link-title="什麼是 AST — 從字串到語法樹的視角轉換" data-link-desc="AST 與 regex 的差異判準：規則需要知道文字處在什麼結構中時 regex 就不夠。附 regex 誤判的具體 case。">什麼是 AST</a>。</p>
<h3 id="idempotent-文字改寫">Idempotent 文字改寫</h3>
<p><code>idempotent 文字改寫</code> 表示對同一輸入跑一次或多次、結果相同的轉換契約。應用在 <code>gofmt</code>、<code>prettier</code>、<code>ruff fix</code> 這類 formatter 與 fixer 上，契約讓工具能安全地接到 pre-commit hook（重複跑不會累積漂移）、能用 <code>--check</code> 跟 <code>--fix</code> 共用同一套邏輯（差別只在要不要寫檔）、能分段除錯。</p>
<p>實作上的核心技巧是<strong>每條規則自己冪等</strong>：判斷「違規才修」而非「無論如何都套用」。多規則串成流水線時，每條規則的輸出要是下一條的合法輸入；行數變動時要重建 LineContext 索引。測試可以用「跑兩次結果相等」當斷言，直接驗證契約。</p>
<p>延伸閱讀：<a href="/blog/go/09-tooling-and-analysis/ast-idempotent-rewriting/" data-link-title="9.3 AST 驅動的 idempotent 文字改寫" data-link-desc="用 AST 定位位置、用 line-based 或 byte-level 改寫；設計多條 rule 的執行順序；--check 跟 --fix 如何共用邏輯">AST 驅動的 idempotent 文字改寫</a>、<a href="/blog/go/09-tooling-and-analysis/pre-commit-and-ci/" data-link-title="9.6 Pre-commit hook 與 CI 整合" data-link-desc="工具寫完只是起點；接到 pre-commit hook 跟 CI 才真正守住品質。Re-staging、dry-run vs apply、不能繞過的邊界">Pre-commit hook 與 CI 整合</a>。</p>
<h3 id="跨檔案-link-graph">跨檔案 Link Graph</h3>
<p><code>跨檔案 link graph</code> 表示把整個 repo 的檔案視為節點、檔案間連結視為邊的資料結構，用一次 parse 之後的 in-memory map 支援反向查詢（這個目標被誰引用？這個連結的目標存在嗎？）。避免 N² 的「每次查詢都重 parse 全部檔案」成本。</p>
<p>典型應用包含 orphan 偵測（節點沒有 inbound edge）、broken link 偵測（邊指到不存在的節點）、dependency cycle 偵測（graph 有環）、reverse index 建構（哪些檔案引用了 X）。Graph 一次建好後，所有後續 query 都是 map lookup 或 slice scan，microsecond 級。</p>
<p>延伸閱讀：<a href="/blog/go/09-tooling-and-analysis/cross-file-graph-analysis/" data-link-title="9.4 跨檔案圖分析：從 lint 走到 static analysis" data-link-desc="Single-file 規則用 AST 搞定；跨檔 orphan 偵測、broken link、backlink 完整性需要把整個 repo 建成圖再走訪。用 mdtools cards 為例">跨檔案圖分析</a>。</p>
<h3 id="tripwire-決策">Tripwire 決策</h3>
<p><code>tripwire 決策</code> 表示用事前約定的可量測條件，在命中時觸發「重新評估是否升級」的決策方法。目的是避開「太早升級」（過度工程化）跟「太晚升級」（信譽破產）兩種失敗。</p>
<p>tripwire 的重點是<strong>把評估時機從模糊直覺變成明確觸發</strong>。設計時要選合適的訊號（誤判率、維護成本、使用者投訴頻率），並定期檢驗條件是否仍然合理。適用於技術選型（regex vs AST、Python vs Go）、架構升級（單體 → 微服務）、工具邊界（lint vs full type-check）等決策。</p>
<p>延伸閱讀：<a href="/blog/go/09-tooling-and-analysis/tool-decision-tripwire/" data-link-title="9.5 工具決策：regex 到 AST、Python 到 Go 的 tripwire" data-link-desc="什麼訊號代表工具該升級到下一個層次；用 WRAP 框架做語言與實作層的技術決策；延遲決策的成本">工具決策的 tripwire</a>。</p>
<h3 id="pre-commit-hook-定位">Pre-commit Hook 定位</h3>
<p><code>pre-commit hook</code> 表示 git commit 流程中、commit 實際建立前自動觸發的檢查點。定位上它是<strong>快速守門員</strong>，負責秒級可完成的 lint / fmt / 基本驗證，把錯誤攔在本機、讓作者早期回饋。</p>
<p>跟 CI 的分工是：hook 守本機（快、只看 staged 檔案），CI 守共享 branch（慢、乾淨環境、完整 test）。兩者互補、共用同一套工具與規則。落地時要處理 re-staging（hook 自動修檔後 <code>git add</code> 回 staged）、exit code 語意（0 = pass、1 = violation block、2 = tool failure）、<code>--no-verify</code> 繞過的邊界（原則上禁止，緊急情境有條件例外）。</p>
<p>延伸閱讀：<a href="/blog/go/09-tooling-and-analysis/pre-commit-and-ci/" data-link-title="9.6 Pre-commit hook 與 CI 整合" data-link-desc="工具寫完只是起點；接到 pre-commit hook 跟 CI 才真正守住品質。Re-staging、dry-run vs apply、不能繞過的邊界">Pre-commit hook 與 CI 整合</a>。</p>
<h2 id="使用方式">使用方式</h2>
<p>閱讀章節時若遇到同一個詞在不同情境出現，先回到本頁確認它的核心責任。入門篇會用簡化範例建立語感；進階篇會把同一批詞彙放進並發、WebSocket、資料庫、觀測與部署壓力中重新檢查；工具類章節會把型別、interface、package 等概念落到 CLI、parser、graph analysis 等非服務場景。</p>
]]></content:encoded></item><item><title>Event Log</title><link>https://tarrragon.github.io/blog/backend/knowledge-cards/event-log/</link><pubDate>Mon, 22 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/knowledge-cards/event-log/</guid><description>&lt;p>Event log 按時間保存已發生事件的不可變紀錄，是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/event-sourcing/" data-link-title="Event Sourcing" data-link-desc="說明用 append-only 事件流取代 mutable state 作為正式紀錄的設計模式、需求判準與代價">event sourcing&lt;/a> 的儲存層。每一筆事件記錄一次狀態變更，整條事件流構成完整的變更歷史。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Event log 是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/event-sourcing/" data-link-title="Event Sourcing" data-link-desc="說明用 append-only 事件流取代 mutable state 作為正式紀錄的設計模式、需求判準與代價">event sourcing&lt;/a> 的儲存層。在 event sourcing 架構中，event log 是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth&lt;/a>，current state 透過 replay 事件流推算。在非 event sourcing 架構中，event log 是輔助紀錄 — 正式狀態仍由 mutable record 承擔，event log 提供變更歷史跟 replay 能力。&lt;/p>
&lt;p>Event log 的讀取面透過 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection&lt;/a> 轉換成 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="說明為查詢場景建立的讀取模型，與正式狀態的責任分離">read model&lt;/a>，讓消費者不需要每次 replay 整條事件流。在訊息傳遞面，event log 常搭配 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/replay-runbook/" data-link-title="Replay Runbook" data-link-desc="說明事件重放前需要控制的範圍、順序、驗證與副作用">replay runbook&lt;/a> 使用。&lt;/p>
&lt;h2 id="使用情境">使用情境&lt;/h2>
&lt;p>訂單狀態變更可寫入 event log，後續由報表、通知、稽核服務各自消費。當下游落後時，可用 replay 補齊資料。金融帳務的每一筆增減、權限變更的每一次授權與撤銷、訂閱方案的每一次升降級，都是典型的 event log 應用。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>設計時要定義事件 schema 演進（新版 consumer 要能消費舊版事件）、保留期限（無限保留 vs retention-based 清理）、重播邊界（從哪個 offset 開始 replay）與去重策略（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a> 保證）。Event log 的儲存成長是長期成本 — 高頻寫入的系統需要 snapshot 機制或 retention 策略來控制。&lt;/p></description><content:encoded><![CDATA[<p>Event log 按時間保存已發生事件的不可變紀錄，是 <a href="/blog/backend/knowledge-cards/event-sourcing/" data-link-title="Event Sourcing" data-link-desc="說明用 append-only 事件流取代 mutable state 作為正式紀錄的設計模式、需求判準與代價">event sourcing</a> 的儲存層。每一筆事件記錄一次狀態變更，整條事件流構成完整的變更歷史。</p>
<h2 id="概念位置">概念位置</h2>
<p>Event log 是 <a href="/blog/backend/knowledge-cards/event-sourcing/" data-link-title="Event Sourcing" data-link-desc="說明用 append-only 事件流取代 mutable state 作為正式紀錄的設計模式、需求判準與代價">event sourcing</a> 的儲存層。在 event sourcing 架構中，event log 是 <a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth</a>，current state 透過 replay 事件流推算。在非 event sourcing 架構中，event log 是輔助紀錄 — 正式狀態仍由 mutable record 承擔，event log 提供變更歷史跟 replay 能力。</p>
<p>Event log 的讀取面透過 <a href="/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection</a> 轉換成 <a href="/blog/backend/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="說明為查詢場景建立的讀取模型，與正式狀態的責任分離">read model</a>，讓消費者不需要每次 replay 整條事件流。在訊息傳遞面，event log 常搭配 <a href="/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group</a>、<a href="/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset</a> 與 <a href="/blog/backend/knowledge-cards/replay-runbook/" data-link-title="Replay Runbook" data-link-desc="說明事件重放前需要控制的範圍、順序、驗證與副作用">replay runbook</a> 使用。</p>
<h2 id="使用情境">使用情境</h2>
<p>訂單狀態變更可寫入 event log，後續由報表、通知、稽核服務各自消費。當下游落後時，可用 replay 補齊資料。金融帳務的每一筆增減、權限變更的每一次授權與撤銷、訂閱方案的每一次升降級，都是典型的 event log 應用。</p>
<h2 id="設計責任">設計責任</h2>
<p>設計時要定義事件 schema 演進（新版 consumer 要能消費舊版事件）、保留期限（無限保留 vs retention-based 清理）、重播邊界（從哪個 offset 開始 replay）與去重策略（<a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 保證）。Event log 的儲存成長是長期成本 — 高頻寫入的系統需要 snapshot 機制或 retention 策略來控制。</p>
]]></content:encoded></item><item><title>Read Model</title><link>https://tarrragon.github.io/blog/backend/knowledge-cards/read-model/</link><pubDate>Mon, 22 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/knowledge-cards/read-model/</guid><description>&lt;p>Read model 的核心概念是「為特定查詢需求建立專用的資料形狀」。它跟正式狀態（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth&lt;/a>）的責任分離 — 正式狀態為寫入的正確性最佳化，read model 為讀取的效率與體驗最佳化。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Read model 是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cqrs/" data-link-title="CQRS" data-link-desc="說明讀寫不對稱時為何需要分離查詢與寫入責任、分離的判準與代價">CQRS&lt;/a> 的讀取面產物。在 CQRS 架構中，write model 跟 read model 各自獨立，read model 透過同步機制（event handler、CDC、定期刷新）從 write model 更新。&lt;/p>
&lt;p>Read model 的來源可以是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection&lt;/a>（從 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/event-log/" data-link-title="Event Log" data-link-desc="說明事件歷史如何保存、重播與支援跨服務資料重建">event log&lt;/a> 持續推算）、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/materialized-view/" data-link-title="Materialized View" data-link-desc="說明預先計算並儲存查詢結果以加速讀取的資料結構">materialized view&lt;/a>（從 SQL 查詢預計算）、CDC consumer（從 row change 同步到搜尋索引）或批次 ETL（定期從 OLTP 匯出到 analytics store）。不同的來源機制有不同的更新延遲跟維護成本。&lt;/p>
&lt;p>在觀測領域，&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/recording-rule/" data-link-title="Recording Rule" data-link-desc="說明把 query-time 聚合計算推到寫入時的 pre-aggregation 機制">recording rule&lt;/a> 跟 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/rollup/" data-link-title="Rollup / Downsampling" data-link-desc="說明時間序列資料隨時間降低精度以控制儲存成本與查詢效能的機制">rollup&lt;/a> 扮演類似 read model 的角色 — 從 raw time series 預計算聚合結果，讓 dashboard 讀取預聚合資料而非重算 raw data。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>設計 read model 時要定義同步延遲（read model 落後 write model 多久可以接受）、重建流程（read model 損壞或 schema 變更時如何從頭重建）、欄位語意（read model 的欄位定義跟 write model 可能不同）與查詢邊界（這個 read model 能回答什麼問題、不能回答什麼問題）。&lt;/p>
&lt;p>Read model 是派生狀態，修復方式是「砍掉重建」而非直接修改。把 read model 當正式狀態修改會導致 write model 跟 read model 分岔、後續同步覆蓋修改。&lt;/p></description><content:encoded><![CDATA[<p>Read model 的核心概念是「為特定查詢需求建立專用的資料形狀」。它跟正式狀態（<a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth</a>）的責任分離 — 正式狀態為寫入的正確性最佳化，read model 為讀取的效率與體驗最佳化。</p>
<h2 id="概念位置">概念位置</h2>
<p>Read model 是 <a href="/blog/backend/knowledge-cards/cqrs/" data-link-title="CQRS" data-link-desc="說明讀寫不對稱時為何需要分離查詢與寫入責任、分離的判準與代價">CQRS</a> 的讀取面產物。在 CQRS 架構中，write model 跟 read model 各自獨立，read model 透過同步機制（event handler、CDC、定期刷新）從 write model 更新。</p>
<p>Read model 的來源可以是 <a href="/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection</a>（從 <a href="/blog/backend/knowledge-cards/event-log/" data-link-title="Event Log" data-link-desc="說明事件歷史如何保存、重播與支援跨服務資料重建">event log</a> 持續推算）、<a href="/blog/backend/knowledge-cards/materialized-view/" data-link-title="Materialized View" data-link-desc="說明預先計算並儲存查詢結果以加速讀取的資料結構">materialized view</a>（從 SQL 查詢預計算）、CDC consumer（從 row change 同步到搜尋索引）或批次 ETL（定期從 OLTP 匯出到 analytics store）。不同的來源機制有不同的更新延遲跟維護成本。</p>
<p>在觀測領域，<a href="/blog/backend/knowledge-cards/recording-rule/" data-link-title="Recording Rule" data-link-desc="說明把 query-time 聚合計算推到寫入時的 pre-aggregation 機制">recording rule</a> 跟 <a href="/blog/backend/knowledge-cards/rollup/" data-link-title="Rollup / Downsampling" data-link-desc="說明時間序列資料隨時間降低精度以控制儲存成本與查詢效能的機制">rollup</a> 扮演類似 read model 的角色 — 從 raw time series 預計算聚合結果，讓 dashboard 讀取預聚合資料而非重算 raw data。</p>
<h2 id="設計責任">設計責任</h2>
<p>設計 read model 時要定義同步延遲（read model 落後 write model 多久可以接受）、重建流程（read model 損壞或 schema 變更時如何從頭重建）、欄位語意（read model 的欄位定義跟 write model 可能不同）與查詢邊界（這個 read model 能回答什麼問題、不能回答什麼問題）。</p>
<p>Read model 是派生狀態，修復方式是「砍掉重建」而非直接修改。把 read model 當正式狀態修改會導致 write model 跟 read model 分岔、後續同步覆蓋修改。</p>
]]></content:encoded></item><item><title>Projection</title><link>https://tarrragon.github.io/blog/backend/knowledge-cards/projection/</link><pubDate>Mon, 22 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/knowledge-cards/projection/</guid><description>&lt;p>Projection 從事件流或資料變更中持續推算出特定用途的讀取視圖，連接寫入端（事件產生）跟讀取端（查詢消費）。Projection 的輸出是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="說明為查詢場景建立的讀取模型，與正式狀態的責任分離">read model&lt;/a> — 為特定查詢需求反正規化的資料形狀。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Projection 在 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/event-sourcing/" data-link-title="Event Sourcing" data-link-desc="說明用 append-only 事件流取代 mutable state 作為正式紀錄的設計模式、需求判準與代價">event sourcing&lt;/a> 架構中扮演「event → current state」的推算角色。Event log 是 append-only 的事件序列，直接對 event log 做查詢效率低；projection 持續消費事件、維護可查詢的 read model，讓讀取端不需要每次 replay 整條事件流。&lt;/p>
&lt;p>Projection 不限於 event sourcing。CDC（Change Data Capture）把資料庫的 row 變更推送到下游、下游建立搜尋索引或統計摘要，這也是 projection — 來源是 row change event 而非 domain event。觀測領域的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/recording-rule/" data-link-title="Recording Rule" data-link-desc="說明把 query-time 聚合計算推到寫入時的 pre-aggregation 機制">recording rule&lt;/a> 也是一種 projection — 從 raw time series 持續推算預聚合的 metrics。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>設計 projection 時要定義四個面向：&lt;/p>
&lt;p>&lt;strong>更新策略&lt;/strong>：同步（事件寫入時立即更新 read model）或非同步（事件寫入後由背景消費者更新）。同步更新延遲低但耦合寫入路徑的效能；非同步更新解耦但 read model 有 lag。&lt;/p>
&lt;p>&lt;strong>重建流程&lt;/strong>：當 projection 邏輯改變或 read model 損壞時，需要從 event log 重新 replay 建立 read model。重建流程要能離線執行、不影響線上讀取。大量事件的 replay 可能需要數小時，設計時要估算重建時間跟資源需求。&lt;/p>
&lt;p>&lt;strong>正確性驗證&lt;/strong>：projection 是持續運行的計算，任何 bug 都會讓 read model 靜默偏離真實狀態。需要定期的 reconciliation（拿 projection 結果跟 event log 全量 replay 比較）來偵測漂移。&lt;/p>
&lt;p>&lt;strong>schema evolution&lt;/strong>：當來源事件的 schema 改版，projection 邏輯要能同時處理新舊版本的事件。這跟 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/event-sourcing/" data-link-title="Event Sourcing" data-link-desc="說明用 append-only 事件流取代 mutable state 作為正式紀錄的設計模式、需求判準與代價">event sourcing&lt;/a> 的 upcasting 問題直接相關。&lt;/p>
&lt;h2 id="使用情境">使用情境&lt;/h2>
&lt;p>需要 projection 的訊號是：讀取需求跟寫入結構不同（列表頁需要反正規化 view、搜尋需要全文索引、報表需要聚合摘要），而且這些讀取視圖需要隨資料變更持續更新而非批次重建。&lt;/p>
&lt;p>常見的 projection 實作包括：event handler 更新 read DB、CDC consumer 更新 Elasticsearch index、Kafka Streams 維護 KTable、觀測 collector 做 log-to-metric 轉換。&lt;/p></description><content:encoded><![CDATA[<p>Projection 從事件流或資料變更中持續推算出特定用途的讀取視圖，連接寫入端（事件產生）跟讀取端（查詢消費）。Projection 的輸出是 <a href="/blog/backend/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="說明為查詢場景建立的讀取模型，與正式狀態的責任分離">read model</a> — 為特定查詢需求反正規化的資料形狀。</p>
<h2 id="概念位置">概念位置</h2>
<p>Projection 在 <a href="/blog/backend/knowledge-cards/event-sourcing/" data-link-title="Event Sourcing" data-link-desc="說明用 append-only 事件流取代 mutable state 作為正式紀錄的設計模式、需求判準與代價">event sourcing</a> 架構中扮演「event → current state」的推算角色。Event log 是 append-only 的事件序列，直接對 event log 做查詢效率低；projection 持續消費事件、維護可查詢的 read model，讓讀取端不需要每次 replay 整條事件流。</p>
<p>Projection 不限於 event sourcing。CDC（Change Data Capture）把資料庫的 row 變更推送到下游、下游建立搜尋索引或統計摘要，這也是 projection — 來源是 row change event 而非 domain event。觀測領域的 <a href="/blog/backend/knowledge-cards/recording-rule/" data-link-title="Recording Rule" data-link-desc="說明把 query-time 聚合計算推到寫入時的 pre-aggregation 機制">recording rule</a> 也是一種 projection — 從 raw time series 持續推算預聚合的 metrics。</p>
<h2 id="設計責任">設計責任</h2>
<p>設計 projection 時要定義四個面向：</p>
<p><strong>更新策略</strong>：同步（事件寫入時立即更新 read model）或非同步（事件寫入後由背景消費者更新）。同步更新延遲低但耦合寫入路徑的效能；非同步更新解耦但 read model 有 lag。</p>
<p><strong>重建流程</strong>：當 projection 邏輯改變或 read model 損壞時，需要從 event log 重新 replay 建立 read model。重建流程要能離線執行、不影響線上讀取。大量事件的 replay 可能需要數小時，設計時要估算重建時間跟資源需求。</p>
<p><strong>正確性驗證</strong>：projection 是持續運行的計算，任何 bug 都會讓 read model 靜默偏離真實狀態。需要定期的 reconciliation（拿 projection 結果跟 event log 全量 replay 比較）來偵測漂移。</p>
<p><strong>schema evolution</strong>：當來源事件的 schema 改版，projection 邏輯要能同時處理新舊版本的事件。這跟 <a href="/blog/backend/knowledge-cards/event-sourcing/" data-link-title="Event Sourcing" data-link-desc="說明用 append-only 事件流取代 mutable state 作為正式紀錄的設計模式、需求判準與代價">event sourcing</a> 的 upcasting 問題直接相關。</p>
<h2 id="使用情境">使用情境</h2>
<p>需要 projection 的訊號是：讀取需求跟寫入結構不同（列表頁需要反正規化 view、搜尋需要全文索引、報表需要聚合摘要），而且這些讀取視圖需要隨資料變更持續更新而非批次重建。</p>
<p>常見的 projection 實作包括：event handler 更新 read DB、CDC consumer 更新 Elasticsearch index、Kafka Streams 維護 KTable、觀測 collector 做 log-to-metric 轉換。</p>
]]></content:encoded></item><item><title>CQRS</title><link>https://tarrragon.github.io/blog/backend/knowledge-cards/cqrs/</link><pubDate>Mon, 22 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/knowledge-cards/cqrs/</guid><description>&lt;p>CQRS（Command Query Responsibility Segregation）的核心概念是「把寫入路徑跟讀取路徑拆成各自獨立的模型，各自依自身需求最佳化」。分離後讀取面的具體產物是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="說明為查詢場景建立的讀取模型，與正式狀態的責任分離">read model&lt;/a>。它處理的根本問題是讀寫不對稱 — 同一份資料的寫入形狀跟讀取形狀不同、寫入頻率跟讀取頻率不同、寫入 SLA 跟讀取 SLA 不同。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>CQRS 是一種架構分離策略，位於資料存取模式的設計層。它跟 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="說明為查詢場景建立的讀取模型，與正式狀態的責任分離">read model&lt;/a> 的關係是：CQRS 是分離的決策框架，read model 是分離之後「讀取面」的具體產物。&lt;/p>
&lt;p>CQRS 經常跟 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/event-sourcing/" data-link-title="Event Sourcing" data-link-desc="說明用 append-only 事件流取代 mutable state 作為正式紀錄的設計模式、需求判準與代價">event sourcing&lt;/a> 一起出現，但兩者是獨立概念。CQRS 只要求讀寫模型分離；&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/event-sourcing/" data-link-title="Event Sourcing" data-link-desc="說明用 append-only 事件流取代 mutable state 作為正式紀錄的設計模式、需求判準與代價">event sourcing&lt;/a> 是把寫入模型改成 append-only 的事件流。可以有 CQRS 但沒有 event sourcing（寫入仍用傳統 CRUD，讀取用獨立的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="說明為查詢場景建立的讀取模型，與正式狀態的責任分離">read model&lt;/a>），也可以有 event sourcing 但沒有 CQRS（讀寫都直接操作 event store）。&lt;/p>
&lt;h2 id="讀寫不對稱的三個維度">讀寫不對稱的三個維度&lt;/h2>
&lt;p>分離的動機來自三種不對稱，當任一種超過單一模型能承受的範圍時，CQRS 開始有設計價值。&lt;/p>
&lt;p>&lt;strong>形狀不對稱&lt;/strong>：寫入時資料以正規化、事務安全的結構進入系統；讀取時不同消費者需要不同的反正規化形狀。一個訂單寫入時是 order + line items + payment 三張表的事務；列表頁需要扁平的 order summary，報表需要跨訂單的聚合，搜尋需要全文索引。強迫同一個模型同時服務這些形狀，會讓寫入模型變得過度複雜或讀取效能退化。&lt;/p>
&lt;p>&lt;strong>頻率不對稱&lt;/strong>：讀取頻率遠高於寫入頻率是常見的服務模型（商品頁的瀏覽量遠大於商品更新頻率）。讀寫共用模型時，高頻讀取的效能需求會推動寫入模型往讀取最佳化靠攏，犧牲寫入的簡潔性跟一致性保證。&lt;/p>
&lt;p>&lt;strong>SLA 不對稱&lt;/strong>：不同讀取消費者的延遲容忍跟一致性需求不同。即時顯示需要毫秒級回應但容忍短暫不一致；報表需要完整一致但容忍分鐘級延遲；稽核需要長期可查但容忍更高延遲。單一模型難以同時滿足多種 SLA。&lt;/p>
&lt;h2 id="分離的設計判準">分離的設計判準&lt;/h2>
&lt;p>讀寫不對稱存在不代表一定需要 CQRS。分離的判準是不對稱的程度是否已經超過「在同一個模型上做最佳化」能解決的範圍。&lt;/p>
&lt;p>&lt;strong>可以不分離的情境&lt;/strong>：讀寫形狀接近（CRUD 應用、管理後台）、讀取消費者單一（只有一種 UI）、流量規模讓讀寫共用模型的效能足夠、團隊規模小到維護兩套模型的成本大於效能收益。&lt;/p>
&lt;p>&lt;strong>需要考慮分離的訊號&lt;/strong>：讀取效能持續退化但寫入側無法再為讀取最佳化（加 index 已到極限、反正規化導致寫入複雜度上升）；多種讀取消費者對同一份資料有互斥的形狀需求；讀寫的擴展需求方向不同（讀取要水平擴展、寫入要強一致性）。&lt;/p>
&lt;h2 id="分離的代價">分離的代價&lt;/h2>
&lt;p>CQRS 的代價集中在同步、一致性與維護三個面向。&lt;/p>
&lt;p>&lt;strong>最終一致性&lt;/strong>：read model 透過事件或同步機制從 write model 更新，中間有延遲。使用者寫入後立即讀取可能看不到自己的變更。這個延遲窗口需要被明確設計（多長、可接受嗎、UI 怎麼處理）而非假裝不存在。&lt;/p>
&lt;p>&lt;strong>同步機制的可靠性&lt;/strong>：write model 到 read model 的同步本身是一個需要監控跟治理的資料路徑。同步失敗、同步延遲、同步漂移都需要被偵測跟處理。&lt;/p>
&lt;p>&lt;strong>多模型維護&lt;/strong>：schema 變更需要同時更新 write model 跟所有 read model。read model 的數量增長後，每次 schema migration 的變更面會擴大。&lt;/p>
&lt;h2 id="跨領域的應用">跨領域的應用&lt;/h2>
&lt;p>讀寫分離的設計張力不限於 application data。觀測資料的讀取路徑設計（&lt;a href="https://tarrragon.github.io/blog/backend/04-observability/observability-query-design/" data-link-title="4.23 觀測查詢設計" data-link-desc="把觀測資料的讀取路徑當系統設計問題處理：三種查詢模式、storage tiering、pre-aggregation 與資源治理">4.23 觀測查詢設計&lt;/a>）面臨同樣的不對稱：寫入是高吞吐的 append-only，讀取被至少三種不同 SLA 的消費者（即席診斷、聚合趨勢、鑑識回溯）拉扯。觀測領域用 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/recording-rule/" data-link-title="Recording Rule" data-link-desc="說明把 query-time 聚合計算推到寫入時的 pre-aggregation 機制">recording rule&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/rollup/" data-link-title="Rollup / Downsampling" data-link-desc="說明時間序列資料隨時間降低精度以控制儲存成本與查詢效能的機制">rollup&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/storage-tiering/" data-link-title="Storage Tiering" data-link-desc="說明按資料熱度分層儲存以平衡查詢速度、儲存成本與保留完整性的機制">storage tiering&lt;/a> 來實作讀寫分離，概念上對應 CQRS 的 read model，但術語跟實作層級不同。&lt;/p>
&lt;p>Message queue 的消費端也有類似結構：同一份事件被多個 consumer 以不同速度、不同形狀讀取，fan-out 跟 consumer group 是另一種讀寫分離的實作。&lt;/p></description><content:encoded><![CDATA[<p>CQRS（Command Query Responsibility Segregation）的核心概念是「把寫入路徑跟讀取路徑拆成各自獨立的模型，各自依自身需求最佳化」。分離後讀取面的具體產物是 <a href="/blog/backend/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="說明為查詢場景建立的讀取模型，與正式狀態的責任分離">read model</a>。它處理的根本問題是讀寫不對稱 — 同一份資料的寫入形狀跟讀取形狀不同、寫入頻率跟讀取頻率不同、寫入 SLA 跟讀取 SLA 不同。</p>
<h2 id="概念位置">概念位置</h2>
<p>CQRS 是一種架構分離策略，位於資料存取模式的設計層。它跟 <a href="/blog/backend/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="說明為查詢場景建立的讀取模型，與正式狀態的責任分離">read model</a> 的關係是：CQRS 是分離的決策框架，read model 是分離之後「讀取面」的具體產物。</p>
<p>CQRS 經常跟 <a href="/blog/backend/knowledge-cards/event-sourcing/" data-link-title="Event Sourcing" data-link-desc="說明用 append-only 事件流取代 mutable state 作為正式紀錄的設計模式、需求判準與代價">event sourcing</a> 一起出現，但兩者是獨立概念。CQRS 只要求讀寫模型分離；<a href="/blog/backend/knowledge-cards/event-sourcing/" data-link-title="Event Sourcing" data-link-desc="說明用 append-only 事件流取代 mutable state 作為正式紀錄的設計模式、需求判準與代價">event sourcing</a> 是把寫入模型改成 append-only 的事件流。可以有 CQRS 但沒有 event sourcing（寫入仍用傳統 CRUD，讀取用獨立的 <a href="/blog/backend/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="說明為查詢場景建立的讀取模型，與正式狀態的責任分離">read model</a>），也可以有 event sourcing 但沒有 CQRS（讀寫都直接操作 event store）。</p>
<h2 id="讀寫不對稱的三個維度">讀寫不對稱的三個維度</h2>
<p>分離的動機來自三種不對稱，當任一種超過單一模型能承受的範圍時，CQRS 開始有設計價值。</p>
<p><strong>形狀不對稱</strong>：寫入時資料以正規化、事務安全的結構進入系統；讀取時不同消費者需要不同的反正規化形狀。一個訂單寫入時是 order + line items + payment 三張表的事務；列表頁需要扁平的 order summary，報表需要跨訂單的聚合，搜尋需要全文索引。強迫同一個模型同時服務這些形狀，會讓寫入模型變得過度複雜或讀取效能退化。</p>
<p><strong>頻率不對稱</strong>：讀取頻率遠高於寫入頻率是常見的服務模型（商品頁的瀏覽量遠大於商品更新頻率）。讀寫共用模型時，高頻讀取的效能需求會推動寫入模型往讀取最佳化靠攏，犧牲寫入的簡潔性跟一致性保證。</p>
<p><strong>SLA 不對稱</strong>：不同讀取消費者的延遲容忍跟一致性需求不同。即時顯示需要毫秒級回應但容忍短暫不一致；報表需要完整一致但容忍分鐘級延遲；稽核需要長期可查但容忍更高延遲。單一模型難以同時滿足多種 SLA。</p>
<h2 id="分離的設計判準">分離的設計判準</h2>
<p>讀寫不對稱存在不代表一定需要 CQRS。分離的判準是不對稱的程度是否已經超過「在同一個模型上做最佳化」能解決的範圍。</p>
<p><strong>可以不分離的情境</strong>：讀寫形狀接近（CRUD 應用、管理後台）、讀取消費者單一（只有一種 UI）、流量規模讓讀寫共用模型的效能足夠、團隊規模小到維護兩套模型的成本大於效能收益。</p>
<p><strong>需要考慮分離的訊號</strong>：讀取效能持續退化但寫入側無法再為讀取最佳化（加 index 已到極限、反正規化導致寫入複雜度上升）；多種讀取消費者對同一份資料有互斥的形狀需求；讀寫的擴展需求方向不同（讀取要水平擴展、寫入要強一致性）。</p>
<h2 id="分離的代價">分離的代價</h2>
<p>CQRS 的代價集中在同步、一致性與維護三個面向。</p>
<p><strong>最終一致性</strong>：read model 透過事件或同步機制從 write model 更新，中間有延遲。使用者寫入後立即讀取可能看不到自己的變更。這個延遲窗口需要被明確設計（多長、可接受嗎、UI 怎麼處理）而非假裝不存在。</p>
<p><strong>同步機制的可靠性</strong>：write model 到 read model 的同步本身是一個需要監控跟治理的資料路徑。同步失敗、同步延遲、同步漂移都需要被偵測跟處理。</p>
<p><strong>多模型維護</strong>：schema 變更需要同時更新 write model 跟所有 read model。read model 的數量增長後，每次 schema migration 的變更面會擴大。</p>
<h2 id="跨領域的應用">跨領域的應用</h2>
<p>讀寫分離的設計張力不限於 application data。觀測資料的讀取路徑設計（<a href="/blog/backend/04-observability/observability-query-design/" data-link-title="4.23 觀測查詢設計" data-link-desc="把觀測資料的讀取路徑當系統設計問題處理：三種查詢模式、storage tiering、pre-aggregation 與資源治理">4.23 觀測查詢設計</a>）面臨同樣的不對稱：寫入是高吞吐的 append-only，讀取被至少三種不同 SLA 的消費者（即席診斷、聚合趨勢、鑑識回溯）拉扯。觀測領域用 <a href="/blog/backend/knowledge-cards/recording-rule/" data-link-title="Recording Rule" data-link-desc="說明把 query-time 聚合計算推到寫入時的 pre-aggregation 機制">recording rule</a>、<a href="/blog/backend/knowledge-cards/rollup/" data-link-title="Rollup / Downsampling" data-link-desc="說明時間序列資料隨時間降低精度以控制儲存成本與查詢效能的機制">rollup</a>、<a href="/blog/backend/knowledge-cards/storage-tiering/" data-link-title="Storage Tiering" data-link-desc="說明按資料熱度分層儲存以平衡查詢速度、儲存成本與保留完整性的機制">storage tiering</a> 來實作讀寫分離，概念上對應 CQRS 的 read model，但術語跟實作層級不同。</p>
<p>Message queue 的消費端也有類似結構：同一份事件被多個 consumer 以不同速度、不同形狀讀取，fan-out 跟 consumer group 是另一種讀寫分離的實作。</p>
]]></content:encoded></item><item><title>Event Sourcing</title><link>https://tarrragon.github.io/blog/backend/knowledge-cards/event-sourcing/</link><pubDate>Mon, 22 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/knowledge-cards/event-sourcing/</guid><description>&lt;p>Event sourcing 的核心概念是「不存 current state、存產生 current state 的所有事件」。儲存層是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/event-log/" data-link-title="Event Log" data-link-desc="說明事件歷史如何保存、重播與支援跨服務資料重建">event log&lt;/a>，讀取面透過 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection&lt;/a> 推算 current state。每一次狀態變更被記錄為一筆不可變的事件（event），current state 透過重播（replay）事件序列推算出來。正式紀錄是事件流本身，current state 是派生物。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Event sourcing 是一種資料持久化策略，改變的是「狀態怎麼被記錄」而非「狀態怎麼被讀取」。它跟 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cqrs/" data-link-title="CQRS" data-link-desc="說明讀寫不對稱時為何需要分離查詢與寫入責任、分離的判準與代價">CQRS&lt;/a> 經常搭配但概念獨立 — event sourcing 處理寫入模型（append-only event log 取代 mutable row），CQRS 處理讀寫分離。可以有 event sourcing 但沒有 CQRS（讀寫都直接操作 event store），也可以有 CQRS 但沒有 event sourcing（寫入仍用 CRUD）。&lt;/p>
&lt;p>Event sourcing 的儲存層是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/event-log/" data-link-title="Event Log" data-link-desc="說明事件歷史如何保存、重播與支援跨服務資料重建">event log&lt;/a>。讀取面透過 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection&lt;/a> 把事件流轉換成查詢用的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="說明為查詢場景建立的讀取模型，與正式狀態的責任分離">read model&lt;/a>。&lt;/p>
&lt;h2 id="設計判準">設計判準&lt;/h2>
&lt;p>Event sourcing 的設計價值來自「需要完整變更歷史」的業務需求。判準是：業務是否需要回答「某個時間點的狀態是什麼」或「狀態怎麼從 A 變成 B」。&lt;/p>
&lt;p>&lt;strong>適合的場景&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>金融帳務 — 餘額的每一筆增減都是 audit 事件，法規要求能追溯任意時點的 balance&lt;/li>
&lt;li>訂單流程 — 每個狀態轉換（建立→付款→出貨→完成）是 business event，需要重建任意階段&lt;/li>
&lt;li>法規合規 — 完整變更歷史是合規證據，刪除或覆寫正式紀錄違反要求&lt;/li>
&lt;li>需要 replay 能力 — downstream consumer 落後或資料損壞時，能從 event log 重建&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>不適合的場景&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>簡單 CRUD — 狀態覆寫即可、不需要歷史、event sourcing 的 overhead 遠大於收益&lt;/li>
&lt;li>需要直接查 current state 的高頻場景 — 每次讀取都 replay 整條事件流延遲太高，必須搭配 projection 維護 snapshot，增加系統複雜度&lt;/li>
&lt;li>事件 schema 變更頻繁 — 舊事件需要被新版 schema 正確 replay，schema evolution 成本高&lt;/li>
&lt;/ul>
&lt;h2 id="代價">代價&lt;/h2>
&lt;p>&lt;strong>讀取複雜度&lt;/strong>：current state 不再是一筆 row，而是需要 replay 或 projection 推算。讀取路徑的設計從「查一筆 record」變成「維護多個 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="說明為查詢場景建立的讀取模型，與正式狀態的責任分離">read model&lt;/a> + 保證 projection 正確性 + 處理 projection lag」。&lt;/p>
&lt;p>&lt;strong>事件 schema evolution&lt;/strong>：事件一旦寫入就不可變，但業務需求會改變事件結構。版本化 event schema（upcasting）是長期維護的核心挑戰 — 新版 projection 要能正確消費舊版事件。&lt;/p>
&lt;p>&lt;strong>儲存成長&lt;/strong>：事件永不刪除（或只做 retention），儲存量隨時間持續成長。高頻寫入的系統可能需要 snapshot 機制（定期存一份 current state 快照，replay 從 snapshot 開始而非從頭）來控制 replay 時間。&lt;/p>
&lt;p>&lt;strong>除錯難度&lt;/strong>：bug 可能是某個 event handler 在 replay 時產生錯誤結果。除錯需要重現特定事件序列的 replay，比查一筆 mutable record 的 diff 更複雜。&lt;/p>
&lt;h2 id="跟其他概念的關係">跟其他概念的關係&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/event-log/" data-link-title="Event Log" data-link-desc="說明事件歷史如何保存、重播與支援跨服務資料重建">Event log&lt;/a> — event sourcing 的儲存層，append-only 的事件序列&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">Projection&lt;/a> — 把 event log 轉換成可查詢的 read model 的機制&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="說明為查詢場景建立的讀取模型，與正式狀態的責任分離">Read model&lt;/a> — projection 的輸出，為特定查詢需求最佳化的資料形狀&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cqrs/" data-link-title="CQRS" data-link-desc="說明讀寫不對稱時為何需要分離查詢與寫入責任、分離的判準與代價">CQRS&lt;/a> — 讀寫分離的設計框架，event sourcing 是其中一種 write model 實作&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/saga/" data-link-title="Saga" data-link-desc="處理跨服務分散事務的補償型 transaction 序列、用最終一致換 ACID atomic">Saga&lt;/a> — 跨服務的分散事務，event sourcing 提供每個 step 的事件紀錄&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Event sourcing 的核心概念是「不存 current state、存產生 current state 的所有事件」。儲存層是 <a href="/blog/backend/knowledge-cards/event-log/" data-link-title="Event Log" data-link-desc="說明事件歷史如何保存、重播與支援跨服務資料重建">event log</a>，讀取面透過 <a href="/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection</a> 推算 current state。每一次狀態變更被記錄為一筆不可變的事件（event），current state 透過重播（replay）事件序列推算出來。正式紀錄是事件流本身，current state 是派生物。</p>
<h2 id="概念位置">概念位置</h2>
<p>Event sourcing 是一種資料持久化策略，改變的是「狀態怎麼被記錄」而非「狀態怎麼被讀取」。它跟 <a href="/blog/backend/knowledge-cards/cqrs/" data-link-title="CQRS" data-link-desc="說明讀寫不對稱時為何需要分離查詢與寫入責任、分離的判準與代價">CQRS</a> 經常搭配但概念獨立 — event sourcing 處理寫入模型（append-only event log 取代 mutable row），CQRS 處理讀寫分離。可以有 event sourcing 但沒有 CQRS（讀寫都直接操作 event store），也可以有 CQRS 但沒有 event sourcing（寫入仍用 CRUD）。</p>
<p>Event sourcing 的儲存層是 <a href="/blog/backend/knowledge-cards/event-log/" data-link-title="Event Log" data-link-desc="說明事件歷史如何保存、重播與支援跨服務資料重建">event log</a>。讀取面透過 <a href="/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection</a> 把事件流轉換成查詢用的 <a href="/blog/backend/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="說明為查詢場景建立的讀取模型，與正式狀態的責任分離">read model</a>。</p>
<h2 id="設計判準">設計判準</h2>
<p>Event sourcing 的設計價值來自「需要完整變更歷史」的業務需求。判準是：業務是否需要回答「某個時間點的狀態是什麼」或「狀態怎麼從 A 變成 B」。</p>
<p><strong>適合的場景</strong>：</p>
<ul>
<li>金融帳務 — 餘額的每一筆增減都是 audit 事件，法規要求能追溯任意時點的 balance</li>
<li>訂單流程 — 每個狀態轉換（建立→付款→出貨→完成）是 business event，需要重建任意階段</li>
<li>法規合規 — 完整變更歷史是合規證據，刪除或覆寫正式紀錄違反要求</li>
<li>需要 replay 能力 — downstream consumer 落後或資料損壞時，能從 event log 重建</li>
</ul>
<p><strong>不適合的場景</strong>：</p>
<ul>
<li>簡單 CRUD — 狀態覆寫即可、不需要歷史、event sourcing 的 overhead 遠大於收益</li>
<li>需要直接查 current state 的高頻場景 — 每次讀取都 replay 整條事件流延遲太高，必須搭配 projection 維護 snapshot，增加系統複雜度</li>
<li>事件 schema 變更頻繁 — 舊事件需要被新版 schema 正確 replay，schema evolution 成本高</li>
</ul>
<h2 id="代價">代價</h2>
<p><strong>讀取複雜度</strong>：current state 不再是一筆 row，而是需要 replay 或 projection 推算。讀取路徑的設計從「查一筆 record」變成「維護多個 <a href="/blog/backend/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="說明為查詢場景建立的讀取模型，與正式狀態的責任分離">read model</a> + 保證 projection 正確性 + 處理 projection lag」。</p>
<p><strong>事件 schema evolution</strong>：事件一旦寫入就不可變，但業務需求會改變事件結構。版本化 event schema（upcasting）是長期維護的核心挑戰 — 新版 projection 要能正確消費舊版事件。</p>
<p><strong>儲存成長</strong>：事件永不刪除（或只做 retention），儲存量隨時間持續成長。高頻寫入的系統可能需要 snapshot 機制（定期存一份 current state 快照，replay 從 snapshot 開始而非從頭）來控制 replay 時間。</p>
<p><strong>除錯難度</strong>：bug 可能是某個 event handler 在 replay 時產生錯誤結果。除錯需要重現特定事件序列的 replay，比查一筆 mutable record 的 diff 更複雜。</p>
<h2 id="跟其他概念的關係">跟其他概念的關係</h2>
<ul>
<li><a href="/blog/backend/knowledge-cards/event-log/" data-link-title="Event Log" data-link-desc="說明事件歷史如何保存、重播與支援跨服務資料重建">Event log</a> — event sourcing 的儲存層，append-only 的事件序列</li>
<li><a href="/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">Projection</a> — 把 event log 轉換成可查詢的 read model 的機制</li>
<li><a href="/blog/backend/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="說明為查詢場景建立的讀取模型，與正式狀態的責任分離">Read model</a> — projection 的輸出，為特定查詢需求最佳化的資料形狀</li>
<li><a href="/blog/backend/knowledge-cards/cqrs/" data-link-title="CQRS" data-link-desc="說明讀寫不對稱時為何需要分離查詢與寫入責任、分離的判準與代價">CQRS</a> — 讀寫分離的設計框架，event sourcing 是其中一種 write model 實作</li>
<li><a href="/blog/backend/knowledge-cards/saga/" data-link-title="Saga" data-link-desc="處理跨服務分散事務的補償型 transaction 序列、用最終一致換 ACID atomic">Saga</a> — 跨服務的分散事務，event sourcing 提供每個 step 的事件紀錄</li>
</ul>
]]></content:encoded></item><item><title>Dart StreamController：single-subscription vs broadcast 的設計選型問題</title><link>https://tarrragon.github.io/blog/work-log/dart-streamcontrollersingle-subscription-vs-broadcast-%E7%9A%84%E8%A8%AD%E8%A8%88%E9%81%B8%E5%9E%8B%E5%95%8F%E9%A1%8C/</link><pubDate>Tue, 05 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/dart-streamcontrollersingle-subscription-vs-broadcast-%E7%9A%84%E8%A8%AD%E8%A8%88%E9%81%B8%E5%9E%8B%E5%95%8F%E9%A1%8C/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>事故類型&lt;/strong>：潛伏型設計缺陷、第二個訂閱者出現時才暴露
&lt;strong>症狀&lt;/strong>：&lt;code>Bad state: Stream has already been listened to.&lt;/code>
&lt;strong>根因&lt;/strong>：在「&lt;code>StreamController()&lt;/code> vs &lt;code>StreamController.broadcast()&lt;/code>」這個零成本差異的選擇下、選了限制更高的單訂閱版本——當下只有一個訂閱者、限制沒曝光；新增第二個訂閱者就觸發底層型別契約。設計缺陷的本質是「&lt;strong>在零成本差異下不必要地縮小了未來空間&lt;/strong>」、不是「沒預測到後來需求」。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="事故場景">事故場景&lt;/h2>
&lt;h3 id="業務背景pos-的多視角狀態同步">業務背景：POS 的多視角狀態同步&lt;/h3>
&lt;p>POS 系統本質上是「&lt;strong>單一交易狀態 + 多個視角同步呈現&lt;/strong>」。一筆購物車的變化通常要立刻反映到：&lt;/p>
&lt;ul>
&lt;li>收銀員操作的主螢幕&lt;/li>
&lt;li>給顧客看的副螢幕（純顯示，看商品、總價、找零）&lt;/li>
&lt;li>廚房或後場的出餐顯示&lt;/li>
&lt;li>列印機（結帳當下觸發）&lt;/li>
&lt;li>雲端同步、報表、會員紀錄&lt;/li>
&lt;/ul>
&lt;p>這些視角各自關心交易狀態的不同切面，但&lt;strong>都需要在狀態變動的當下被通知&lt;/strong>。在系統設計上，這是個典型的「一個資料源、多個訂閱者」場景，本質就是事件廣播。&lt;/p>
&lt;h3 id="原始設計一個事件來源一個訂閱者">原始設計：一個事件來源，一個訂閱者&lt;/h3>
&lt;p>實作初期，「需要訂閱購物車變動」的角色只有一個——副螢幕。副螢幕在 app 啟動時就訂閱、整個 app 生命週期都在聽，純粹做主畫面的鏡像顯示。&lt;/p>
&lt;p>於是負責提供「狀態變更通知」的 service 用了 dart:async 預設的 &lt;code>StreamController&lt;/code> 對外發事件。事件 payload 設計成兩段資訊：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>當前完整商品列表&lt;/strong>（給副螢幕這類「鏡像當前狀態」的訂閱者用）&lt;/li>
&lt;li>&lt;strong>這次變動的具體品項&lt;/strong>（移除或清空時為 null，預留給「需要知道改了哪一筆」的訂閱者）&lt;/li>
&lt;/ol>
&lt;p>第二段資訊當下沒人用，但 service 設計者保留了它，理由是「未來如果有訂閱者需要知道每次具體變動是什麼，不必再改介面」——一個合理的擴充性設計。&lt;/p>
&lt;p>幾個月過去，這條 stream 只有副螢幕一個訂閱者，運作正常。&lt;/p>
&lt;h3 id="新需求操作體驗優化">新需求：操作體驗優化&lt;/h3>
&lt;p>新需求出現：收銀員在尖峰時段連續掃商品，&lt;strong>畫面更新太快會分不清剛剛動到的是哪一筆&lt;/strong>。如果是改價、改數量這類修改更明顯——數字突然變了，但視線焦點不在那一行就會錯過。&lt;/p>
&lt;p>業務上希望：每次操作後，被改動的那一行在 UI 上有個視覺標記（高亮、邊框或角標都可），讓收銀員一眼確認剛剛動的是對的品項。標記停在最後一次操作的那行，直到下一次操作才轉移。&lt;/p>
&lt;p>這個需求對應 service 已經備妥但尚未被消費的資訊——service 對外的事件 payload 從原始設計就分兩段：一段是「當前完整的商品列表」、另一段是「這次變動的具體品項」。第二段是當初為「需要追蹤單筆變動的訂閱者」預留的擴充欄位、過去幾個月一直沒被消費。新需求只要新增一個訂閱者讀這段資訊、再把它對應到 UI 上的視覺標記即可——介面不需要變動、payload 結構不需要調整、實作範圍只限於新增訂閱端。&lt;/p>
&lt;h3 id="第二個訂閱者觸發底層限制">第二個訂閱者觸發底層限制&lt;/h3>
&lt;p>第二個訂閱者寫好、進入收銀頁面當下就 throw：&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">The following StateError was thrown building Obx(...):
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">Bad state: Stream has already been listened to.&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>第一反應通常是「我哪裡寫錯了 / 是不是哪邊忘了 cancel」。檢查程式碼會發現新訂閱者寫得沒問題，副螢幕的訂閱也沒問題——&lt;strong>問題在底層 stream 的型別契約：整個生命週期內只允許被 listen 一次&lt;/strong>。&lt;/p>
&lt;p>這是 &lt;code>StreamController()&lt;/code> 預設建構子的契約：建立的是 single-subscription stream、生命週期內最多承載&lt;strong>一個&lt;/strong> listener。副螢幕第一個訂閱後佔據了唯一的 listener 位置；新加第二個訂閱者直接違反契約、執行期 throw。&lt;/p>
&lt;p>更深一層的觀察是設計層面的不一致：業務需求一直具備廣播語義（多個視角同步呈現）、技術選型卻是「單一管線」的工具。需求初期只有一個訂閱者讓限制沒有可見的影響、但限制一直存在於型別契約裡。第二個訂閱者只是觸發條件、不是根因。&lt;/p>
&lt;hr>
&lt;h2 id="兩種-streamcontroller-的核心差異">兩種 StreamController 的核心差異&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>&lt;code>StreamController()&lt;/code>（單訂閱）&lt;/th>
 &lt;th>&lt;code>StreamController.broadcast()&lt;/code>&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>同時 listener 數&lt;/td>
 &lt;td>至多 1 個&lt;/td>
 &lt;td>任意&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>第二個 &lt;code>.listen()&lt;/code>&lt;/td>
 &lt;td>throw &lt;code>Bad state&lt;/code>&lt;/td>
 &lt;td>OK&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>listener cancel 後重新 listen&lt;/td>
 &lt;td>throw &lt;code>Bad state&lt;/code>&lt;/td>
 &lt;td>OK&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>無 listener 時 add 的事件&lt;/td>
 &lt;td>&lt;strong>buffer&lt;/strong>，listener 出現時補送&lt;/td>
 &lt;td>&lt;strong>直接丟棄&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>listener &lt;code>pause()&lt;/code> 行為&lt;/td>
 &lt;td>整個 stream 暫停（上游也卡）&lt;/td>
 &lt;td>對其他 listener 無影響&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;hr>
&lt;h2 id="三組行為差異的程式碼驗證">三組行為差異的程式碼驗證&lt;/h2>
&lt;h3 id="1-重複監聽">1. 重複監聽&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">final&lt;/span> &lt;span class="n">c&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">StreamController&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="kt">int&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="n">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">stream&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">listen&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">print&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">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">stream&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">listen&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">print&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="c1">// 錯誤：Bad state: Stream has already been listened to.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="kd">final&lt;/span> &lt;span class="n">b&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">StreamController&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="kt">int&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">broadcast&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="n">b&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">stream&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">listen&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">v&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;A: &lt;/span>&lt;span class="si">$&lt;/span>&lt;span class="n">v&lt;/span>&lt;span class="s1">&amp;#39;&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="n">b&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">stream&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">listen&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">v&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;B: &lt;/span>&lt;span class="si">$&lt;/span>&lt;span class="n">v&lt;/span>&lt;span class="s1">&amp;#39;&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="n">b&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="m">1&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="c1">// A: 1
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="o">//&lt;/span> &lt;span class="nl">B:&lt;/span> &lt;span class="m">1&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>值得注意的不只是「不能同時兩個 listener」——單訂閱 stream 的限制是&lt;strong>整個 lifecycle 只能 listen 一次&lt;/strong>。即使第一個 listener 已經 &lt;code>cancel()&lt;/code>、再呼叫 &lt;code>.listen()&lt;/code> 仍會違反契約 throw。要重新訂閱必須重建 &lt;code>StreamController&lt;/code>。&lt;/p>
&lt;p>對 POS 場景的意義：副螢幕服務在 app 啟動時就建立訂閱、且不會 cancel——換句話說、stream 在啟動時就把唯一的 listener 配額分配給副螢幕、之後沒有可釋出的空間。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>事故類型</strong>：潛伏型設計缺陷、第二個訂閱者出現時才暴露
<strong>症狀</strong>：<code>Bad state: Stream has already been listened to.</code>
<strong>根因</strong>：在「<code>StreamController()</code> vs <code>StreamController.broadcast()</code>」這個零成本差異的選擇下、選了限制更高的單訂閱版本——當下只有一個訂閱者、限制沒曝光；新增第二個訂閱者就觸發底層型別契約。設計缺陷的本質是「<strong>在零成本差異下不必要地縮小了未來空間</strong>」、不是「沒預測到後來需求」。</p></blockquote>
<hr>
<h2 id="事故場景">事故場景</h2>
<h3 id="業務背景pos-的多視角狀態同步">業務背景：POS 的多視角狀態同步</h3>
<p>POS 系統本質上是「<strong>單一交易狀態 + 多個視角同步呈現</strong>」。一筆購物車的變化通常要立刻反映到：</p>
<ul>
<li>收銀員操作的主螢幕</li>
<li>給顧客看的副螢幕（純顯示，看商品、總價、找零）</li>
<li>廚房或後場的出餐顯示</li>
<li>列印機（結帳當下觸發）</li>
<li>雲端同步、報表、會員紀錄</li>
</ul>
<p>這些視角各自關心交易狀態的不同切面，但<strong>都需要在狀態變動的當下被通知</strong>。在系統設計上，這是個典型的「一個資料源、多個訂閱者」場景，本質就是事件廣播。</p>
<h3 id="原始設計一個事件來源一個訂閱者">原始設計：一個事件來源，一個訂閱者</h3>
<p>實作初期，「需要訂閱購物車變動」的角色只有一個——副螢幕。副螢幕在 app 啟動時就訂閱、整個 app 生命週期都在聽，純粹做主畫面的鏡像顯示。</p>
<p>於是負責提供「狀態變更通知」的 service 用了 dart:async 預設的 <code>StreamController</code> 對外發事件。事件 payload 設計成兩段資訊：</p>
<ol>
<li><strong>當前完整商品列表</strong>（給副螢幕這類「鏡像當前狀態」的訂閱者用）</li>
<li><strong>這次變動的具體品項</strong>（移除或清空時為 null，預留給「需要知道改了哪一筆」的訂閱者）</li>
</ol>
<p>第二段資訊當下沒人用，但 service 設計者保留了它，理由是「未來如果有訂閱者需要知道每次具體變動是什麼，不必再改介面」——一個合理的擴充性設計。</p>
<p>幾個月過去，這條 stream 只有副螢幕一個訂閱者，運作正常。</p>
<h3 id="新需求操作體驗優化">新需求：操作體驗優化</h3>
<p>新需求出現：收銀員在尖峰時段連續掃商品，<strong>畫面更新太快會分不清剛剛動到的是哪一筆</strong>。如果是改價、改數量這類修改更明顯——數字突然變了，但視線焦點不在那一行就會錯過。</p>
<p>業務上希望：每次操作後，被改動的那一行在 UI 上有個視覺標記（高亮、邊框或角標都可），讓收銀員一眼確認剛剛動的是對的品項。標記停在最後一次操作的那行，直到下一次操作才轉移。</p>
<p>這個需求對應 service 已經備妥但尚未被消費的資訊——service 對外的事件 payload 從原始設計就分兩段：一段是「當前完整的商品列表」、另一段是「這次變動的具體品項」。第二段是當初為「需要追蹤單筆變動的訂閱者」預留的擴充欄位、過去幾個月一直沒被消費。新需求只要新增一個訂閱者讀這段資訊、再把它對應到 UI 上的視覺標記即可——介面不需要變動、payload 結構不需要調整、實作範圍只限於新增訂閱端。</p>
<h3 id="第二個訂閱者觸發底層限制">第二個訂閱者觸發底層限制</h3>
<p>第二個訂閱者寫好、進入收銀頁面當下就 throw：</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">The following StateError was thrown building Obx(...):
</span></span><span class="line"><span class="ln">2</span><span class="cl">Bad state: Stream has already been listened to.</span></span></code></pre></div><p>第一反應通常是「我哪裡寫錯了 / 是不是哪邊忘了 cancel」。檢查程式碼會發現新訂閱者寫得沒問題，副螢幕的訂閱也沒問題——<strong>問題在底層 stream 的型別契約：整個生命週期內只允許被 listen 一次</strong>。</p>
<p>這是 <code>StreamController()</code> 預設建構子的契約：建立的是 single-subscription stream、生命週期內最多承載<strong>一個</strong> listener。副螢幕第一個訂閱後佔據了唯一的 listener 位置；新加第二個訂閱者直接違反契約、執行期 throw。</p>
<p>更深一層的觀察是設計層面的不一致：業務需求一直具備廣播語義（多個視角同步呈現）、技術選型卻是「單一管線」的工具。需求初期只有一個訂閱者讓限制沒有可見的影響、但限制一直存在於型別契約裡。第二個訂閱者只是觸發條件、不是根因。</p>
<hr>
<h2 id="兩種-streamcontroller-的核心差異">兩種 StreamController 的核心差異</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th><code>StreamController()</code>（單訂閱）</th>
          <th><code>StreamController.broadcast()</code></th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>同時 listener 數</td>
          <td>至多 1 個</td>
          <td>任意</td>
      </tr>
      <tr>
          <td>第二個 <code>.listen()</code></td>
          <td>throw <code>Bad state</code></td>
          <td>OK</td>
      </tr>
      <tr>
          <td>listener cancel 後重新 listen</td>
          <td>throw <code>Bad state</code></td>
          <td>OK</td>
      </tr>
      <tr>
          <td>無 listener 時 add 的事件</td>
          <td><strong>buffer</strong>，listener 出現時補送</td>
          <td><strong>直接丟棄</strong></td>
      </tr>
      <tr>
          <td>listener <code>pause()</code> 行為</td>
          <td>整個 stream 暫停（上游也卡）</td>
          <td>對其他 listener 無影響</td>
      </tr>
      <tr>
          <td>適用語義</td>
          <td>資料管線（單一消費者）</td>
          <td>事件佈告欄（多消費者）</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="三組行為差異的程式碼驗證">三組行為差異的程式碼驗證</h2>
<h3 id="1-重複監聽">1. 重複監聽</h3>





<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">c</span> <span class="o">=</span> <span class="n">StreamController</span><span class="o">&lt;</span><span class="kt">int</span><span class="o">&gt;</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">c</span><span class="p">.</span><span class="n">stream</span><span class="p">.</span><span class="n">listen</span><span class="p">(</span><span class="n">print</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">c</span><span class="p">.</span><span class="n">stream</span><span class="p">.</span><span class="n">listen</span><span class="p">(</span><span class="n">print</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1">// 錯誤：Bad state: Stream has already been listened to.
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kd">final</span> <span class="n">b</span> <span class="o">=</span> <span class="n">StreamController</span><span class="o">&lt;</span><span class="kt">int</span><span class="o">&gt;</span><span class="p">.</span><span class="n">broadcast</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">b</span><span class="p">.</span><span class="n">stream</span><span class="p">.</span><span class="n">listen</span><span class="p">((</span><span class="n">v</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">print</span><span class="p">(</span><span class="s1">&#39;A: </span><span class="si">$</span><span class="n">v</span><span class="s1">&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">b</span><span class="p">.</span><span class="n">stream</span><span class="p">.</span><span class="n">listen</span><span class="p">((</span><span class="n">v</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">print</span><span class="p">(</span><span class="s1">&#39;B: </span><span class="si">$</span><span class="n">v</span><span class="s1">&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">b</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="m">1</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">// A: 1
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="o">//</span> <span class="nl">B:</span> <span class="m">1</span></span></span></code></pre></div><p>值得注意的不只是「不能同時兩個 listener」——單訂閱 stream 的限制是<strong>整個 lifecycle 只能 listen 一次</strong>。即使第一個 listener 已經 <code>cancel()</code>、再呼叫 <code>.listen()</code> 仍會違反契約 throw。要重新訂閱必須重建 <code>StreamController</code>。</p>
<p>對 POS 場景的意義：副螢幕服務在 app 啟動時就建立訂閱、且不會 cancel——換句話說、stream 在啟動時就把唯一的 listener 配額分配給副螢幕、之後沒有可釋出的空間。</p>
<h3 id="2-監聽前的事件處理">2. 監聽前的事件處理</h3>





<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">single</span> <span class="o">=</span> <span class="n">StreamController</span><span class="o">&lt;</span><span class="kt">int</span><span class="o">&gt;</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">single</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="m">1</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">single</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="m">2</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1">// 此時還沒有 listener
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="n">single</span><span class="p">.</span><span class="n">stream</span><span class="p">.</span><span class="n">listen</span><span class="p">(</span><span class="n">print</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">single</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="m">3</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1">// 輸出：1, 2, 3 ← 之前的事件被 buffer，listener 接上後補送
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">final</span> <span class="n">broadcast</span> <span class="o">=</span> <span class="n">StreamController</span><span class="o">&lt;</span><span class="kt">int</span><span class="o">&gt;</span><span class="p">.</span><span class="n">broadcast</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">broadcast</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="m">1</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">broadcast</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="m">2</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1">// 此時還沒有 listener
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span><span class="n">broadcast</span><span class="p">.</span><span class="n">stream</span><span class="p">.</span><span class="n">listen</span><span class="p">(</span><span class="n">print</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">broadcast</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="m">3</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="o">//</span> <span class="err">輸出：</span><span class="m">3</span> <span class="err">←</span> <span class="err">監聽前的事件全部丟掉</span></span></span></code></pre></div><p>這個差異對應用設計的影響：</p>
<ul>
<li><strong>單訂閱</strong>保證 listener 不漏接，適合「資料完整性 &gt; 即時性」（檔案讀取、計算結果序列）</li>
<li><strong>broadcast</strong> 不保留歷史，適合「即時性 &gt; 完整性」（UI 事件、狀態變更通知）</li>
</ul>
<p>如果改成 broadcast 後，希望「新訂閱者進場時能拿到一次當下的狀態」（例如 controller 進場時想知道當前購物車內容），broadcast 本身做不到，要靠 service 自己保留 <code>latest</code> 或在新訂閱時手動 push 一次。RxDart 的 <code>BehaviorSubject</code> 內建這行為，純 dart:async 沒有。</p>
<p>對 POS 案例：sticky 高亮只關心未來變更，<strong>不在意歷史事件</strong>——broadcast 的丟棄行為跟這個語義一致、不造成資料缺失。但如果是「副螢幕鏡像當前購物車」這種需求，新副螢幕插入時若需要立即顯示當下狀態，就要在訂閱後手動 read 一次 <code>cart.items</code>。</p>
<h3 id="3-pause-行為最反直覺">3. Pause 行為（最反直覺）</h3>





<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">single</span> <span class="o">=</span> <span class="n">StreamController</span><span class="o">&lt;</span><span class="kt">int</span><span class="o">&gt;</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">sub</span> <span class="o">=</span> <span class="n">single</span><span class="p">.</span><span class="n">stream</span><span class="p">.</span><span class="n">listen</span><span class="p">(</span><span class="n">print</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">sub</span><span class="p">.</span><span class="n">pause</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">single</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="m">1</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="n">sub</span><span class="p">.</span><span class="n">resume</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="o">//</span> <span class="err">輸出：</span><span class="m">1</span> <span class="err">←</span> <span class="err">暫停期間的事件</span> <span class="n">resume</span> <span class="err">後補送</span></span></span></code></pre></div>




<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">broadcast</span> <span class="o">=</span> <span class="n">StreamController</span><span class="o">&lt;</span><span class="kt">int</span><span class="o">&gt;</span><span class="p">.</span><span class="n">broadcast</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">subA</span> <span class="o">=</span> <span class="n">broadcast</span><span class="p">.</span><span class="n">stream</span><span class="p">.</span><span class="n">listen</span><span class="p">((</span><span class="n">v</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">print</span><span class="p">(</span><span class="s1">&#39;A: </span><span class="si">$</span><span class="n">v</span><span class="s1">&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kd">final</span> <span class="n">subB</span> <span class="o">=</span> <span class="n">broadcast</span><span class="p">.</span><span class="n">stream</span><span class="p">.</span><span class="n">listen</span><span class="p">((</span><span class="n">v</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">print</span><span class="p">(</span><span class="s1">&#39;B: </span><span class="si">$</span><span class="n">v</span><span class="s1">&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">subA</span><span class="p">.</span><span class="n">pause</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="n">broadcast</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="m">1</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">// 輸出：B: 1   ← B 照收，A 暫存
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span><span class="n">subA</span><span class="p">.</span><span class="n">resume</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="o">//</span> <span class="err">輸出：</span><span class="nl">A:</span> <span class="m">1</span>   <span class="err">←</span> <span class="n">A</span> <span class="n">resume</span> <span class="err">後補回</span></span></span></code></pre></div><p>單訂閱的 pause 等於「整條管線暫停」，上游 add 的資料堆在 controller 內部、記憶體會漲。Broadcast 是 per-listener 暫停，互不影響。</p>
<p>POS 的副螢幕場景如果搭配無界事件源（例如背景條碼掃描器）、用單訂閱且某條路徑沒 resume、<strong>會在 controller 內部累積未送出的事件、記憶體佔用持續上升</strong>——這是 production OOM 的常見來源之一。</p>
<hr>
<h2 id="設計缺陷為什麼在初期沒有可見影響">設計缺陷為什麼在初期沒有可見影響</h2>
<h3 id="訂閱者單一時限制處於沉默狀態">訂閱者單一時、限制處於沉默狀態</h3>
<p>副螢幕訂閱寫在 service 啟動時、屬於 app lifetime 訂閱、沒有 cancel / 重新訂閱的情境。在這個訂閱模式下：</p>
<ol>
<li>副螢幕第一個訂閱 → 佔據 single-subscription 的「唯一 listener」配額</li>
<li>沒有第二個訂閱方 → 違反契約的條件不會出現</li>
<li>限制存在於型別契約裡、但沒有可見的影響</li>
</ol>
<p>當訂閱者擴增到第二個時、<strong>這條 stream 的型別契約「整個生命週期只承載 1 個 listener」才開始產生可見的執行期影響</strong>。注意這裡描述的是「<strong>契約一直存在、只是沒有觸發違反條件</strong>」——不是「契約因為新需求才變成限制」。型別契約是當下選擇 <code>StreamController()</code> 時就確定的、訂閱者數量只決定它何時被觸發。</p>
<h3 id="設計缺陷-vs-需求演化的分界">設計缺陷 vs 需求演化的分界</h3>
<p>但「為什麼能算設計缺陷」這個問題值得停下來釐清——當下只有一個訂閱者、需求變了才需要多訂閱、這聽起來不像是「設計缺陷」、更像是「需求演化」。兩者怎麼分？</p>
<p>關鍵不是「<strong>有沒有預測到後來的需求</strong>」、是「<strong>當下的選擇是否在零成本差異下不必要地縮小了未來空間</strong>」：</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>算什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>當下零成本差、選了限制更高的選項（本 case：single 的 11 字元差）</td>
          <td><strong>設計缺陷</strong></td>
      </tr>
      <tr>
          <td>當下高成本差、選了便宜的、後來需求變了（如「沒先建 plugin 系統」）</td>
          <td><strong>需求演化、非缺陷</strong></td>
      </tr>
      <tr>
          <td>當下零成本差、選了通用的、後來真的不需要</td>
          <td>中性、額外彈性留著</td>
      </tr>
      <tr>
          <td>當下高成本差、為「可能的未來」付了昂貴成本</td>
          <td><strong>過度設計</strong></td>
      </tr>
  </tbody>
</table>
<p>本 case 落在第一格——<code>StreamController()</code> vs <code>StreamController.broadcast()</code> 是 11 字元差、零認知負擔、零維護成本差異。即使當下只有副螢幕一個訂閱者、選 broadcast 也沒付任何代價、卻保留了未來的彈性。寫成 single 不是「對當下需求的精確匹配」、是<strong>在零成本差異下不必要地縮小了未來空間</strong>——這才是「設計缺陷」這個詞要描述的事。</p>
<p>加上 POS 系統的領域先驗強烈指向「多視角同步」（主螢幕 / 副螢幕 / 廚顯 / 雲端 / 列印是教科書級的 pub-sub 場景）、選 single-subscription 等於假設「這個 service 不會有多訂閱需求」——這個假設跟領域常識矛盾、即使在當下也站不住。</p>
<blockquote>
<p>「成本對稱性 / 可逆性 / 領域先驗」三軸框架的完整推導見 <a href="/blog/record/%E8%A8%AD%E8%A8%88%E7%91%95%E7%96%B5%E9%82%84%E6%98%AF%E9%81%BF%E5%85%8D%E9%81%8E%E5%BA%A6%E8%A8%AD%E8%A8%88yagni-%E7%9A%84%E7%9C%9F%E5%AF%A6%E9%81%A9%E7%94%A8%E6%A2%9D%E4%BB%B6/" data-link-title="設計瑕疵還是避免過度設計？YAGNI 的真實適用條件" data-link-desc="YAGNI 不是「永遠選最受限選項」、是「不為未來投入額外成本」的原則。用成本對稱性、可逆性、領域先驗三軸框架釐清「該選通用 default」與「該避免過度設計」的邊界、並補上 review checklist、架構規範、領域先驗清單三層制度補強。">設計瑕疵還是避免過度設計？YAGNI 的真實適用條件</a>——本 case 三軸都指向 broadcast、屬於 YAGNI 不適用的標準情境。</p></blockquote>
<h3 id="為什麼-ide-與測試抓不到">為什麼 IDE 與測試抓不到</h3>
<ul>
<li><strong>Dart 編譯器</strong>：型別簽章一樣（<code>Stream&lt;T&gt;</code>），編譯不會錯</li>
<li><strong>靜態分析</strong>：<code>dart analyze</code> 不會警告 single-subscription 用法的潛在風險</li>
<li><strong>單元測試</strong>：通常 mock 整條 stream，不會驗證真實 controller 是不是支援多訂閱</li>
<li><strong>Widget test</strong>：只跑單一頁面，不會同時掛多個訂閱模組</li>
<li><strong>整合測試</strong>：理論上能抓，但成本高，多數專案在這層覆蓋稀疏</li>
</ul>
<p>要在事前抓到，可行的方式：</p>
<ul>
<li><strong>Lint rule</strong>：自訂規則檢查 <code>StreamController()</code> 預設用法，要求加註解說明「為何刻意不用 broadcast」</li>
<li><strong>Code review checklist</strong>：service 對外暴露 stream 時，預設假設要 broadcast，single 必須有書面理由</li>
<li><strong>架構規範</strong>：直接禁用 raw <code>StreamController</code> 在 service 層，強制透過框架的廣播原語（<code>Rx</code>, <code>BehaviorSubject</code>, <code>ValueNotifier</code>）</li>
</ul>
<hr>
<h2 id="修復決策過程">修復決策過程</h2>
<h3 id="選項列舉">選項列舉</h3>
<p>事故當下的選項：</p>
<table>
  <thead>
      <tr>
          <th>選項</th>
          <th>改動範圍</th>
          <th>風險</th>
          <th>適用條件</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>A. 改成 <code>.broadcast()</code></td>
          <td>service 一行</td>
          <td>低</td>
          <td>多訂閱本來就合理</td>
      </tr>
      <tr>
          <td>B. 第二個訂閱者透過第一個轉送</td>
          <td>副螢幕服務變成 hub</td>
          <td>高，副螢幕不該知道 sticky 高亮</td>
          <td>第二個需求是第一個的 strict subset</td>
      </tr>
      <tr>
          <td>C. 新加一條平行 broadcast stream</td>
          <td>service 增 API</td>
          <td>中</td>
          <td>兩訂閱關心不同維度</td>
      </tr>
      <tr>
          <td>D. 改用框架的廣播原語（<code>Rx</code>、<code>Subject</code>）</td>
          <td>service 介面變動</td>
          <td>中</td>
          <td>系統性重構契機</td>
      </tr>
  </tbody>
</table>
<h3 id="為什麼選-a">為什麼選 A</h3>
<p>POS 的這條 stream 語義就是「購物車狀態變更廣播」、多訂閱者本來就符合領域模型。選 B 會讓副螢幕服務變成轉發中樞、跟它「純顯示」的職責衝突。選 C 增加重複資料源、未來容易兩條 stream 不同步。選 D 雖然在架構層更一致、但 scope 過大、不是事故當下適合做的決定。</p>
<p>A 是改一行的 minimal fix，且<strong>修正了原本的設計缺陷</strong>而不是繞過它。</p>
<h3 id="容易漏的細節mock-也要改">容易漏的細節：mock 也要改</h3>
<p>Service 如果有 mock 實作（測試替身）、mock 端也要同步改成 broadcast。否則會出現「測試環境通過、production 仍然 throw」的不對齊狀況——單元測試（注入 mock）跟 production（真實 service）使用不同的 stream 契約、限制沒被測試覆蓋。</p>
<p>這是「測試環境與 production 配置不對齊」的典型陷阱。事故當下要把「修真實實作」「修 mock」當成同一件事的兩個必做動作，分開做就會漏。比較好的長期策略是把這個約束放進 code review checklist，或在 service 介面層加註解註明「實作不論真假都必須是 broadcast 語義」。</p>
<h3 id="還要檢查所有寫入路徑都有完整-emit">還要檢查：所有寫入路徑都有完整 emit</h3>
<p>事故修復不只是改 stream 類型，還要回頭審視「事件 payload 的完整性」。</p>
<p>回到事故場景：事件 payload 第二段（這次變動是哪筆）原本沒人用，所以幾個寫入路徑可能根本沒傳。副螢幕只看第一段（完整列表），傳不傳第二段對它沒差。<strong>只有第二個訂閱者開始消費這段資訊時，遺漏才會暴露</strong>。</p>
<p>這是廣播設計的一個系統性風險：<strong>service 提供「為未來訂閱者保留」的擴充欄位時、這些欄位若沒有當下的消費者、缺漏不會在測試中浮現</strong>。第一個真正使用該欄位的訂閱者出現後、才會暴露出某些 mutation 路徑沒填寫該欄位。</p>
<p>修復清單：</p>
<ul>
<li><input disabled="" type="checkbox"> 把 single-subscription 改成 broadcast（真實實作 + mock 雙改）</li>
<li><input disabled="" type="checkbox"> 審視所有寫入路徑，確保事件 payload 的每個欄位都正確填寫</li>
<li><input disabled="" type="checkbox"> 確認第二個訂閱者的 dispose / cancel 邏輯</li>
<li><input disabled="" type="checkbox"> 訂閱者進場時若需要「當下狀態」，要補一次直接讀取（broadcast 不保留歷史）</li>
</ul>
<hr>
<h2 id="何時該選哪個">何時該選哪個</h2>
<h3 id="選-streamcontroller-的情境">選 <code>StreamController()</code> 的情境</h3>
<ul>
<li>確定<strong>只有一個消費者</strong>，且這個契約被寫進文件 / 介面註解</li>
<li>需要保證<strong>每個事件都被消費</strong>（buffer 是 feature）</li>
<li>像 Future 但會發多個值：檔案讀取、HTTP response body chunks、long-running task 進度回報</li>
</ul>
<h3 id="選-streamcontrollerbroadcast-的情境">選 <code>StreamController.broadcast()</code> 的情境</h3>
<ul>
<li>有<strong>多個訂閱者</strong>，或不確定未來會不會多</li>
<li>事件是「正在發生」的通知，<strong>錯過就算了</strong>（UI 事件、狀態變更廣播、event bus、application-level domain events）</li>
<li>不在意進場前的歷史事件（如果在意，自己保留 <code>latestValue</code>）</li>
</ul>
<h3 id="一個粗略的決策法">一個粗略的決策法</h3>
<blockquote>
<p>「如果某天有人想加第二個 listener，這在語義上合理嗎？」</p>
<ul>
<li>合理 → 一開始就用 broadcast</li>
<li>不合理 → 用單訂閱，並在註解寫清楚為什麼</li>
</ul></blockquote>
<p>應用層的 service 通知絕大多數情境都偏向 broadcast；single-subscription 的甜蜜點在底層 I/O 或一次性 task 進度（兩者都有「單一消費者 + 不能漏接」的明確契約）。</p>
<p>對 POS 場景：service 對外暴露的「狀態變更通知」幾乎都落在 broadcast 區——POS 的本質就是多裝置 / 多視圖共享同一份交易狀態（主螢幕、副螢幕、廚顯、雲端、列印機）。</p>
<hr>
<h2 id="補救與替代方案">補救與替代方案</h2>
<h3 id="已有-single-subscription-stream想對外提供-broadcast">已有 single-subscription stream，想對外提供 broadcast</h3>
<p>不用改 controller 類型，可以包一層：</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">singleStream</span> <span class="o">=</span> <span class="n">someController</span><span class="p">.</span><span class="n">stream</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">broadcastView</span> <span class="o">=</span> <span class="n">singleStream</span><span class="p">.</span><span class="n">asBroadcastStream</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="o">//</span> <span class="err">對外公開</span> <span class="n">broadcastView</span><span class="err">，原本的</span> <span class="n">singleStream</span> <span class="err">內部仍是</span> <span class="n">single</span><span class="o">-</span><span class="n">subscription</span></span></span></code></pre></div><p><code>asBroadcastStream()</code> 把單訂閱當 source，對外提供 broadcast view。一旦呼叫過一次，後續訂閱者都拿這個 view。</p>
<p>注意：這個方法只能呼叫<strong>一次</strong>、第二次會 throw。實務上要保留回傳值在 service 內部做 cache。</p>
<h3 id="想要broadcast--新訂閱拿最後一次值">想要「broadcast + 新訂閱拿最後一次值」</h3>
<p>標準 <code>dart:async</code> 沒有這功能。要嘛自己實作：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">class</span> <span class="nc">ReplayLastNotifier</span><span class="o">&lt;</span><span class="n">T</span><span class="o">&gt;</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">_controller</span> <span class="o">=</span> <span class="n">StreamController</span><span class="o">&lt;</span><span class="n">T</span><span class="o">&gt;</span><span class="p">.</span><span class="n">broadcast</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="n">T</span><span class="o">?</span> <span class="n">_latest</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="n">Stream</span><span class="o">&lt;</span><span class="n">T</span><span class="o">&gt;</span> <span class="kd">get</span> <span class="n">stream</span> <span class="kd">async</span><span class="o">*</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">_latest</span> <span class="o">!=</span> <span class="kc">null</span><span class="p">)</span> <span class="kd">yield</span> <span class="n">_latest</span> <span class="o">as</span> <span class="n">T</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="kd">yield</span><span class="o">*</span> <span class="n">_controller</span><span class="p">.</span><span class="n">stream</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="kt">void</span> <span class="n">add</span><span class="p">(</span><span class="n">T</span> <span class="n">value</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="n">_latest</span> <span class="o">=</span> <span class="n">value</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="n">_controller</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="n">value</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>要嘛用 RxDart 的 <code>BehaviorSubject</code>，內建這行為。POS 副螢幕鏡像場景特別適合 <code>BehaviorSubject</code>：副螢幕進場時就能立即看到當下購物車內容，不必等下一次變更。</p>
<h3 id="flutter-生態系的替代">Flutter 生態系的替代</h3>
<p>純 <code>StreamController</code> 在 Flutter app 層比較少見，更常用的是：</p>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>廣播語義</th>
          <th>內建保留最後值</th>
          <th>備註</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>ValueNotifier&lt;T&gt;</code></td>
          <td>是</td>
          <td>是</td>
          <td>適合單一值狀態</td>
      </tr>
      <tr>
          <td><code>ChangeNotifier</code></td>
          <td>是</td>
          <td>N/A（無資料傳遞）</td>
          <td>訂閱者自己讀狀態</td>
      </tr>
      <tr>
          <td><code>Rx&lt;T&gt;</code>（GetX）</td>
          <td>是</td>
          <td>是</td>
          <td><code>.listen()</code> / <code>ever()</code></td>
      </tr>
      <tr>
          <td><code>BehaviorSubject</code>（RxDart）</td>
          <td>是</td>
          <td>是</td>
          <td>API 接近原生 stream</td>
      </tr>
      <tr>
          <td><code>StateNotifier</code>（Riverpod）</td>
          <td>是</td>
          <td>是</td>
          <td>不可變狀態風格</td>
      </tr>
  </tbody>
</table>
<p>如果你已經在用某個狀態管理框架，優先用框架的廣播原語，而不是 raw <code>StreamController</code>。<code>StreamController</code> 在 Flutter app 通常是底層 I/O service 才用（藍牙、socket、sensor）。</p>
<p>下一節對其中最常被混用的一組——raw <code>StreamController</code> 跟 GetX 的 <code>Rx</code> / <code>.obs</code>——做完整對比，因為這也是事故當下會考慮「是不是該整個換掉」的對象。</p>
<hr>
<h2 id="深入比較raw-streamcontroller-vs-getx-的-rx--obs">深入比較：raw StreamController vs GetX 的 Rx / .obs</h2>
<h3 id="先釐清rx-跟-obs-的關係">先釐清：Rx 跟 .obs 的關係</h3>
<p>在 GetX 裡，<code>Rx&lt;T&gt;</code> 是底層 reactive value container，<code>.obs</code> 是把任何值包成對應 Rx 子類的 syntax sugar：</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">// 三種寫法本質等價
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kd">final</span> <span class="n">count1</span> <span class="o">=</span> <span class="m">0.</span><span class="n">obs</span><span class="p">;</span>            <span class="c1">// 推導為 RxInt
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="kd">final</span> <span class="n">count2</span> <span class="o">=</span> <span class="n">RxInt</span><span class="p">(</span><span class="m">0</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">final</span> <span class="n">count3</span> <span class="o">=</span> <span class="n">Rx</span><span class="o">&lt;</span><span class="kt">int</span><span class="o">&gt;</span><span class="p">(</span><span class="m">0</span><span class="p">);</span>       <span class="c1">// 較少用，因為 RxInt 提供更多 operator overload
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="n">count1</span><span class="p">.</span><span class="n">value</span><span class="o">++</span><span class="p">;</span>  <span class="c1">// RxInt 可直接用 ++
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span><span class="n">count3</span><span class="p">.</span><span class="n">value</span><span class="o">++</span><span class="p">;</span>  <span class="o">//</span> <span class="n">Rx</span><span class="o">&lt;</span><span class="kt">int</span><span class="o">&gt;</span> <span class="err">也行，但缺了</span> <span class="n">RxInt</span> <span class="err">的算術特化</span></span></span></code></pre></div><p><code>.obs</code> 對不同型別回傳不同特化子類：</p>
<table>
  <thead>
      <tr>
          <th>寫法</th>
          <th>回傳型別</th>
          <th>特化能力</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>0.obs</code></td>
          <td><code>RxInt</code></td>
          <td>算術 operator (<code>+=</code>, <code>++</code>, <code>&lt;</code> 等)</td>
      </tr>
      <tr>
          <td><code>0.0.obs</code></td>
          <td><code>RxDouble</code></td>
          <td>算術 operator</td>
      </tr>
      <tr>
          <td><code>''.obs</code></td>
          <td><code>RxString</code></td>
          <td>字串 operator (<code>+</code>, <code>==</code>, <code>compareTo</code>)</td>
      </tr>
      <tr>
          <td><code>false.obs</code></td>
          <td><code>RxBool</code></td>
          <td><code>toggle()</code>、邏輯 operator</td>
      </tr>
      <tr>
          <td><code>[1,2].obs</code></td>
          <td><code>RxList&lt;int&gt;</code></td>
          <td><code>add</code>/<code>remove</code>/<code>assignAll</code> 自動觸發</td>
      </tr>
      <tr>
          <td><code>{}.obs</code></td>
          <td><code>RxMap</code>/<code>RxSet</code></td>
          <td>集合 mutation 自動觸發</td>
      </tr>
      <tr>
          <td><code>User().obs</code></td>
          <td><code>Rx&lt;User&gt;</code></td>
          <td>一般 reassign 觸發</td>
      </tr>
  </tbody>
</table>
<p>特化子類的核心好處：<strong>原生語法的 mutation（<code>+=</code>、list <code>add</code>、string concat）都直接觸發 reactive 通知</strong>，不需要手動 <code>notifyListeners()</code> 或 <code>add()</code>。</p>
<p>結論：<code>.obs</code> 跟 <code>Rx</code> 不是兩個不同概念，是同一個機制的兩種建構寫法。後者多了型別推導與特化命名。</p>
<h3 id="概念差異">概念差異</h3>
<table>
  <thead>
      <tr>
          <th></th>
          <th>StreamController</th>
          <th>Rx<T> / .obs</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>本質</td>
          <td>事件管線（push events）</td>
          <td>反應式值容器（push values + 保留 current）</td>
      </tr>
      <tr>
          <td>比喻</td>
          <td>水管</td>
          <td>帶讀數的水位感應器</td>
      </tr>
      <tr>
          <td>起始狀態</td>
          <td>沒有 latest，listener 加入後才開始接</td>
          <td>出生就有 <code>.value</code>，隨時可讀</td>
      </tr>
      <tr>
          <td>設計目的</td>
          <td>通用非同步資料流</td>
          <td>專為 UI 反應式更新設計</td>
      </tr>
  </tbody>
</table>
<h3 id="相同任務的程式碼對比">相同任務的程式碼對比</h3>
<p><strong>任務</strong>：service 對外暴露一個整數狀態，UI 顯示它且當值變化時自動 rebuild。</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">// ===== Raw StreamController 寫法 =====
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kd">class</span> <span class="nc">CounterService</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="kt">int</span> <span class="n">_value</span> <span class="o">=</span> <span class="m">0</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="kd">final</span> <span class="n">_controller</span> <span class="o">=</span> <span class="n">StreamController</span><span class="o">&lt;</span><span class="kt">int</span><span class="o">&gt;</span><span class="p">.</span><span class="n">broadcast</span><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="kt">int</span> <span class="kd">get</span> <span class="n">value</span> <span class="o">=&gt;</span> <span class="n">_value</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="n">Stream</span><span class="o">&lt;</span><span class="kt">int</span><span class="o">&gt;</span> <span class="kd">get</span> <span class="n">stream</span> <span class="o">=&gt;</span> <span class="n">_controller</span><span class="p">.</span><span class="n">stream</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="kt">void</span> <span class="n">increment</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="n">_value</span><span class="o">++</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="n">_controller</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="n">_value</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="kt">void</span> <span class="n">dispose</span><span class="p">()</span> <span class="o">=&gt;</span> <span class="n">_controller</span><span class="p">.</span><span class="n">close</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="c1">// UI:
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1"></span><span class="n">StreamBuilder</span><span class="o">&lt;</span><span class="kt">int</span><span class="o">&gt;</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">  <span class="nl">stream:</span> <span class="n">service</span><span class="p">.</span><span class="n">stream</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">  <span class="nl">initialData:</span> <span class="n">service</span><span class="p">.</span><span class="n">value</span><span class="p">,</span>  <span class="c1">// 不帶這個首次 build 是 null
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="c1"></span>  <span class="nl">builder:</span> <span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="n">snap</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">Text</span><span class="p">(</span><span class="s1">&#39;</span><span class="si">${</span><span class="n">snap</span><span class="p">.</span><span class="n">data</span><span class="si">}</span><span class="s1">&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">)</span></span></span></code></pre></div>




<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">// ===== Rx / .obs 寫法 =====
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kd">class</span> <span class="nc">CounterService</span> <span class="kd">extends</span> <span class="n">GetxController</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="kd">final</span> <span class="n">value</span> <span class="o">=</span> <span class="m">0.</span><span class="n">obs</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="kt">void</span> <span class="n">increment</span><span class="p">()</span> <span class="o">=&gt;</span> <span class="n">value</span><span class="p">.</span><span class="n">value</span><span class="o">++</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="c1">// 不需要寫 dispose；Rx 隨 controller 生命週期自動清理
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">// UI:
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span><span class="n">Obx</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="n">Text</span><span class="p">(</span><span class="s1">&#39;</span><span class="si">${</span><span class="n">service</span><span class="p">.</span><span class="n">value</span><span class="p">.</span><span class="n">value</span><span class="si">}</span><span class="s1">&#39;</span><span class="p">))</span></span></span></code></pre></div><p>差異一目了然：</p>
<ul>
<li><strong>樣板量約 4-5 倍差距</strong></li>
<li>StreamController 要自己維護 latest value</li>
<li>StreamController 要記得寫 <code>dispose</code></li>
<li><code>Obx</code> 自動追蹤所有 <code>.value</code> 讀取，不需要手動 listen/cancel</li>
<li>StreamBuilder 要處理 <code>initialData</code> 與 <code>snap.data</code> 為 null 的情境，Rx 沒這問題（永遠有值）</li>
</ul>
<h3 id="rx-內部其實就是-streamcontroller--valuenotifier">Rx 內部其實就是 StreamController + ValueNotifier</h3>
<p><code>Rx&lt;T&gt;</code> 底層用 <code>StreamController.broadcast()</code> 加上一個 <code>_value</code> 欄位。<code>Obx</code> widget 在 build 時開一個訂閱範圍，期間任何 <code>.value</code> getter 會被追蹤；build 結束後對應的 stream 訂閱自動建立，值變化時觸發 widget rebuild。</p>
<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="kd">class</span> <span class="nc">Rx</span><span class="o">&lt;</span><span class="n">T</span><span class="o">&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="n">T</span> <span class="n">_value</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kd">final</span> <span class="n">_ctrl</span> <span class="o">=</span> <span class="n">StreamController</span><span class="o">&lt;</span><span class="n">T</span><span class="o">&gt;</span><span class="p">.</span><span class="n">broadcast</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="n">Rx</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="n">_value</span><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="n">T</span> <span class="kd">get</span> <span class="n">value</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="n">RxInterface</span><span class="p">.</span><span class="n">proxy</span><span class="o">?</span><span class="p">.</span><span class="n">addListener</span><span class="p">(</span><span class="n">_ctrl</span><span class="p">.</span><span class="n">stream</span><span class="p">);</span>  <span class="c1">// Obx 注入的依賴追蹤代理
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span>    <span class="k">return</span> <span class="n">_value</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="kd">set</span> <span class="n">value</span><span class="p">(</span><span class="n">T</span> <span class="n">v</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">_value</span> <span class="o">==</span> <span class="n">v</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>  <span class="c1">// ← 等值不觸發
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span>    <span class="n">_value</span> <span class="o">=</span> <span class="n">v</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="n">_ctrl</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="n">v</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>（真實實作更複雜，但骨架是這樣。）</p>
<p>換句話說 <strong>Rx ≈ broadcast StreamController + ValueNotifier + 自動依賴追蹤 + 特化子類</strong>。理解這層之後，後面所有「Rx 為什麼這樣」的問題都能從這個本質推回去。</p>
<h3 id="完整對比表格">完整對比表格</h3>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>StreamController</th>
          <th>Rx<T> / .obs</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Framework 依賴</td>
          <td>無（dart:async 標準庫）</td>
          <td>需 GetX</td>
      </tr>
      <tr>
          <td>同訂閱數</td>
          <td>single 或 broadcast 二選一</td>
          <td>永遠 broadcast</td>
      </tr>
      <tr>
          <td>Latest value 保留</td>
          <td>不保留，自己管 <code>_latest</code></td>
          <td>內建 <code>.value</code></td>
      </tr>
      <tr>
          <td>訂閱機制</td>
          <td>手動 <code>.listen()</code></td>
          <td><code>Obx</code> 自動 / <code>ever()</code> worker 手動</td>
      </tr>
      <tr>
          <td>取消訂閱</td>
          <td>手動 <code>sub.cancel()</code></td>
          <td>Obx widget dispose 時自動 / worker 綁 controller 時自動</td>
      </tr>
      <tr>
          <td>Widget 整合</td>
          <td><code>StreamBuilder</code></td>
          <td><code>Obx</code> / <code>GetX&lt;T&gt;</code></td>
      </tr>
      <tr>
          <td>初始值處理</td>
          <td>需 <code>initialData</code> 或 listener 加入後才有</td>
          <td>出生就有，無 null 期</td>
      </tr>
      <tr>
          <td>等值是否觸發</td>
          <td>是，每次 add 都送</td>
          <td>否，<code>==</code> 相等不觸發（可 <code>.refresh()</code> 強制）</td>
      </tr>
      <tr>
          <td>集合反應性</td>
          <td>List 變動要自己 emit</td>
          <td>RxList/Map/Set 內建 mutation hook</td>
      </tr>
      <tr>
          <td>物件內部變動</td>
          <td>自己控制何時 emit</td>
          <td>需 <code>.refresh()</code> 或換新 reference</td>
      </tr>
      <tr>
          <td>Stream operators (map/where/buffer/&hellip;)</td>
          <td>完整 dart:async API</td>
          <td>用 <code>.stream</code> 取出後可接</td>
      </tr>
      <tr>
          <td>Pause/resume</td>
          <td>支援（broadcast 為 per-listener）</td>
          <td>透過 underlying stream 才有</td>
      </tr>
      <tr>
          <td>Error 傳遞</td>
          <td><code>addError()</code> + <code>onError</code> callback</td>
          <td>較少使用，多以 try/catch 處理上游</td>
      </tr>
      <tr>
          <td>樣板量</td>
          <td>多（5-10 行/欄位）</td>
          <td>少（1 行/欄位）</td>
      </tr>
      <tr>
          <td>學習曲線</td>
          <td>標準 Stream 概念，跨框架通用</td>
          <td>GetX 特有 API，受框架綁定</td>
      </tr>
      <tr>
          <td>測試</td>
          <td>直接測 stream，工具豐富（<code>expectLater</code>/<code>emitsInOrder</code>）</td>
          <td>Rx 可用 <code>.value</code> assert，跨 controller 測試要 mock GetX 注入</td>
      </tr>
      <tr>
          <td>跨 isolate</td>
          <td>支援</td>
          <td>不支援（Obx 依賴 main isolate）</td>
      </tr>
      <tr>
          <td>Type safety</td>
          <td>強 generic</td>
          <td>強 generic，但 <code>.obs</code> 推導要注意特化型別</td>
      </tr>
      <tr>
          <td>適用場景</td>
          <td>底層 I/O、需要 stream 組合運算</td>
          <td>UI state、application state</td>
      </tr>
  </tbody>
</table>
<h3 id="rx-的特殊行為與陷阱">Rx 的特殊行為與陷阱</h3>
<h4 id="1-等值不觸發更新">1. 等值不觸發更新</h4>





<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">name</span> <span class="o">=</span> <span class="s1">&#39;&#39;</span><span class="p">.</span><span class="n">obs</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">name</span><span class="p">.</span><span class="n">value</span> <span class="o">=</span> <span class="s1">&#39;&#39;</span><span class="p">;</span>     <span class="c1">// 不觸發 listener（&#39;&#39; == &#39;&#39;）
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="n">name</span><span class="p">.</span><span class="n">value</span> <span class="o">=</span> <span class="s1">&#39;A&#39;</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="n">name</span><span class="p">.</span><span class="n">value</span> <span class="o">=</span> <span class="s1">&#39;A&#39;</span><span class="p">;</span>    <span class="o">//</span> <span class="err">不觸發（</span><span class="s1">&#39;A&#39;</span> <span class="o">==</span> <span class="s1">&#39;A&#39;</span><span class="err">）</span></span></span></code></pre></div><p>如果需要「每次 set 都觸發」（例如重新打 API 不管值有沒有變），用 <code>.refresh()</code> 或 <code>.trigger()</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="n">name</span><span class="p">.</span><span class="n">refresh</span><span class="p">();</span>              <span class="c1">// 強制通知所有 listener，不變更 value
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="n">name</span><span class="p">.</span><span class="n">trigger</span><span class="p">(</span><span class="s1">&#39;A&#39;</span><span class="p">);</span>           <span class="o">//</span> <span class="err">強制通知，且</span> <span class="kd">set</span> <span class="n">value</span></span></span></code></pre></div><h4 id="2-物件內部變動不觸發">2. 物件內部變動不觸發</h4>





<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">user</span> <span class="o">=</span> <span class="n">User</span><span class="p">(</span><span class="nl">name:</span> <span class="s1">&#39;A&#39;</span><span class="p">).</span><span class="n">obs</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">user</span><span class="p">.</span><span class="n">value</span><span class="p">.</span><span class="n">name</span> <span class="o">=</span> <span class="s1">&#39;B&#39;</span><span class="p">;</span>                         <span class="c1">// 不觸發，reference 沒變
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="n">user</span><span class="p">.</span><span class="n">refresh</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="n">user</span><span class="p">.</span><span class="n">value</span> <span class="o">=</span> <span class="n">user</span><span class="p">.</span><span class="n">value</span><span class="p">.</span><span class="n">copyWith</span><span class="p">(</span><span class="nl">name:</span> <span class="s1">&#39;B&#39;</span><span class="p">);</span>   <span class="o">//</span> <span class="err">換新</span> <span class="n">reference</span> <span class="err">自然觸發</span></span></span></code></pre></div><p>這跟 immutable 風格（Freezed、Equatable）配合最自然，<code>copyWith</code> 一定產出新 reference。</p>
<h4 id="3-obx-必須讀到至少一個-value">3. Obx 必須讀到至少一個 <code>.value</code></h4>





<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">Obx</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="n">Text</span><span class="p">(</span><span class="s1">&#39;hello&#39;</span><span class="p">))</span>                  <span class="c1">// warning: improper use
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="n">Obx</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="n">Text</span><span class="p">(</span><span class="s1">&#39;</span><span class="si">${</span><span class="n">counter</span><span class="p">.</span><span class="n">value</span><span class="si">}</span><span class="s1">&#39;</span><span class="p">))</span>       <span class="o">//</span> <span class="err">正確</span></span></span></code></pre></div><p><code>Obx</code> 靠 build 期間攔截 <code>.value</code> getter 建立訂閱關係，build callback 內完全沒讀任何 Rx 就不知道要 subscribe 誰。</p>
<h4 id="4-rxlist--rxmap-的-mutation-規則">4. RxList / RxMap 的 mutation 規則</h4>





<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">items</span> <span class="o">=</span> <span class="o">&lt;</span><span class="kt">int</span><span class="o">&gt;</span><span class="p">[].</span><span class="n">obs</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">items</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="m">1</span><span class="p">);</span>          <span class="c1">// 觸發（RxList 重寫了 add）
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="n">items</span><span class="p">.</span><span class="n">value</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="m">2</span><span class="p">);</span>    <span class="c1">// 不觸發（操作的是底層 List）
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="n">items</span><span class="p">[</span><span class="m">0</span><span class="p">]</span> <span class="o">=</span> <span class="m">99</span><span class="p">;</span>         <span class="c1">// 觸發（RxList 重寫了 []=）
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="n">items</span><span class="p">.</span><span class="n">refresh</span><span class="p">();</span>       <span class="o">//</span> <span class="err">補救</span></span></span></code></pre></div><p>特化集合類別重寫了 <code>add</code>/<code>remove</code>/<code>[]=</code>/<code>clear</code> 等 method 讓它們自動 emit；繞過 wrapper 直接操作 <code>.value</code> 就會跳過這層。</p>
<h4 id="5-obs-推導出的特化型別可能不是你想要的">5. .obs 推導出的特化型別可能不是你想要的</h4>





<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">list</span> <span class="o">=</span> <span class="p">[</span><span class="m">1</span><span class="p">,</span> <span class="m">2</span><span class="p">,</span> <span class="m">3</span><span class="p">].</span><span class="n">obs</span><span class="p">;</span>        <span class="c1">// RxList&lt;int&gt;
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kd">final</span> <span class="n">list2</span> <span class="o">=</span> <span class="o">&lt;</span><span class="kt">num</span><span class="o">&gt;</span><span class="p">[</span><span class="m">1</span><span class="p">,</span> <span class="m">2</span><span class="p">,</span> <span class="m">3</span><span class="p">].</span><span class="n">obs</span><span class="p">;</span>  <span class="c1">// RxList&lt;num&gt; — 注意泛型推導
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">// 自定義型別需明確
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="kd">final</span> <span class="n">user</span> <span class="o">=</span> <span class="n">User</span><span class="p">(</span><span class="nl">name:</span> <span class="s1">&#39;A&#39;</span><span class="p">).</span><span class="n">obs</span><span class="p">;</span>  <span class="o">//</span> <span class="n">Rx</span><span class="o">&lt;</span><span class="n">User</span><span class="o">&gt;</span><span class="err">，不是「</span><span class="n">RxUser</span><span class="err">」</span></span></span></code></pre></div><h3 id="rx-的-worker-類型service-之間的訂閱模式">Rx 的 worker 類型（service 之間的訂閱模式）</h3>
<p><code>Obx</code> 是 widget 自動訂閱；service 內或 controller 之間的訂閱用 <code>worker</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="c1">// 每次變化都觸發
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kd">final</span> <span class="n">disposer</span> <span class="o">=</span> <span class="n">ever</span><span class="p">(</span><span class="n">counter</span><span class="p">,</span> <span class="p">(</span><span class="n">value</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">print</span><span class="p">(</span><span class="s1">&#39;changed to </span><span class="si">$</span><span class="n">value</span><span class="s1">&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1">// debounce — 連續變化只取最後一次
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="n">debounce</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="n">searchText</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="p">(</span><span class="n">value</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">searchAPI</span><span class="p">(</span><span class="n">value</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="nl">time:</span> <span class="n">Duration</span><span class="p">(</span><span class="nl">milliseconds:</span> <span class="m">500</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="p">);</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">// throttle — 固定間隔最多觸發一次
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span><span class="n">interval</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="n">scrollPosition</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="p">(</span><span class="n">value</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">analytics</span><span class="p">(</span><span class="n">value</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="nl">time:</span> <span class="n">Duration</span><span class="p">(</span><span class="nl">seconds:</span> <span class="m">1</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">);</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="c1">// 只觸發一次後自動移除
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1"></span><span class="n">once</span><span class="p">(</span><span class="n">loginState</span><span class="p">,</span> <span class="p">(</span><span class="n">value</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">navigateHome</span><span class="p">());</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="c1">// 監聽多個 Rx，任一變動就觸發
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="c1"></span><span class="n">everAll</span><span class="p">([</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">,</span> <span class="n">c</span><span class="p">],</span> <span class="p">(</span><span class="n">_</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">recompute</span><span class="p">());</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">
</span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="c1">// 手動清理
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="c1"></span><span class="n">disposer</span><span class="p">.</span><span class="n">dispose</span><span class="p">();</span></span></span></code></pre></div><p>這些 worker 在 <code>GetxController.onInit</code> 裡註冊時會被綁定到 controller 生命週期，controller dispose 時自動清；在 controller 外註冊就要自己 <code>.dispose()</code>。</p>
<h3 id="何時選哪個">何時選哪個</h3>
<h4 id="選-raw-streamcontroller">選 raw <code>StreamController</code></h4>
<ul>
<li>寫<strong>底層 service</strong>（藍牙、socket、sensor、background isolate 通訊）</li>
<li>需要<strong>豐富的 stream operators 鏈</strong>（<code>map</code>/<code>where</code>/<code>buffer</code>/<code>distinct</code>/<code>merge</code>/<code>combineLatest</code>&hellip;）</li>
<li>對外提供的 API 不想綁特定狀態管理框架，要保持框架中立</li>
<li>需要 backpressure / pause-resume 等進階流量控制</li>
<li>跨 isolate 資料傳遞</li>
</ul>
<h4 id="選-rx--obs">選 <code>Rx</code> / <code>.obs</code></h4>
<ul>
<li>寫 <strong>UI state</strong> 或 <strong>application state</strong></li>
<li>已在用 GetX，沿用一致</li>
<li>需要「保留當前值 + 多訂閱者」這個常見組合</li>
<li>想要 widget 自動追蹤，不想手動寫 listen/cancel</li>
<li>service 內部 latest value 與通知的樣板太多次，懶得繼續寫</li>
</ul>
<h3 id="把事故場景改寫成-rx-看看">把事故場景改寫成 Rx 看看</h3>
<p>回到事故場景。如果 service 從一開始就用 reactive value container（如 Rx）來表達它的對外契約，整個問題會以另一種方式消失。</p>
<p><strong>對外契約的轉變</strong>：service 不再「對外發送事件」，而是「對外暴露兩個可被觀察的狀態屬性」——當前完整的商品列表、最後一次變動的品項。訂閱方不需要 <code>listen()</code> 一條 stream，而是直接讀取屬性的當前值，並且系統保證屬性變化時觀察者會被通知。</p>
<p><strong>在這個契約下回頭看每個訂閱方的需求</strong>：</p>
<ul>
<li><strong>副螢幕（鏡像當前商品列表）</strong>：只關心「列表屬性」變動，不在乎是哪一筆變動。它建立一個對列表屬性的觀察，每次變動就重畫</li>
<li><strong>收銀主畫面（最後變更項標記）</strong>：只關心「最後變動屬性」，每次變動就更新高亮哪一行</li>
<li><strong>未來的訂閱方</strong>（KDS、列印、雲端、analytics）：各自選關心的屬性建立觀察</li>
</ul>
<p>兩個訂閱者觀察的是<strong>不同屬性</strong>，互不干擾；同一個屬性也允許多個觀察者（reactive value 天生是廣播語義）。</p>
<p><strong>事故的兩個技術問題在這個契約下自動消失</strong>：</p>
<ol>
<li><strong>single vs broadcast 的選擇問題不存在</strong>——reactive value 沒有「單訂閱版本」，每個觀察者天生並存</li>
<li><strong>進場拿不到歷史事件的問題不存在</strong>——觀察者進場時可以直接讀屬性的「當前值」，不必等下一次變動</li>
</ol>
<p>更深一層的觀察：raw stream 是「以時間軸上的事件為一等公民」的工具，適合「事件本身就是有意義的（log、命令、訊息）」場景；reactive value 是「以狀態為一等公民」的工具，適合「下游關心的是當前是什麼，不是過去發生了什麼」場景。<strong>POS 多視角同步的本質是後者</strong>——副螢幕關心的是「現在購物車裡有什麼」，不是「過去 5 分鐘掃進了哪些商品的時序」。</p>
<p>把這個認知一般化：當業務語義是「多個視角共享當前狀態」時，工具應該是 reactive value（Rx / ValueNotifier / BehaviorSubject）；當業務語義是「事件流的時序」時，工具才是 stream。本案的根因是「業務語義（共享狀態）」跟「工具語義（事件流）」錯配；single-subscription 是錯配關係下第一個被觸發的契約限制、但即使換成 broadcast、仍會在「進場拿不到歷史事件」這個層次暴露語義錯配。</p>
<h3 id="是否該全面改寫成-rx">是否該全面改寫成 Rx</h3>
<p>事故當下不該。理由：</p>
<ol>
<li><strong>scope 控制</strong>：事故修復原則是 minimal change，<code>StreamController()</code> → <code>.broadcast()</code> 一字之差就解決</li>
<li><strong>回歸風險</strong>：把 service 介面從 <code>Stream&lt;T&gt;</code> 改成 <code>Rx&lt;T&gt;</code>，所有訂閱方（副螢幕、UI、未來的 KDS / 雲端同步）都要改 listen 方式</li>
<li><strong>耦合代價</strong>：如果 service 介面原本是 framework-neutral 的（純 dart:async），改 Rx 等於把 GetX 綁進公開 API，未來要換框架成本變高</li>
<li><strong>測試成本</strong>：改 Rx 之後，所有針對該 service 的測試都要改 mock 方式</li>
</ol>
<p>該重構的時機：</p>
<ul>
<li>整個系統已經 implicit 綁 GetX，介面 framework-neutral 的成本沒實質效益</li>
<li>新增 service 時直接用 Rx，舊的 stream-based service 等下次大改一起換</li>
<li>發現自己重複寫「<code>_latest</code> + <code>StreamController.broadcast</code> + getter + emit + close」的樣板太多次，Rx 是現成解</li>
<li>整理技術債的專屬 sprint，可以系統性換掉</li>
</ul>
<p>事故修復應該專注 minimal fix；架構改造是另一張單。</p>
<hr>
<h2 id="除錯思維">除錯思維</h2>
<p><code>Bad state: Stream has already been listened to.</code> 的根因落在 stream 定義端的型別契約、不在訂閱端。檢查順序：</p>
<ol>
<li><strong>這條 stream 是 single-subscription 還是 broadcast？</strong>
<ul>
<li>從定義端確認（<code>StreamController()</code> vs <code>StreamController.broadcast()</code>）、訂閱端只承載限制、看不出契約類型</li>
</ul>
</li>
<li><strong>若是 single、選 single 的理由有書面記錄嗎？</strong>
<ul>
<li>介面註解 / 設計文件有記錄 → 看理由是否仍成立</li>
<li>沒有記錄 → 屬於「用了預設建構子、沒做選擇」、回到當下三軸判斷</li>
</ul>
</li>
<li><strong>多訂閱在語義上合理嗎？</strong>
<ul>
<li>合理 → 改 broadcast、屬於修正型別契約跟業務語義對齊</li>
<li>不合理 → 第二個訂閱者的需求要重新設計（透過第一個 listener 轉送、或拉新 stream）</li>
</ul>
</li>
</ol>
<p>把「這條 stream 該不該支援多訂閱」做為設計階段的明確決策、判斷成本（跑三軸）落在當下、且不依賴未來需求是否實際出現。</p>
<hr>
<h2 id="延伸pos-場景的多訂閱模式">延伸：POS 場景的多訂閱模式</h2>
<p>POS 系統本質上就是「中央交易狀態 + 多視圖/多裝置鏡像」，是 broadcast stream 最自然的應用領域。常見訂閱者：</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>商品名、單價、總價、找零</td>
          <td>App lifetime</td>
      </tr>
      <tr>
          <td>廚房顯示（KDS）</td>
          <td>已下單品項、出餐順序</td>
          <td>App lifetime</td>
      </tr>
      <tr>
          <td>列印服務</td>
          <td>結帳明細、會員資訊</td>
          <td>觸發式（結帳當下）</td>
      </tr>
      <tr>
          <td>雲端同步</td>
          <td>所有交易事件</td>
          <td>App lifetime</td>
      </tr>
      <tr>
          <td>Analytics</td>
          <td>使用者行為、轉換率</td>
          <td>App lifetime</td>
      </tr>
  </tbody>
</table>
<p>設計階段先假設「會有多個訂閱者」、「未來訂閱者數量會增加」、「每個訂閱者只關心事件的一部分屬性」——這正是 broadcast 的典型語義；之後新功能要訂閱、設計上會自然容納。</p>
<p>對應的設計建議：</p>
<ol>
<li><strong>Service 對外的事件 stream 預設 broadcast</strong>——single-subscription 視為例外、要在介面註解書面說明</li>
<li><strong>事件 payload 設計成 record 或 sealed class</strong>——包含「是什麼變動 + 變動的詳細資料」、讓不同訂閱者各取所需</li>
<li><strong>不要假設訂閱者之間的觸發順序</strong>——broadcast 的 listener 之間沒有保證順序、訂閱者要假設彼此獨立</li>
<li><strong>進場時若需要初始狀態、提供 <code>currentValue</code> getter</strong>——broadcast 不保留歷史、用 explicit getter 補這個缺口</li>
</ol>
<hr>
<h2 id="參考資料">參考資料</h2>
<ul>
<li><a href="https://api.dart.dev/stable/dart-async/StreamController-class.html">Dart <code>StreamController</code> API doc</a></li>
<li><a href="https://api.dart.dev/stable/dart-async/StreamController/StreamController.broadcast.html">Dart <code>StreamController.broadcast</code> constructor</a></li>
<li><a href="https://api.dart.dev/stable/dart-async/Stream/asBroadcastStream.html">Dart <code>Stream.asBroadcastStream</code> method</a></li>
<li><a href="https://dart.dev/tutorials/language/streams">Dart language tour - Asynchronous programming: streams</a></li>
<li><a href="https://pub.dev/documentation/rxdart/latest/rx/BehaviorSubject-class.html">RxDart <code>BehaviorSubject</code> doc</a></li>
</ul>
]]></content:encoded></item><item><title>設計瑕疵還是避免過度設計？YAGNI 的真實適用條件</title><link>https://tarrragon.github.io/blog/record/%E8%A8%AD%E8%A8%88%E7%91%95%E7%96%B5%E9%82%84%E6%98%AF%E9%81%BF%E5%85%8D%E9%81%8E%E5%BA%A6%E8%A8%AD%E8%A8%88yagni-%E7%9A%84%E7%9C%9F%E5%AF%A6%E9%81%A9%E7%94%A8%E6%A2%9D%E4%BB%B6/</link><pubDate>Tue, 05 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/record/%E8%A8%AD%E8%A8%88%E7%91%95%E7%96%B5%E9%82%84%E6%98%AF%E9%81%BF%E5%85%8D%E9%81%8E%E5%BA%A6%E8%A8%AD%E8%A8%88yagni-%E7%9A%84%E7%9C%9F%E5%AF%A6%E9%81%A9%E7%94%A8%E6%A2%9D%E4%BB%B6/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>核心命題&lt;/strong>：YAGNI 不是「永遠選最受限選項」的原則，是「不為未來投入額外成本」的原則。
&lt;strong>判斷工具&lt;/strong>：成本對稱性、可逆性、領域先驗——三軸框架。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="起點一個常見的工程爭論">起點：一個常見的工程爭論&lt;/h2>
&lt;p>「最早的設計者沒考慮到多個監聽需求，這算設計瑕疵，還是避免過度設計？」&lt;/p>
&lt;p>這類問題在 code review、事故檢討、技術選型討論裡反覆出現。指控太重會打擊個別工程師的判斷力信心，放任又會讓同類事故反覆發生。&lt;/p>
&lt;p>要釐清這個爭論，得先回到 YAGNI 原則的真實定義——很多被當成 YAGNI 的例子根本不在它的射程內。&lt;/p>
&lt;hr>
&lt;h2 id="yagni-的真實範圍">YAGNI 的真實範圍&lt;/h2>
&lt;p>YAGNI（You Aren&amp;rsquo;t Gonna Need It）的原意是：&lt;strong>不要投入額外成本去蓋你尚未需要的東西&lt;/strong>。它防的是這類情境：&lt;/p>
&lt;ul>
&lt;li>「我先寫個 plugin 系統，未來可以擴充」（成本：協議設計、抽象層、擴充點測試）&lt;/li>
&lt;li>「我先做多語系，未來會國際化」（成本：i18n 框架、所有字串外移）&lt;/li>
&lt;li>「我先支援多資料庫」（成本：repository 抽象、SQL 方言處理）&lt;/li>
&lt;li>「我先建多租戶切割」（成本：資料 schema 加 tenant 欄位、所有 query 加過濾）&lt;/li>
&lt;/ul>
&lt;p>這些選擇的共通特徵是：&lt;strong>為了未來付出當下的具體成本&lt;/strong>——抽象層、額外測試、複雜配置、學習負擔。YAGNI 說：別付，等真正需要再付，因為很可能你永遠不需要。&lt;/p>
&lt;p>但很多被指控為「過度設計」的選擇其實&lt;strong>沒有 upfront cost 差異&lt;/strong>。例如：&lt;/p>
&lt;ul>
&lt;li>Stream 工具用單訂閱版本還是廣播版本：建構子多打 11 個字元&lt;/li>
&lt;li>&lt;code>var&lt;/code> 還是 &lt;code>final&lt;/code>：3 個字元&lt;/li>
&lt;li>ID 用 &lt;code>int&lt;/code> 還是 &lt;code>String&lt;/code>（UUID）：抽象層成本一樣&lt;/li>
&lt;li>API 設計成同步還是 async：簽章只差 &lt;code>Future&amp;lt;&amp;gt;&lt;/code> 包裝&lt;/li>
&lt;li>Class 預設可繼承還是 sealed：一個 modifier&lt;/li>
&lt;li>Database column 預設 nullable 還是 NOT NULL：一個 keyword&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>這些不在 YAGNI 的射程內&lt;/strong>。把它們當成 YAGNI 來防禦會選錯方向。&lt;/p>
&lt;hr>
&lt;h2 id="真正的判斷軸成本不對稱性">真正的判斷軸：成本不對稱性&lt;/h2>
&lt;p>判斷「該不該選更通用的選項」，跑三個軸。&lt;/p>
&lt;h3 id="軸-1成本對稱性">軸 1：成本對稱性&lt;/h3>
&lt;p>「選擇 A 比選擇 B 多付出多少當下成本？」&lt;/p>
&lt;ul>
&lt;li>&lt;strong>對稱&lt;/strong>（成本相當、差幾個字元、無新概念）：選&lt;strong>未來更可能需要&lt;/strong>的那個——這不是過度設計，是合理 default&lt;/li>
&lt;li>&lt;strong>不對稱&lt;/strong>（一邊明顯較貴、要多寫框架、多加抽象、多學概念）：YAGNI 適用，選便宜的，需要時再升級&lt;/li>
&lt;/ul>
&lt;h3 id="軸-2改變決定的成本">軸 2：改變決定的成本&lt;/h3>
&lt;p>「如果選錯了，未來修正要付出什麼？」&lt;/p>
&lt;ul>
&lt;li>&lt;strong>可逆&lt;/strong>（一行改完、無 API 契約變動、無資料遷移）：YAGNI 適用，先選簡單的&lt;/li>
&lt;li>&lt;strong>不可逆 / 修正昂貴&lt;/strong>（牽動 API 契約、資料庫 schema、客戶端版本相容性、第三方 integration）：偏向預先選擇通用的&lt;/li>
&lt;/ul>
&lt;h3 id="軸-3領域先驗domain-prior">軸 3：領域先驗（domain prior）&lt;/h3>
&lt;p>「這個領域裡、這個模式發生的機率有多高？」——「先驗」（prior）借自 Bayesian 統計、用來指「在沒看到具體證據前、我們對某事發生機率的合理預期」。在工程領域、這個機率來自累積的領域知識（多視角同步、retry、併發、認證⋯⋯這些 pattern 的歷史發生率）。&lt;/p>
&lt;ul>
&lt;li>&lt;strong>強先驗&lt;/strong>（教科書級別）：多視角狀態同步是廣播、有用戶系統一定有 logged-in / anonymous 兩種、長時間運行服務一定會有 retry 需求、有交易就會有併發&lt;/li>
&lt;li>&lt;strong>弱先驗&lt;/strong>（純臆測）：「未來可能會有 plugin 機制吧」「未來可能要換資料庫吧」「未來可能要支援其他平台吧」&lt;/li>
&lt;/ul>
&lt;h3 id="三軸的綜合判斷">三軸的綜合判斷&lt;/h3>
&lt;p>任一軸顯著偏向「該選通用」，YAGNI 就不適用。&lt;/p>
&lt;p>&lt;strong>選通用不是過度設計，是對工具屬性與領域常識的尊重&lt;/strong>。&lt;/p>
&lt;hr>
&lt;h2 id="案例對照兩個極端">案例對照：兩個極端&lt;/h2>
&lt;h3 id="案例-astream-預設選錯">案例 A：Stream 預設選錯&lt;/h3>
&lt;p>某個事件廣播 service 用了 &lt;code>StreamController()&lt;/code> 預設建構子（單訂閱）。當下只有一個訂閱者，運作正常數個月。後來加第二個訂閱者，瞬間 throw &lt;code>Bad state: Stream has already been listened to&lt;/code>。&lt;/p>
&lt;p>跑三軸：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>成本對稱性&lt;/strong>：對稱（差 11 個字元、零認知負擔）&lt;/li>
&lt;li>&lt;strong>可逆性&lt;/strong>：中等偏高（事故必須在 production 暴露才會發現，要審所有訂閱方、改實作 + mock）&lt;/li>
&lt;li>&lt;strong>領域先驗&lt;/strong>：強（pub-sub / 事件廣播場景天生多訂閱）&lt;/li>
&lt;/ul>
&lt;p>三軸都指向廣播版本。&lt;strong>這是設計瑕疵&lt;/strong>——不是因為「沒考慮多訂閱」，而是&lt;strong>在三軸都不利於單訂閱的情況下選了單訂閱&lt;/strong>。&lt;/p>
&lt;blockquote>
&lt;p>完整事故重現、單訂閱 vs broadcast 的程式碼對比、修復決策過程：&lt;a href="https://tarrragon.github.io/blog/work-log/dart-streamcontrollersingle-subscription-vs-broadcast-%E7%9A%84%E8%A8%AD%E8%A8%88%E9%81%B8%E5%9E%8B%E5%95%8F%E9%A1%8C/" data-link-title="Dart StreamController：single-subscription vs broadcast 的設計選型問題" data-link-desc="Dart `Bad state: Stream has already been listened to.` 的根因：預設單訂閱在第二個訂閱者出現時才爆。StreamController vs .broadcast() 修復決策、與 Rx / .obs 的比較。">Dart StreamController：single-subscription vs broadcast 的事故實錄&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;h3 id="案例-b建立-plugin-系統">案例 B：建立 plugin 系統&lt;/h3>
&lt;p>「我先建個 plugin 系統，未來功能模組可以動態擴充」——典型的 over-engineering 焦慮表現。&lt;/p>
&lt;p>跑三軸：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>成本對稱性&lt;/strong>：嚴重不對稱（plugin 系統需要設計協議、加載機制、版本管理、隔離測試）&lt;/li>
&lt;li>&lt;strong>可逆性&lt;/strong>：可逆（之後要做的話成本跟現在做差不多）&lt;/li>
&lt;li>&lt;strong>領域先驗&lt;/strong>：弱（多數應用程式不會有第三方擴充需求）&lt;/li>
&lt;/ul>
&lt;p>三軸都指向「先別做」。&lt;strong>這是 YAGNI 的標準適用情境&lt;/strong>。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>核心命題</strong>：YAGNI 不是「永遠選最受限選項」的原則，是「不為未來投入額外成本」的原則。
<strong>判斷工具</strong>：成本對稱性、可逆性、領域先驗——三軸框架。</p></blockquote>
<hr>
<h2 id="起點一個常見的工程爭論">起點：一個常見的工程爭論</h2>
<p>「最早的設計者沒考慮到多個監聽需求，這算設計瑕疵，還是避免過度設計？」</p>
<p>這類問題在 code review、事故檢討、技術選型討論裡反覆出現。指控太重會打擊個別工程師的判斷力信心，放任又會讓同類事故反覆發生。</p>
<p>要釐清這個爭論，得先回到 YAGNI 原則的真實定義——很多被當成 YAGNI 的例子根本不在它的射程內。</p>
<hr>
<h2 id="yagni-的真實範圍">YAGNI 的真實範圍</h2>
<p>YAGNI（You Aren&rsquo;t Gonna Need It）的原意是：<strong>不要投入額外成本去蓋你尚未需要的東西</strong>。它防的是這類情境：</p>
<ul>
<li>「我先寫個 plugin 系統，未來可以擴充」（成本：協議設計、抽象層、擴充點測試）</li>
<li>「我先做多語系，未來會國際化」（成本：i18n 框架、所有字串外移）</li>
<li>「我先支援多資料庫」（成本：repository 抽象、SQL 方言處理）</li>
<li>「我先建多租戶切割」（成本：資料 schema 加 tenant 欄位、所有 query 加過濾）</li>
</ul>
<p>這些選擇的共通特徵是：<strong>為了未來付出當下的具體成本</strong>——抽象層、額外測試、複雜配置、學習負擔。YAGNI 說：別付，等真正需要再付，因為很可能你永遠不需要。</p>
<p>但很多被指控為「過度設計」的選擇其實<strong>沒有 upfront cost 差異</strong>。例如：</p>
<ul>
<li>Stream 工具用單訂閱版本還是廣播版本：建構子多打 11 個字元</li>
<li><code>var</code> 還是 <code>final</code>：3 個字元</li>
<li>ID 用 <code>int</code> 還是 <code>String</code>（UUID）：抽象層成本一樣</li>
<li>API 設計成同步還是 async：簽章只差 <code>Future&lt;&gt;</code> 包裝</li>
<li>Class 預設可繼承還是 sealed：一個 modifier</li>
<li>Database column 預設 nullable 還是 NOT NULL：一個 keyword</li>
</ul>
<p><strong>這些不在 YAGNI 的射程內</strong>。把它們當成 YAGNI 來防禦會選錯方向。</p>
<hr>
<h2 id="真正的判斷軸成本不對稱性">真正的判斷軸：成本不對稱性</h2>
<p>判斷「該不該選更通用的選項」，跑三個軸。</p>
<h3 id="軸-1成本對稱性">軸 1：成本對稱性</h3>
<p>「選擇 A 比選擇 B 多付出多少當下成本？」</p>
<ul>
<li><strong>對稱</strong>（成本相當、差幾個字元、無新概念）：選<strong>未來更可能需要</strong>的那個——這不是過度設計，是合理 default</li>
<li><strong>不對稱</strong>（一邊明顯較貴、要多寫框架、多加抽象、多學概念）：YAGNI 適用，選便宜的，需要時再升級</li>
</ul>
<h3 id="軸-2改變決定的成本">軸 2：改變決定的成本</h3>
<p>「如果選錯了，未來修正要付出什麼？」</p>
<ul>
<li><strong>可逆</strong>（一行改完、無 API 契約變動、無資料遷移）：YAGNI 適用，先選簡單的</li>
<li><strong>不可逆 / 修正昂貴</strong>（牽動 API 契約、資料庫 schema、客戶端版本相容性、第三方 integration）：偏向預先選擇通用的</li>
</ul>
<h3 id="軸-3領域先驗domain-prior">軸 3：領域先驗（domain prior）</h3>
<p>「這個領域裡、這個模式發生的機率有多高？」——「先驗」（prior）借自 Bayesian 統計、用來指「在沒看到具體證據前、我們對某事發生機率的合理預期」。在工程領域、這個機率來自累積的領域知識（多視角同步、retry、併發、認證⋯⋯這些 pattern 的歷史發生率）。</p>
<ul>
<li><strong>強先驗</strong>（教科書級別）：多視角狀態同步是廣播、有用戶系統一定有 logged-in / anonymous 兩種、長時間運行服務一定會有 retry 需求、有交易就會有併發</li>
<li><strong>弱先驗</strong>（純臆測）：「未來可能會有 plugin 機制吧」「未來可能要換資料庫吧」「未來可能要支援其他平台吧」</li>
</ul>
<h3 id="三軸的綜合判斷">三軸的綜合判斷</h3>
<p>任一軸顯著偏向「該選通用」，YAGNI 就不適用。</p>
<p><strong>選通用不是過度設計，是對工具屬性與領域常識的尊重</strong>。</p>
<hr>
<h2 id="案例對照兩個極端">案例對照：兩個極端</h2>
<h3 id="案例-astream-預設選錯">案例 A：Stream 預設選錯</h3>
<p>某個事件廣播 service 用了 <code>StreamController()</code> 預設建構子（單訂閱）。當下只有一個訂閱者，運作正常數個月。後來加第二個訂閱者，瞬間 throw <code>Bad state: Stream has already been listened to</code>。</p>
<p>跑三軸：</p>
<ul>
<li><strong>成本對稱性</strong>：對稱（差 11 個字元、零認知負擔）</li>
<li><strong>可逆性</strong>：中等偏高（事故必須在 production 暴露才會發現，要審所有訂閱方、改實作 + mock）</li>
<li><strong>領域先驗</strong>：強（pub-sub / 事件廣播場景天生多訂閱）</li>
</ul>
<p>三軸都指向廣播版本。<strong>這是設計瑕疵</strong>——不是因為「沒考慮多訂閱」，而是<strong>在三軸都不利於單訂閱的情況下選了單訂閱</strong>。</p>
<blockquote>
<p>完整事故重現、單訂閱 vs broadcast 的程式碼對比、修復決策過程：<a href="/blog/work-log/dart-streamcontrollersingle-subscription-vs-broadcast-%E7%9A%84%E8%A8%AD%E8%A8%88%E9%81%B8%E5%9E%8B%E5%95%8F%E9%A1%8C/" data-link-title="Dart StreamController：single-subscription vs broadcast 的設計選型問題" data-link-desc="Dart `Bad state: Stream has already been listened to.` 的根因：預設單訂閱在第二個訂閱者出現時才爆。StreamController vs .broadcast() 修復決策、與 Rx / .obs 的比較。">Dart StreamController：single-subscription vs broadcast 的事故實錄</a>。</p></blockquote>
<h3 id="案例-b建立-plugin-系統">案例 B：建立 plugin 系統</h3>
<p>「我先建個 plugin 系統，未來功能模組可以動態擴充」——典型的 over-engineering 焦慮表現。</p>
<p>跑三軸：</p>
<ul>
<li><strong>成本對稱性</strong>：嚴重不對稱（plugin 系統需要設計協議、加載機制、版本管理、隔離測試）</li>
<li><strong>可逆性</strong>：可逆（之後要做的話成本跟現在做差不多）</li>
<li><strong>領域先驗</strong>：弱（多數應用程式不會有第三方擴充需求）</li>
</ul>
<p>三軸都指向「先別做」。<strong>這是 YAGNI 的標準適用情境</strong>。</p>
<h3 id="兩個案例的對比">兩個案例的對比</h3>
<table>
  <thead>
      <tr>
          <th>案例</th>
          <th>成本對稱性</th>
          <th>可逆性</th>
          <th>領域先驗</th>
          <th>該怎麼選</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Stream 預設</td>
          <td>對稱</td>
          <td>中等偏高</td>
          <td>強</td>
          <td>提前選通用</td>
      </tr>
      <tr>
          <td>Plugin 系統</td>
          <td>嚴重不對稱</td>
          <td>可逆</td>
          <td>弱</td>
          <td>YAGNI（先別做）</td>
      </tr>
  </tbody>
</table>
<p>兩者表面看都是「未來可能需要」，但三軸框架告訴你它們是<strong>完全不同類別</strong>的決定。一概而論「該/不該為未來準備」會兩邊都做錯。</p>
<hr>
<h2 id="為什麼這類瑕疵可被原諒">為什麼這類瑕疵「可被原諒」</h2>
<p>要老實講：<strong>指出某個選擇是設計瑕疵，不等於把責任全部推給個別工程師</strong>。</p>
<p>同類型瑕疵在實務上極常見，原因往往是系統性陷阱。</p>
<h3 id="1-語言--工具的預設值誤導">1. 語言 / 工具的預設值誤導</h3>
<p>很多語言把「需要明確選擇」的東西做成「最少打字的預設」：</p>
<ul>
<li>Dart 的 <code>StreamController()</code> 是 single-subscription</li>
<li>多數 SQL 的 column 預設 nullable</li>
<li>JavaScript 的 <code>==</code> 預設寬鬆比對</li>
<li>多數語言的 class 預設可繼承</li>
<li>HTTP 預設不加密</li>
<li>多數語言的 mutable 是 default</li>
</ul>
<p>這些預設都把多數人推向「比較容易出錯但不立即爆」的選項。<strong>API 設計把成本均衡的選擇做成「便宜便輸出受限」vs「貴一點輸出通用」是 framework 設計的責任轉嫁</strong>——把跨用例的判斷成本丟給用戶。</p>
<h3 id="2-領域知識需要被觸發過才會內化">2. 領域知識需要被觸發過才會內化</h3>
<p>很多事是遇過一次才會記得。「stream 預設是單訂閱」「nullable column 之後加 NOT NULL 要 backfill」「同步 API 之後改 async 是 breaking change」——這些不是經驗少的問題，是這些事實<strong>需要遇到才會內化進直覺判斷</strong>。</p>
<p>新人讀文件不會看到、code review 不會自動 catch、靜態分析不會主動警告——只能等某次遇到。</p>
<h3 id="3-失敗模式的低調性掩蓋風險">3. 失敗模式的低調性掩蓋風險</h3>
<p>很多設計瑕疵的失敗模式只在特定觸發條件下顯現：</p>
<ul>
<li>Stream 多訂閱限制只在第二次 <code>listen()</code> 時暴露</li>
<li>Mutable shared state 的 race condition 只在高併發下爆</li>
<li>Cache 失效邏輯只在 cache miss 模式變化時出問題</li>
<li>API 沒做 idempotent 只在重試時出現重複</li>
</ul>
<p>平常測試跑都過，給人「沒問題」的錯覺。<strong>沒有立即反饋的設計瑕疵 = 隱形的技術債</strong>。</p>
<h3 id="4-工具替代品掩蓋知識需求">4. 工具替代品掩蓋知識需求</h3>
<p>有些底層概念被高層框架封裝後，使用者根本不會碰到，所以「應該知道」的知識沒有被反覆強化。例如：</p>
<ul>
<li>Flutter 開發者多用 GetX / Riverpod / Bloc，極少碰 raw <code>StreamController</code></li>
<li>ORM 用戶多不寫 SQL，極少思考 query plan</li>
<li>雲端 SDK 用戶多不思考 retry / backoff，極少接觸底層 HTTP</li>
</ul>
<p>當有一天必須繞過框架直接用底層工具時，那個事故就會發生。</p>
<h3 id="結論">結論</h3>
<p>設計者只承擔最後一棒。要把同類瑕疵變少，<strong>修補方向在制度層面</strong>。</p>
<hr>
<h2 id="制度層面的補強">制度層面的補強</h2>
<p>要把「該選通用 default 但選了受限預設」的錯誤變少，個人記憶不可靠，要靠三層機制。</p>
<h3 id="機制-1介面層的-review-checklist">機制 1：介面層的 review checklist</h3>
<p>把容易出錯的 default 列入 PR review 檢查清單。例如：</p>
<ul>
<li>Service 對外暴露 <code>Stream&lt;T&gt;</code> 時、預設用 broadcast；用 single 要在註解寫明理由</li>
<li>資料庫 column 預設用 NOT NULL；nullable 要在註解寫明業務理由</li>
<li>公開 API 預設用 async；sync 要寫明理由</li>
<li>公開類別預設用 sealed / final；可繼承要寫明理由</li>
<li>HTTP 預設用 HTTPS；plain HTTP 要寫明理由</li>
</ul>
<p>把「需要記得」變成「review 強制檢查」。Checklist 不需要多，每個項目對應一個遇過的事故。</p>
<h3 id="機制-2架構規範把選擇從-default-取消">機制 2：架構規範把選擇從 default 取消</h3>
<p>更徹底的做法是用工具或規範<strong>禁掉問題 default</strong>：</p>
<ul>
<li>App 層 service 禁用 raw <code>StreamController</code>，強制用框架的廣播原語</li>
<li>用 lint rule 警告 <code>StreamController()</code> 的無參數呼叫</li>
<li>DB schema migration 工具預設產出 NOT NULL，nullable 要明確指定</li>
<li>API gateway 預設 deny，要顯式 allow 才放行</li>
</ul>
<p>這把選擇從「需要記得」變成「<strong>不需要選，做錯會被擋</strong>」。是最高效的補強。</p>
<h3 id="機制-3領域先驗清單">機制 3：領域先驗清單</h3>
<p>每個團隊應該維護一份「<strong>我們的領域裡這些事一定會發生</strong>」的清單。範例：</p>
<p>POS 系統：</p>
<ul>
<li>一台主機要服務多視角（多顯示螢幕、多通知模組）</li>
<li>會員身份會即時切換</li>
<li>有離線運作需求</li>
<li>多分店不同設定</li>
</ul>
<p>電商：</p>
<ul>
<li>商品價格會變動，歷史訂單要保留下單當時的價格</li>
<li>庫存會超賣，需要 reserve / commit 機制</li>
<li>退款是必然發生的，不是 edge case</li>
<li>客戶會有多個收件地址</li>
</ul>
<p>新功能設計時對照清單——強領域先驗就直接設計進去，<strong>不必每次重新評估</strong>。新進團隊成員也能快速吸收領域常識。</p>
<hr>
<h2 id="一個能套到無數情境的-heuristic">一個能套到無數情境的 heuristic</h2>
<p>把整個討論濃縮成一句話：</p>
<blockquote>
<p>當你的選擇「<strong>沒有 upfront cost 差異</strong>」時、就該選未來自由度高的那個。</p></blockquote>
<p>這個 heuristic 能套到無數技術決定：</p>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>「便宜但受限」</th>
          <th>「同樣便宜但通用」</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Stream 廣播</td>
          <td><code>StreamController()</code></td>
          <td><code>StreamController.broadcast()</code></td>
      </tr>
      <tr>
          <td>集合不可變性</td>
          <td><code>var list = [1, 2]</code></td>
          <td><code>final list = const [1, 2]</code></td>
      </tr>
      <tr>
          <td>API 回傳值</td>
          <td>同步 method</td>
          <td><code>Future&lt;&gt;</code> 包裝</td>
      </tr>
      <tr>
          <td>函式參數</td>
          <td>positional args</td>
          <td>named args</td>
      </tr>
      <tr>
          <td>Class 設計</td>
          <td>預設可繼承</td>
          <td><code>sealed</code> / <code>final class</code></td>
      </tr>
      <tr>
          <td>Resource handle</td>
          <td>manual cleanup</td>
          <td>RAII / <code>using</code> block</td>
      </tr>
      <tr>
          <td>Time</td>
          <td>local time</td>
          <td>UTC + timezone metadata</td>
      </tr>
      <tr>
          <td>ID 型別</td>
          <td><code>int</code> auto-increment</td>
          <td><code>String</code> (UUID)</td>
      </tr>
      <tr>
          <td>Money</td>
          <td><code>double</code></td>
          <td>專用 <code>Decimal</code> 型別</td>
      </tr>
      <tr>
          <td>字串編碼</td>
          <td>平台預設</td>
          <td>顯式 UTF-8</td>
      </tr>
  </tbody>
</table>
<p>這些都不是「過度設計」，是<strong>在零成本差異下選擇未來自由度更高的選項</strong>。YAGNI 不適用——YAGNI 的成本門檻在這裡根本不存在。</p>
<hr>
<h2 id="反向校正什麼時候該堅持-yagni">反向校正：什麼時候該堅持 YAGNI？</h2>
<p>為了避免本文被讀成「永遠選通用」，補一個反向案例。</p>
<p>YAGNI 在這些情境是對的：</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>為什麼 YAGNI 適用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>「先做個 admin 後台，未來方便」</td>
          <td>成本巨大，需求未確認，可逆</td>
      </tr>
      <tr>
          <td>「先支援自訂主題系統」</td>
          <td>成本中等，弱領域先驗，可逆</td>
      </tr>
      <tr>
          <td>「先做 API rate limiting」</td>
          <td>成本中等，現階段流量沒問題，可逆</td>
      </tr>
      <tr>
          <td>「先設計 multi-region 部署」</td>
          <td>成本巨大，多數產品永遠單 region</td>
      </tr>
      <tr>
          <td>「先抽 service 層」</td>
          <td>成本中等，function 直接呼叫已經夠用</td>
      </tr>
  </tbody>
</table>
<p>這些都是<strong>為了未來付出當下具體成本</strong>——抽象層、新概念、額外測試、配置複雜度。YAGNI 在這些情境會帶你做出對的選擇。</p>
<p>判斷的差異是：<strong>這個決定是「選哪個免費選項」，還是「要不要付一筆額外開發成本」？</strong> 前者三軸框架；後者 YAGNI。</p>
<hr>
<h2 id="總結">總結</h2>
<p>YAGNI vs 過度設計的爭論，常常因為兩邊在用不同定義而無法收斂。釐清如下：</p>
<blockquote>
<p><strong>YAGNI 適用於「為了未來而付出當下的具體成本」</strong>
<strong>不適用於「在成本相當的選項中選擇更通用的那個」</strong></p></blockquote>
<p>判斷時跑三軸：</p>
<ol>
<li><strong>成本對稱性</strong>：兩個選項的 upfront cost 是否相當？</li>
<li><strong>可逆性</strong>：選錯的話修正昂貴嗎？</li>
<li><strong>領域先驗</strong>：這個模式在領域裡發生機率多高？</li>
</ol>
<p>任一軸顯著偏向「該選通用」，YAGNI 就不適用，這不是過度設計。</p>
<p>回到開頭問題——「最早的設計者沒考慮到多個監聽需求、這算設計瑕疵還是避免過度設計？」答案<strong>取決於這三軸的具體狀況</strong>、不能一概而論。</p>
<p>但如果像 Stream 這個案例、三軸全部不利於受限預設、那就是設計瑕疵。<strong>只是這類瑕疵反映的是工具預設與領域知識內化的系統性問題、不是個別工程師的判斷力不足</strong>——修補方向是制度而非個人責備。</p>
<h3 id="一句話帶走">一句話帶走</h3>
<p>日常情境中、把三軸壓縮成一個問題就夠用：</p>
<blockquote>
<p>「<strong>我在多付什麼成本？</strong>」</p></blockquote>
<ul>
<li>多付<strong>抽象層、新概念、額外測試</strong> → YAGNI 適用、先別付</li>
<li>多付<strong>幾個字元、一個關鍵字</strong> → 不是 YAGNI、選通用的</li>
</ul>
<p>需要更精細的時候、再回頭跑完整三軸框架。</p>
]]></content:encoded></item><item><title>7.21 資安如何成為服務設計輸入</title><link>https://tarrragon.github.io/blog/backend/07-security-data-protection/security-as-service-design-input/</link><pubDate>Thu, 30 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/07-security-data-protection/security-as-service-design-input/</guid><description>&lt;p>本篇的責任是把資安需求前移到服務設計。讀者讀完後，能在設計評審階段就建立風險欄位、控制假設與交接路由。&lt;/p>
&lt;h2 id="核心論點">核心論點&lt;/h2>
&lt;p>資安作為設計輸入的核心概念是讓風險在架構形成前被看見。設計輸入固定後，後續控制、驗證與回應可以沿同一語意展開。&lt;/p>
&lt;h2 id="設計輸入欄位">設計輸入欄位&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>欄位&lt;/th>
 &lt;th>責任&lt;/th>
 &lt;th>產出&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Asset scope&lt;/td>
 &lt;td>定義保護資產與邊界&lt;/td>
 &lt;td>asset map&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Trust boundary&lt;/td>
 &lt;td>定義跨域交互與責任分界&lt;/td>
 &lt;td>boundary map&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Threat hypothesis&lt;/td>
 &lt;td>定義高風險行為假設&lt;/td>
 &lt;td>threat note&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Control intent&lt;/td>
 &lt;td>定義控制目標與能力&lt;/td>
 &lt;td>control intent sheet&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Evidence plan&lt;/td>
 &lt;td>定義驗證與回查資料&lt;/td>
 &lt;td>evidence plan&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Handoff route&lt;/td>
 &lt;td>定義交接模組與 owner&lt;/td>
 &lt;td>routing sheet&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="設計評審節點">設計評審節點&lt;/h2>
&lt;p>設計評審節點的責任是讓資安欄位進入標準流程。每次 design review 可固定檢查資產邊界、身份假設、資料流向、供應鏈路徑與回應路由。&lt;/p>
&lt;h2 id="與-api-與資料流整合">與 API 與資料流整合&lt;/h2>
&lt;p>與 API 與資料流整合的責任是讓資安需求變成介面契約。高風險 API 與資料流在設計階段就綁定身份約束、審計欄位與異常路由。&lt;/p>
&lt;h2 id="與控制面交接">與控制面交接&lt;/h2>
&lt;p>與控制面交接的責任是把設計輸入推進到藍隊章節。設計輸入可直接輸出到 7.B1 控制面地圖、7.B5 規則生命週期與 7.B6 triage loop。&lt;/p>
&lt;h2 id="判讀訊號與路由">判讀訊號與路由&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>判讀訊號&lt;/th>
 &lt;th>代表需求&lt;/th>
 &lt;th>下一步路由&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>設計文件缺少資產邊界&lt;/td>
 &lt;td>需要補 asset 與 trust 欄位&lt;/td>
 &lt;td>7.21 → 7.2 / 7.4&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>設計完成後才補資安條件&lt;/td>
 &lt;td>需要前移到 design review&lt;/td>
 &lt;td>7.21 → 7.8&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>API 契約缺少安全欄位&lt;/td>
 &lt;td>需要補 control intent&lt;/td>
 &lt;td>7.21 → 05&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>設計輸入尚未對應驗證&lt;/td>
 &lt;td>需要補 evidence plan&lt;/td>
 &lt;td>7.21 → 7.B3&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="必連章節">必連章節&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/security-routing-from-case-to-service/" data-link-title="7.8 模組路由：問題到服務實作" data-link-desc="整理問題節點如何路由到部署、可靠性與事故處理章節">7.8 模組路由：問題到服務實作&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/blue-team/defense-control-map/" data-link-title="7.B1 防守控制面地圖" data-link-desc="建立防守控制面如何對應身份、入口、資料、供應鏈、偵測與治理問題的大綱">7.B1 防守控制面地圖&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/blue-team/security-control-validation/" data-link-title="7.B3 資安控制驗證" data-link-desc="建立資安控制面如何用證據、演練與 release gate 驗證的大綱">7.B3 資安控制驗證&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/security-risk-in-release-gate/" data-link-title="7.22 資安風險如何進入 Release Gate" data-link-desc="把資安風險、例外與驗證證據納入 release gate，建立可稽核的放行判準">7.22 資安風險如何進入 Release Gate&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="完稿判準">完稿判準&lt;/h2>
&lt;p>完稿時要讓讀者能在設計評審中加入資安輸入。輸出至少包含資產邊界、威脅假設、控制目標、證據計畫與交接路由。&lt;/p></description><content:encoded><![CDATA[<p>本篇的責任是把資安需求前移到服務設計。讀者讀完後，能在設計評審階段就建立風險欄位、控制假設與交接路由。</p>
<h2 id="核心論點">核心論點</h2>
<p>資安作為設計輸入的核心概念是讓風險在架構形成前被看見。設計輸入固定後，後續控制、驗證與回應可以沿同一語意展開。</p>
<h2 id="設計輸入欄位">設計輸入欄位</h2>
<table>
  <thead>
      <tr>
          <th>欄位</th>
          <th>責任</th>
          <th>產出</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Asset scope</td>
          <td>定義保護資產與邊界</td>
          <td>asset map</td>
      </tr>
      <tr>
          <td>Trust boundary</td>
          <td>定義跨域交互與責任分界</td>
          <td>boundary map</td>
      </tr>
      <tr>
          <td>Threat hypothesis</td>
          <td>定義高風險行為假設</td>
          <td>threat note</td>
      </tr>
      <tr>
          <td>Control intent</td>
          <td>定義控制目標與能力</td>
          <td>control intent sheet</td>
      </tr>
      <tr>
          <td>Evidence plan</td>
          <td>定義驗證與回查資料</td>
          <td>evidence plan</td>
      </tr>
      <tr>
          <td>Handoff route</td>
          <td>定義交接模組與 owner</td>
          <td>routing sheet</td>
      </tr>
  </tbody>
</table>
<h2 id="設計評審節點">設計評審節點</h2>
<p>設計評審節點的責任是讓資安欄位進入標準流程。每次 design review 可固定檢查資產邊界、身份假設、資料流向、供應鏈路徑與回應路由。</p>
<h2 id="與-api-與資料流整合">與 API 與資料流整合</h2>
<p>與 API 與資料流整合的責任是讓資安需求變成介面契約。高風險 API 與資料流在設計階段就綁定身份約束、審計欄位與異常路由。</p>
<h2 id="與控制面交接">與控制面交接</h2>
<p>與控制面交接的責任是把設計輸入推進到藍隊章節。設計輸入可直接輸出到 7.B1 控制面地圖、7.B5 規則生命週期與 7.B6 triage loop。</p>
<h2 id="判讀訊號與路由">判讀訊號與路由</h2>
<table>
  <thead>
      <tr>
          <th>判讀訊號</th>
          <th>代表需求</th>
          <th>下一步路由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>設計文件缺少資產邊界</td>
          <td>需要補 asset 與 trust 欄位</td>
          <td>7.21 → 7.2 / 7.4</td>
      </tr>
      <tr>
          <td>設計完成後才補資安條件</td>
          <td>需要前移到 design review</td>
          <td>7.21 → 7.8</td>
      </tr>
      <tr>
          <td>API 契約缺少安全欄位</td>
          <td>需要補 control intent</td>
          <td>7.21 → 05</td>
      </tr>
      <tr>
          <td>設計輸入尚未對應驗證</td>
          <td>需要補 evidence plan</td>
          <td>7.21 → 7.B3</td>
      </tr>
  </tbody>
</table>
<h2 id="必連章節">必連章節</h2>
<ul>
<li><a href="/blog/backend/07-security-data-protection/security-routing-from-case-to-service/" data-link-title="7.8 模組路由：問題到服務實作" data-link-desc="整理問題節點如何路由到部署、可靠性與事故處理章節">7.8 模組路由：問題到服務實作</a></li>
<li><a href="/blog/backend/07-security-data-protection/blue-team/defense-control-map/" data-link-title="7.B1 防守控制面地圖" data-link-desc="建立防守控制面如何對應身份、入口、資料、供應鏈、偵測與治理問題的大綱">7.B1 防守控制面地圖</a></li>
<li><a href="/blog/backend/07-security-data-protection/blue-team/security-control-validation/" data-link-title="7.B3 資安控制驗證" data-link-desc="建立資安控制面如何用證據、演練與 release gate 驗證的大綱">7.B3 資安控制驗證</a></li>
<li><a href="/blog/backend/07-security-data-protection/security-risk-in-release-gate/" data-link-title="7.22 資安風險如何進入 Release Gate" data-link-desc="把資安風險、例外與驗證證據納入 release gate，建立可稽核的放行判準">7.22 資安風險如何進入 Release Gate</a></li>
</ul>
<h2 id="完稿判準">完稿判準</h2>
<p>完稿時要讓讀者能在設計評審中加入資安輸入。輸出至少包含資產邊界、威脅假設、控制目標、證據計畫與交接路由。</p>
]]></content:encoded></item><item><title>7.24 資安事故如何回寫產品與架構</title><link>https://tarrragon.github.io/blog/backend/07-security-data-protection/security-incident-write-back-to-product-and-architecture/</link><pubDate>Thu, 30 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/07-security-data-protection/security-incident-write-back-to-product-and-architecture/</guid><description>&lt;p>本篇的責任是建立事故回寫路由。讀者讀完後，能把 incident 結果回寫到產品、架構、控制模式與章節知識網。&lt;/p>
&lt;h2 id="核心論點">核心論點&lt;/h2>
&lt;p>事故回寫的核心概念是把一次事件轉成長期能力。回寫完成後，下一次同類事件會在更早階段被辨識與收斂。&lt;/p>
&lt;h2 id="回寫層級">回寫層級&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>層級&lt;/th>
 &lt;th>回寫目標&lt;/th>
 &lt;th>產出&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Rule layer&lt;/td>
 &lt;td>偵測規則與調校策略&lt;/td>
 &lt;td>rule update&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Control layer&lt;/td>
 &lt;td>控制面與驗證條件&lt;/td>
 &lt;td>control update&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Workflow layer&lt;/td>
 &lt;td>triage、升級、通訊流程&lt;/td>
 &lt;td>workflow update&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Product layer&lt;/td>
 &lt;td>需求優先序與設計輸入&lt;/td>
 &lt;td>product backlog&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Knowledge layer&lt;/td>
 &lt;td>章節、案例、卡片&lt;/td>
 &lt;td>documentation update&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="回寫欄位">回寫欄位&lt;/h2>
&lt;p>回寫欄位的責任是讓教訓可重用。每次回寫至少記錄事件訊號、決策原因、成本影響、改進方案、驗收條件與下一次檢查點。&lt;/p>
&lt;h2 id="與產品決策連結">與產品決策連結&lt;/h2>
&lt;p>與產品決策連結的責任是讓安全改進進入 roadmap。高影響教訓可轉成設計約束、放行條件與資源分配調整。&lt;/p>
&lt;h2 id="與架構決策連結">與架構決策連結&lt;/h2>
&lt;p>與架構決策連結的責任是讓技術改進可追溯。回寫到架構時需標示控制責任、邊界改動與相依影響。&lt;/p>
&lt;h2 id="與知識網連結">與知識網連結&lt;/h2>
&lt;p>與知識網連結的責任是讓教訓可查詢。回寫結果可同步更新 7.x 章節、藍隊素材庫與知識卡片連結。&lt;/p>
&lt;h2 id="素材回寫入口">素材回寫入口&lt;/h2>
&lt;p>素材回寫入口的責任是把 field case、scenario 與 control pattern 轉成文章更新路由。案例提供壓力，情境提供演練，控制模式提供可搬運欄位。&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;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/blue-team/materials/field-cases/" data-link-title="7.BM2 藍隊現場案例素材" data-link-desc="定義藍隊現場案例的收錄規則，支援後續防守推演與控制面補強">Field cases&lt;/a>&lt;/td>
 &lt;td>把真實事件壓力整理成 defender pressure&lt;/td>
 &lt;td>&lt;code>7.B12&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/blue-team/materials/scenarios/" data-link-title="7.BM3 藍隊推演情境素材" data-link-desc="定義藍隊推演情境模板，協助把來源與案例轉成 tabletop 與 Game Day">Scenarios&lt;/a>&lt;/td>
 &lt;td>把案例壓力轉成 tabletop 與 Game Day&lt;/td>
 &lt;td>&lt;code>7.B9&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/blue-team/materials/control-patterns/" data-link-title="7.BM4 藍隊控制模式素材" data-link-desc="定義藍隊控制模式分類，支援 release gate、偵測驗證與事故交接">Control patterns&lt;/a>&lt;/td>
 &lt;td>把重複做法抽成 owner、evidence、lifecycle 與 write-back 欄位&lt;/td>
 &lt;td>&lt;code>7.B1&lt;/code> + &lt;code>7.B3&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/blue-team/materials/control-patterns/exercise-write-back-pattern/" data-link-title="Exercise Write-back Pattern" data-link-desc="定義 tabletop 與 game day 如何把 finding 回寫成控制更新、runbook 更新與 [tripwire](/backend/knowledge-cards/tripwire/)">Exercise write-back pattern&lt;/a>&lt;/td>
 &lt;td>把演練 finding 轉成控制、runbook、owner 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/tripwire/" data-link-title="Tripwire" data-link-desc="說明風險決策在條件變化時如何自動回到評估流程">tripwire&lt;/a> 任務&lt;/td>
 &lt;td>&lt;code>7.24&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/blue-team/materials/control-patterns/credential-hygiene-pattern/" data-link-title="Credential Hygiene Pattern" data-link-desc="定義 credential、MFA、輪替、infostealer 監控與 network boundary 的共同基線">Credential hygiene pattern&lt;/a>&lt;/td>
 &lt;td>把 MFA、rotation、reset workflow 與 exposure monitoring 寫進產品基線&lt;/td>
 &lt;td>&lt;code>7.2&lt;/code> + &lt;code>7.B12&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/blue-team/materials/control-patterns/recovery-readiness-pattern/" data-link-title="Recovery Readiness Pattern" data-link-desc="定義長時間 outage 復原、備援存取與外部依賴溝通的共同欄位">Recovery readiness pattern&lt;/a>&lt;/td>
 &lt;td>把復原目標、備援存取、依賴地圖與通報節奏寫進架構決策&lt;/td>
 &lt;td>&lt;code>7.24&lt;/code> + &lt;code>08&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="判讀訊號與路由">判讀訊號與路由&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>判讀訊號&lt;/th>
 &lt;th>代表需求&lt;/th>
 &lt;th>下一步路由&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>事故後只有修補任務&lt;/td>
 &lt;td>需要補產品與架構回寫&lt;/td>
 &lt;td>7.24 → 7.21&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>回寫內容找不到驗收條件&lt;/td>
 &lt;td>需要補回寫欄位&lt;/td>
 &lt;td>7.24 → 7.B3&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>同類事件重複出現&lt;/td>
 &lt;td>需要補 workflow 與規則更新&lt;/td>
 &lt;td>7.24 → 7.B5 / 7.B6&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>教訓留在單次會議紀錄&lt;/td>
 &lt;td>需要補知識網連結&lt;/td>
 &lt;td>7.24 → 7.26&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="必連章節">必連章節&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/blue-team/detection-engineering-lifecycle/" data-link-title="7.B5 Detection Engineering Lifecycle" data-link-desc="把偵測規則視為可維護資產，建立從來源、測試、調校到退場的完整生命週期">7.B5 Detection Engineering Lifecycle&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/blue-team/incident-triage-loop/" data-link-title="7.B6 Incident Triage Loop" data-link-desc="把資安訊號轉成 triage、severity、owner、containment 與 evidence 的回應循環">7.B6 Incident Triage Loop&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/blue-team/blue-team-scenario-library/" data-link-title="7.B9 Blue Team Scenario Library" data-link-desc="把高風險服務情境轉成可重用推演素材，支援 tabletop 與 game day 設計">7.B9 Blue Team Scenario Library&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/blue-team/defender-pressure-from-real-incidents/" data-link-title="7.B12 Defender Pressure From Real Incidents" data-link-desc="從真實事故抽出防守壓力模型，補強藍隊判讀、演練與交接設計">7.B12 Defender Pressure From Real Incidents&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/security-as-service-design-input/" data-link-title="7.21 資安如何成為服務設計輸入" data-link-desc="把資安需求前移到服務設計階段，建立可交接的設計輸入欄位與判讀路由">7.21 資安如何成為服務設計輸入&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/security-material-library-for-engineering-simulation/" data-link-title="7.26 資安素材庫如何支援工程推演" data-link-desc="說明專業來源、案例、情境與控制模式如何組合成工程推演與章節回寫流程">7.26 資安素材庫如何支援工程推演&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="完稿判準">完稿判準&lt;/h2>
&lt;p>完稿時要讓讀者能把事故教訓寫成回寫任務。輸出至少包含回寫層級、回寫欄位、產品路由、架構路由與知識路由。&lt;/p></description><content:encoded><![CDATA[<p>本篇的責任是建立事故回寫路由。讀者讀完後，能把 incident 結果回寫到產品、架構、控制模式與章節知識網。</p>
<h2 id="核心論點">核心論點</h2>
<p>事故回寫的核心概念是把一次事件轉成長期能力。回寫完成後，下一次同類事件會在更早階段被辨識與收斂。</p>
<h2 id="回寫層級">回寫層級</h2>
<table>
  <thead>
      <tr>
          <th>層級</th>
          <th>回寫目標</th>
          <th>產出</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Rule layer</td>
          <td>偵測規則與調校策略</td>
          <td>rule update</td>
      </tr>
      <tr>
          <td>Control layer</td>
          <td>控制面與驗證條件</td>
          <td>control update</td>
      </tr>
      <tr>
          <td>Workflow layer</td>
          <td>triage、升級、通訊流程</td>
          <td>workflow update</td>
      </tr>
      <tr>
          <td>Product layer</td>
          <td>需求優先序與設計輸入</td>
          <td>product backlog</td>
      </tr>
      <tr>
          <td>Knowledge layer</td>
          <td>章節、案例、卡片</td>
          <td>documentation update</td>
      </tr>
  </tbody>
</table>
<h2 id="回寫欄位">回寫欄位</h2>
<p>回寫欄位的責任是讓教訓可重用。每次回寫至少記錄事件訊號、決策原因、成本影響、改進方案、驗收條件與下一次檢查點。</p>
<h2 id="與產品決策連結">與產品決策連結</h2>
<p>與產品決策連結的責任是讓安全改進進入 roadmap。高影響教訓可轉成設計約束、放行條件與資源分配調整。</p>
<h2 id="與架構決策連結">與架構決策連結</h2>
<p>與架構決策連結的責任是讓技術改進可追溯。回寫到架構時需標示控制責任、邊界改動與相依影響。</p>
<h2 id="與知識網連結">與知識網連結</h2>
<p>與知識網連結的責任是讓教訓可查詢。回寫結果可同步更新 7.x 章節、藍隊素材庫與知識卡片連結。</p>
<h2 id="素材回寫入口">素材回寫入口</h2>
<p>素材回寫入口的責任是把 field case、scenario 與 control pattern 轉成文章更新路由。案例提供壓力，情境提供演練，控制模式提供可搬運欄位。</p>
<table>
  <thead>
      <tr>
          <th>素材</th>
          <th>回寫責任</th>
          <th>文章路由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/07-security-data-protection/blue-team/materials/field-cases/" data-link-title="7.BM2 藍隊現場案例素材" data-link-desc="定義藍隊現場案例的收錄規則，支援後續防守推演與控制面補強">Field cases</a></td>
          <td>把真實事件壓力整理成 defender pressure</td>
          <td><code>7.B12</code></td>
      </tr>
      <tr>
          <td><a href="/blog/backend/07-security-data-protection/blue-team/materials/scenarios/" data-link-title="7.BM3 藍隊推演情境素材" data-link-desc="定義藍隊推演情境模板，協助把來源與案例轉成 tabletop 與 Game Day">Scenarios</a></td>
          <td>把案例壓力轉成 tabletop 與 Game Day</td>
          <td><code>7.B9</code></td>
      </tr>
      <tr>
          <td><a href="/blog/backend/07-security-data-protection/blue-team/materials/control-patterns/" data-link-title="7.BM4 藍隊控制模式素材" data-link-desc="定義藍隊控制模式分類，支援 release gate、偵測驗證與事故交接">Control patterns</a></td>
          <td>把重複做法抽成 owner、evidence、lifecycle 與 write-back 欄位</td>
          <td><code>7.B1</code> + <code>7.B3</code></td>
      </tr>
      <tr>
          <td><a href="/blog/backend/07-security-data-protection/blue-team/materials/control-patterns/exercise-write-back-pattern/" data-link-title="Exercise Write-back Pattern" data-link-desc="定義 tabletop 與 game day 如何把 finding 回寫成控制更新、runbook 更新與 [tripwire](/backend/knowledge-cards/tripwire/)">Exercise write-back pattern</a></td>
          <td>把演練 finding 轉成控制、runbook、owner 與 <a href="/blog/backend/knowledge-cards/tripwire/" data-link-title="Tripwire" data-link-desc="說明風險決策在條件變化時如何自動回到評估流程">tripwire</a> 任務</td>
          <td><code>7.24</code></td>
      </tr>
      <tr>
          <td><a href="/blog/backend/07-security-data-protection/blue-team/materials/control-patterns/credential-hygiene-pattern/" data-link-title="Credential Hygiene Pattern" data-link-desc="定義 credential、MFA、輪替、infostealer 監控與 network boundary 的共同基線">Credential hygiene pattern</a></td>
          <td>把 MFA、rotation、reset workflow 與 exposure monitoring 寫進產品基線</td>
          <td><code>7.2</code> + <code>7.B12</code></td>
      </tr>
      <tr>
          <td><a href="/blog/backend/07-security-data-protection/blue-team/materials/control-patterns/recovery-readiness-pattern/" data-link-title="Recovery Readiness Pattern" data-link-desc="定義長時間 outage 復原、備援存取與外部依賴溝通的共同欄位">Recovery readiness pattern</a></td>
          <td>把復原目標、備援存取、依賴地圖與通報節奏寫進架構決策</td>
          <td><code>7.24</code> + <code>08</code></td>
      </tr>
  </tbody>
</table>
<h2 id="判讀訊號與路由">判讀訊號與路由</h2>
<table>
  <thead>
      <tr>
          <th>判讀訊號</th>
          <th>代表需求</th>
          <th>下一步路由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>事故後只有修補任務</td>
          <td>需要補產品與架構回寫</td>
          <td>7.24 → 7.21</td>
      </tr>
      <tr>
          <td>回寫內容找不到驗收條件</td>
          <td>需要補回寫欄位</td>
          <td>7.24 → 7.B3</td>
      </tr>
      <tr>
          <td>同類事件重複出現</td>
          <td>需要補 workflow 與規則更新</td>
          <td>7.24 → 7.B5 / 7.B6</td>
      </tr>
      <tr>
          <td>教訓留在單次會議紀錄</td>
          <td>需要補知識網連結</td>
          <td>7.24 → 7.26</td>
      </tr>
  </tbody>
</table>
<h2 id="必連章節">必連章節</h2>
<ul>
<li><a href="/blog/backend/07-security-data-protection/blue-team/detection-engineering-lifecycle/" data-link-title="7.B5 Detection Engineering Lifecycle" data-link-desc="把偵測規則視為可維護資產，建立從來源、測試、調校到退場的完整生命週期">7.B5 Detection Engineering Lifecycle</a></li>
<li><a href="/blog/backend/07-security-data-protection/blue-team/incident-triage-loop/" data-link-title="7.B6 Incident Triage Loop" data-link-desc="把資安訊號轉成 triage、severity、owner、containment 與 evidence 的回應循環">7.B6 Incident Triage Loop</a></li>
<li><a href="/blog/backend/07-security-data-protection/blue-team/blue-team-scenario-library/" data-link-title="7.B9 Blue Team Scenario Library" data-link-desc="把高風險服務情境轉成可重用推演素材，支援 tabletop 與 game day 設計">7.B9 Blue Team Scenario Library</a></li>
<li><a href="/blog/backend/07-security-data-protection/blue-team/defender-pressure-from-real-incidents/" data-link-title="7.B12 Defender Pressure From Real Incidents" data-link-desc="從真實事故抽出防守壓力模型，補強藍隊判讀、演練與交接設計">7.B12 Defender Pressure From Real Incidents</a></li>
<li><a href="/blog/backend/07-security-data-protection/security-as-service-design-input/" data-link-title="7.21 資安如何成為服務設計輸入" data-link-desc="把資安需求前移到服務設計階段，建立可交接的設計輸入欄位與判讀路由">7.21 資安如何成為服務設計輸入</a></li>
<li><a href="/blog/backend/07-security-data-protection/security-material-library-for-engineering-simulation/" data-link-title="7.26 資安素材庫如何支援工程推演" data-link-desc="說明專業來源、案例、情境與控制模式如何組合成工程推演與章節回寫流程">7.26 資安素材庫如何支援工程推演</a></li>
</ul>
<h2 id="完稿判準">完稿判準</h2>
<p>完稿時要讓讀者能把事故教訓寫成回寫任務。輸出至少包含回寫層級、回寫欄位、產品路由、架構路由與知識路由。</p>
]]></content:encoded></item><item><title>Data Flow and Filter Composition — Filter × Source 層錯位與五策略</title><link>https://tarrragon.github.io/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/</guid><description>&lt;p>設計 filter / sort / count / transform 等 stream 操作時、確保操作位置跟資料源同層、避免層錯位產生 silent 缺口。原則跨 UI / 後端 / 演算法管線通用 — 不只是前端問題。&lt;/p>
&lt;p>適用：前端 paginated UI 加 filter、後端 API + middleware filter、演算法 pipeline 加 transform、map-reduce 加 post-filter、資料庫 materialized view 加 query。
不適用：純運算演算法（沒有 stream / 沒有 materialization 概念）、純 React state 管理（沒有外部 source）。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>自包含聲明&lt;/strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋層錯位識別、五策略選擇、跨領域範例、playwright 驗證方法。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="何時參閱本文件">何時參閱本文件&lt;/h2>
&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>forEach(el =&amp;gt; el.hidden = !matches(el))&lt;/code>&lt;/td>
 &lt;td>停 — 確認 source 是不是分批 / streaming&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Source 是 &lt;code>pagefind.search()&lt;/code> / &lt;code>paginatedFetch()&lt;/code> / &lt;code>for await&lt;/code>&lt;/td>
 &lt;td>filter 必須跟 source 同層、不能在 view 層後處理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>「filter 後 0 筆但 source 還有未載入」可能發生&lt;/td>
 &lt;td>必須補自動續抓 / 推進 query / 誠實 UX&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Backend middleware / response wrapper 加 filter&lt;/td>
 &lt;td>推進 ORM query / SQL &lt;code>WHERE&lt;/code>、不在 response 後&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>演算法 pipeline 末端 filter&lt;/td>
 &lt;td>推進 pipeline stage 內、stream-aware&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Map-reduce 完成後加 post-filter&lt;/td>
 &lt;td>推進 map / reduce 階段&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>「畫面 / 結果對了但邊界 case 怪」&lt;/td>
 &lt;td>識別這是層錯位、不是 bug 修補能解&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="為什麼-filter--source-是個結構性議題">為什麼 filter × source 是個結構性議題&lt;/h2>
&lt;p>Filter 操作的定義是「從 stream 中過濾出符合條件的元素」 — &lt;strong>stream&lt;/strong> 是隱含的對象。當 stream 被分層 materialize 時、filter 套在哪一層、決定它能「看見」的元素範圍：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>層&lt;/th>
 &lt;th>能看到的範圍&lt;/th>
 &lt;th>filter 結果的語意&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Source 層&lt;/td>
 &lt;td>完整 stream&lt;/td>
 &lt;td>「stream 中所有符合的」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Materialization 中&lt;/td>
 &lt;td>已 materialize 的部分&lt;/td>
 &lt;td>「目前載入的符合的」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>下游（view / response）&lt;/td>
 &lt;td>Materialized 之後 + downstream filter 之前的子集&lt;/td>
 &lt;td>「下游可見的子集中符合的」&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>使用者 / 呼叫者意圖的「filter」通常是第一層（stream 全集）— 但寫程式當下手邊的對象通常是第三層（已 materialize 的 subset）。&lt;strong>寫起來最便利的位置 ≠ 對齊意圖的位置&lt;/strong>。&lt;/p>
&lt;p>這是 &lt;a href="https://tarrragon.github.io/blog/report/ease-of-writing-vs-intent-alignment/" data-link-title="寫作便利度跟意圖對齊反相關" data-link-desc="寫程式時最容易寫出的版本、通常是離意圖最遠的版本。便利度建立在「現有上下文 / 已 materialize 資料 / 已存在 API」上、而意圖對齊需要找到正確的層、處理上游、跨抽象層 — 兩者方向相反。識別這個反相關 = 識別自己掉進「容易寫的陷阱」。">#67 寫作便利度跟意圖對齊反相關&lt;/a> 在 stream 操作上的具體展現。&lt;/p></description><content:encoded><![CDATA[<p>設計 filter / sort / count / transform 等 stream 操作時、確保操作位置跟資料源同層、避免層錯位產生 silent 缺口。原則跨 UI / 後端 / 演算法管線通用 — 不只是前端問題。</p>
<p>適用：前端 paginated UI 加 filter、後端 API + middleware filter、演算法 pipeline 加 transform、map-reduce 加 post-filter、資料庫 materialized view 加 query。
不適用：純運算演算法（沒有 stream / 沒有 materialization 概念）、純 React state 管理（沒有外部 source）。</p>
<blockquote>
<p><strong>自包含聲明</strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋層錯位識別、五策略選擇、跨領域範例、playwright 驗證方法。</p></blockquote>
<hr>
<h2 id="何時參閱本文件">何時參閱本文件</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的第一件事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>即將寫 <code>forEach(el =&gt; el.hidden = !matches(el))</code></td>
          <td>停 — 確認 source 是不是分批 / streaming</td>
      </tr>
      <tr>
          <td>Source 是 <code>pagefind.search()</code> / <code>paginatedFetch()</code> / <code>for await</code></td>
          <td>filter 必須跟 source 同層、不能在 view 層後處理</td>
      </tr>
      <tr>
          <td>「filter 後 0 筆但 source 還有未載入」可能發生</td>
          <td>必須補自動續抓 / 推進 query / 誠實 UX</td>
      </tr>
      <tr>
          <td>Backend middleware / response wrapper 加 filter</td>
          <td>推進 ORM query / SQL <code>WHERE</code>、不在 response 後</td>
      </tr>
      <tr>
          <td>演算法 pipeline 末端 filter</td>
          <td>推進 pipeline stage 內、stream-aware</td>
      </tr>
      <tr>
          <td>Map-reduce 完成後加 post-filter</td>
          <td>推進 map / reduce 階段</td>
      </tr>
      <tr>
          <td>「畫面 / 結果對了但邊界 case 怪」</td>
          <td>識別這是層錯位、不是 bug 修補能解</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="為什麼-filter--source-是個結構性議題">為什麼 filter × source 是個結構性議題</h2>
<p>Filter 操作的定義是「從 stream 中過濾出符合條件的元素」 — <strong>stream</strong> 是隱含的對象。當 stream 被分層 materialize 時、filter 套在哪一層、決定它能「看見」的元素範圍：</p>
<table>
  <thead>
      <tr>
          <th>層</th>
          <th>能看到的範圍</th>
          <th>filter 結果的語意</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Source 層</td>
          <td>完整 stream</td>
          <td>「stream 中所有符合的」</td>
      </tr>
      <tr>
          <td>Materialization 中</td>
          <td>已 materialize 的部分</td>
          <td>「目前載入的符合的」</td>
      </tr>
      <tr>
          <td>下游（view / response）</td>
          <td>Materialized 之後 + downstream filter 之前的子集</td>
          <td>「下游可見的子集中符合的」</td>
      </tr>
  </tbody>
</table>
<p>使用者 / 呼叫者意圖的「filter」通常是第一層（stream 全集）— 但寫程式當下手邊的對象通常是第三層（已 materialize 的 subset）。<strong>寫起來最便利的位置 ≠ 對齊意圖的位置</strong>。</p>
<p>這是 <a href="/blog/report/ease-of-writing-vs-intent-alignment/" data-link-title="寫作便利度跟意圖對齊反相關" data-link-desc="寫程式時最容易寫出的版本、通常是離意圖最遠的版本。便利度建立在「現有上下文 / 已 materialize 資料 / 已存在 API」上、而意圖對齊需要找到正確的層、處理上游、跨抽象層 — 兩者方向相反。識別這個反相關 = 識別自己掉進「容易寫的陷阱」。">#67 寫作便利度跟意圖對齊反相關</a> 在 stream 操作上的具體展現。</p>
<hr>
<h2 id="跨領域同個結構五個情境">跨領域：同個結構、五個情境</h2>
<h3 id="情境-1前端-ui--pagefind-paginated-search">情境 1：前端 UI + Pagefind paginated search</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 反例：post-filter on view layer
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kr">const</span> <span class="nx">all</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">pagefind</span><span class="p">.</span><span class="nx">search</span><span class="p">(</span><span class="nx">query</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">all</span><span class="p">.</span><span class="nx">results</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="nx">start</span><span class="p">,</span> <span class="nx">start</span> <span class="o">+</span> <span class="mi">10</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">render</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.result&#39;</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">el</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nx">el</span><span class="p">.</span><span class="nx">hidden</span> <span class="o">=</span> <span class="o">!</span><span class="nx">matches</span><span class="p">(</span><span class="nx">el</span><span class="p">);</span>  <span class="c1">// view 層 filter
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="p">});</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1">// 第二批全 hidden、使用者看到「load more 沒效果」
</span></span></span></code></pre></div><h3 id="情境-2後端-api--orm-middleware">情境 2：後端 API + ORM middleware</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 反例：middleware 在 pagination 之後 filter</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nd">@app.route</span><span class="p">(</span><span class="s2">&#34;/posts&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">def</span> <span class="nf">posts</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="n">page</span> <span class="o">=</span> <span class="n">Post</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">paginate</span><span class="p">(</span><span class="n">page</span><span class="o">=</span><span class="mi">1</span><span class="p">,</span> <span class="n">per_page</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">return</span> <span class="p">[</span><span class="n">p</span> <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="n">page</span><span class="o">.</span><span class="n">items</span> <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">author</span> <span class="o">==</span> <span class="s2">&#34;author_x&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="c1"># 漏掉沒在這頁的符合項</span></span></span></code></pre></div><h3 id="情境-3async-iterator--taken">情境 3：Async iterator + take(N)</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 反例：先 take 後 filter</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">items</span> <span class="o">=</span> <span class="nb">list</span><span class="p">(</span><span class="n">itertools</span><span class="o">.</span><span class="n">islice</span><span class="p">(</span><span class="n">stream</span><span class="p">(),</span> <span class="mi">100</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">filtered</span> <span class="o">=</span> <span class="p">[</span><span class="n">x</span> <span class="k">for</span> <span class="n">x</span> <span class="ow">in</span> <span class="n">items</span> <span class="k">if</span> <span class="n">matches</span><span class="p">(</span><span class="n">x</span><span class="p">)]</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># stream 後面可能還有符合的、但被 take 100 切斷了</span></span></span></code></pre></div><h3 id="情境-4map-reduce--post-reduce-filter">情境 4：Map-reduce + post-reduce filter</h3>





<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">[shards] → [map output] → [reduce]
</span></span><span class="line"><span class="ln">2</span><span class="cl">                              ↓
</span></span><span class="line"><span class="ln">3</span><span class="cl">                         [filter]  ← 已是 reduce 後的結果</span></span></code></pre></div><p>Filter 應該在 map 階段（per-shard）或 reduce 內、不是 reduce 後。</p>
<h3 id="情境-5materialized-view--select">情境 5：Materialized view + SELECT</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">-- 反例：在 stale view 上 filter
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">posts_summary</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">author_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">42</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- view 可能是某個時點的 snapshot、漏掉之後寫入的 posts
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- 對例：filter 推進原表
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">posts</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">author_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">42</span><span class="p">;</span></span></span></code></pre></div><p><strong>五個情境共用結構</strong>：source 是分層 materialize 的、filter 套在下游 → silent 缺口。</p>
<hr>
<h2 id="五種解法策略">五種解法策略</h2>
<p>詳細展開見 <a href="/blog/report/filter-source-composition-strategies/" data-link-title="Filter × Source 的合成策略五選一" data-link-desc="Filter 跟 paginated / streaming source 合成的五種策略、各自機會成本不同：A 推進 query / B 自動續抓 / C 預先 index / D 誠實 UX / E 接受語意縮小。沒有絕對最佳、看 source capabilities、match 密度、UX 容忍度而定。">#59 Filter × Source 合成策略五選一</a>。本卡只列總覽：</p>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>一句話</th>
          <th>對 source 的需求</th>
          <th>工程量</th>
          <th>UX 影響</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>A</td>
          <td>把 filter 推進 source 的 query</td>
          <td>必須支援該 filter 條件</td>
          <td>中-高</td>
          <td>透明（無感）</td>
      </tr>
      <tr>
          <td>B</td>
          <td>自動續抓直到湊滿 N 個 match</td>
          <td>任何分批 source</td>
          <td>中</td>
          <td>透明（稍慢）</td>
      </tr>
      <tr>
          <td>C</td>
          <td>預先建獨立 index（每種 mode 一份）</td>
          <td>能控 source 的 build pipeline</td>
          <td>高</td>
          <td>透明（最快）</td>
      </tr>
      <tr>
          <td>D</td>
          <td>誠實 UX 顯示「已掃 N / 命中 K」</td>
          <td>任何 source</td>
          <td>低</td>
          <td>顯眼（多按鈕）</td>
      </tr>
      <tr>
          <td>E</td>
          <td>明示語意縮小（filter 範圍 = 已載入）</td>
          <td>任何 source</td>
          <td>最低</td>
          <td>隱性語意縮小</td>
      </tr>
  </tbody>
</table>
<p>選擇順序：<strong>A → C → B → D → E</strong>（不寫不告知的 silent 縮小、那是反模式）。</p>
<p>對應的 pattern 卡片：<a href="/blog/report/pattern-fetch-until-quota/" data-link-title="Pattern：自動續抓直到湊滿 quota" data-link-desc="Pattern 卡片：分批 source &#43; post-filter 時、自動續抓直到湊滿 N 個 match。含上限保護、進度顯示、可中斷三個必要元件。對應 #59 策略 B 的具體實作。">#60 自動續抓</a> / <a href="/blog/report/pattern-query-side-pushdown/" data-link-title="Pattern：把 filter 推進 query 引擎" data-link-desc="Pattern 卡片：把 client-side filter 推進 source 的 query 引擎、由 source 直接回符合的。對應 #59 策略 A 的具體實作。前提是 source capabilities 支援該 filter 條件、否則要評估重 index。">#61 推進 query</a> / <a href="/blog/report/pattern-honest-progress-ui/" data-link-title="Pattern：誠實進度 UX（已掃 N / 命中 K / 共 M）" data-link-desc="Pattern 卡片：當 filter 跟 source 必然有層錯位、用三數字（已掃 N / 命中 K / 共 M）讓使用者看見掃描範圍、避免誤以為「沒命中」。對應 #59 策略 D 的具體實作。">#62 誠實進度 UX</a> / <a href="/blog/report/pattern-multiple-indexes/" data-link-title="Pattern：預先建獨立 index（每種 mode 一份）" data-link-desc="Pattern 卡片：build time 為每種 filter mode 各建一份 source / index、runtime 切換 mode = 切 source。對應 #59 策略 C 的具體實作。前提是能控 source 的 build pipeline、且 mode 數量有限。">#65 多 index</a> / <a href="/blog/report/pattern-explicit-semantic-narrowing/" data-link-title="Pattern：明示語意縮小（不承諾全集）" data-link-desc="Pattern 卡片：當 filter 必然只能在 subset 上做、明確告訴使用者「這只在已載入範圍內找」、不假裝是全集 filter。對應 #59 策略 E 的具體實作。重點是「明示」、silent 縮小是反模式。">#66 明示語意縮小</a></p>
<hr>
<h2 id="三變數決定策略選擇">三變數決定策略選擇</h2>
<p>選 A / B / C / D / E 看三個變數：</p>
<h3 id="變數-1source-capabilities">變數 1：Source capabilities</h3>
<p>Source 支援哪些 server-side filter？</p>
<ul>
<li>支援該 filter 條件 → A 最優</li>
<li>不支援、能控 build → 評估 C</li>
<li>都不行 → B / D / E</li>
</ul>
<h3 id="變數-2match-密度">變數 2：Match 密度</h3>
<p>每抓一批、預期多少筆 match？</p>
<ul>
<li>高密度（每批 ≥ 1 個 match）→ B 自動續抓 OK</li>
<li>稀疏（要抓很多批才湊到一個）→ B 會拉爆、用 D / E</li>
<li>不可預期 → 加上限保護的 B + fallback 到 D</li>
</ul>
<h3 id="變數-3ux-容忍度">變數 3：UX 容忍度</h3>
<p>使用者能接受多顯眼的「掃描範圍」UX？</p>
<ul>
<li>完全不行（filter 是核心互動）→ A / C</li>
<li>可以顯示三數字 → D</li>
<li>一次性文字告知就行 → E</li>
</ul>
<hr>
<h2 id="playwright-驗證-filter--source-行為">Playwright 驗證 filter × source 行為</h2>
<p>寫完 filter 後、用 playwright 驗證是否有層錯位 silent 缺口。</p>
<h3 id="驗證-1load-more-後-filter-後是否該有結果">驗證 1：「Load more 後 filter 後是否該有結果」</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span><span class="s1">&#39;/search/?q=pre&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">click</span><span class="p">(</span><span class="s1">&#39;[data-scope=&#34;title&#34;]&#39;</span><span class="p">);</span>  <span class="c1">// 選 title-only
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="c1">// 載入第一批、量已掃 / 命中
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span>  <span class="kr">const</span> <span class="nx">before</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">loaded</span><span class="o">:</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">$$eval</span><span class="p">(</span><span class="s1">&#39;.result&#39;</span><span class="p">,</span> <span class="nx">els</span> <span class="p">=&gt;</span> <span class="nx">els</span><span class="p">.</span><span class="nx">length</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">visible</span><span class="o">:</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">$$eval</span><span class="p">(</span><span class="s1">&#39;.result:not([hidden])&#39;</span><span class="p">,</span> <span class="nx">els</span> <span class="p">=&gt;</span> <span class="nx">els</span><span class="p">.</span><span class="nx">length</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="p">};</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">click</span><span class="p">(</span><span class="s1">&#39;button.load-more&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">waitForTimeout</span><span class="p">(</span><span class="mi">500</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="kr">const</span> <span class="nx">after</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">loaded</span><span class="o">:</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">$$eval</span><span class="p">(</span><span class="s1">&#39;.result&#39;</span><span class="p">,</span> <span class="nx">els</span> <span class="p">=&gt;</span> <span class="nx">els</span><span class="p">.</span><span class="nx">length</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">visible</span><span class="o">:</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">$$eval</span><span class="p">(</span><span class="s1">&#39;.result:not([hidden])&#39;</span><span class="p">,</span> <span class="nx">els</span> <span class="p">=&gt;</span> <span class="nx">els</span><span class="p">.</span><span class="nx">length</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">  <span class="p">};</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl">  <span class="c1">// 層錯位徵兆：loaded 增加、visible 沒增加
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="c1"></span>  <span class="k">return</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="nx">deltaLoaded</span><span class="o">:</span> <span class="nx">after</span><span class="p">.</span><span class="nx">loaded</span> <span class="o">-</span> <span class="nx">before</span><span class="p">.</span><span class="nx">loaded</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="nx">deltaVisible</span><span class="o">:</span> <span class="nx">after</span><span class="p">.</span><span class="nx">visible</span> <span class="o">-</span> <span class="nx">before</span><span class="p">.</span><span class="nx">visible</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="nx">isSilentGap</span><span class="o">:</span> <span class="nx">after</span><span class="p">.</span><span class="nx">loaded</span> <span class="o">&gt;</span> <span class="nx">before</span><span class="p">.</span><span class="nx">loaded</span> <span class="o">&amp;&amp;</span> <span class="nx">after</span><span class="p">.</span><span class="nx">visible</span> <span class="o">===</span> <span class="nx">before</span><span class="p">.</span><span class="nx">visible</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">  <span class="p">};</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h3 id="驗證-2稀疏-case-是否拉爆">驗證 2：「稀疏 case 是否拉爆」</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 用一個極少 match 的 query 觸發 B 策略
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span><span class="s1">&#39;/search/?q=very_rare_keyword&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">click</span><span class="p">(</span><span class="s1">&#39;[data-scope=&#34;title&#34;]&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="kr">const</span> <span class="nx">startTime</span> <span class="o">=</span> <span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">waitForSelector</span><span class="p">(</span><span class="s1">&#39;.scan-status&#39;</span><span class="p">,</span> <span class="p">{</span> <span class="nx">timeout</span><span class="o">:</span> <span class="mi">10000</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">// 應該在 5s 內顯示「已掃完、共命中 K 個」、不該無限續抓
</span></span></span></code></pre></div><h3 id="驗證-3使用者能否區分四狀態">驗證 3：「使用者能否區分四狀態」</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">const</span> <span class="nx">statusVisible</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;.filter-status&#39;</span><span class="p">).</span><span class="nx">textContent</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">// 應該明示 loading / partial / end / empty 之一、不只是 spinner
</span></span></span></code></pre></div><p>寫成 playwright test 固化 — 未來架構改動時 CI 立刻發現 regression（<a href="/blog/report/layout-tests-with-playwright/" data-link-title="用前端測試把排版問題自動化" data-link-desc="排版問題傳統靠人眼檢查、容易遺漏邊界 case。當一個版型被 debug 兩次以上、就值得寫成 playwright 測試把規範固定下來。本文展開測試替代手動檢查的時機。">#15 layout-tests-with-playwright</a>）。</p>
<hr>
<h2 id="設計決策的-checklist">設計決策的 checklist</h2>
<p>寫 filter 之前、跑這份 checklist：</p>
<ul>
<li><input disabled="" type="checkbox"> Source 是不是分批 / streaming / cached / lazy？（<a href="/blog/report/data-source-shape-defines-feature-shape/" data-link-title="資料源的形狀決定 feature 的形狀" data-link-desc="Feature 設計要服從資料源的形狀（一次性 / 分批 / streaming / cached）— 不能憑 UI 想要的形狀去倒推資料層。憑 UI 倒推 = 在錯誤的層解錯誤的問題、產生 #55 層錯位類 bug。">#63 資料源形狀</a>）</li>
<li><input disabled="" type="checkbox"> Filter 的定義域是已載入子集還是 source 全集？（使用者意圖三問、見 <a href="/blog/report/filter-instruction-clarification/" data-link-title="篩選類指令的澄清時機" data-link-desc="「依 X 篩選」這類指令必須先澄清三件事才能寫：定義域（已載入 / 全部 / 子集）、資料分批方式、空狀態的語意。三問跑完才寫、否則必然寫成視覺層 post-filter、撞上 #55 層錯位。">#58</a>）</li>
<li><input disabled="" type="checkbox"> Source 是否支援 server-side filter？（決定能不能用 A）</li>
<li><input disabled="" type="checkbox"> Match 密度可預期嗎？（決定 B 是否可行）</li>
<li><input disabled="" type="checkbox"> 三狀態（loading / empty / end）UX 怎麼區分？（<a href="/blog/report/loading-empty-end-state-distinction/" data-link-title="Loading / Empty / End 三狀態的區分" data-link-desc="「還沒抓」「沒命中」「抓完無更多」三個狀態語意不同、UX 必須區分。共用同個畫面（「空白」或 spinner）會讓使用者無法判斷下一步。本文展開三狀態的內在屬性與 UX 規則。">#57</a>）</li>
<li><input disabled="" type="checkbox"> 對於「filter 後 0 筆」的情境、使用者能否區分「沒命中」vs「還沒抓到」？</li>
</ul>
<hr>
<h2 id="wrong-vs-right-對照">Wrong vs Right 對照</h2>
<h3 id="範例-1搜尋頁-title-only-filter">範例 1：搜尋頁 title-only filter</h3>
<p><strong>錯</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// pagefind 分批載入、view 層 post-filter
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="nx">input</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;input&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kr">const</span> <span class="nx">results</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">pagefind</span><span class="p">.</span><span class="nx">search</span><span class="p">(</span><span class="nx">input</span><span class="p">.</span><span class="nx">value</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="nx">results</span><span class="p">.</span><span class="nx">results</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">10</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">render</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="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.scope-title&#39;</span><span class="p">).</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;click&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.result&#39;</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">el</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="kr">const</span> <span class="nx">title</span> <span class="o">=</span> <span class="nx">el</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.title&#39;</span><span class="p">).</span><span class="nx">textContent</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">el</span><span class="p">.</span><span class="nx">hidden</span> <span class="o">=</span> <span class="o">!</span><span class="nx">title</span><span class="p">.</span><span class="nx">includes</span><span class="p">(</span><span class="nx">query</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>第二批 8 筆 title 不含 query → 全 hidden、使用者看到「load more 沒效果」。</p>
<p><strong>對</strong>（策略 C：多 index + 切換）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Build 階段</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">pagefind --site public --output-subdir _pagefind-all
</span></span><span class="line"><span class="ln">3</span><span class="cl">pagefind --site public --root-selector <span class="s2">&#34;article h1, article h2&#34;</span> --output-subdir _pagefind-title</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kr">const</span> <span class="nx">indexes</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nx">all</span><span class="o">:</span> <span class="kr">await</span> <span class="kr">import</span><span class="p">(</span><span class="s1">&#39;/_pagefind-all/pagefind.js&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nx">title</span><span class="o">:</span> <span class="kr">await</span> <span class="kr">import</span><span class="p">(</span><span class="s1">&#39;/_pagefind-title/pagefind.js&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">};</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="nx">input</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;input&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="kr">const</span> <span class="nx">pf</span> <span class="o">=</span> <span class="nx">currentScope</span> <span class="o">===</span> <span class="s1">&#39;title&#39;</span> <span class="o">?</span> <span class="nx">indexes</span><span class="p">.</span><span class="nx">title</span> <span class="o">:</span> <span class="nx">indexes</span><span class="p">.</span><span class="nx">all</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="kr">const</span> <span class="nx">results</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">pf</span><span class="p">.</span><span class="nx">search</span><span class="p">(</span><span class="nx">input</span><span class="p">.</span><span class="nx">value</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="c1">// results 已是「該 scope 的全集」、無層錯位
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span>  <span class="nx">results</span><span class="p">.</span><span class="nx">results</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">10</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">render</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p><strong>對</strong>（策略 D：誠實進度 UX、保留 view 層 filter）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;filter-status&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  已掃 <span class="p">&lt;</span><span class="nt">strong</span><span class="p">&gt;</span>24<span class="p">&lt;/</span><span class="nt">strong</span><span class="p">&gt;</span> / <span class="p">&lt;</span><span class="nt">strong</span><span class="p">&gt;</span>~150<span class="p">&lt;/</span><span class="nt">strong</span><span class="p">&gt;</span> 筆 — 命中 <span class="p">&lt;</span><span class="nt">strong</span><span class="p">&gt;</span>3<span class="p">&lt;/</span><span class="nt">strong</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="p">&lt;</span><span class="nt">button</span><span class="p">&gt;</span>再掃一批<span class="p">&lt;/</span><span class="nt">button</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// view 層 filter 保留、但 UI 顯示掃描範圍 + 提供續抓
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kd">function</span> <span class="nx">updateStatus</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kr">const</span> <span class="nx">all</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.result&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="kr">const</span> <span class="nx">visible</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.result:not([hidden])&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.scanned&#39;</span><span class="p">).</span><span class="nx">textContent</span> <span class="o">=</span> <span class="nx">all</span><span class="p">.</span><span class="nx">length</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.matched&#39;</span><span class="p">).</span><span class="nx">textContent</span> <span class="o">=</span> <span class="nx">visible</span><span class="p">.</span><span class="nx">length</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h3 id="範例-2後端-api-filter">範例 2：後端 API filter</h3>
<p><strong>錯</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="nd">@app.route</span><span class="p">(</span><span class="s2">&#34;/posts&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">def</span> <span class="nf">list_posts</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="n">page</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">args</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s1">&#39;page&#39;</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="n">posts</span> <span class="o">=</span> <span class="n">Post</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">paginate</span><span class="p">(</span><span class="n">page</span><span class="o">=</span><span class="n">page</span><span class="p">,</span> <span class="n">per_page</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">if</span> <span class="n">author</span> <span class="o">:=</span> <span class="n">request</span><span class="o">.</span><span class="n">args</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s1">&#39;author&#39;</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="k">return</span> <span class="p">[</span><span class="n">p</span> <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="n">posts</span><span class="o">.</span><span class="n">items</span> <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">author</span> <span class="o">==</span> <span class="n">author</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="k">return</span> <span class="n">posts</span><span class="o">.</span><span class="n">items</span></span></span></code></pre></div><p>中間的 list comprehension 在 pagination 之後 filter — 漏掉沒在這頁的符合項。</p>
<p><strong>對</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="nd">@app.route</span><span class="p">(</span><span class="s2">&#34;/posts&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">def</span> <span class="nf">list_posts</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="n">query</span> <span class="o">=</span> <span class="n">Post</span><span class="o">.</span><span class="n">objects</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="k">if</span> <span class="n">author</span> <span class="o">:=</span> <span class="n">request</span><span class="o">.</span><span class="n">args</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s1">&#39;author&#39;</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="o">.</span><span class="n">filter_by</span><span class="p">(</span><span class="n">author</span><span class="o">=</span><span class="n">author</span><span class="p">)</span>  <span class="c1"># 推進 ORM</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="n">page</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">args</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s1">&#39;page&#39;</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="k">return</span> <span class="n">query</span><span class="o">.</span><span class="n">paginate</span><span class="p">(</span><span class="n">page</span><span class="o">=</span><span class="n">page</span><span class="p">,</span> <span class="n">per_page</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span><span class="o">.</span><span class="n">items</span></span></span></code></pre></div><p>Filter 在 query 層、pagination 在 filter 之後、無層錯位。</p>
<hr>
<h2 id="自檢清單dogfooding">自檢清單（dogfooding）</h2>
<p>寫 filter / sort / count / transform 前：</p>
<ul>
<li><input disabled="" type="checkbox"> 我有沒有問「這個操作的對象是哪一層的 stream」？</li>
<li><input disabled="" type="checkbox"> Source 是分批的嗎？是 → filter 必須同層或推進上游</li>
<li><input disabled="" type="checkbox"> 寫了 view 層 filter？檢查：稀疏 case 會不會 silent 失敗？</li>
<li><input disabled="" type="checkbox"> 用了 B（自動續抓）？有沒有 MAX_BATCHES + MAX_TIME_MS 上限保護？</li>
<li><input disabled="" type="checkbox"> UX 能否區分「載入中 / 沒命中 / 還沒抓到 / 抓完了」四狀態？</li>
<li><input disabled="" type="checkbox"> Playwright 驗證有沒有覆蓋「稀疏 case」「load more 後 visible 是否變」？</li>
</ul>
<hr>
<h2 id="延伸閱讀">延伸閱讀</h2>
<p>問題分析：</p>
<ul>
<li><a href="/blog/report/view-layer-filter-vs-source-layer/" data-link-title="Filter 與 Source 的抽象層錯位" data-link-desc="Filter 必須跟它過濾的資料源在同一層運作。視覺層的 filter 套在資料層分批產出的 source 上、會在「一筆」的定義上產生語意縫 — 使用者要的「全部符合」變成「目前載入的符合」、然後 silent 失敗。本文展開層錯位的識別與糾正。">#55 Filter 與 Source 的抽象層錯位</a> — 根因</li>
<li><a href="/blog/report/visual-completion-vs-functional-completion/" data-link-title="視覺完成 ≠ 功能完成" data-link-desc="「畫面對了」是視覺驗收訊號、不是功能驗收訊號。視覺完成早於功能完成、容易掩蓋語意缺口。本文展開兩者的區分與識別「畫面對但功能漏」的訊號。">#56 視覺完成 ≠ 功能完成</a> — 「畫面對」是低資訊量訊號</li>
<li><a href="/blog/report/loading-empty-end-state-distinction/" data-link-title="Loading / Empty / End 三狀態的區分" data-link-desc="「還沒抓」「沒命中」「抓完無更多」三個狀態語意不同、UX 必須區分。共用同個畫面（「空白」或 spinner）會讓使用者無法判斷下一步。本文展開三狀態的內在屬性與 UX 規則。">#57 Loading / Empty / End 三狀態的區分</a> — UX 落地</li>
</ul>
<p>指令澄清（在 requirement-protocol skill）：</p>
<ul>
<li><a href="/blog/report/filter-instruction-clarification/" data-link-title="篩選類指令的澄清時機" data-link-desc="「依 X 篩選」這類指令必須先澄清三件事才能寫：定義域（已載入 / 全部 / 子集）、資料分批方式、空狀態的語意。三問跑完才寫、否則必然寫成視覺層 post-filter、撞上 #55 層錯位。">#58 篩選類指令的澄清時機</a> — 三問模板</li>
</ul>
<p>解法策略：</p>
<ul>
<li><a href="/blog/report/filter-source-composition-strategies/" data-link-title="Filter × Source 的合成策略五選一" data-link-desc="Filter 跟 paginated / streaming source 合成的五種策略、各自機會成本不同：A 推進 query / B 自動續抓 / C 預先 index / D 誠實 UX / E 接受語意縮小。沒有絕對最佳、看 source capabilities、match 密度、UX 容忍度而定。">#59 Filter × Source 合成策略五選一</a> — 總覽</li>
<li><a href="/blog/report/pattern-fetch-until-quota/" data-link-title="Pattern：自動續抓直到湊滿 quota" data-link-desc="Pattern 卡片：分批 source &#43; post-filter 時、自動續抓直到湊滿 N 個 match。含上限保護、進度顯示、可中斷三個必要元件。對應 #59 策略 B 的具體實作。">#60-#62, #65-#66 五張 Pattern 卡片</a> — 各策略具體實作</li>
</ul>
<p>抽象原則：</p>
<ul>
<li><a href="/blog/report/data-source-shape-defines-feature-shape/" data-link-title="資料源的形狀決定 feature 的形狀" data-link-desc="Feature 設計要服從資料源的形狀（一次性 / 分批 / streaming / cached）— 不能憑 UI 想要的形狀去倒推資料層。憑 UI 倒推 = 在錯誤的層解錯誤的問題、產生 #55 層錯位類 bug。">#63 資料源的形狀決定 feature 的形狀</a> — 形狀是硬約束</li>
<li><a href="/blog/report/compose-feature-at-source-layer/" data-link-title="Feature 操作要跟 Source 同層合成" data-link-desc="Filter / sort / count / transform / search 是 stream 操作、必須跟 stream 的 materialization 同層或更上游合成。在下游做 = 操作 subset 不是 stream。本原則跨前端 UI、後端 API、演算法管線通用、不只是視覺層 vs 資料層。">#64 Feature 操作要跟 Source 同層合成</a> — 跨領域通用原則</li>
<li><a href="/blog/report/ease-of-writing-vs-intent-alignment/" data-link-title="寫作便利度跟意圖對齊反相關" data-link-desc="寫程式時最容易寫出的版本、通常是離意圖最遠的版本。便利度建立在「現有上下文 / 已 materialize 資料 / 已存在 API」上、而意圖對齊需要找到正確的層、處理上游、跨抽象層 — 兩者方向相反。識別這個反相關 = 識別自己掉進「容易寫的陷阱」。">#67 寫作便利度跟意圖對齊反相關</a> — meta-principle</li>
<li><a href="/blog/report/verification-timeline-checkpoints/" data-link-title="驗收的時間軸：四個 checkpoint" data-link-desc="驗收不是單一動作、是分散在四個時點（寫之前 / 開發中 / ship 前 / ship 後）的累積判斷。每個 checkpoint 能 catch 不同類型的失敗、成本不同。早期 checkpoint 抓越多、晚期 checkpoint 越輕鬆。實務上常常 collapse 成「寫的時候 &#43; ship 後出問題才修」、跳過寫之前 / ship 前。">#68 驗收的時間軸：四個 checkpoint</a> — 驗收策略</li>
</ul>
<hr>
<p><strong>Last Updated</strong>: 2026-04-26
<strong>Version</strong>: 0.1.0</p>
]]></content:encoded></item><item><title>8.0 Go 的選型案例總覽</title><link>https://tarrragon.github.io/blog/go/08-case-studies/selection-patterns/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/08-case-studies/selection-patterns/</guid><description>&lt;p>Go 選型案例的核心用途是把語言特性對回服務壓力。公司案例提供的價值通常來自三件事：服務遇到什麼壓力、Go 解決哪一段工程問題、團隊因此得到什麼維護收益。&lt;/p>
&lt;h2 id="案例類型總覽">案例類型總覽&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>類型&lt;/th>
 &lt;th>觀察訊號&lt;/th>
 &lt;th>代表案例&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>大規模平台服務&lt;/td>
 &lt;td>服務多、部署頻繁、依賴邊界需要清楚&lt;/td>
 &lt;td>Google、Microsoft、CloudWeGo&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>高併發即時服務&lt;/td>
 &lt;td>長連線、低延遲、client 數量大&lt;/td>
 &lt;td>Twitch、Stream、Cloudflare&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>效能敏感遷移&lt;/td>
 &lt;td>既有系統已有瓶頸，局部元件需要更穩定的效能&lt;/td>
 &lt;td>Dropbox、PayPal&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>分散式基礎設施&lt;/td>
 &lt;td>一致性、複製、排程、網路協調是核心問題&lt;/td>
 &lt;td>Cockroach Labs、Kubernetes 生態&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="大規模平台服務先看團隊是否需要一致的服務形狀">大規模平台服務：先看團隊是否需要一致的服務形狀&lt;/h3>
&lt;p>大規模平台服務的核心訊號是「很多服務需要用相近方式開發、部署與維護」。例如一組雲端基礎設施服務需要共用 HTTP &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/health-check-liveness/" data-link-title="Liveness" data-link-desc="說明平台如何判斷 process 是否仍然存活，以及何時應重啟">health check&lt;/a>、structured &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a>、configuration、context cancellation 與單一 binary 部署流程。Go 的價值在於讓服務骨架簡單、依賴明確，讓不同團隊看到相似的程式入口與 package 結構。&lt;/p>
&lt;p>這類案例可以回到 &lt;a href="https://tarrragon.github.io/blog/go/00-philosophy/simplicity/" data-link-title="0.1 Go 的簡單哲學與認知負擔" data-link-desc="理解 Go 為什麼偏好顯式、直線流程與少量語法">Go 的簡單哲學與認知負擔&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">composition root 與依賴組裝&lt;/a> 對照。&lt;/p>
&lt;h3 id="高併發即時服務先看連線與事件是否長時間存在">高併發即時服務：先看連線與事件是否長時間存在&lt;/h3>
&lt;p>高併發即時服務的核心訊號是「server 需要同時管理大量仍然在線的工作」。聊天室、即時通知、直播狀態、代理服務與邊緣網路服務，都可能同時面對大量 connection、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a> 與 cleanup。Go 的 goroutine、channel、context 與標準網路庫讓這些生命週期可以直接寫在程式裡。&lt;/p>
&lt;p>這類案例可以回到 &lt;a href="https://tarrragon.github.io/blog/go/04-concurrency/goroutine/" data-link-title="4.1 goroutine：輕量並發工作" data-link-desc="用 goroutine 啟動並發工作，並設計清楚的退出條件">goroutine：背景工作與服務生命週期&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">channel：事件流與 backpressure &lt;/a>、&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/" data-link-title="模組二：WebSocket 服務架構" data-link-desc="WebSocket client lifecycle、heartbeat、訂閱路由與慢客戶端管理">WebSocket 服務架構&lt;/a> 對照。&lt;/p>
&lt;h3 id="效能敏感遷移先看瓶頸是否集中在清楚邊界">效能敏感遷移：先看瓶頸是否集中在清楚邊界&lt;/h3>
&lt;p>效能敏感遷移的核心訊號是「整個產品仍可沿用原本架構，但某段服務已經成為穩定性或成本瓶頸」。例如檔案同步、資料轉換、API gateway、build pipeline 或推送服務。這時 Go 常作為局部重寫選項，讓瓶頸元件取得更好的 CPU、memory、部署與並發表現。&lt;/p>
&lt;p>這類案例可以回到 &lt;a href="https://tarrragon.github.io/blog/go/00-philosophy/concurrency-language-position/" data-link-title="0.5 Go 和其他並發語言的差異" data-link-desc="比較 Go、Java、C#、Rust、Node.js、Python async、Erlang/Elixir 在並發服務中的工程定位">Go 和其他並發語言的差異&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/" data-link-title="模組三：Runtime 與效能診斷" data-link-desc="GC、memory limit、pprof、goroutine leak 與 allocation 壓力">Runtime 與效能診斷&lt;/a> 對照。&lt;/p>
&lt;h3 id="分散式基礎設施先看主要問題是否在協調與可靠性">分散式基礎設施：先看主要問題是否在協調與可靠性&lt;/h3>
&lt;p>分散式基礎設施的核心訊號是「系統價值來自多節點協調」。資料庫、排程器、服務治理框架與網路控制平面，都需要清楚處理 context、retry、timeout、狀態同步與觀測訊號。Go 在這裡的價值通常是簡單語法、明確錯誤路徑、標準工具鏈與可讀的並發模型。&lt;/p>
&lt;p>這類案例可以回到 &lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/" data-link-title="模組四：架構邊界與事件系統" data-link-desc="用事件驅動架構拆解事件來源、處理流程、狀態邊界與即時推送">架構邊界與事件系統&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/" data-link-title="模組七：跨節點與平台整合" data-link-desc="把單一 Go 服務延伸到資料庫、queue、跨節點 WebSocket、可觀測性與部署平台">跨節點與平台整合&lt;/a> 對照。&lt;/p>
&lt;h2 id="閱讀案例的判斷順序">閱讀案例的判斷順序&lt;/h2>
&lt;ol>
&lt;li>先找服務壓力：併發、部署、效能、協調或長期維護。&lt;/li>
&lt;li>再找 Go 的切入點：goroutine、標準庫、單一 binary、型別與 package 邊界。&lt;/li>
&lt;li>最後回到章節：把案例對應到前面已學過的 Go 概念。&lt;/li>
&lt;/ol>
&lt;p>案例閱讀的重點是建立選型判斷，而非模仿公司規模。小型服務也可能遇到長連線、背景 worker 或部署簡化問題；大型公司案例只是把這些壓力放大到更容易觀察。&lt;/p></description><content:encoded><![CDATA[<p>Go 選型案例的核心用途是把語言特性對回服務壓力。公司案例提供的價值通常來自三件事：服務遇到什麼壓力、Go 解決哪一段工程問題、團隊因此得到什麼維護收益。</p>
<h2 id="案例類型總覽">案例類型總覽</h2>
<table>
  <thead>
      <tr>
          <th>類型</th>
          <th>觀察訊號</th>
          <th>代表案例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>大規模平台服務</td>
          <td>服務多、部署頻繁、依賴邊界需要清楚</td>
          <td>Google、Microsoft、CloudWeGo</td>
      </tr>
      <tr>
          <td>高併發即時服務</td>
          <td>長連線、低延遲、client 數量大</td>
          <td>Twitch、Stream、Cloudflare</td>
      </tr>
      <tr>
          <td>效能敏感遷移</td>
          <td>既有系統已有瓶頸，局部元件需要更穩定的效能</td>
          <td>Dropbox、PayPal</td>
      </tr>
      <tr>
          <td>分散式基礎設施</td>
          <td>一致性、複製、排程、網路協調是核心問題</td>
          <td>Cockroach Labs、Kubernetes 生態</td>
      </tr>
  </tbody>
</table>
<h3 id="大規模平台服務先看團隊是否需要一致的服務形狀">大規模平台服務：先看團隊是否需要一致的服務形狀</h3>
<p>大規模平台服務的核心訊號是「很多服務需要用相近方式開發、部署與維護」。例如一組雲端基礎設施服務需要共用 HTTP <a href="/blog/backend/knowledge-cards/health-check-liveness/" data-link-title="Liveness" data-link-desc="說明平台如何判斷 process 是否仍然存活，以及何時應重啟">health check</a>、structured <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>、configuration、context cancellation 與單一 binary 部署流程。Go 的價值在於讓服務骨架簡單、依賴明確，讓不同團隊看到相似的程式入口與 package 結構。</p>
<p>這類案例可以回到 <a href="/blog/go/00-philosophy/simplicity/" data-link-title="0.1 Go 的簡單哲學與認知負擔" data-link-desc="理解 Go 為什麼偏好顯式、直線流程與少量語法">Go 的簡單哲學與認知負擔</a>、<a href="/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">composition root 與依賴組裝</a> 對照。</p>
<h3 id="高併發即時服務先看連線與事件是否長時間存在">高併發即時服務：先看連線與事件是否長時間存在</h3>
<p>高併發即時服務的核心訊號是「server 需要同時管理大量仍然在線的工作」。聊天室、即時通知、直播狀態、代理服務與邊緣網路服務，都可能同時面對大量 connection、<a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a>、<a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a>、<a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 與 cleanup。Go 的 goroutine、channel、context 與標準網路庫讓這些生命週期可以直接寫在程式裡。</p>
<p>這類案例可以回到 <a href="/blog/go/04-concurrency/goroutine/" data-link-title="4.1 goroutine：輕量並發工作" data-link-desc="用 goroutine 啟動並發工作，並設計清楚的退出條件">goroutine：背景工作與服務生命週期</a>、<a href="/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">channel：事件流與 backpressure </a>、<a href="/blog/go-advanced/02-networking-websocket/" data-link-title="模組二：WebSocket 服務架構" data-link-desc="WebSocket client lifecycle、heartbeat、訂閱路由與慢客戶端管理">WebSocket 服務架構</a> 對照。</p>
<h3 id="效能敏感遷移先看瓶頸是否集中在清楚邊界">效能敏感遷移：先看瓶頸是否集中在清楚邊界</h3>
<p>效能敏感遷移的核心訊號是「整個產品仍可沿用原本架構，但某段服務已經成為穩定性或成本瓶頸」。例如檔案同步、資料轉換、API gateway、build pipeline 或推送服務。這時 Go 常作為局部重寫選項，讓瓶頸元件取得更好的 CPU、memory、部署與並發表現。</p>
<p>這類案例可以回到 <a href="/blog/go/00-philosophy/concurrency-language-position/" data-link-title="0.5 Go 和其他並發語言的差異" data-link-desc="比較 Go、Java、C#、Rust、Node.js、Python async、Erlang/Elixir 在並發服務中的工程定位">Go 和其他並發語言的差異</a>、<a href="/blog/go-advanced/03-runtime-profiling/" data-link-title="模組三：Runtime 與效能診斷" data-link-desc="GC、memory limit、pprof、goroutine leak 與 allocation 壓力">Runtime 與效能診斷</a> 對照。</p>
<h3 id="分散式基礎設施先看主要問題是否在協調與可靠性">分散式基礎設施：先看主要問題是否在協調與可靠性</h3>
<p>分散式基礎設施的核心訊號是「系統價值來自多節點協調」。資料庫、排程器、服務治理框架與網路控制平面，都需要清楚處理 context、retry、timeout、狀態同步與觀測訊號。Go 在這裡的價值通常是簡單語法、明確錯誤路徑、標準工具鏈與可讀的並發模型。</p>
<p>這類案例可以回到 <a href="/blog/go-advanced/04-architecture-boundaries/" data-link-title="模組四：架構邊界與事件系統" data-link-desc="用事件驅動架構拆解事件來源、處理流程、狀態邊界與即時推送">架構邊界與事件系統</a>、<a href="/blog/go-advanced/07-distributed-operations/" data-link-title="模組七：跨節點與平台整合" data-link-desc="把單一 Go 服務延伸到資料庫、queue、跨節點 WebSocket、可觀測性與部署平台">跨節點與平台整合</a> 對照。</p>
<h2 id="閱讀案例的判斷順序">閱讀案例的判斷順序</h2>
<ol>
<li>先找服務壓力：併發、部署、效能、協調或長期維護。</li>
<li>再找 Go 的切入點：goroutine、標準庫、單一 binary、型別與 package 邊界。</li>
<li>最後回到章節：把案例對應到前面已學過的 Go 概念。</li>
</ol>
<p>案例閱讀的重點是建立選型判斷，而非模仿公司規模。小型服務也可能遇到長連線、背景 worker 或部署簡化問題；大型公司案例只是把這些壓力放大到更容易觀察。</p>
]]></content:encoded></item></channel></rss>