<?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>模組四：架構邊界與事件系統 on Tarragon</title><link>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/</link><description>Recent content in 模組四：架構邊界與事件系統 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/go-advanced/04-architecture-boundaries/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.2 事件去重與語義鍵設計</title><link>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/dedup-key/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/dedup-key/</guid><description>&lt;p>事件去重的核心規則是用領域語意判斷「哪兩筆事件代表同一件事」。原始 payload、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/request-id/" data-link-title="Request ID" data-link-desc="說明單次 request 的識別碼如何支援 log 搜尋與問題定位">request ID&lt;/a>、收到時間和重試次數常常每次都不同，直接拿來比對會讓去重失效。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨 event ID 去重與 domain key 去重的差異&lt;/li>
&lt;li>用 subject、event type、source group 與時間窗口設計 &lt;code>DedupKey&lt;/code>&lt;/li>
&lt;li>避免把不穩定欄位放進去重鍵&lt;/li>
&lt;li>設計去重表的過期與清理策略&lt;/li>
&lt;li>用 table-driven test 驗證去重邊界&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察重複事件不一定長得一樣">【觀察】重複事件不一定長得一樣&lt;/h2>
&lt;p>重複事件的核心困難是外觀可能不同。HTTP callback 可能每次都有新的 request ID，&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> message 可能因 retry 改變 delivery tag，timer 可能在下一輪掃描再次產生類似事件。&lt;/p>
&lt;p>兩筆外部輸入可能長這樣：&lt;/p>





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




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





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





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





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




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





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





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





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





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestDedupKey</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">base</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">tests</span> <span class="o">:=</span> <span class="p">[]</span><span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">name</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">a</span>    <span class="nx">DomainEvent</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">b</span>    <span class="nx">DomainEvent</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">same</span> <span class="kt">bool</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="nx">name</span><span class="p">:</span> <span class="s">&#34;same subject type and window&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="nx">a</span><span class="p">:</span> <span class="nx">DomainEvent</span><span class="p">{</span><span class="nx">SubjectID</span><span class="p">:</span> <span class="s">&#34;acct_1&#34;</span><span class="p">,</span> <span class="nx">Type</span><span class="p">:</span> <span class="nx">EventAccountActivated</span><span class="p">,</span> <span class="nx">Source</span><span class="p">:</span> <span class="nx">SourceHTTPCallback</span><span class="p">,</span> <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">base</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="nx">b</span><span class="p">:</span> <span class="nx">DomainEvent</span><span class="p">{</span><span class="nx">SubjectID</span><span class="p">:</span> <span class="s">&#34;acct_1&#34;</span><span class="p">,</span> <span class="nx">Type</span><span class="p">:</span> <span class="nx">EventAccountActivated</span><span class="p">,</span> <span class="nx">Source</span><span class="p">:</span> <span class="nx">SourceQueue</span><span class="p">,</span> <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">base</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">5</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">)},</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="nx">same</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">            <span class="nx">name</span><span class="p">:</span> <span class="s">&#34;different subject&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">            <span class="nx">a</span><span class="p">:</span> <span class="nx">DomainEvent</span><span class="p">{</span><span class="nx">SubjectID</span><span class="p">:</span> <span class="s">&#34;acct_1&#34;</span><span class="p">,</span> <span class="nx">Type</span><span class="p">:</span> <span class="nx">EventAccountActivated</span><span class="p">,</span> <span class="nx">Source</span><span class="p">:</span> <span class="nx">SourceHTTPCallback</span><span class="p">,</span> <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">base</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">            <span class="nx">b</span><span class="p">:</span> <span class="nx">DomainEvent</span><span class="p">{</span><span class="nx">SubjectID</span><span class="p">:</span> <span class="s">&#34;acct_2&#34;</span><span class="p">,</span> <span class="nx">Type</span><span class="p">:</span> <span class="nx">EventAccountActivated</span><span class="p">,</span> <span class="nx">Source</span><span class="p">:</span> <span class="nx">SourceHTTPCallback</span><span class="p">,</span> <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">base</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">            <span class="nx">same</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">            <span class="nx">name</span><span class="p">:</span> <span class="s">&#34;outside window&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">            <span class="nx">a</span><span class="p">:</span> <span class="nx">DomainEvent</span><span class="p">{</span><span class="nx">SubjectID</span><span class="p">:</span> <span class="s">&#34;acct_1&#34;</span><span class="p">,</span> <span class="nx">Type</span><span class="p">:</span> <span class="nx">EventAccountActivated</span><span class="p">,</span> <span class="nx">Source</span><span class="p">:</span> <span class="nx">SourceHTTPCallback</span><span class="p">,</span> <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">base</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">            <span class="nx">b</span><span class="p">:</span> <span class="nx">DomainEvent</span><span class="p">{</span><span class="nx">SubjectID</span><span class="p">:</span> <span class="s">&#34;acct_1&#34;</span><span class="p">,</span> <span class="nx">Type</span><span class="p">:</span> <span class="nx">EventAccountActivated</span><span class="p">,</span> <span class="nx">Source</span><span class="p">:</span> <span class="nx">SourceHTTPCallback</span><span class="p">,</span> <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">base</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">)},</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">            <span class="nx">same</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">tt</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">tests</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Run</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">name</span><span class="p">,</span> <span class="kd">func</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">            <span class="nx">got</span> <span class="o">:=</span> <span class="nf">NewDedupKey</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">a</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">)</span> <span class="o">==</span> <span class="nf">NewDedupKey</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">b</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">            <span class="k">if</span> <span class="nx">got</span> <span class="o">!=</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">same</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">                <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;same key = %v, want %v&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">same</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">        <span class="p">})</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試把來源融合、subject 差異與時間窗口都明確化。未來調整 key 時，測試會提醒你正在改變事件語意，而不只是改一個 struct。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一服務內的事件去重語意；跨節點一致性與 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> store，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/backend/02-cache-redis/" data-link-title="模組二：快取與 Redis" data-link-desc="整理快取策略、Redis 資料型別與分散式狀態輔助能力">Backend：快取與 Redis</a></li>
<li><a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">Backend：資料庫與持久化</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Go 進階：Durable queue、outbox 與 idempotency</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 event normalization、processor 與 source priority；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">Go：如何新增一種 domain event</a></li>
<li><a href="/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程</a></li>
<li><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
<li><a href="/blog/go/07-refactoring/dedup-refactor/" data-link-title="7.3 事件去重邏輯的重構策略" data-link-desc="保留語義鍵並降低重複流程">Go：事件去重邏輯的重構策略</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>事件去重是領域語意設計，不是 payload 比對。好的 <code>DedupKey</code> 會使用 subject、event type、source set 與合適的 occurred time window，並避免 request ID、收到時間與 raw payload hash 這類不穩定欄位。去重表還必須有清理策略，否則事件系統會用記憶體 leak 換取短期正確性。</p>
]]></content:encoded></item><item><title>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>4.4 多來源 event 融合</title><link>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/event-fusion/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/event-fusion/</guid><description>&lt;p>事件融合的核心目標是讓不同來源的同類事件進入同一套內部規則。HTTP callback、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> message、timer scan 與檔案 reader 都只是輸入方式；進入 processor 前，它們應該被轉成一致的 &lt;code>DomainEvent&lt;/code>。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨來源差異與 domain 規則差異&lt;/li>
&lt;li>為不同來源設計 adapter 與 normalize&lt;/li>
&lt;li>用 channel 或直接呼叫收斂事件入口&lt;/li>
&lt;li>為突發流量設計 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a> 策略&lt;/li>
&lt;li>決定錯誤應回給上游、重試、丟棄或記錄&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察來源增加後規則容易分裂">【觀察】來源增加後規則容易分裂&lt;/h2>
&lt;p>事件來源增加的核心風險是每個來源各自實作一套處理規則。HTTP handler 有一套 validation，queue &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer&lt;/a> 有一套 retry 判斷，timer worker 又有一套狀態更新；最後同一種 domain event 在不同入口產生不同結果。&lt;/p>
&lt;p>反模式示意：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">HTTP callback ──&amp;gt; validate A ──&amp;gt; update state A
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">queue message ──&amp;gt; validate B ──&amp;gt; update state B
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">timer scan ──&amp;gt; validate C ──&amp;gt; update state C&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這種結構的問題是 domain 規則分裂。新增來源時，應該新增 adapter，不應複製 processor。&lt;/p>
&lt;h2 id="判讀來源差異應限制在-adapter">【判讀】來源差異應限制在 adapter&lt;/h2>
&lt;p>事件融合的核心原則是來源差異停在 adapter 與 normalizer。來源可以有不同 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/authentication/" data-link-title="Authentication" data-link-desc="說明系統如何確認呼叫者身份">authentication&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack&lt;/a>、HTTP status、payload 格式與重試語意；但轉成 &lt;code>DomainEvent&lt;/code> 後，processor 應該面對一致模型。&lt;/p>
&lt;p>目標結構：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">HTTP callback ─┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">queue message ─┼─&amp;gt; normalize ─&amp;gt; DomainEvent ─&amp;gt; processor
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">timer scan ─┘&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個結構讓新增來源變成局部擴充。你新增一個 adapter 與 normalize test，而不是複製 validation、dedup、repository update 與 publish 邏輯。&lt;/p>
&lt;h2 id="策略先定義每個來源的責任">【策略】先定義每個來源的責任&lt;/h2>
&lt;p>來源設計的核心動作是明確寫出每個 adapter 對上游的承諾。不同來源的錯誤回應方式不同，但進入 processor 的事件語意應一致。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>來源&lt;/th>
 &lt;th>adapter 責任&lt;/th>
 &lt;th>失敗回應&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>HTTP callback&lt;/td>
 &lt;td>decode JSON、驗證簽章、normalize&lt;/td>
 &lt;td>回 4xx/5xx&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>queue consumer&lt;/td>
 &lt;td>decode message、控制 ack/nack、normalize&lt;/td>
 &lt;td>ack、nack 或 retry&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>timer scan&lt;/td>
 &lt;td>讀取本地狀態、產生內部事件&lt;/td>
 &lt;td>記錄錯誤或下次再掃&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>file reader&lt;/td>
 &lt;td>讀取增量資料、normalize&lt;/td>
 &lt;td>記錄 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset&lt;/a> 或停下&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>表格是設計工具。若某一列寫不清楚，代表 adapter 與 processor 的邊界還不清楚。&lt;/p>
&lt;h2 id="執行http-adapter-轉成-domainevent">【執行】HTTP adapter 轉成 DomainEvent&lt;/h2>
&lt;p>HTTP adapter 的核心責任是處理 HTTP 協定與外部 payload。它可以回應 status code，但不應直接決定狀態如何更新。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">HTTPEventHandler&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">processor&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">EventProcessor&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">now&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">h&lt;/span> &lt;span class="nx">HTTPEventHandler&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">ServeHTTP&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">raw&lt;/span> &lt;span class="nx">RawHTTPEvent&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewDecoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Decode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">raw&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;invalid_json&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">event&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NormalizeHTTPEvent&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">raw&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">h&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">now&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;invalid_event&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">h&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">processor&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Process&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Context&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusServiceUnavailable&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;event_not_accepted&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusAccepted&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>StatusAccepted&lt;/code> 表示事件已被系統接收，不一定表示所有下游推送都完成。若 API 語意要求同步完成，就需要在文件與測試中明確定義成功條件。&lt;/p></description><content:encoded><![CDATA[<p>事件融合的核心目標是讓不同來源的同類事件進入同一套內部規則。HTTP callback、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> message、timer scan 與檔案 reader 都只是輸入方式；進入 processor 前，它們應該被轉成一致的 <code>DomainEvent</code>。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨來源差異與 domain 規則差異</li>
<li>為不同來源設計 adapter 與 normalize</li>
<li>用 channel 或直接呼叫收斂事件入口</li>
<li>為突發流量設計 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 策略</li>
<li>決定錯誤應回給上游、重試、丟棄或記錄</li>
</ol>
<hr>
<h2 id="觀察來源增加後規則容易分裂">【觀察】來源增加後規則容易分裂</h2>
<p>事件來源增加的核心風險是每個來源各自實作一套處理規則。HTTP handler 有一套 validation，queue <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a> 有一套 retry 判斷，timer worker 又有一套狀態更新；最後同一種 domain event 在不同入口產生不同結果。</p>
<p>反模式示意：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">HTTP callback ──&gt; validate A ──&gt; update state A
</span></span><span class="line"><span class="ln">2</span><span class="cl">queue message ──&gt; validate B ──&gt; update state B
</span></span><span class="line"><span class="ln">3</span><span class="cl">timer scan    ──&gt; validate C ──&gt; update state C</span></span></code></pre></div><p>這種結構的問題是 domain 規則分裂。新增來源時，應該新增 adapter，不應複製 processor。</p>
<h2 id="判讀來源差異應限制在-adapter">【判讀】來源差異應限制在 adapter</h2>
<p>事件融合的核心原則是來源差異停在 adapter 與 normalizer。來源可以有不同 <a href="/blog/backend/knowledge-cards/authentication/" data-link-title="Authentication" data-link-desc="說明系統如何確認呼叫者身份">authentication</a>、<a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack</a>、HTTP status、payload 格式與重試語意；但轉成 <code>DomainEvent</code> 後，processor 應該面對一致模型。</p>
<p>目標結構：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">HTTP callback ─┐
</span></span><span class="line"><span class="ln">2</span><span class="cl">queue message ─┼─&gt; normalize ─&gt; DomainEvent ─&gt; processor
</span></span><span class="line"><span class="ln">3</span><span class="cl">timer scan    ─┘</span></span></code></pre></div><p>這個結構讓新增來源變成局部擴充。你新增一個 adapter 與 normalize test，而不是複製 validation、dedup、repository update 與 publish 邏輯。</p>
<h2 id="策略先定義每個來源的責任">【策略】先定義每個來源的責任</h2>
<p>來源設計的核心動作是明確寫出每個 adapter 對上游的承諾。不同來源的錯誤回應方式不同，但進入 processor 的事件語意應一致。</p>
<table>
  <thead>
      <tr>
          <th>來源</th>
          <th>adapter 責任</th>
          <th>失敗回應</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>HTTP callback</td>
          <td>decode JSON、驗證簽章、normalize</td>
          <td>回 4xx/5xx</td>
      </tr>
      <tr>
          <td>queue consumer</td>
          <td>decode message、控制 ack/nack、normalize</td>
          <td>ack、nack 或 retry</td>
      </tr>
      <tr>
          <td>timer scan</td>
          <td>讀取本地狀態、產生內部事件</td>
          <td>記錄錯誤或下次再掃</td>
      </tr>
      <tr>
          <td>file reader</td>
          <td>讀取增量資料、normalize</td>
          <td>記錄 <a href="/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset</a> 或停下</td>
      </tr>
  </tbody>
</table>
<p>表格是設計工具。若某一列寫不清楚，代表 adapter 與 processor 的邊界還不清楚。</p>
<h2 id="執行http-adapter-轉成-domainevent">【執行】HTTP adapter 轉成 DomainEvent</h2>
<p>HTTP adapter 的核心責任是處理 HTTP 協定與外部 payload。它可以回應 status code，但不應直接決定狀態如何更新。</p>





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





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





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





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





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestHTTPAndQueueNormalizeToSameDomainEvent</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">receivedAt</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">httpEvent</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">NormalizeHTTPEvent</span><span class="p">(</span><span class="nx">RawHTTPEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">EventID</span><span class="p">:</span>   <span class="s">&#34;evt_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">AccountID</span><span class="p">:</span> <span class="s">&#34;acct_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">EventName</span><span class="p">:</span> <span class="s">&#34;activated&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">Timestamp</span><span class="p">:</span> <span class="s">&#34;2026-04-22T10:00:00Z&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">},</span> <span class="nx">receivedAt</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;normalize http event: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">queueEvent</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">NormalizeQueueMessage</span><span class="p">([]</span><span class="nb">byte</span><span class="p">(</span><span class="s">`{
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="s">        &#34;id&#34;:&#34;evt_1&#34;,
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="s">        &#34;subject&#34;:&#34;acct_1&#34;,
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="s">        &#34;type&#34;:&#34;account.activated&#34;,
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="s">        &#34;occurred_at&#34;:&#34;2026-04-22T10:00:00Z&#34;
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="s">    }`</span><span class="p">),</span> <span class="nx">receivedAt</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;normalize queue event: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="k">if</span> <span class="nx">httpEvent</span><span class="p">.</span><span class="nx">Type</span> <span class="o">!=</span> <span class="nx">queueEvent</span><span class="p">.</span><span class="nx">Type</span> <span class="o">||</span> <span class="nx">httpEvent</span><span class="p">.</span><span class="nx">SubjectID</span> <span class="o">!=</span> <span class="nx">queueEvent</span><span class="p">.</span><span class="nx">SubjectID</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;sources should normalize to same domain semantics&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試不是要求兩個 event 完全相同。<code>Source</code> 可以不同；重點是 domain semantics 一致，processor 才能共用規則。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理多來源事件如何在單一服務內融合；queue driver、outbox 與 tracing，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Go 進階：Durable queue、outbox 與 idempotency</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">Go 進階：Observability pipeline、metrics 與 tracing</a></li>
<li><a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是多來源 adapter、normalize 與 processor 的路線；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/new-websocket-action/" data-link-title="6.1 如何新增一個即時訊息 action" data-link-desc="修改 client message、路由與 handler">Go：如何新增一個即時訊息 action</a></li>
<li><a href="/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程</a></li>
<li><a href="/blog/go/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">Go：如何新增一種 domain event</a></li>
<li><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>事件融合的核心是把來源差異限制在 adapter 與 normalizer，讓 processor 只面對一致的 <code>DomainEvent</code>。HTTP、queue、timer 可以有不同的 backpressure 與錯誤回應，但不應複製 domain 規則。當來源增加時，系統應該增加 adapter，而不是增加另一套狀態更新流程。</p>
]]></content:encoded></item></channel></rss>