<?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>Clean Architecture on Tarragon</title><link>https://tarrragon.github.io/blog/tags/clean-architecture/</link><description>Recent content in Clean Architecture on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Wed, 22 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/clean-architecture/index.xml" rel="self" type="application/rss+xml"/><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>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>模組四：架構邊界與事件系統</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>BDD 測試方法論</title><link>https://tarrragon.github.io/blog/record/bdd-%E6%B8%AC%E8%A9%A6%E6%96%B9%E6%B3%95%E8%AB%96/</link><pubDate>Wed, 04 Mar 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/record/bdd-%E6%B8%AC%E8%A9%A6%E6%96%B9%E6%B3%95%E8%AB%96/</guid><description>&lt;p>三個月的重構週期結束後，我們檢視了測試套件，發現一個令人沮喪的問題：每次修改內部實作，即使業務邏輯完全沒變，也需要跟著修改大量測試。一個 Repository 實作替換，導致二十幾個測試需要逐一調整。&lt;/p>
&lt;p>這不是測試該有的樣子。問題根源在於測試耦合了實作細節，而非行為。&lt;/p></description><content:encoded><![CDATA[<p>三個月的重構週期結束後，我們檢視了測試套件，發現一個令人沮喪的問題：每次修改內部實作，即使業務邏輯完全沒變，也需要跟著修改大量測試。一個 Repository 實作替換，導致二十幾個測試需要逐一調整。</p>
<p>這不是測試該有的樣子。問題根源在於測試耦合了實作細節，而非行為。</p>
<h2 id="bdd-的核心定位">BDD 的核心定位</h2>
<p>BDD 是 TDD 的演進，它要求測試描述系統的「行為」而非「實作」。</p>
<p>行為是使用者視角觀察到的系統反應；實作是程式內部的技術細節。這個區別看起來簡單，實際撰寫測試時卻很容易模糊。</p>
<p>BDD 解決三個問題：</p>
<p><strong>測試維護成本高</strong>。傳統單元測試緊密耦合實作細節，重構時即使行為沒變，測試仍需大量修改。BDD 讓重構時測試保持穩定。</p>
<p><strong>需求追溯困難</strong>。測試充滿技術細節，無法對應業務需求。Given-When-Then 場景即是需求文件，測試即規格。</p>
<p><strong>溝通成本高</strong>。開發、測試和業務人員用不同語言描述系統行為。BDD 統一使用業務語言，建立共通溝通基礎。</p>
<p>我們的分工是：Clean Architecture 定義架構分層，TDD 四階段流程定義開發節奏，BDD 定義測試內容和撰寫規範。</p>
<h2 id="given-when-then-結構">Given-When-Then 結構</h2>
<p>Given 描述系統的初始狀態，必須明確完整，只包含與此場景相關的資料。常見錯誤是前置條件模糊，或包含大量無關測試資料。</p>
<p>When 描述使用者執行的操作，必須是單一動作，使用業務語言。「呼叫 Repository 的 save 方法」是技術術語；「使用者提交訂單」是業務語言。一個 When 不能包含多個動作。</p>
<p>Then 描述執行後的狀態變化或結果，必須是可觀察的行為。「Repository 的 save 方法被呼叫一次」是實作細節；「訂單成功儲存並回傳訂單編號」是可觀察的行為。</p>
<p>判斷行為還是實作的方法很簡單：使用者能否觀察到？改變實作會影響這個結果嗎？產品經理需要關心嗎？都是「能觀察、不影響、需要關心」就是行為，反之是實作細節。</p>
<h2 id="行為測試和實作測試的差異">行為測試和實作測試的差異</h2>
<p>測試實作：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">test</span><span class="p">(</span><span class="s1">&#39;OrderRepository.save should call database.insert&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="n">repository</span><span class="p">.</span><span class="n">save</span><span class="p">(</span><span class="n">order</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="n">verify</span><span class="p">(</span><span class="n">database</span><span class="p">.</span><span class="n">insert</span><span class="p">(</span><span class="s1">&#39;orders&#39;</span><span class="p">,</span> <span class="n">order</span><span class="p">.</span><span class="n">toJson</span><span class="p">()));</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>這個測試關注「如何儲存」，替換資料庫或重構儲存邏輯就會失敗。</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="n">test</span><span class="p">(</span><span class="s1">&#39;使用者提交訂單 - 訂單成功儲存&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="c1">// Given: 使用者已選擇商品並填寫完整資訊
</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">order</span> <span class="o">=</span> <span class="n">validOrder</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">// When: 使用者提交訂單
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">result</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">submitOrderUseCase</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">order</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">// Then: 系統確認訂單已儲存
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span>  <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">isSuccess</span><span class="p">,</span> <span class="kc">true</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">orderId</span><span class="p">,</span> <span class="n">isNotEmpty</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>這個測試關注「訂單是否成功儲存」，重構儲存機制不會影響結果。</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="n">test</span><span class="p">(</span><span class="s1">&#39;當 Repository 回傳 null 時 UseCase 拋出例外&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">});</span></span></span></code></pre></div><p>從使用者視角：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">test</span><span class="p">(</span><span class="s1">&#39;使用者提交訂單失敗 - 商品庫存不足&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="c1">// Given: 商品庫存為 0
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="c1">// When: 使用者嘗試提交訂單
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>  <span class="c1">// Then: 系統回應「庫存不足」錯誤
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="p">});</span></span></span></code></pre></div><h2 id="分層測試策略">分層測試策略</h2>
<p>BDD 不適用所有架構層級，每層特性不同，測試策略也不同。</p>
<p><strong>UseCase 層</strong>是 BDD 的核心應用層，代表完整的使用者操作流程，必須使用 Given-When-Then 結構，涵蓋所有業務場景。</p>
<p><strong>Domain 層</strong>包含核心業務規則、值物件驗證和實體不變量，需要細緻的邊界條件測試，單元測試更適合。</p>
<p><strong>Behavior 層</strong>負責 ViewModel 轉換和事件處理，只有複雜轉換邏輯需要獨立測試，簡單轉換由 UseCase 層覆蓋即可。</p>
<p><strong>UI 層</strong>測試成本高，只測試關鍵互動路徑，使用整合測試。</p>
<p><strong>Interface 層</strong>只定義契約，沒有實作邏輯，不需要測試。</p>
<h2 id="mock-策略">Mock 策略</h2>
<p>核心原則：只 Mock 外層依賴，不 Mock 內層邏輯。</p>
<p>外層依賴（Repository、Service、Event Publisher）透過 Interface 進行 Mock，隔離外部系統。內層邏輯（Domain Entity、Value Object）必須使用真實物件，確保測試涵蓋真實業務邏輯。</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="n">test</span><span class="p">(</span><span class="s1">&#39;使用者提交訂單成功&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="c1">// Mock Repository（外層依賴）
</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">mockRepository</span> <span class="o">=</span> <span class="n">MockOrderRepository</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="n">when</span><span class="p">(</span><span class="n">mockRepository</span><span class="p">.</span><span class="n">save</span><span class="p">(</span><span class="n">any</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">      <span class="p">.</span><span class="n">thenAnswer</span><span class="p">((</span><span class="n">_</span><span class="p">)</span> <span class="kd">async</span> <span class="o">=&gt;</span> <span class="n">SaveResult</span><span class="p">.</span><span class="n">success</span><span class="p">(</span><span class="s1">&#39;order-123&#39;</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="c1">// 使用真實的 Domain Entity（內層邏輯）
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">order</span> <span class="o">=</span> <span class="n">Order</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nl">amount:</span> <span class="n">OrderAmount</span><span class="p">(</span><span class="m">100</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nl">userId:</span> <span class="n">UserId</span><span class="p">(</span><span class="s1">&#39;user-001&#39;</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="kd">final</span> <span class="n">useCase</span> <span class="o">=</span> <span class="n">SubmitOrderUseCase</span><span class="p">(</span><span class="nl">repository:</span> <span class="n">mockRepository</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="kd">final</span> <span class="n">result</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">useCase</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">order</span><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="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">isSuccess</span><span class="p">,</span> <span class="kc">true</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">orderId</span><span class="p">,</span> <span class="s1">&#39;order-123&#39;</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>錯誤寫法是 Mock Domain Entity：</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">test</span><span class="p">(</span><span class="s1">&#39;使用者提交訂單成功&#39;</span><span class="p">,</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">final</span> <span class="n">mockOrder</span> <span class="o">=</span> <span class="n">MockOrder</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="n">when</span><span class="p">(</span><span class="n">mockOrder</span><span class="p">.</span><span class="n">validate</span><span class="p">()).</span><span class="n">thenReturn</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="c1">// 沒有測試到任何真實業務邏輯
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="p">});</span></span></span></code></pre></div><h2 id="與-tdd-階段整合">與 TDD 階段整合</h2>
<p><strong>階段一（功能設計）</strong>：從需求識別使用者行為場景。「使用者可以提交訂單」需要提取多個場景：成功提交、庫存不足失敗、金額無效失敗等，每個場景涵蓋正常流程、異常流程和邊界條件。</p>
<p><strong>階段二（測試設計）</strong>：將行為場景轉換為可執行的測試程式碼，先建立結構，設置 Mock，再依 Given-When-Then 填入邏輯。</p>
<p><strong>階段三（實作策略）</strong>：測試先行。先完成所有測試場景並確認失敗（Red），才開始實作 UseCase 讓測試通過（Green）。</p>
<p><strong>階段四（重構優化）</strong>：重構時，行為測試必須保持穩定。重構導致測試需要修改，代表測試耦合了實作。</p>
<p>判斷重構品質的標準很清楚：替換 Repository 實作、改變演算法，不應讓測試失敗；改變業務規則、調整可觀察的錯誤訊息，才應讓測試失敗。</p>
<h2 id="常見挑戰">常見挑戰</h2>
<h3 id="測試覆蓋率盲點">測試覆蓋率盲點</h3>
<p>BDD 強調測試「重要行為」，可能讓某些程式碼未被覆蓋。混合策略解決這個問題：UseCase 層 100% BDD 測試，Domain 層複雜邏輯 100% 單元測試，整體維持 80% 程式碼覆蓋率目標。</p>
<h3 id="學習曲線">學習曲線</h3>
<p>從「測試實作」轉向「測試行為」需要思維轉換，初期容易寫出「假行為測試」（實際上還是在測試實作）。建立範例庫和測試模板很有幫助：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">test</span><span class="p">(</span><span class="s1">&#39;[業務場景描述] - 成功&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="c1">// Given: [前置條件]
</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">input</span> <span class="o">=</span> <span class="p">[</span><span class="err">準備測試資料</span><span class="p">];</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="p">[</span><span class="err">設置</span> <span class="n">Mock</span> <span class="err">行為</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="c1">// When: [觸發動作]
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">result</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">useCase</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">input</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="c1">// Then: [預期結果]
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span>  <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">isSuccess</span><span class="p">,</span> <span class="kc">true</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="n">expect</span><span class="p">([</span><span class="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></code></pre></div><h3 id="邊界條件容易被忽略">邊界條件容易被忽略</h3>
<p>業務場景描述容易遺漏技術性的邊界條件（null、異常、極端值）。每個 UseCase 最少需要：一個正常流程、兩個異常流程、三個邊界條件。建立技術性測試檢查清單並在 Code Review 重點確認。</p>
<h3 id="測試設置複雜度">測試設置複雜度</h3>
<p>UseCase 層的 BDD 測試需要 Mock 多個依賴，建立 Test Helper 和 Builder Pattern 減少重複：</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">UseCaseTestHelper</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">static</span> <span class="n">MockOrderRepository</span> <span class="n">createMockRepository</span><span class="p">({</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="kd">required</span> <span class="n">SaveResult</span> <span class="n">saveResult</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="p">})</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">mock</span> <span class="o">=</span> <span class="n">MockOrderRepository</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="n">when</span><span class="p">(</span><span class="n">mock</span><span class="p">.</span><span class="n">save</span><span class="p">(</span><span class="n">any</span><span class="p">)).</span><span class="n">thenAnswer</span><span class="p">((</span><span class="n">_</span><span class="p">)</span> <span class="kd">async</span> <span class="o">=&gt;</span> <span class="n">saveResult</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">mock</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></span><span class="line"><span class="ln">11</span><span class="cl"><span class="kd">class</span> <span class="nc">OrderBuilder</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="kt">int</span> <span class="n">_amount</span> <span class="o">=</span> <span class="m">100</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="kt">String</span> <span class="n">_userId</span> <span class="o">=</span> <span class="s1">&#39;user-001&#39;</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="n">OrderBuilder</span> <span class="n">withAmount</span><span class="p">(</span><span class="kt">int</span> <span class="n">amount</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="n">_amount</span> <span class="o">=</span> <span class="n">amount</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="k">this</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="n">Order</span> <span class="n">build</span><span class="p">()</span> <span class="o">=&gt;</span> <span class="n">Order</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="nl">amount:</span> <span class="n">OrderAmount</span><span class="p">(</span><span class="n">_amount</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="nl">userId:</span> <span class="n">UserId</span><span class="p">(</span><span class="n">_userId</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="p">}</span></span></span></code></pre></div><h3 id="行為粒度">行為粒度</h3>
<p>粒度太粗，失敗時難以定位；太細則接近單元測試，失去 BDD 優勢。採用「一個 UseCase 等於一個核心行為」的原則：UseCase 代表完整業務流程，名稱以動詞開頭（Submit, Cancel, Query），所有測試場景屬於同一個業務流程。</p>
<h3 id="業務需求變更">業務需求變更</h3>
<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">OrderBusinessRules</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">static</span> <span class="kd">const</span> <span class="kt">int</span> <span class="n">freeShippingThreshold</span> <span class="o">=</span> <span class="m">1000</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kd">static</span> <span class="kd">const</span> <span class="kt">int</span> <span class="n">maxOrderAmount</span> <span class="o">=</span> <span class="m">100000</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="kd">static</span> <span class="kd">const</span> <span class="kt">int</span> <span class="n">minOrderAmount</span> <span class="o">=</span> <span class="m">1</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><h2 id="完整範例">完整範例</h2>
<p>以「使用者提交訂單」為例：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">  1</span><span class="cl"><span class="n">group</span><span class="p">(</span><span class="s1">&#39;SubmitOrderUseCase&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">  2</span><span class="cl">  <span class="n">late</span> <span class="n">MockOrderRepository</span> <span class="n">mockRepository</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">  3</span><span class="cl">  <span class="n">late</span> <span class="n">MockInventoryService</span> <span class="n">mockInventoryService</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">  4</span><span class="cl">  <span class="n">late</span> <span class="n">MockEventPublisher</span> <span class="n">mockEventPublisher</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">  5</span><span class="cl">  <span class="n">late</span> <span class="n">SubmitOrderUseCase</span> <span class="n">useCase</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">setUp</span><span class="p">(()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">  8</span><span class="cl">    <span class="n">mockRepository</span> <span class="o">=</span> <span class="n">MockOrderRepository</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">  9</span><span class="cl">    <span class="n">mockInventoryService</span> <span class="o">=</span> <span class="n">MockInventoryService</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 10</span><span class="cl">    <span class="n">mockEventPublisher</span> <span class="o">=</span> <span class="n">MockEventPublisher</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 11</span><span class="cl">    <span class="n">useCase</span> <span class="o">=</span> <span class="n">SubmitOrderUseCase</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 12</span><span class="cl">      <span class="nl">repository:</span> <span class="n">mockRepository</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 13</span><span class="cl">      <span class="nl">inventoryService:</span> <span class="n">mockInventoryService</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 14</span><span class="cl">      <span class="nl">eventPublisher:</span> <span class="n">mockEventPublisher</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></span><span class="line"><span class="ln"> 18</span><span class="cl">  <span class="n">group</span><span class="p">(</span><span class="s1">&#39;正常流程&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 19</span><span class="cl">    <span class="n">test</span><span class="p">(</span><span class="s1">&#39;使用者提交訂單成功&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 20</span><span class="cl">      <span class="c1">// Given: 使用者已選擇商品且填寫完整資訊
</span></span></span><span class="line"><span class="ln"> 21</span><span class="cl"><span class="c1"></span>      <span class="kd">final</span> <span class="n">order</span> <span class="o">=</span> <span class="n">Order</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 22</span><span class="cl">        <span class="nl">amount:</span> <span class="n">OrderAmount</span><span class="p">(</span><span class="m">100</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 23</span><span class="cl">        <span class="nl">userId:</span> <span class="n">UserId</span><span class="p">(</span><span class="s1">&#39;user-001&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 24</span><span class="cl">        <span class="nl">items:</span> <span class="p">[</span><span class="n">OrderItem</span><span class="p">(</span><span class="nl">productId:</span> <span class="s1">&#39;prod-001&#39;</span><span class="p">,</span> <span class="nl">quantity:</span> <span class="m">2</span><span class="p">)],</span>
</span></span><span class="line"><span class="ln"> 25</span><span class="cl">        <span class="nl">shippingAddress:</span> <span class="n">Address</span><span class="p">(</span><span class="nl">city:</span> <span class="s1">&#39;台北市&#39;</span><span class="p">,</span> <span class="nl">district:</span> <span class="s1">&#39;信義區&#39;</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="n">when</span><span class="p">(</span><span class="n">mockInventoryService</span><span class="p">.</span><span class="n">checkStock</span><span class="p">(</span><span class="s1">&#39;prod-001&#39;</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 28</span><span class="cl">          <span class="p">.</span><span class="n">thenAnswer</span><span class="p">((</span><span class="n">_</span><span class="p">)</span> <span class="kd">async</span> <span class="o">=&gt;</span> <span class="n">StockStatus</span><span class="p">.</span><span class="n">available</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 29</span><span class="cl">      <span class="n">when</span><span class="p">(</span><span class="n">mockRepository</span><span class="p">.</span><span class="n">save</span><span class="p">(</span><span class="n">any</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 30</span><span class="cl">          <span class="p">.</span><span class="n">thenAnswer</span><span class="p">((</span><span class="n">_</span><span class="p">)</span> <span class="kd">async</span> <span class="o">=&gt;</span> <span class="n">SaveResult</span><span class="p">.</span><span class="n">success</span><span class="p">(</span><span class="s1">&#39;order-123&#39;</span><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="c1">// When: 使用者點擊「提交訂單」
</span></span></span><span class="line"><span class="ln"> 33</span><span class="cl"><span class="c1"></span>      <span class="kd">final</span> <span class="n">result</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">useCase</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">order</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 34</span><span class="cl">
</span></span><span class="line"><span class="ln"> 35</span><span class="cl">      <span class="c1">// Then: 系統確認訂單已儲存並回傳訂單編號
</span></span></span><span class="line"><span class="ln"> 36</span><span class="cl"><span class="c1"></span>      <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">isSuccess</span><span class="p">,</span> <span class="kc">true</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 37</span><span class="cl">      <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">orderId</span><span class="p">,</span> <span class="s1">&#39;order-123&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 38</span><span class="cl">      <span class="n">verify</span><span class="p">(</span><span class="n">mockEventPublisher</span><span class="p">.</span><span class="n">publish</span><span class="p">(</span><span class="n">any</span><span class="p">.</span><span class="n">having</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 39</span><span class="cl">        <span class="p">(</span><span class="n">e</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">e</span><span class="p">.</span><span class="n">type</span><span class="p">,</span> <span class="s1">&#39;event type&#39;</span><span class="p">,</span> <span class="n">EventType</span><span class="p">.</span><span class="n">orderCreated</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 40</span><span class="cl">      <span class="p">))).</span><span class="n">called</span><span class="p">(</span><span class="m">1</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 41</span><span class="cl">    <span class="p">});</span>
</span></span><span class="line"><span class="ln"> 42</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln"> 43</span><span class="cl">
</span></span><span class="line"><span class="ln"> 44</span><span class="cl">  <span class="n">group</span><span class="p">(</span><span class="s1">&#39;異常流程&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 45</span><span class="cl">    <span class="n">test</span><span class="p">(</span><span class="s1">&#39;使用者提交訂單失敗 - 商品庫存不足&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 46</span><span class="cl">      <span class="c1">// Given: 選擇的商品庫存為 0
</span></span></span><span class="line"><span class="ln"> 47</span><span class="cl"><span class="c1"></span>      <span class="kd">final</span> <span class="n">order</span> <span class="o">=</span> <span class="n">Order</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 48</span><span class="cl">        <span class="nl">amount:</span> <span class="n">OrderAmount</span><span class="p">(</span><span class="m">100</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 49</span><span class="cl">        <span class="nl">userId:</span> <span class="n">UserId</span><span class="p">(</span><span class="s1">&#39;user-001&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 50</span><span class="cl">        <span class="nl">items:</span> <span class="p">[</span><span class="n">OrderItem</span><span class="p">(</span><span class="nl">productId:</span> <span class="s1">&#39;prod-001&#39;</span><span class="p">,</span> <span class="nl">quantity:</span> <span class="m">2</span><span class="p">)],</span>
</span></span><span class="line"><span class="ln"> 51</span><span class="cl">      <span class="p">);</span>
</span></span><span class="line"><span class="ln"> 52</span><span class="cl">      <span class="n">when</span><span class="p">(</span><span class="n">mockInventoryService</span><span class="p">.</span><span class="n">checkStock</span><span class="p">(</span><span class="s1">&#39;prod-001&#39;</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 53</span><span class="cl">          <span class="p">.</span><span class="n">thenAnswer</span><span class="p">((</span><span class="n">_</span><span class="p">)</span> <span class="kd">async</span> <span class="o">=&gt;</span> <span class="n">StockStatus</span><span class="p">.</span><span class="n">outOfStock</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 54</span><span class="cl">
</span></span><span class="line"><span class="ln"> 55</span><span class="cl">      <span class="c1">// When: 使用者點擊「提交訂單」
</span></span></span><span class="line"><span class="ln"> 56</span><span class="cl"><span class="c1"></span>      <span class="kd">final</span> <span class="n">result</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">useCase</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">order</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 57</span><span class="cl">
</span></span><span class="line"><span class="ln"> 58</span><span class="cl">      <span class="c1">// Then: 系統回應庫存不足錯誤，不儲存訂單
</span></span></span><span class="line"><span class="ln"> 59</span><span class="cl"><span class="c1"></span>      <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">isSuccess</span><span class="p">,</span> <span class="kc">false</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 60</span><span class="cl">      <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">error</span><span class="p">,</span> <span class="n">ErrorType</span><span class="p">.</span><span class="n">outOfStock</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 61</span><span class="cl">      <span class="n">verifyNever</span><span class="p">(</span><span class="n">mockRepository</span><span class="p">.</span><span class="n">save</span><span class="p">(</span><span class="n">any</span><span class="p">));</span>
</span></span><span class="line"><span class="ln"> 62</span><span class="cl">    <span class="p">});</span>
</span></span><span class="line"><span class="ln"> 63</span><span class="cl">
</span></span><span class="line"><span class="ln"> 64</span><span class="cl">    <span class="n">test</span><span class="p">(</span><span class="s1">&#39;使用者提交訂單失敗 - Repository 儲存失敗&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 65</span><span class="cl">      <span class="c1">// Given: Repository 無法儲存（網路錯誤）
</span></span></span><span class="line"><span class="ln"> 66</span><span class="cl"><span class="c1"></span>      <span class="kd">final</span> <span class="n">order</span> <span class="o">=</span> <span class="n">Order</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 67</span><span class="cl">        <span class="nl">amount:</span> <span class="n">OrderAmount</span><span class="p">(</span><span class="m">100</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 68</span><span class="cl">        <span class="nl">userId:</span> <span class="n">UserId</span><span class="p">(</span><span class="s1">&#39;user-001&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 69</span><span class="cl">        <span class="nl">items:</span> <span class="p">[</span><span class="n">OrderItem</span><span class="p">(</span><span class="nl">productId:</span> <span class="s1">&#39;prod-001&#39;</span><span class="p">,</span> <span class="nl">quantity:</span> <span class="m">1</span><span class="p">)],</span>
</span></span><span class="line"><span class="ln"> 70</span><span class="cl">      <span class="p">);</span>
</span></span><span class="line"><span class="ln"> 71</span><span class="cl">      <span class="n">when</span><span class="p">(</span><span class="n">mockInventoryService</span><span class="p">.</span><span class="n">checkStock</span><span class="p">(</span><span class="n">any</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 72</span><span class="cl">          <span class="p">.</span><span class="n">thenAnswer</span><span class="p">((</span><span class="n">_</span><span class="p">)</span> <span class="kd">async</span> <span class="o">=&gt;</span> <span class="n">StockStatus</span><span class="p">.</span><span class="n">available</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 73</span><span class="cl">      <span class="n">when</span><span class="p">(</span><span class="n">mockRepository</span><span class="p">.</span><span class="n">save</span><span class="p">(</span><span class="n">any</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 74</span><span class="cl">          <span class="p">.</span><span class="n">thenAnswer</span><span class="p">((</span><span class="n">_</span><span class="p">)</span> <span class="kd">async</span> <span class="o">=&gt;</span> <span class="n">SaveResult</span><span class="p">.</span><span class="n">failure</span><span class="p">(</span><span class="s1">&#39;網路連線失敗&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln"> 75</span><span class="cl">
</span></span><span class="line"><span class="ln"> 76</span><span class="cl">      <span class="c1">// When: 使用者點擊「提交訂單」
</span></span></span><span class="line"><span class="ln"> 77</span><span class="cl"><span class="c1"></span>      <span class="kd">final</span> <span class="n">result</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">useCase</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">order</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 78</span><span class="cl">
</span></span><span class="line"><span class="ln"> 79</span><span class="cl">      <span class="c1">// Then: 系統回應訂單提交失敗
</span></span></span><span class="line"><span class="ln"> 80</span><span class="cl"><span class="c1"></span>      <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">isSuccess</span><span class="p">,</span> <span class="kc">false</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 81</span><span class="cl">      <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">error</span><span class="p">,</span> <span class="n">ErrorType</span><span class="p">.</span><span class="n">saveFailed</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 82</span><span class="cl">    <span class="p">});</span>
</span></span><span class="line"><span class="ln"> 83</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln"> 84</span><span class="cl">
</span></span><span class="line"><span class="ln"> 85</span><span class="cl">  <span class="n">group</span><span class="p">(</span><span class="s1">&#39;邊界條件&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 86</span><span class="cl">    <span class="n">test</span><span class="p">(</span><span class="s1">&#39;使用者提交訂單失敗 - 訂單金額為 0&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 87</span><span class="cl">      <span class="kd">final</span> <span class="n">order</span> <span class="o">=</span> <span class="n">Order</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 88</span><span class="cl">        <span class="nl">amount:</span> <span class="n">OrderAmount</span><span class="p">(</span><span class="m">0</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 89</span><span class="cl">        <span class="nl">userId:</span> <span class="n">UserId</span><span class="p">(</span><span class="s1">&#39;user-001&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 90</span><span class="cl">        <span class="nl">items:</span> <span class="p">[],</span>
</span></span><span class="line"><span class="ln"> 91</span><span class="cl">      <span class="p">);</span>
</span></span><span class="line"><span class="ln"> 92</span><span class="cl">      <span class="kd">final</span> <span class="n">result</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">useCase</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">order</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 93</span><span class="cl">      <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">isSuccess</span><span class="p">,</span> <span class="kc">false</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 94</span><span class="cl">      <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">error</span><span class="p">,</span> <span class="n">ErrorType</span><span class="p">.</span><span class="n">invalidAmount</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 95</span><span class="cl">    <span class="p">});</span>
</span></span><span class="line"><span class="ln"> 96</span><span class="cl">
</span></span><span class="line"><span class="ln"> 97</span><span class="cl">    <span class="n">test</span><span class="p">(</span><span class="s1">&#39;建立負數金額訂單拋出例外&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 98</span><span class="cl">      <span class="n">expect</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 99</span><span class="cl">        <span class="p">()</span> <span class="o">=&gt;</span> <span class="n">Order</span><span class="p">(</span><span class="nl">amount:</span> <span class="n">OrderAmount</span><span class="p">(</span><span class="o">-</span><span class="m">100</span><span class="p">),</span> <span class="nl">userId:</span> <span class="n">UserId</span><span class="p">(</span><span class="s1">&#39;user-001&#39;</span><span class="p">)),</span>
</span></span><span class="line"><span class="ln">100</span><span class="cl">        <span class="n">throwsA</span><span class="p">(</span><span class="n">isA</span><span class="o">&lt;</span><span class="n">InvalidAmountException</span><span class="o">&gt;</span><span class="p">()),</span>
</span></span><span class="line"><span class="ln">101</span><span class="cl">      <span class="p">);</span>
</span></span><span class="line"><span class="ln">102</span><span class="cl">    <span class="p">});</span>
</span></span><span class="line"><span class="ln">103</span><span class="cl">
</span></span><span class="line"><span class="ln">104</span><span class="cl">    <span class="n">test</span><span class="p">(</span><span class="s1">&#39;使用者提交訂單失敗 - 訂單金額超過上限&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">105</span><span class="cl">      <span class="kd">final</span> <span class="n">order</span> <span class="o">=</span> <span class="n">Order</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">106</span><span class="cl">        <span class="nl">amount:</span> <span class="n">OrderAmount</span><span class="p">(</span><span class="m">1000001</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">107</span><span class="cl">        <span class="nl">userId:</span> <span class="n">UserId</span><span class="p">(</span><span class="s1">&#39;user-001&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">108</span><span class="cl">        <span class="nl">items:</span> <span class="p">[</span><span class="n">OrderItem</span><span class="p">(</span><span class="nl">productId:</span> <span class="s1">&#39;prod-001&#39;</span><span class="p">,</span> <span class="nl">quantity:</span> <span class="m">10000</span><span class="p">)],</span>
</span></span><span class="line"><span class="ln">109</span><span class="cl">      <span class="p">);</span>
</span></span><span class="line"><span class="ln">110</span><span class="cl">      <span class="kd">final</span> <span class="n">result</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">useCase</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">order</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">111</span><span class="cl">      <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">isSuccess</span><span class="p">,</span> <span class="kc">false</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">112</span><span class="cl">      <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">error</span><span class="p">,</span> <span class="n">ErrorType</span><span class="p">.</span><span class="n">amountExceedsLimit</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">113</span><span class="cl">    <span class="p">});</span>
</span></span><span class="line"><span class="ln">114</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">115</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><h2 id="結論">結論</h2>
<p>回頭看最初那個重構週期，二十幾個因為替換 Repository 實作而失敗的測試，問題很清楚：測試在監視實作細節，而不是守護業務行為。</p>
<p>切換到 BDD 之後，同樣的重構只需確認業務行為沒有改變，測試套件就能保持穩定。</p>
<p>但 BDD 不是萬靈丹。它需要思維轉換，需要建立明確規範，需要持續 Code Review 維持品質。混合策略（UseCase 層 BDD、Domain 層單元測試、UI 層整合測試）才能真正發揮效果。</p>]]></content:encoded></item><item><title>行為優先的TDD方法論 - Sociable Unit Tests實踐指南</title><link>https://tarrragon.github.io/blog/record/%E8%A1%8C%E7%82%BA%E5%84%AA%E5%85%88%E7%9A%84tdd%E6%96%B9%E6%B3%95%E8%AB%96-sociable-unit-tests%E5%AF%A6%E8%B8%90%E6%8C%87%E5%8D%97/</link><pubDate>Wed, 04 Mar 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/record/%E8%A1%8C%E7%82%BA%E5%84%AA%E5%85%88%E7%9A%84tdd%E6%96%B9%E6%B3%95%E8%AB%96-sociable-unit-tests%E5%AF%A6%E8%B8%90%E6%8C%87%E5%8D%97/</guid><description>&lt;p>曾經有一段時間，我們團隊對TDD又愛又恨。「寫測試讓我們更有信心」，但「重構時要改一堆測試，還不如不寫」。這種矛盾讓我們反覆懷疑：TDD到底有沒有用？&lt;/p>
&lt;p>深入研究Kent Beck的原著和Valentina Jemuović的演講後，才發現問題出在我們誤解了「測試單元」是什麼。&lt;/p></description><content:encoded><![CDATA[<p>曾經有一段時間，我們團隊對TDD又愛又恨。「寫測試讓我們更有信心」，但「重構時要改一堆測試，還不如不寫」。這種矛盾讓我們反覆懷疑：TDD到底有沒有用？</p>
<p>深入研究Kent Beck的原著和Valentina Jemuović的演講後，才發現問題出在我們誤解了「測試單元」是什麼。</p>
<h2 id="痛苦的根本原因">痛苦的根本原因</h2>
<p>許多團隊學TDD時，都被教導「每個class寫一個test class，每個method寫一個test method」。這個看似合理的原則，埋下了長期的痛苦。</p>
<p>問題在於，這樣的測試耦合到了程式的<strong>結構</strong>，而非<strong>行為</strong>。只要重構——把一個class拆成兩個、把方法提取到新類別——測試就跟著破裂。維護測試的時間甚至超過寫功能本身。</p>
<p>Kent Beck在《Test Driven Development By Example》第一頁就寫道：</p>
<blockquote>
<p>&ldquo;Programmer tests should be sensitive to behavior changes and insensitive to structure changes.&rdquo;</p></blockquote>
<p>測試應該對行為的改變敏感，對結構的改變不敏感。如果重構時測試跟著爆炸，原因就在這裡。</p>
<h2 id="測試是可執行的需求規格">測試是可執行的需求規格</h2>
<p>需要先轉換一個根本認知：測試不是「驗證實作正確的工具」，而是<strong>用程式碼表達的需求規格書</strong>。</p>
<p>需求定義系統「應該做什麼」，實作是「怎麼做」的一種方式。需求應該保持穩定，實作可以隨時改變。Martin Fowler在《Refactoring》中說：</p>
<blockquote>
<p>&ldquo;Refactoring is a way of restructuring an existing body of code, altering its internal structure without changing its external behavior.&rdquo;</p></blockquote>
<p>重構改變內部結構，不改變外部行為。耦合到行為的測試，在重構時自然保持穩定。</p>
<h2 id="sociable-unit-tests把module當作測試單元">Sociable Unit Tests：把Module當作測試單元</h2>
<p>TDD有兩種截然不同的流派。</p>
<p><strong>Classical TDD</strong>（Kent Beck、Martin Fowler的做法）把Unit定義為Module——一個或多個協同工作的類別組合，對外提供清晰的Public API。測試只透過這個Public API互動，不知道Module內部有哪些類別、它們如何協作。唯一需要Mock的是真正的外部依賴：資料庫、檔案系統、外部服務。這種風格稱為<strong>Sociable Unit Tests</strong>。</p>
<p><strong>Mockist TDD</strong>（London School）把Unit定義為單一Class，Mock所有協作者。這種風格稱為<strong>Solitary Unit Tests</strong>。</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">Sociable: Test → [Module API] → Module Implementation（黑盒）
</span></span><span class="line"><span class="ln">2</span><span class="cl">Solitary: Test → Mock(B) → Class A → Class B
</span></span><span class="line"><span class="ln">3</span><span class="cl">                 Mock(C)           → Class C</span></span></code></pre></div><p>Sociable只有一條耦合線，Solitary有多條。每一條耦合線都是日後的維護成本。</p>
<h2 id="重構安全性的驗證">重構安全性的驗證</h2>
<p>判斷自己的測試是Sociable還是Solitary，有個簡單的驗證方法：</p>
<p>改變Module的內部邏輯、調整類別結構、重新命名內部方法。如果所有測試依然通過，不需要修改，那你寫的是Sociable（正確）。如果任何測試需要跟著改，那你寫的是Solitary（需要重新設計）。</p>
<p>以一個訂單提交的例子來說，Sociable測試看起來像這樣：</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">test</span><span class="p">(</span><span class="s1">&#39;使用者提交訂單成功&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="c1">// Given: Mock外部依賴（只Mock Repository）
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span>  <span class="n">when</span><span class="p">(</span><span class="n">mockRepository</span><span class="p">.</span><span class="n">save</span><span class="p">(</span><span class="n">any</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">      <span class="p">.</span><span class="n">thenAnswer</span><span class="p">((</span><span class="n">_</span><span class="p">)</span> <span class="kd">async</span> <span class="o">=&gt;</span> <span class="n">SaveResult</span><span class="p">.</span><span class="n">success</span><span class="p">(</span><span class="s1">&#39;order-123&#39;</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="c1">// When: 透過Use Case API提交訂單
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">result</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">submitOrderUseCase</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">order</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="c1">// Then: 驗證可觀察的行為結果
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span>  <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">isSuccess</span><span class="p">,</span> <span class="kc">true</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">orderId</span><span class="p">,</span> <span class="s1">&#39;order-123&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="c1">// 測試不知道Order內部如何計算、驗證
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span>  <span class="c1">// 測試使用真實的Domain Entities
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span><span class="p">});</span></span></span></code></pre></div><p>而Solitary測試會是：</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">test</span><span class="p">(</span><span class="s1">&#39;OrderService.submitOrder calls Repository.save&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="c1">// Given: Mock所有協作者
</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">mockOrder</span> <span class="o">=</span> <span class="n">MockOrder</span><span class="p">();</span>          <span class="c1">// 連Order也Mock了
</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">mockValidator</span> <span class="o">=</span> <span class="n">MockOrderValidator</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">mockCalculator</span> <span class="o">=</span> <span class="n">MockPriceCalculator</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">when</span><span class="p">(</span><span class="n">mockValidator</span><span class="p">.</span><span class="n">validate</span><span class="p">(</span><span class="n">mockOrder</span><span class="p">)).</span><span class="n">thenReturn</span><span class="p">(</span><span class="kc">true</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="n">when</span><span class="p">(</span><span class="n">mockCalculator</span><span class="p">.</span><span class="n">calculate</span><span class="p">(</span><span class="n">mockOrder</span><span class="p">)).</span><span class="n">thenReturn</span><span class="p">(</span><span class="m">100</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="n">when</span><span class="p">(</span><span class="n">mockRepository</span><span class="p">.</span><span class="n">save</span><span class="p">(</span><span class="n">mockOrder</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">      <span class="p">.</span><span class="n">thenAnswer</span><span class="p">((</span><span class="n">_</span><span class="p">)</span> <span class="kd">async</span> <span class="o">=&gt;</span> <span class="n">SaveResult</span><span class="p">.</span><span class="n">success</span><span class="p">(</span><span class="s1">&#39;order-123&#39;</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="c1">// Then: 驗證方法呼叫次數（實作細節）
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span>  <span class="n">verify</span><span class="p">(</span><span class="n">mockRepository</span><span class="p">.</span><span class="n">save</span><span class="p">(</span><span class="n">mockOrder</span><span class="p">)).</span><span class="n">called</span><span class="p">(</span><span class="m">1</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="c1">// 這個測試一旦重構OrderService的內部邏輯就會破裂
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1"></span><span class="p">});</span></span></span></code></pre></div><h2 id="test-first的速度優勢">Test-First的速度優勢</h2>
<p>Test-First（先寫測試）比Test-Last（先寫程式再補測試）快，原因是問題被發現的時間點更早。</p>
<p>Test-First的Red-Green-Refactor循環強迫你在寫實作之前先思考介面：「這個功能怎麼用？」、「測試容不容易寫？」介面設計問題在寫測試時（最早期）就暴露，修復成本最低。</p>
<p>Test-Last則是程式寫完了才發現難以測試，這時通常意味著設計有問題，要改動的範圍更大。Kent Beck說TDD更快，指的正是這個。</p>
<h2 id="bdd不是新方法是修正命名">BDD不是新方法，是修正命名</h2>
<p>Dan North在2006年創造「BDD」，目的是修正TDD命名造成的混淆。</p>
<p>他發現「Test」這個詞讓開發人員誤以為要測試每個類別和方法，於是用「Behavior」取代，讓意圖更清楚：測試的是行為，不是程式結構。這和Kent Beck 2003年說的完全一致，只是換了個能讓人更直覺理解的詞。</p>
<p>Google在《Software Engineering at Google》中也驗證同樣的結論：「Don&rsquo;t write a test for each method. Write a test for each behavior.」</p>
<h2 id="與clean-architecture的結合">與Clean Architecture的結合</h2>
<p>Sociable Unit Tests和Clean Architecture是天然的組合，因為建立在相同原則上：業務邏輯獨立於外部世界。</p>
<p>在Clean Architecture中，Use Cases層是業務邏輯的進入點，對外提供清晰的API，對內只使用Domain Entities和透過介面隔離的外部依賴（Repository、Gateway等）。這個結構天然對應Sociable的需求：Use Cases的Public API就是測試邊界，Domain Entities用真實物件，只有Repository需要Mock。</p>
<p>更重要的是，對Use Cases的Unit Test同時就是業務驗收測試。一個寫著「使用者提交訂單成功」的案例，不需要啟動UI也不需要真實資料庫，但驗證了完整的業務流程。Alistair Cockburn在提出Hexagonal Architecture時說：「Tests are another user of the system.」</p>
<p>並非所有情況都適合Sociable。數學演算法、加密系統這類需要細粒度驗證的場景，精確定位到具體類別比重構穩定性更重要，用Solitary合理。但大多數商業應用不是這類。</p>
<h2 id="結論">結論</h2>
<p>我們曾以為TDD很痛苦，但那是因為我們測試的是程式<strong>長什麼樣子</strong>，而不是它<strong>做什麼</strong>。</p>
<p>正確的做法只有一句話：測試透過Module的Public API互動，只Mock真正的外部依賴，使用真實的Domain Entities。</p>
<p>這樣的測試在重構時保持穩定，在功能改變時精準報警。Kent Beck、Dan North、Martin Fowler在不同年代說的是同一件事：<strong>測試行為，而非結構</strong>。</p>
<hr>
<p>參考資料：</p>
<ul>
<li>Kent Beck，《Test Driven Development By Example》，2003</li>
<li>Martin Fowler，《Refactoring: Improving the Design of Existing Code》，1999</li>
<li>Dan North，《Introducing BDD》，2006</li>
<li>Google，《Software Engineering at Google》，2020</li>
<li>Valentina (Cupać) Jemuović，<a href="https://www.youtube.com/watch?v=3wxiQB2-m2k">TDD and Clean Architecture - Driven by Behaviour</a></li>
</ul>]]></content:encoded></item><item><title>混合測試策略：根據架構層級選擇測試方法</title><link>https://tarrragon.github.io/blog/record/%E6%B7%B7%E5%90%88%E6%B8%AC%E8%A9%A6%E7%AD%96%E7%95%A5%E6%A0%B9%E6%93%9A%E6%9E%B6%E6%A7%8B%E5%B1%A4%E7%B4%9A%E9%81%B8%E6%93%87%E6%B8%AC%E8%A9%A6%E6%96%B9%E6%B3%95/</link><pubDate>Wed, 04 Mar 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/record/%E6%B7%B7%E5%90%88%E6%B8%AC%E8%A9%A6%E7%AD%96%E7%95%A5%E6%A0%B9%E6%93%9A%E6%9E%B6%E6%A7%8B%E5%B1%A4%E7%B4%9A%E9%81%B8%E6%93%87%E6%B8%AC%E8%A9%A6%E6%96%B9%E6%B3%95/</guid><description>&lt;p>開始實踐 TDD 時，我們遇到一個困惑的問題：什麼都測，還是只測部分？&lt;/p>
&lt;p>追求覆蓋率，會寫出大量測試 getter 和直接欄位映射的測試，維護成本高，保護力低。不管覆蓋率，又很難有信心說業務邏輯正確。&lt;/p>
&lt;p>答案是：測試策略跟著架構走。&lt;/p></description><content:encoded><![CDATA[<p>開始實踐 TDD 時，我們遇到一個困惑的問題：什麼都測，還是只測部分？</p>
<p>追求覆蓋率，會寫出大量測試 getter 和直接欄位映射的測試，維護成本高，保護力低。不管覆蓋率，又很難有信心說業務邏輯正確。</p>
<p>答案是：測試策略跟著架構走。</p>
<h2 id="問題的根源">問題的根源</h2>
<p>全面單元測試的問題是，重構 ViewModel 內部實作時，大量測試跟著壞掉，但行為根本沒變。全面 BDD 的問題是，Domain 層邊界條件很難透過業務語言的場景完整覆蓋。</p>
<p>不同層級的程式碼，用不同的測試方法。</p>
<h2 id="五層架構的測試分工">五層架構的測試分工</h2>
<p><strong>Layer 1（UI/Presentation）</strong> 只針對關鍵互動流程撰寫整合測試。判斷標準：流程失敗是否影響核心業務、是否需要多步驟操作、是否涉及金流或敏感資料。靜態展示頁面、簡單列表交給人工測試。</p>
<p><strong>Layer 2（Application/Behavior）</strong> 只針對複雜轉換邏輯撰寫單元測試。判斷標準：轉換是否包含條件判斷、計算邏輯、多個來源資料，或邏輯超過十行。簡單的 DTO 欄位直接映射，不需要獨立測試，由 UseCase 層間接覆蓋。</p>
<p><strong>Layer 3（UseCase）</strong> 所有業務場景都必須撰寫 BDD 測試，沒有例外。每個 UseCase 至少涵蓋一個正常流程、兩個異常流程、三個邊界條件。格式使用 Given-When-Then，只 Mock 外層依賴，使用真實的 Domain Entity。</p>
<p><strong>Layer 4（Interface）</strong> 不測試。介面只定義合約，沒有可測試的行為。</p>
<p><strong>Layer 5（Domain Implementation）</strong> 複雜業務規則必須撰寫單元測試。判斷標準：是否包含業務規則驗證、計算邏輯、狀態轉換、不變量檢查。Email 格式驗證、金額範圍、訂單狀態轉換都需要完整的單元測試。純資料容器的 Entity 不需要獨立測試。</p>
<h2 id="做決策的流程">做決策的流程</h2>
<p>確定程式碼屬於哪一層（目錄結構直接反映架構層級），然後問一個問題：</p>
<ul>
<li>UI 層：這是關鍵互動流程嗎？</li>
<li>Behavior 層：這裡有複雜轉換邏輯嗎？</li>
<li>UseCase 層：直接寫 BDD 測試。</li>
<li>Interface 層：不測試。</li>
<li>Domain 層：這裡有複雜業務規則嗎？</li>
</ul>
<p>答案是就寫，不是就跳過讓上層覆蓋。</p>
<h2 id="技術性測試項目">技術性測試項目</h2>
<p>不分層級都要納入：</p>
<ul>
<li>Null 值和空集合</li>
<li>邊界值（零、負數、最大值）</li>
<li>異常處理（網路錯誤、儲存失敗）</li>
<li>資料驗證（格式、範圍、必填欄位）</li>
</ul>
<p>這些容易被忽略，但往往是上線後出問題的地方。</p>
<h2 id="覆蓋率的意義">覆蓋率的意義</h2>
<p>這套策略讓覆蓋率指標更有意義。UseCase 層要求行為場景覆蓋率 100%，是所有業務場景都有測試，不是追求程式碼行數百分比。Domain 層複雜邏輯要求分支覆蓋率 100%，每個分支都代表一個業務決策。整體新增程式碼維持 80% 以上。</p>
<p>數字背後有實際意義，不是為了報告好看。</p>
<h2 id="測試的穩定性">測試的穩定性</h2>
<p>UseCase 層的 BDD 測試關注行為，重構內部邏輯只要業務行為沒變，測試就不需要動。Domain 層只有規則本身改變才需要更新。Behavior 層只測複雜的轉換邏輯，重構簡單映射不影響測試。</p>
<p>測試應該是開發的保護網，不是阻力。測試因為業務改變而失敗，那很好；因為重構而大量失敗，那是設計問題。</p>]]></content:encoded></item><item><title>層級架構品質檢查機制 - Clean Architecture 合規性驗證</title><link>https://tarrragon.github.io/blog/record/%E5%B1%A4%E7%B4%9A%E6%9E%B6%E6%A7%8B%E5%93%81%E8%B3%AA%E6%AA%A2%E6%9F%A5%E6%A9%9F%E5%88%B6-clean-architecture-%E5%90%88%E8%A6%8F%E6%80%A7%E9%A9%97%E8%AD%89/</link><pubDate>Wed, 04 Mar 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/record/%E5%B1%A4%E7%B4%9A%E6%9E%B6%E6%A7%8B%E5%93%81%E8%B3%AA%E6%AA%A2%E6%9F%A5%E6%A9%9F%E5%88%B6-clean-architecture-%E5%90%88%E8%A6%8F%E6%80%A7%E9%A9%97%E8%AD%89/</guid><description>&lt;p>在 Flutter 專案裡導入 Clean Architecture 並不難，難的是讓整個團隊在每一次 commit 都確實遵守它。我們曾有過這樣的經驗：架構設計文件寫得很完整，但三個月後打開 codebase，Widget 裡藏著業務規則、Controller 開始自己做驗證、UseCase 直接依賴了具體的資料庫實作。&lt;/p>
&lt;p>問題在於沒有機制讓「做錯事」變得困難。&lt;/p></description><content:encoded><![CDATA[<p>在 Flutter 專案裡導入 Clean Architecture 並不難，難的是讓整個團隊在每一次 commit 都確實遵守它。我們曾有過這樣的經驗：架構設計文件寫得很完整，但三個月後打開 codebase，Widget 裡藏著業務規則、Controller 開始自己做驗證、UseCase 直接依賴了具體的資料庫實作。</p>
<p>問題在於沒有機制讓「做錯事」變得困難。</p>
<h2 id="為什麼架構會悄悄腐化">為什麼架構會悄悄腐化</h2>
<p>Clean Architecture 的核心是依賴方向：外層可以依賴內層，但內層絕對不能依賴外層。這個原則說起來簡單，但「快速解決問題」的衝動很容易讓人走捷徑。一個業務驗證邏輯，放在 Widget 裡只要三行；把它搬到正確的 Domain 層，可能需要新增 Entity 方法、更新 UseCase、再補上測試。</p>
<p>在時間壓力下，捷徑獲勝了。</p>
<p>更麻煩的是，這種腐化是漸進的。第一次違規很小；第二次引用了第一次的前例；到了第六次，層級的邊界已經模糊得看不清楚了。</p>
<p>解法是：不依賴自律，改依賴機制。把架構規則轉化為可以自動執行的檢查。</p>
<h2 id="用檔案路徑判斷層級歸屬">用檔案路徑判斷層級歸屬</h2>
<p>我們採用的策略是<strong>用檔案路徑作為層級的明確宣告</strong>。一個檔案放在什麼目錄，就代表它屬於哪一層：</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">lib/
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">├── ui/                    // 展示層（Layer 1）
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">├── application/           // 應用行為層（Layer 2）
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">├── usecases/              // UseCase 層（Layer 3）
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">├── domain/
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">│   ├── events/            // Domain 事件層（Layer 4）
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">│   ├── interfaces/        // 介面定義層（Layer 4）
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">│   ├── entities/          // Domain 實作層（Layer 5）
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">│   ├── value_objects/     // 值物件（Layer 5）
</span></span><span class="line"><span class="ln">10</span><span class="cl">│   └── services/          // Domain 服務（Layer 5）
</span></span><span class="line"><span class="ln">11</span><span class="cl">└── infrastructure/        // 基礎設施層</span></span></code></pre></div><p>這讓我們可以用簡單的字串比對判斷：這個 PR 動了哪些層的檔案？一個 Ticket 聲稱只修改展示層，但 diff 裡出現了 <code>lib/domain/</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">test/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── ui/           // 對應展示層修改
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── application/  // 對應應用行為層修改
</span></span><span class="line"><span class="ln">4</span><span class="cl">├── usecases/     // 對應 UseCase 層修改
</span></span><span class="line"><span class="ln">5</span><span class="cl">└── domain/       // 對應 Domain 層修改</span></span></code></pre></div><p>修改了某個層，對應的測試目錄裡就必須有覆蓋。「測試覆蓋率」從一個抽象數字，變成了具體的結構性要求。</p>
<h2 id="三種最常見的違規模式">三種最常見的違規模式</h2>
<p>追蹤了幾十個架構違規案例之後，幾乎都落在以下三種模式。</p>
<h3 id="展示層包含業務邏輯">展示層包含業務邏輯</h3>
<p>Widget 直接呼叫過濾、排序、計算這類業務操作：</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">// 違規：Widget 自己做了業務邏輯
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kd">class</span> <span class="nc">BookListWidget</span> <span class="kd">extends</span> <span class="n">StatelessWidget</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="n">Widget</span> <span class="n">build</span><span class="p">(</span><span class="n">BuildContext</span> <span class="n">context</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">final</span> <span class="n">books</span> <span class="o">=</span> <span class="n">_filterNewBooks</span><span class="p">(</span><span class="n">_getAllBooks</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="n">ListView</span><span class="p">.</span><span class="n">builder</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 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="c1">// 正確：Widget 只負責把 controller 的狀態渲染出來
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span><span class="kd">class</span> <span class="nc">BookListWidget</span> <span class="kd">extends</span> <span class="n">StatelessWidget</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="kd">final</span> <span class="n">BookListController</span> <span class="n">controller</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="n">Widget</span> <span class="n">build</span><span class="p">(</span><span class="n">BuildContext</span> <span class="n">context</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="n">ListView</span><span class="p">.</span><span class="n">builder</span><span class="p">(</span><span class="nl">items:</span> <span class="n">controller</span><span class="p">.</span><span class="n">filteredBooks</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>「什麼樣的書算新書」是業務邏輯，應該在 Domain 層定義。Widget 只做一件事：把資料渲染成畫面。</p>
<h3 id="controller-包含業務規則">Controller 包含業務規則</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="c1">// 違規：Controller 自己在做 ISBN 驗證
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kd">class</span> <span class="nc">BookController</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="n">Future</span><span class="o">&lt;</span><span class="kt">void</span><span class="o">&gt;</span> <span class="n">addBook</span><span class="p">(</span><span class="n">Book</span> <span class="n">book</span><span class="p">)</span> <span class="kd">async</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="n">book</span><span class="p">.</span><span class="n">isbn</span><span class="p">.</span><span class="n">length</span> <span class="o">!=</span> <span class="m">13</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">      <span class="k">throw</span> <span class="n">ValidationException</span><span class="p">(</span><span class="s1">&#39;ISBN 必須為 13 碼&#39;</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 class="kd">await</span> <span class="n">bookRepository</span><span class="p">.</span><span class="n">save</span><span class="p">(</span><span class="n">book</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></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">// 正確：Controller 只負責呼叫 UseCase
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span><span class="kd">class</span> <span class="nc">BookController</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="kd">final</span> <span class="n">AddBookUseCase</span> <span class="n">addBookUseCase</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="n">Future</span><span class="o">&lt;</span><span class="kt">void</span><span class="o">&gt;</span> <span class="n">addBook</span><span class="p">(</span><span class="n">Book</span> <span class="n">book</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="kd">await</span> <span class="n">addBookUseCase</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">book</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>「ISBN 必須為 13 碼」是業務規則，應該活在 <code>Book</code> Entity 或 Value Object 裡。Controller 的角色是協調，不是決策。</p>
<h3 id="usecase-依賴具體實作">UseCase 依賴具體實作</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="c1">// 違規：依賴具體的 SQLite 實作
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kd">class</span> <span class="nc">SearchBookUseCase</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">SqliteBookRepository</span> <span class="n">repository</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="c1">// 正確：依賴抽象介面
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span><span class="kd">class</span> <span class="nc">SearchBookUseCase</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="kd">final</span> <span class="n">IBookRepository</span> <span class="n">repository</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>依賴介面讓 UseCase 在測試時注入 Mock，生產環境注入真實實作，兩者互換自如。</p>
<h2 id="把檢查機制自動化">把檢查機制自動化</h2>
<p>辨識出違規模式之後，我們做的第一件事是把檢查寫進工具裡。</p>
<h3 id="pre-commit-hook">Pre-commit Hook</h3>





<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="cp">#!/bin/bash
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="cp"></span>./scripts/check_single_layer_modification.sh <span class="o">||</span> <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">flutter <span class="nb">test</span> --coverage <span class="o">||</span> <span class="nb">exit</span> <span class="m">1</span></span></span></code></pre></div><p><code>check_single_layer_modification.sh</code> 分析 commit 的 diff，確認被修改的檔案是否都屬於同一個架構層。一個本來只應動展示層的 commit，如果同時修改了 Domain 層的檔案，腳本就會退出並阻止 commit。</p>
<h3 id="cicd-整合">CI/CD 整合</h3>
<p>Pre-commit Hook 可以被繞過，但 CI/CD 不會：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">PR Architecture Check</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">on</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">pull_request]</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">  </span><span class="nt">architecture_check</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="nt">steps</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="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">檢查單層修改原則</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">./scripts/check_single_layer_in_pr.sh</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">執行測試並確認覆蓋率</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">flutter test --coverage</span></span></span></code></pre></div><p>架構合規性成為 PR 合併的硬性前置條件。</p>
<h2 id="每次-commit-前的自我檢查">每次 commit 前的自我檢查</h2>
<p>自動化工具處理可以被程式判斷的規則，剩下的需要開發者自己過一遍：</p>
<ul>
<li>這次修改的檔案，是否都屬於同一個架構層？</li>
<li>import 方向是否正確——只有外層依賴內層？</li>
<li>測試檔案路徑和被測試程式碼是否在對應的層級目錄？</li>
<li>有沒有 Widget 直接做業務計算、Controller 直接做驗證？</li>
</ul>
<p>三十秒可以過完，但幾乎每次都能在 commit 前抓住一兩個值得重新考慮的決定。</p>
<h2 id="機制比自律更可靠">機制比自律更可靠</h2>
<p>導入這套機制之後，code review 上花的精力少了很多——大多數架構層面的問題在進入 review 之前就已經被攔截。reviewer 可以把注意力放在邏輯正確性和設計決策上，不用反覆提醒「這段邏輯不應該放在 Widget 裡」。</p>
<p>對新加入的開發者也很友善：不需要先把架構文件背熟才能開始開發，工具會在走錯方向時給出明確的反饋。</p>
<p>架構的生命力在於它能不能在日常開發壓力下被維護下去。</p>]]></content:encoded></item><item><title>層級隔離：讓每張 Ticket 只做一件層級的事</title><link>https://tarrragon.github.io/blog/record/%E5%B1%A4%E7%B4%9A%E9%9A%94%E9%9B%A2%E8%AE%93%E6%AF%8F%E5%BC%B5-ticket-%E5%8F%AA%E5%81%9A%E4%B8%80%E4%BB%B6%E5%B1%A4%E7%B4%9A%E7%9A%84%E4%BA%8B/</link><pubDate>Wed, 04 Mar 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/record/%E5%B1%A4%E7%B4%9A%E9%9A%94%E9%9B%A2%E8%AE%93%E6%AF%8F%E5%BC%B5-ticket-%E5%8F%AA%E5%81%9A%E4%B8%80%E4%BB%B6%E5%B1%A4%E7%B4%9A%E7%9A%84%E4%BA%8B/</guid><description>&lt;p>架構圖貼出來，層級畫得漂漂亮亮，但 PR 送進來還是一次動了 UI、Controller、UseCase 和 Entity 四層。&lt;/p></description><content:encoded><![CDATA[<p>架構圖貼出來，層級畫得漂漂亮亮，但 PR 送進來還是一次動了 UI、Controller、UseCase 和 Entity 四層。</p>
<h2 id="問題不在架構在派工">問題不在架構，在派工</h2>
<p>Clean Architecture 告訴你「怎麼組織程式碼」，但沒告訴你「怎麼拆 Ticket」。每次 Code Review 都像翻地層，從 Widget 翻到 Entity，不知道從哪開始看；Domain 沒穩定，UI 那層就沒辦法測，整個流程互相等待。</p>
<p>這個銜接點，需要一套專門處理 Ticket 拆法的方法論。</p>
<h2 id="核心原則一張-ticket一個層級">核心原則：一張 Ticket，一個層級</h2>
<blockquote>
<p>一個 Ticket 只應該修改單一架構層級的程式碼，變更的原因單一且明確。</p></blockquote>
<p>SRP 說一個類別只有一個改變的原因，我們把它升一層：一張 Ticket 也只有一個改變的原因。</p>
<p>聽起來嚴苛，但實際跑起來好處很直接：Code Review 只需要理解一層的邏輯、測試不需要拉起整個系統、PR 影響範圍可預測，壞掉的時候更容易定位。</p>
<h2 id="我們怎麼定義五層">我們怎麼定義「五層」</h2>
<p>傳統 Clean Architecture 四層中，Interface Adapters 同時處理「事件邏輯」和「資料轉換」，職責太雜，我們把它細分成五層：</p>
<p><strong>Layer 1 — UI/Presentation</strong>：純視覺呈現，Widget 長什麼樣。變更原因只有一個：設計稿改了。</p>
<p><strong>Layer 2 — Application/Behavior</strong>：事件處理和 UI 邏輯。按鈕點擊怎麼處理、Loading 狀態怎麼切換、Domain Entity 怎麼轉成 ViewModel。Flutter 對應 Controller 和 ViewModel。</p>
<p><strong>Layer 3 — UseCase</strong>：業務流程編排。協調多個 Repository 和 Domain Service，把業務步驟串起來。不管 UI 怎麼顯示，也不管資料庫怎麼存。</p>
<p><strong>Layer 4 — Domain Events/Interfaces</strong>：定義契約。Repository 抽象介面、Domain Event 結構、跨層 DTO。只定義，不實作。</p>
<p><strong>Layer 5 — Domain Implementation</strong>：核心業務邏輯。Entity、Value Object、Domain Service、業務規則驗證。整個系統最穩定的部分。</p>
<p>Infrastructure 層（資料庫、外部 API、EventBus）不納入層級隔離，它的變更驅動是技術決策，不是業務需求，Ticket 設計上本來就獨立對待。</p>
<h2 id="從外而內而不是從內而外">從外而內，而不是從內而外</h2>
<p>許多教材說「先設計 Domain 再往外做」，但實際開發時，我們發現從外而內更能控制風險。</p>
<p>原因很簡單：Layer 1 UI 壞掉只影響視覺，Layer 5 Domain 邏輯壞掉影響整個系統的業務規則。從影響最小的地方開始，需求偏差時調整成本低；一開始就動 Domain，到了 UI 才發現需求理解有誤，代價就大得多。</p>
<p>實作順序是 Layer 1 → Layer 2 → Layer 3 → Layer 4 → Layer 5，每層完成後立即驗證。</p>
<p>有幾個例外：架構遷移要先定義 Layer 4 介面契約（Interface-First），讓外層修改有穩定依據；安全性修復從 Layer 5 往外；Bug Fix 從問題根源那層開始。</p>
<h2 id="ticket-拆分的量化標準">Ticket 拆分的量化標準</h2>
<p>幾個判斷指標：修改檔案數 1 到 3 個（最多 5 個）、預估開發時間 2 到 8 小時（超過一天就拆）、修改層級嚴格限制 1 層、新增程式碼測試覆蓋率 100%。</p>
<p>數字可以商議，但有標準就不用靠直覺判斷「感覺差不多」。</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">Ticket：實作書籍收藏功能
</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">- lib/ui/pages/book_detail_page.dart       (Layer 1)
</span></span><span class="line"><span class="ln">5</span><span class="cl">- lib/application/controllers/book_detail_controller.dart  (Layer 2)
</span></span><span class="line"><span class="ln">6</span><span class="cl">- lib/usecases/add_book_to_favorite_usecase.dart  (Layer 3)
</span></span><span class="line"><span class="ln">7</span><span class="cl">- lib/domain/entities/favorite.dart        (Layer 5)</span></span></code></pre></div><p>這張 Ticket 跨了四層，PR 送出來沒人知道從哪開始審，測試也很難設計。正確做法是拆成四張各自獨立的 Ticket，按依賴順序執行。</p>
<h2 id="如何判斷一段程式碼屬於哪一層">如何判斷一段程式碼屬於哪一層</h2>
<p>最常模糊的是 Layer 2 和 Layer 3 之間的邊界。判斷流程：</p>
<ol>
<li>在渲染 UI 元素？→ Layer 1</li>
<li>在處理 UI 事件、控制 UI 狀態、或把 Domain 資料轉成 UI 格式？→ Layer 2（把 Domain Exception 轉成 ErrorViewModel 也是這層的事）</li>
<li>在協調多個 Domain Service 或 Repository、編排業務步驟？→ Layer 3</li>
<li>在定義介面契約或事件結構？→ Layer 4</li>
<li>在實作業務規則或定義 Entity？→ Layer 5</li>
<li>以上都不是 → Infrastructure 層</li>
</ol>
<h2 id="這套方法論的定位">這套方法論的定位</h2>
<p>這是 Clean Architecture 的「派工指南」。Clean Architecture 告訴你程式碼怎麼組織，層級隔離告訴你 Ticket 怎麼拆、按什麼順序做。</p>
<p>它和 Atomic Ticket 方法論也不衝突：Atomic Ticket 強調職責維度（一個 Action 加一個 Target），層級隔離強調層級維度（一個 Ticket 只動一層），兩個維度同時符合才是最完整的 Ticket 設計。</p>
<p>緊急 Hotfix、原型開發、一次性腳本不需要強行套用。但在正常功能開發和重構中，跑起來之後的感覺是：每次把一個大需求拆成按層排好的 Ticket 序列，就等於把架構邊界重新確認了一遍。</p>]]></content:encoded></item></channel></rss>