<?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>Refactoring on Tarragon</title><link>https://tarrragon.github.io/blog/tags/refactoring/</link><description>Recent content in Refactoring on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Thu, 23 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/refactoring/index.xml" rel="self" type="application/rss+xml"/><item><title>7.1 把 handler 邏輯拆成可測單元</title><link>https://tarrragon.github.io/blog/go/07-refactoring/handler-boundary/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/07-refactoring/handler-boundary/</guid><description>&lt;p>handler 重構的核心目標是把 transport concern 和 application concern 分開。handler 應處理 request/response，usecase 應處理行為規則，domain 應保存狀態語意。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>辨識 handler 過重的訊號&lt;/li>
&lt;li>把 request DTO 與 command 分開&lt;/li>
&lt;li>把業務規則搬到 usecase&lt;/li>
&lt;li>讓 handler 只做 request/response 轉換&lt;/li>
&lt;li>分開撰寫 usecase test、handler test 與少量 integration test&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察過重-handler-會混合三種責任">【觀察】過重 handler 會混合三種責任&lt;/h2>
&lt;p>handler 過重的核心問題是 transport、application 與 state concern 混在同一個函式。當一個 handler 同時解析 JSON、驗證欄位、檢查重複、修改 map、組 response，它就很難測，也很難重用。&lt;/p>
&lt;p>常見壞味道：&lt;/p>
&lt;ul>
&lt;li>handler 超過一兩個螢幕。&lt;/li>
&lt;li>測試核心規則必須透過 HTTP。&lt;/li>
&lt;li>JSON tag 出現在 domain type 上。&lt;/li>
&lt;li>handler 直接改 repository 的 map 或 slice。&lt;/li>
&lt;li>多個 handler 重複同樣的驗證與錯誤 mapping。&lt;/li>
&lt;li>想新增 CLI、worker 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> action 時，只能複製 handler 內的邏輯。&lt;/li>
&lt;/ul>
&lt;p>以下是一個過重的建立通知 handler：&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">notifications&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">Notification&lt;/span>&lt;span class="p">{}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">handleCreateNotification&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Method&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">MethodPost&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">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;method not allowed&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">StatusMethodNotAllowed&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">req&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 class="s">`json:&amp;#34;id&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">Topic&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;topic&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">Title&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;title&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewDecoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Decode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">req&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&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;invalid json&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">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">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">TrimSpace&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="nx">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">TrimSpace&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Topic&lt;/span>&lt;span class="p">)&lt;/span> &lt;span 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">20&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 required field&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">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="k">if&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">exists&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">notifications&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">];&lt;/span> &lt;span class="nx">exists&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="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;notification already exists&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">StatusConflict&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl"> &lt;span class="nx">notification&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">Notification&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl"> &lt;span class="nx">ID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">31&lt;/span>&lt;span class="cl"> &lt;span class="nx">Topic&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Topic&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">32&lt;/span>&lt;span class="cl"> &lt;span class="nx">Title&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Title&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">33&lt;/span>&lt;span class="cl"> &lt;span class="nx">CreatedAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Now&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">34&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">35&lt;/span>&lt;span class="cl"> &lt;span class="nx">notifications&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">notification&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">notification&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">36&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">37&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Header&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nf">Set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;Content-Type&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;application/json&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">38&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusCreated&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">39&lt;/span>&lt;span class="cl"> &lt;span class="nx">_&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewEncoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Encode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">notification&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">40&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 邊界。只要要測「重複 ID 不可建立」，就必須走 HTTP；只要要改儲存方式，就必須改 handler。&lt;/p>
&lt;h2 id="判讀先拆-request-dto">【判讀】先拆 request DTO&lt;/h2>
&lt;p>request DTO 的核心責任是描述外部輸入格式。它可以有 JSON tag，但不應直接當成 domain model 或 repository model。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">createNotificationRequest&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">ID&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;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">Topic&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;topic&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">Title&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;title&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="nx">createNotificationRequest&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">validate&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"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">TrimSpace&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">ErrInvalidInput&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">Field&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">Reason&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;required&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="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">if&lt;/span> &lt;span class="nx">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">TrimSpace&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Topic&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">ErrInvalidInput&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">Field&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;topic&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">Reason&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;required&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">nil&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>DTO 可以是 unexported，因為它只服務 HTTP handler。JSON tag 也停在 transport layer，不會污染 application command。&lt;/p>
&lt;p>錯誤可以先用簡單型別表達：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">ErrInvalidInput&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">Field&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">Reason&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="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">e&lt;/span> &lt;span class="nx">ErrInvalidInput&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Error&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">7&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">e&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Field&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="s">&amp;#34;: &amp;#34;&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nx">e&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Reason&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個錯誤型別讓 handler 可以把輸入錯誤轉成 &lt;code>400 Bad Request&lt;/code>，而不必靠字串比對。&lt;/p></description><content:encoded><![CDATA[<p>handler 重構的核心目標是把 transport concern 和 application concern 分開。handler 應處理 request/response，usecase 應處理行為規則，domain 應保存狀態語意。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>辨識 handler 過重的訊號</li>
<li>把 request DTO 與 command 分開</li>
<li>把業務規則搬到 usecase</li>
<li>讓 handler 只做 request/response 轉換</li>
<li>分開撰寫 usecase test、handler test 與少量 integration test</li>
</ol>
<hr>
<h2 id="觀察過重-handler-會混合三種責任">【觀察】過重 handler 會混合三種責任</h2>
<p>handler 過重的核心問題是 transport、application 與 state concern 混在同一個函式。當一個 handler 同時解析 JSON、驗證欄位、檢查重複、修改 map、組 response，它就很難測，也很難重用。</p>
<p>常見壞味道：</p>
<ul>
<li>handler 超過一兩個螢幕。</li>
<li>測試核心規則必須透過 HTTP。</li>
<li>JSON tag 出現在 domain type 上。</li>
<li>handler 直接改 repository 的 map 或 slice。</li>
<li>多個 handler 重複同樣的驗證與錯誤 mapping。</li>
<li>想新增 CLI、worker 或 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> action 時，只能複製 handler 內的邏輯。</li>
</ul>
<p>以下是一個過重的建立通知 handler：</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">notifications</span> <span class="p">=</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">Notification</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kd">func</span> <span class="nf">handleCreateNotification</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="k">if</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Method</span> <span class="o">!=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">MethodPost</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</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;method not allowed&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusMethodNotAllowed</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="kd">var</span> <span class="nx">req</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 class="s">`json:&#34;id&#34;`</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">Topic</span> <span class="kt">string</span> <span class="s">`json:&#34;topic&#34;`</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">Title</span> <span class="kt">string</span> <span class="s">`json:&#34;title&#34;`</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">NewDecoder</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">).</span><span class="nf">Decode</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">req</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</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;invalid json&#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">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">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">ID</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="o">||</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">Topic</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</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 required field&#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">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="k">if</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">exists</span> <span class="o">:=</span> <span class="nx">notifications</span><span class="p">[</span><span class="nx">req</span><span class="p">.</span><span class="nx">ID</span><span class="p">];</span> <span class="nx">exists</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">25</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;notification already exists&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusConflict</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="nx">notification</span> <span class="o">:=</span> <span class="nx">Notification</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>        <span class="nx">req</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span>     <span class="nx">req</span><span class="p">.</span><span class="nx">Topic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span>     <span class="nx">req</span><span class="p">.</span><span class="nx">Title</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</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="nx">notifications</span><span class="p">[</span><span class="nx">notification</span><span class="p">.</span><span class="nx">ID</span><span class="p">]</span> <span class="p">=</span> <span class="nx">notification</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">
</span></span><span class="line"><span class="ln">37</span><span class="cl">    <span class="nx">w</span><span class="p">.</span><span class="nf">Header</span><span class="p">().</span><span class="nf">Set</span><span class="p">(</span><span class="s">&#34;Content-Type&#34;</span><span class="p">,</span> <span class="s">&#34;application/json&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">    <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusCreated</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">NewEncoder</span><span class="p">(</span><span class="nx">w</span><span class="p">).</span><span class="nf">Encode</span><span class="p">(</span><span class="nx">notification</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式可以跑，但它把太多責任放進 HTTP 邊界。只要要測「重複 ID 不可建立」，就必須走 HTTP；只要要改儲存方式，就必須改 handler。</p>
<h2 id="判讀先拆-request-dto">【判讀】先拆 request DTO</h2>
<p>request DTO 的核心責任是描述外部輸入格式。它可以有 JSON tag，但不應直接當成 domain model 或 repository model。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">createNotificationRequest</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">ID</span>    <span class="kt">string</span> <span class="s">`json:&#34;id&#34;`</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">Topic</span> <span class="kt">string</span> <span class="s">`json:&#34;topic&#34;`</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">Title</span> <span class="kt">string</span> <span class="s">`json:&#34;title&#34;`</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="nx">createNotificationRequest</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"> 8</span><span class="cl">    <span class="k">if</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">ID</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">return</span> <span class="nx">ErrInvalidInput</span><span class="p">{</span><span class="nx">Field</span><span class="p">:</span> <span class="s">&#34;id&#34;</span><span class="p">,</span> <span class="nx">Reason</span><span class="p">:</span> <span class="s">&#34;required&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">if</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Topic</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">return</span> <span class="nx">ErrInvalidInput</span><span class="p">{</span><span class="nx">Field</span><span class="p">:</span> <span class="s">&#34;topic&#34;</span><span class="p">,</span> <span class="nx">Reason</span><span class="p">:</span> <span class="s">&#34;required&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">return</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></code></pre></div><p>DTO 可以是 unexported，因為它只服務 HTTP handler。JSON tag 也停在 transport layer，不會污染 application command。</p>
<p>錯誤可以先用簡單型別表達：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">ErrInvalidInput</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">Field</span>  <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">Reason</span> <span class="kt">string</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">e</span> <span class="nx">ErrInvalidInput</span><span class="p">)</span> <span class="nf">Error</span><span class="p">()</span> <span class="kt">string</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">e</span><span class="p">.</span><span class="nx">Field</span> <span class="o">+</span> <span class="s">&#34;: &#34;</span> <span class="o">+</span> <span class="nx">e</span><span class="p">.</span><span class="nx">Reason</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個錯誤型別讓 handler 可以把輸入錯誤轉成 <code>400 Bad Request</code>，而不必靠字串比對。</p>
<h2 id="策略command-表達-usecase-輸入">【策略】command 表達 usecase 輸入</h2>
<p>command 的核心責任是描述 application layer 要執行的行為。它不需要 JSON tag，也不需要知道 request body 來自 HTTP、WebSocket 或 CLI。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">CreateNotificationCommand</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">ID</span>        <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">Topic</span>     <span class="kt">string</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">Title</span>     <span class="kt">string</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">CreatedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>handler 負責 DTO -&gt; command 的轉換：</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="nx">createNotificationRequest</span><span class="p">)</span> <span class="nf">toCommand</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="nx">CreateNotificationCommand</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">CreateNotificationCommand</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>        <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">r</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">Topic</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">r</span><span class="p">.</span><span class="nx">Topic</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span>     <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Title</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">now</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>CreatedAt</code> 由 handler 或 usecase 決定都可以，但要一致。若時間是業務規則的一部分，通常由 usecase 注入 clock 會更穩；若只是 request 接收時間，handler 傳入也合理。重點是不要在測試中散落 <code>time.Now()</code>。</p>
<h2 id="執行usecase-保存行為規則">【執行】usecase 保存行為規則</h2>
<p>usecase 的核心責任是處理行為規則與資料能力。重複檢查、儲存、事件發布或狀態轉移應該在 usecase，而不是 handler。</p>
<p>先定義 usecase 需要的 repository：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">NotificationRepository</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">notification</span> <span class="nx">Notification</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nf">FindByID</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>再定義 service：</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">CreateNotificationUsecase</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">repository</span> <span class="nx">NotificationRepository</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="kd">func</span> <span class="nf">NewCreateNotificationUsecase</span><span class="p">(</span><span class="nx">repository</span> <span class="nx">NotificationRepository</span><span class="p">)</span> <span class="o">*</span><span class="nx">CreateNotificationUsecase</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="o">&amp;</span><span class="nx">CreateNotificationUsecase</span><span class="p">{</span><span class="nx">repository</span><span class="p">:</span> <span class="nx">repository</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>執行 command：</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">u</span> <span class="o">*</span><span class="nx">CreateNotificationUsecase</span><span class="p">)</span> <span class="nf">Execute</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">cmd</span> <span class="nx">CreateNotificationCommand</span><span class="p">)</span> <span class="p">(</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">if</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">cmd</span><span class="p">.</span><span class="nx">ID</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">return</span> <span class="nx">Notification</span><span class="p">{},</span> <span class="nx">ErrInvalidInput</span><span class="p">{</span><span class="nx">Field</span><span class="p">:</span> <span class="s">&#34;id&#34;</span><span class="p">,</span> <span class="nx">Reason</span><span class="p">:</span> <span class="s">&#34;required&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">if</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">cmd</span><span class="p">.</span><span class="nx">Topic</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">return</span> <span class="nx">Notification</span><span class="p">{},</span> <span class="nx">ErrInvalidInput</span><span class="p">{</span><span class="nx">Field</span><span class="p">:</span> <span class="s">&#34;topic&#34;</span><span class="p">,</span> <span class="nx">Reason</span><span class="p">:</span> <span class="s">&#34;required&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">exists</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">u</span><span class="p">.</span><span class="nx">repository</span><span class="p">.</span><span class="nf">FindByID</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">cmd</span><span class="p">.</span><span class="nx">ID</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">return</span> <span class="nx">Notification</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;find notification: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="nx">exists</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">return</span> <span class="nx">Notification</span><span class="p">{},</span> <span class="nx">ErrAlreadyExists</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="nx">cmd</span><span class="p">.</span><span class="nx">ID</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">notification</span> <span class="o">:=</span> <span class="nx">Notification</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>        <span class="nx">cmd</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span>     <span class="nx">cmd</span><span class="p">.</span><span class="nx">Topic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span>     <span class="nx">cmd</span><span class="p">.</span><span class="nx">Title</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">cmd</span><span class="p">.</span><span class="nx">CreatedAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">u</span><span class="p">.</span><span class="nx">repository</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">notification</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="k">return</span> <span class="nx">Notification</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;save notification: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="k">return</span> <span class="nx">notification</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>ErrAlreadyExists</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">ErrAlreadyExists</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="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">e</span> <span class="nx">ErrAlreadyExists</span><span class="p">)</span> <span class="nf">Error</span><span class="p">()</span> <span class="kt">string</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="s">&#34;notification already exists: &#34;</span> <span class="o">+</span> <span class="nx">e</span><span class="p">.</span><span class="nx">ID</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這樣 handler 可以用 <code>errors.As</code> 把它對應到 <code>409 Conflict</code>。</p>
<h2 id="執行handler-只做轉換與-mapping">【執行】handler 只做轉換與 mapping</h2>
<p>重構後 handler 的核心責任是 request -&gt; command、result -&gt; response、error -&gt; HTTP status。它不直接碰 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">type</span> <span class="nx">NotificationCreator</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">Execute</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">cmd</span> <span class="nx">CreateNotificationCommand</span><span class="p">)</span> <span class="p">(</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 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">NotificationHandler</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">creator</span> <span class="nx">NotificationCreator</span>
</span></span><span class="line"><span class="ln"> 7</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"> 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">NewNotificationHandler</span><span class="p">(</span><span class="nx">creator</span> <span class="nx">NotificationCreator</span><span class="p">,</span> <span class="nx">now</span> <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">)</span> <span class="nx">NotificationHandler</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">NotificationHandler</span><span class="p">{</span><span class="nx">creator</span><span class="p">:</span> <span class="nx">creator</span><span class="p">,</span> <span class="nx">now</span><span class="p">:</span> <span class="nx">now</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>handler 實作：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="nx">NotificationHandler</span><span class="p">)</span> <span class="nf">Create</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">if</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Method</span> <span class="o">!=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">MethodPost</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</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">StatusMethodNotAllowed</span><span class="p">,</span> <span class="s">&#34;method_not_allowed&#34;</span><span class="p">,</span> <span class="s">&#34;method not allowed&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">return</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">var</span> <span class="nx">req</span> <span class="nx">createNotificationRequest</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">NewDecoder</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">).</span><span class="nf">Decode</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">req</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">,</span> <span class="s">&#34;invalid_json&#34;</span><span class="p">,</span> <span class="s">&#34;request body must be valid JSON&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">req</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">14</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_input&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">.</span><span class="nf">Error</span><span class="p">())</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="k">return</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="nx">notification</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">h</span><span class="p">.</span><span class="nx">creator</span><span class="p">.</span><span class="nf">Execute</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nf">Context</span><span class="p">(),</span> <span class="nx">req</span><span class="p">.</span><span class="nf">toCommand</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">19</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">20</span><span class="cl">        <span class="nf">writeUsecaseError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">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="nf">writeJSON</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">StatusCreated</span><span class="p">,</span> <span class="nf">newNotificationResponse</span><span class="p">(</span><span class="nx">notification</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>這個 handler 仍然有 HTTP 協定責任，但核心行為已經搬出去。未來 WebSocket action 或 worker 也可以建立 <code>CreateNotificationCommand</code> 呼叫同一個 usecase。</p>
<h2 id="策略response-struct-是對外-contract">【策略】response struct 是對外 contract</h2>
<p>response struct 的核心責任是描述 HTTP 回應格式。不要直接把 domain model 全部輸出，否則內部欄位會變成外部 API 承諾。</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">notificationResponse</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">ID</span>        <span class="kt">string</span>    <span class="s">`json:&#34;id&#34;`</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">Topic</span>     <span class="kt">string</span>    <span class="s">`json:&#34;topic&#34;`</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">Title</span>     <span class="kt">string</span>    <span class="s">`json:&#34;title&#34;`</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">CreatedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span> <span class="s">`json:&#34;createdAt&#34;`</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="kd">func</span> <span class="nf">newNotificationResponse</span><span class="p">(</span><span class="nx">notification</span> <span class="nx">Notification</span><span class="p">)</span> <span class="nx">notificationResponse</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">return</span> <span class="nx">notificationResponse</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>        <span class="nx">notification</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span>     <span class="nx">notification</span><span class="p">.</span><span class="nx">Topic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span>     <span class="nx">notification</span><span class="p">.</span><span class="nx">Title</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">notification</span><span class="p">.</span><span class="nx">CreatedAt</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>error response 也應該穩定：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">errorResponse</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">Code</span>    <span class="kt">string</span> <span class="s">`json:&#34;code&#34;`</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">Message</span> <span class="kt">string</span> <span class="s">`json:&#34;message&#34;`</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kd">func</span> <span class="nf">writeError</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">status</span> <span class="kt">int</span><span class="p">,</span> <span class="nx">code</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">message</span> <span class="kt">string</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nf">writeJSON</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">status</span><span class="p">,</span> <span class="nx">errorResponse</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">Code</span><span class="p">:</span>    <span class="nx">code</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">Message</span><span class="p">:</span> <span class="nx">message</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>writeJSON</code> 集中 JSON response 寫法：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="nf">writeJSON</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">status</span> <span class="kt">int</span><span class="p">,</span> <span class="nx">value</span> <span class="kt">any</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">w</span><span class="p">.</span><span class="nf">Header</span><span class="p">().</span><span class="nf">Set</span><span class="p">(</span><span class="s">&#34;Content-Type&#34;</span><span class="p">,</span> <span class="s">&#34;application/json&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</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">status</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">NewEncoder</span><span class="p">(</span><span class="nx">w</span><span class="p">).</span><span class="nf">Encode</span><span class="p">(</span><span class="nx">value</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 helper 可以忽略 encode error，因為 response 已經開始寫出；正式服務通常會記錄 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>。</p>
<h2 id="判讀error-mapping-是-handler-邊界">【判讀】error mapping 是 handler 邊界</h2>
<p>error mapping 的核心責任是把 application error 轉成 HTTP status 與對外 code。usecase 不應知道 HTTP status；handler 不應靠錯誤字串猜狀態。</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">writeUsecaseError</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">err</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="kd">var</span> <span class="nx">invalid</span> <span class="nx">ErrInvalidInput</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">if</span> <span class="nx">errors</span><span class="p">.</span><span class="nf">As</span><span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">invalid</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">,</span> <span class="s">&#34;invalid_input&#34;</span><span class="p">,</span> <span class="nx">invalid</span><span class="p">.</span><span class="nf">Error</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="kd">var</span> <span class="nx">alreadyExists</span> <span class="nx">ErrAlreadyExists</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">errors</span><span class="p">.</span><span class="nf">As</span><span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">alreadyExists</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusConflict</span><span class="p">,</span> <span class="s">&#34;already_exists&#34;</span><span class="p">,</span> <span class="s">&#34;notification already exists&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="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;internal_error&#34;</span><span class="p">,</span> <span class="s">&#34;internal server error&#34;</span><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>內部錯誤不要直接回給 client。對外 message 應該穩定且安全；詳細錯誤留給 log 與 error chain。</p>
<h2 id="執行usecase-測試不需要-http">【執行】usecase 測試不需要 HTTP</h2>
<p>usecase 測試的核心目標是驗證行為規則。它應該直接建立 command，使用 fake repository，不需要 <code>httptest</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">fakeNotificationRepository</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">existing</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">Notification</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">saved</span>    <span class="p">[]</span><span class="nx">Notification</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">f</span> <span class="o">*</span><span class="nx">fakeNotificationRepository</span><span class="p">)</span> <span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">notification</span> <span class="nx">Notification</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">f</span><span class="p">.</span><span class="nx">saved</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">f</span><span class="p">.</span><span class="nx">saved</span><span class="p">,</span> <span class="nx">notification</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">return</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><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="p">(</span><span class="nx">f</span> <span class="o">*</span><span class="nx">fakeNotificationRepository</span><span class="p">)</span> <span class="nf">FindByID</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">notification</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">f</span><span class="p">.</span><span class="nx">existing</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">return</span> <span class="nx">notification</span><span class="p">,</span> <span class="nx">ok</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>測試建立成功：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestCreateNotificationUsecaseExecute</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">repo</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">fakeNotificationRepository</span><span class="p">{</span><span class="nx">existing</span><span class="p">:</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">Notification</span><span class="p">{}}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">usecase</span> <span class="o">:=</span> <span class="nf">NewCreateNotificationUsecase</span><span class="p">(</span><span class="nx">repo</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">_</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">usecase</span><span class="p">.</span><span class="nf">Execute</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">(),</span> <span class="nx">CreateNotificationCommand</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>        <span class="s">&#34;ntf_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span>     <span class="s">&#34;deployments&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span>     <span class="s">&#34;Deploy finished&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">if</span> <span class="nx">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;execute usecase: %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="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">repo</span><span class="p">.</span><span class="nx">saved</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">1</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">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;saved notifications = %d, want 1&#34;</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">repo</span><span class="p">.</span><span class="nx">saved</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試速度快、錯誤定位明確。若失敗，問題在 usecase，不在 HTTP parsing。</p>
<h2 id="執行handler-test-專注-requestresponse">【執行】handler test 專注 request/response</h2>
<p>handler test 的核心目標是驗證 HTTP 協定行為。它應該使用 fake usecase，而不是真 repository。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">fakeNotificationCreator</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">got</span> <span class="nx">CreateNotificationCommand</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">out</span> <span class="nx">Notification</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">err</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">f</span> <span class="o">*</span><span class="nx">fakeNotificationCreator</span><span class="p">)</span> <span class="nf">Execute</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">cmd</span> <span class="nx">CreateNotificationCommand</span><span class="p">)</span> <span class="p">(</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">f</span><span class="p">.</span><span class="nx">got</span> <span class="p">=</span> <span class="nx">cmd</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">f</span><span class="p">.</span><span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">return</span> <span class="nx">Notification</span><span class="p">{},</span> <span class="nx">f</span><span class="p">.</span><span class="nx">err</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">return</span> <span class="nx">f</span><span class="p">.</span><span class="nx">out</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>測試成功 response：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestNotificationHandlerCreate</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">creator</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">fakeNotificationCreator</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">out</span><span class="p">:</span> <span class="nx">Notification</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">            <span class="nx">ID</span><span class="p">:</span>        <span class="s">&#34;ntf_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="nx">Topic</span><span class="p">:</span>     <span class="s">&#34;deployments&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">            <span class="nx">Title</span><span class="p">:</span>     <span class="s">&#34;Deploy finished&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 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="nx">handler</span> <span class="o">:=</span> <span class="nf">NewNotificationHandler</span><span class="p">(</span><span class="nx">creator</span><span class="p">,</span> <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="k">return</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">req</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRequest</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">MethodPost</span><span class="p">,</span> <span class="s">&#34;/notifications&#34;</span><span class="p">,</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">NewReader</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;ntf_1&#34;,
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="s">        &#34;topic&#34;: &#34;deployments&#34;,
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="s">        &#34;title&#34;: &#34;Deploy finished&#34;
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="s">    }`</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="nx">rec</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRecorder</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="nx">handler</span><span class="p">.</span><span class="nf">Create</span><span class="p">(</span><span class="nx">rec</span><span class="p">,</span> <span class="nx">req</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="k">if</span> <span class="nx">rec</span><span class="p">.</span><span class="nx">Code</span> <span class="o">!=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusCreated</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;status = %d, want %d&#34;</span><span class="p">,</span> <span class="nx">rec</span><span class="p">.</span><span class="nx">Code</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusCreated</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">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">creator</span><span class="p">.</span><span class="nx">got</span><span class="p">.</span><span class="nx">Topic</span> <span class="o">!=</span> <span class="s">&#34;deployments&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;topic = %q, want deployments&#34;</span><span class="p">,</span> <span class="nx">creator</span><span class="p">.</span><span class="nx">got</span><span class="p">.</span><span class="nx">Topic</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試確認 handler 能解析 JSON、建立 command、呼叫 usecase、寫出狀態碼。它不測重複 ID 的儲存規則，那已經是 usecase 測試的責任。</p>
<h2 id="策略integration-test-只保留少數端到端路徑">【策略】integration test 只保留少數端到端路徑</h2>
<p>integration test 的核心用途是確認組裝正確，不是覆蓋所有規則。當 usecase 與 handler 都已有單元測試，端到端測試只需要保留代表性成功與失敗路徑。</p>
<p>例如：</p>
<ul>
<li><code>POST /notifications</code> 成功建立。</li>
<li>invalid JSON 回 <code>400</code>。</li>
<li>重複 ID 回 <code>409</code>。</li>
</ul>
<p>不要把所有欄位驗證都只放在 integration test。那會讓測試慢、失敗定位模糊，也讓重構成本升高。</p>
<h2 id="重構步驟">重構步驟</h2>
<p>從過重 handler 重構時，可以按這個順序：</p>
<ol>
<li>先補 handler 現有行為測試，鎖住 status code 與 response body。</li>
<li>抽出 request DTO，但暫時不改行為。</li>
<li>抽出 command 與 usecase，讓 handler 呼叫 usecase。</li>
<li>把 repository 或 map 寫入移到 usecase 後方。</li>
<li>抽出 response struct 與 error mapping helper。</li>
<li>補 usecase 單元測試。</li>
<li>縮減 handler 測試範圍，保留 request/response 行為。</li>
</ol>
<p>每一步都應該讓程式可編譯、測試可跑。不要一次把 handler、repository、package 結構全部搬完。</p>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="檢查一抽出真正的行為邊界">檢查一：抽出真正的行為邊界</h3>
<p>如果新函式仍然接收 <code>http.ResponseWriter</code> 和 <code>*http.Request</code>，那只是移動程式碼，還沒有分離 transport concern。</p>
<h3 id="檢查二domain-model-和-response-model-分開">檢查二：domain model 和 response model 分開</h3>
<p>JSON tag 是 transport contract。domain model 若直接承擔對外格式，未來內部欄位調整就會牽動 API 相容性。</p>
<h3 id="檢查三錯誤類型對應-http-回應">檢查三：錯誤類型對應 HTTP 回應</h3>
<p>輸入錯誤、重複資料、權限問題與內部錯誤應該對應不同 status code。錯誤型別與 error mapping helper 可以避免字串判斷。</p>
<h3 id="檢查四分層測試保護不同責任">檢查四：分層測試保護不同責任</h3>
<p>端到端測試重要，但不應是唯一測試。usecase 規則越多，越需要直接測 command 與 fake repository。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理 HTTP handler 的轉換邊界；router、middleware 與 <a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a>，會在下列章節再往外延伸：</p>
<ul>
<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>
<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>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 request DTO、command 與 usecase 分層；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
<li><a href="/blog/go/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/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>
</ul>
]]></content:encoded></item><item><title>7.2 用 interface 隔離外部依賴</title><link>https://tarrragon.github.io/blog/go/07-refactoring/interface-boundary/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/07-refactoring/interface-boundary/</guid><description>&lt;p>interface 邊界重構的核心規則是由使用端定義需要的能力。介面的目的是讓 usecase 不依賴外部技術細節。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>辨識哪些依賴值得用 interface 隔離&lt;/li>
&lt;li>讓 interface 由使用端定義&lt;/li>
&lt;li>設計小而穩定的 port&lt;/li>
&lt;li>分辨 fake test 與 contract test&lt;/li>
&lt;li>避免過早抽象與巨大 interface&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察interface-是依賴邊界">【觀察】interface 是依賴邊界&lt;/h2>
&lt;p>interface 重構的核心目標是讓高層邏輯只依賴需要的能力。Go 的 interface 讓呼叫端不必知道具體實作。&lt;/p>
&lt;p>過重依賴常見在這些地方：&lt;/p>
&lt;ul>
&lt;li>usecase 直接依賴 &lt;code>*sql.DB&lt;/code>。&lt;/li>
&lt;li>handler 直接依賴 concrete service，測試很難替換。&lt;/li>
&lt;li>background worker 直接呼叫外部 API client。&lt;/li>
&lt;li>processor 直接知道 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> hub。&lt;/li>
&lt;li>測試為了建一個 usecase，必須初始化真資料庫、真檔案或真網路。&lt;/li>
&lt;/ul>
&lt;p>interface 的價值是讓 usecase 可以說：「我只需要儲存 notification」、「我只需要 append event」、「我只需要 publish message」。至於能力怎麼實作，是 adapter 的責任。&lt;/p>
&lt;h2 id="判讀先辨識外部依賴">【判讀】先辨識外部依賴&lt;/h2>
&lt;p>外部依賴的核心特徵是慢、不穩、難測或帶有技術細節。這些依賴通常適合被 interface 隔離。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>依賴&lt;/th>
 &lt;th>隔離原因&lt;/th>
 &lt;th>可能 interface&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>clock&lt;/td>
 &lt;td>測試需要固定時間&lt;/td>
 &lt;td>&lt;code>Clock&lt;/code> 或 &lt;code>func() time.Time&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>repository&lt;/td>
 &lt;td>儲存技術可替換&lt;/td>
 &lt;td>&lt;code>NotificationRepository&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>[event &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a>](/go/backend/knowledge-cards/event-log)&lt;/td>
 &lt;td>記錄實作可替換&lt;/td>
 &lt;td>&lt;code>EventLog&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>publisher&lt;/td>
 &lt;td>WebSocket、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a>、log 都可能是輸出&lt;/td>
 &lt;td>&lt;code>Publisher&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>external client&lt;/td>
 &lt;td>網路失敗與測試替身&lt;/td>
 &lt;td>&lt;code>NotificationSource&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>command runner&lt;/td>
 &lt;td>外部程序慢且不穩&lt;/td>
 &lt;td>&lt;code>CommandRunner&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>不是所有型別都需要 interface。純資料 struct、簡單 helper、沒有替換需求的內部物件，通常先保持 concrete type 更清楚。&lt;/p>
&lt;h2 id="策略interface-放在使用端">【策略】interface 放在使用端&lt;/h2>
&lt;p>interface 位置的核心規則是：誰需要這個能力，誰定義 interface。這會讓 interface 保持小，也避免 implementation 暴露太多方法。&lt;/p>
&lt;p>usecase 需要儲存通知，就在 usecase 附近定義：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">NotificationRepository&lt;/span> &lt;span class="kd">interface&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nf">Save&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">notification&lt;/span> &lt;span class="nx">Notification&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nf">FindByID&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">id&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">Notification&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">bool&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>in-memory adapter 只要方法集合符合，就自然實作這個 interface：&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">InMemoryNotificationRepository&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">notifications&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">Notification&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">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">InMemoryNotificationRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Save&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">notification&lt;/span> &lt;span class="nx">Notification&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span> &lt;span 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">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"> 8&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"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">notifications&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">notification&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">notification&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="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;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">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">InMemoryNotificationRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">FindByID&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">id&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">Notification&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">bool&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">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">RLock&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">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">RUnlock&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="nx">notification&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">notifications&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">id&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">notification&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>InMemoryNotificationRepository&lt;/code> 不需要宣告自己 implements 了什麼。Go 的 implicit interface 讓實作端保持乾淨。&lt;/p>
&lt;h2 id="執行用小-port-隔離-event-log">【執行】用小 port 隔離 event log&lt;/h2>
&lt;p>event log port 的核心語意是「可以記錄已發生的事件」。usecase 或 processor 不需要知道事件記錄到 memory、檔案還是資料庫。&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">EventLog&lt;/span> &lt;span class="kd">interface&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nf">Append&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>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>processor 依賴這個 port：&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">EventProcessor&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">eventLog&lt;/span> &lt;span class="nx">EventLog&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">NewEventProcessor&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">eventLog&lt;/span> &lt;span class="nx">EventLog&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">EventProcessor&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">EventProcessor&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">eventLog&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">eventLog&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span 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">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">p&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">EventProcessor&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">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">10&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">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Validate&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">11&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Errorf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;validate event: %w&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">p&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">eventLog&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">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">14&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Errorf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;append event log: %w&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個 interface 很小，但它已經足夠讓 processor 測試脫離真正儲存實作。&lt;/p></description><content:encoded><![CDATA[<p>interface 邊界重構的核心規則是由使用端定義需要的能力。介面的目的是讓 usecase 不依賴外部技術細節。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>辨識哪些依賴值得用 interface 隔離</li>
<li>讓 interface 由使用端定義</li>
<li>設計小而穩定的 port</li>
<li>分辨 fake test 與 contract test</li>
<li>避免過早抽象與巨大 interface</li>
</ol>
<hr>
<h2 id="觀察interface-是依賴邊界">【觀察】interface 是依賴邊界</h2>
<p>interface 重構的核心目標是讓高層邏輯只依賴需要的能力。Go 的 interface 讓呼叫端不必知道具體實作。</p>
<p>過重依賴常見在這些地方：</p>
<ul>
<li>usecase 直接依賴 <code>*sql.DB</code>。</li>
<li>handler 直接依賴 concrete service，測試很難替換。</li>
<li>background worker 直接呼叫外部 API client。</li>
<li>processor 直接知道 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> hub。</li>
<li>測試為了建一個 usecase，必須初始化真資料庫、真檔案或真網路。</li>
</ul>
<p>interface 的價值是讓 usecase 可以說：「我只需要儲存 notification」、「我只需要 append event」、「我只需要 publish message」。至於能力怎麼實作，是 adapter 的責任。</p>
<h2 id="判讀先辨識外部依賴">【判讀】先辨識外部依賴</h2>
<p>外部依賴的核心特徵是慢、不穩、難測或帶有技術細節。這些依賴通常適合被 interface 隔離。</p>
<table>
  <thead>
      <tr>
          <th>依賴</th>
          <th>隔離原因</th>
          <th>可能 interface</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>clock</td>
          <td>測試需要固定時間</td>
          <td><code>Clock</code> 或 <code>func() time.Time</code></td>
      </tr>
      <tr>
          <td>repository</td>
          <td>儲存技術可替換</td>
          <td><code>NotificationRepository</code></td>
      </tr>
      <tr>
          <td>[event <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>](/go/backend/knowledge-cards/event-log)</td>
          <td>記錄實作可替換</td>
          <td><code>EventLog</code></td>
      </tr>
      <tr>
          <td>publisher</td>
          <td>WebSocket、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a>、log 都可能是輸出</td>
          <td><code>Publisher</code></td>
      </tr>
      <tr>
          <td>external client</td>
          <td>網路失敗與測試替身</td>
          <td><code>NotificationSource</code></td>
      </tr>
      <tr>
          <td>command runner</td>
          <td>外部程序慢且不穩</td>
          <td><code>CommandRunner</code></td>
      </tr>
  </tbody>
</table>
<p>不是所有型別都需要 interface。純資料 struct、簡單 helper、沒有替換需求的內部物件，通常先保持 concrete type 更清楚。</p>
<h2 id="策略interface-放在使用端">【策略】interface 放在使用端</h2>
<p>interface 位置的核心規則是：誰需要這個能力，誰定義 interface。這會讓 interface 保持小，也避免 implementation 暴露太多方法。</p>
<p>usecase 需要儲存通知，就在 usecase 附近定義：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">NotificationRepository</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">notification</span> <span class="nx">Notification</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nf">FindByID</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>in-memory adapter 只要方法集合符合，就自然實作這個 interface：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">InMemoryNotificationRepository</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">mu</span>            <span class="nx">sync</span><span class="p">.</span><span class="nx">RWMutex</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">notifications</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">Notification</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">InMemoryNotificationRepository</span><span class="p">)</span> <span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">notification</span> <span class="nx">Notification</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</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"> 8</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"> 9</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">notifications</span><span class="p">[</span><span class="nx">notification</span><span class="p">.</span><span class="nx">ID</span><span class="p">]</span> <span class="p">=</span> <span class="nx">notification</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">return</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><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">r</span> <span class="o">*</span><span class="nx">InMemoryNotificationRepository</span><span class="p">)</span> <span class="nf">FindByID</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</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">15</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">16</span><span class="cl">    <span class="nx">notification</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">notifications</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">return</span> <span class="nx">notification</span><span class="p">,</span> <span class="nx">ok</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>InMemoryNotificationRepository</code> 不需要宣告自己 implements 了什麼。Go 的 implicit interface 讓實作端保持乾淨。</p>
<h2 id="執行用小-port-隔離-event-log">【執行】用小 port 隔離 event log</h2>
<p>event log port 的核心語意是「可以記錄已發生的事件」。usecase 或 processor 不需要知道事件記錄到 memory、檔案還是資料庫。</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">EventLog</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">Append</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">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></code></pre></div><p>processor 依賴這個 port：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">EventProcessor</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">eventLog</span> <span class="nx">EventLog</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kd">func</span> <span class="nf">NewEventProcessor</span><span class="p">(</span><span class="nx">eventLog</span> <span class="nx">EventLog</span><span class="p">)</span> <span class="o">*</span><span class="nx">EventProcessor</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="o">&amp;</span><span class="nx">EventProcessor</span><span class="p">{</span><span class="nx">eventLog</span><span class="p">:</span> <span class="nx">eventLog</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">func</span> <span class="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">10</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">11</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;validate event: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">p</span><span class="p">.</span><span class="nx">eventLog</span><span class="p">.</span><span class="nf">Append</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;append event log: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 interface 很小，但它已經足夠讓 processor 測試脫離真正儲存實作。</p>
<h2 id="執行publisher-port-隔離輸出技術">【執行】publisher port 隔離輸出技術</h2>
<p>publisher port 的核心語意是「把結果送出去」。即時推送可以用 WebSocket，非同步流程可以用 queue，測試可以用 recording fake。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">Publisher</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">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">3</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>processor 可以同時依賴 event log 與 publisher：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">RecordingProcessor</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">eventLog</span>  <span class="nx">EventLog</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">publisher</span> <span class="nx">Publisher</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">p</span> <span class="o">*</span><span class="nx">RecordingProcessor</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"> 7</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">eventLog</span><span class="p">.</span><span class="nf">Append</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;append event: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="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">11</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;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">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這裡的 processor 不知道輸出是 WebSocket 還是 message queue。這就是 interface 邊界的目的。</p>
<h2 id="策略clock-可以用函式不一定要-interface">【策略】clock 可以用函式，不一定要 interface</h2>
<p>時間依賴的核心問題是測試需要固定現在時間。最小解法通常是注入函式，而不是建立完整 interface。</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">Clock</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">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kd">type</span> <span class="nx">NotificationService</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">now</span> <span class="nx">Clock</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="kd">func</span> <span class="nf">NewNotificationService</span><span class="p">(</span><span class="nx">now</span> <span class="nx">Clock</span><span class="p">)</span> <span class="o">*</span><span class="nx">NotificationService</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="k">return</span> <span class="o">&amp;</span><span class="nx">NotificationService</span><span class="p">{</span><span class="nx">now</span><span class="p">:</span> <span class="nx">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></code></pre></div><p>使用時：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">s</span> <span class="o">*</span><span class="nx">NotificationService</span><span class="p">)</span> <span class="nf">NewNotification</span><span class="p">(</span><span class="nx">id</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">topic</span> <span class="kt">string</span><span class="p">)</span> <span class="nx">Notification</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">return</span> <span class="nx">Notification</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>        <span class="nx">id</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span>     <span class="nx">topic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">s</span><span class="p">.</span><span class="nf">now</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>測試時傳固定時間：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">fixedNow</span> <span class="o">:=</span> <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">return</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 class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nx">service</span> <span class="o">:=</span> <span class="nf">NewNotificationService</span><span class="p">(</span><span class="nx">fixedNow</span><span class="p">)</span></span></span></code></pre></div><p>若只需要「現在時間」，函式比 <code>Clock interface { Now() time.Time }</code> 更簡單。Go 的抽象不一定要用 interface。</p>
<h2 id="判讀小-interface-比大-interface-更穩定">【判讀】小 interface 比大 interface 更穩定</h2>
<p>小 interface 的核心好處是測試替身容易寫，使用端只知道自己需要的能力。巨大 interface 會把不相關 usecase 綁在一起。</p>
<p>不佳：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">ApplicationService</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">CreateNotification</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">cmd</span> <span class="nx">CreateNotificationCommand</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nf">ListNotifications</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">topic</span> <span class="kt">string</span><span class="p">)</span> <span class="p">([]</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nf">AppendEvent</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln">5</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">6</span><span class="cl">    <span class="nf">RunSync</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></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>較佳：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">NotificationCreator</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">Create</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">cmd</span> <span class="nx">CreateNotificationCommand</span><span class="p">)</span> <span class="kt">error</span>
</span></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">EventLog</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">Append</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">type</span> <span class="nx">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></code></pre></div><p>不同呼叫端依賴不同能力。handler 只依賴 creator，processor 只依賴 event log 與 publisher，worker 只依賴 source 與 processor。</p>
<h2 id="執行fake-test-驗證使用端行為">【執行】fake test 驗證使用端行為</h2>
<p>fake test 的核心目標是測使用端怎麼使用依賴。fake 可以很小，只實作測試需要的行為。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">fakeEventLog</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">appended</span> <span class="p">[]</span><span class="nx">DomainEvent</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">err</span>      <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">f</span> <span class="o">*</span><span class="nx">fakeEventLog</span><span class="p">)</span> <span class="nf">Append</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">DomainEvent</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="k">if</span> <span class="nx">f</span><span class="p">.</span><span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">return</span> <span class="nx">f</span><span class="p">.</span><span class="nx">err</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">f</span><span class="p">.</span><span class="nx">appended</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">f</span><span class="p">.</span><span class="nx">appended</span><span class="p">,</span> <span class="nx">event</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="kc">nil</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>測 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">TestEventProcessorAppendsEvent</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">eventLog</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">fakeEventLog</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">processor</span> <span class="o">:=</span> <span class="nf">NewEventProcessor</span><span class="p">(</span><span class="nx">eventLog</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">event</span> <span class="o">:=</span> <span class="nf">validDomainEvent</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">err</span> <span class="o">:=</span> <span class="nx">processor</span><span class="p">.</span><span class="nf">Process</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">(),</span> <span class="nx">event</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</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"> 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="nb">len</span><span class="p">(</span><span class="nx">eventLog</span><span class="p">.</span><span class="nx">appended</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">1</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">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;appended events = %d, want 1&#34;</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">eventLog</span><span class="p">.</span><span class="nx">appended</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試不關心 event log 如何保存資料。它只驗證 processor 在正確情境下呼叫了 port。</p>
<h2 id="執行contract-test-驗證-adapter-行為">【執行】contract test 驗證 adapter 行為</h2>
<p>contract test 的核心目標是讓不同 adapter 都符合 port 行為。這類測試測 implementation，不測 usecase。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">runEventLogContract</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">,</span> <span class="nx">newLog</span> <span class="kd">func</span><span class="p">()</span> <span class="nx">EventLogWithList</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">t</span><span class="p">.</span><span class="nf">Helper</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">log</span> <span class="o">:=</span> <span class="nf">newLog</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">event</span> <span class="o">:=</span> <span class="nf">validDomainEvent</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">log</span><span class="p">.</span><span class="nf">Append</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"> 8</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;append 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"> 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">events</span> <span class="o">:=</span> <span class="nx">log</span><span class="p">.</span><span class="nf">List</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">events</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">1</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;events = %d, want 1&#34;</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">events</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">events</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">ID</span> <span class="o">!=</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">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;event ID = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">events</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">ID</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">ID</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="kd">type</span> <span class="nx">EventLogWithList</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="nx">EventLog</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="nf">List</span><span class="p">()</span> <span class="p">[]</span><span class="nx">DomainEvent</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>List</code> 不一定屬於 production port，它可以是測試用輔助介面。若未來有 SQLite event log，也可以跑同一套 contract test。</p>
<h2 id="策略避免過早抽象">【策略】避免過早抽象</h2>
<p>避免過早抽象的核心判斷是：沒有替換、測試或技術隔離需求時，先用 concrete type。interface 不是越多越好。</p>
<p>先不要抽 interface 的情境：</p>
<ul>
<li>只有一個 implementation。</li>
<li>測試不需要 fake。</li>
<li>concrete type 很小，直接使用更清楚。</li>
<li>interface 只是完整複製 concrete type 的所有方法。</li>
<li>邊界還不穩，方法很快會變。</li>
</ul>
<p>可以抽 interface 的情境：</p>
<ul>
<li>usecase 不應依賴技術細節。</li>
<li>測試需要替換慢或不穩的依賴。</li>
<li>同一個能力有多種 implementation。</li>
<li>依賴跨越 package 邊界，使用端只需要小部分能力。</li>
</ul>
<p>重構時可以先寫 concrete type，等第二個使用端或測試壓力出現，再抽出使用端 interface。</p>
<h2 id="重構步驟">重構步驟</h2>
<p>把 concrete dependency 改成 interface 時，可以按這個順序：</p>
<ol>
<li>找出使用端真正呼叫的方法。</li>
<li>在使用端附近定義小 interface。</li>
<li>把 struct 欄位型別從 concrete type 改成 interface。</li>
<li>確認現有 concrete type 自然符合 interface。</li>
<li>在測試中建立 fake。</li>
<li>為 adapter 補 contract test。</li>
<li>移除不再需要的直接依賴。</li>
</ol>
<p>不要先設計一個完美的全域介面。從使用端需要的最小方法開始，介面會更穩。</p>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="檢查一interface-由使用端定義">檢查一：interface 由使用端定義</h3>
<p>implementation 定義的 interface 往往暴露太多方法。使用端定義 interface，才能只依賴自己真正需要的能力。</p>
<h3 id="檢查二有替換需求再建立-interface">檢查二：有替換需求再建立 interface</h3>
<p><code>Foo</code> 搭配 <code>FooInterface</code> 不是 Go 的慣例。interface 應該來自使用需求，而不是來自型別存在。</p>
<h3 id="檢查三fake-服務當前測試行為">檢查三：fake 服務當前測試行為</h3>
<p>fake 是測試工具，不是真 adapter。它只需要支援測試情境，不需要重建資料庫、網路或完整狀態機。</p>
<h3 id="檢查四公開-interface-需要穩定承諾">檢查四：公開 interface 需要穩定承諾</h3>
<p>一旦 interface 被多個 package 依賴，修改成本會提高。邊界還在探索時，保持 unexported 或使用 concrete type 更務實。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理 interface 如何讓使用端依賴能力；全專案 DI 框架與 mock generator，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go/02-types-data/interfaces/" data-link-title="2.3 interface：用行為定義依賴" data-link-desc="用小介面描述元件需要的能力">Go 入門：interface：用行為定義依賴</a></li>
<li><a href="/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go 入門：testing 基礎</a></li>
<li><a href="/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">Go 進階：composition root 與依賴組裝</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 usecase、adapter 與測試替身邊界；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">Go：把 handler 邏輯拆成可測單元</a></li>
<li><a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go：如何新增 repository port</a></li>
<li><a href="/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程</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>
]]></content:encoded></item><item><title>7.3 事件去重邏輯的重構策略</title><link>https://tarrragon.github.io/blog/go/07-refactoring/dedup-refactor/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/07-refactoring/dedup-refactor/</guid><description>&lt;p>事件去重重構的核心目標是把語義鍵、時間窗口與來源優先順序整理成可測規則。本章用一般事件處理流程說明如何降低重複邏輯，同時保留事件合併的判斷依據。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>辨識 raw payload 去重的風險&lt;/li>
&lt;li>用 domain dedup key 表達同一件事&lt;/li>
&lt;li>把去重邏輯抽成 &lt;code>Deduper&lt;/code>&lt;/li>
&lt;li>設計時間窗口與 cleanup&lt;/li>
&lt;li>測試同窗口、跨窗口、不同來源與過期清理&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察重複事件通常先散落在入口層">【觀察】重複事件通常先散落在入口層&lt;/h2>
&lt;p>去重邏輯重構的核心觸發點是多個入口開始各自判斷「這筆事件看過了嗎」。HTTP callback、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer&lt;/a>、background worker 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> action 都可能收到同一件事，若每個入口各自去重，規則很快會不一致。&lt;/p>
&lt;p>重構前常見寫法：&lt;/p>





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





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





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





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





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





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





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





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





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





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





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





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





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





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





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





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





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Server&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">jobs&lt;/span> &lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="nx">JobProjection&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">s&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Server&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">handleJobStarted&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"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">id&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">URL&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Query&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nf">Get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">s&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">jobs&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">id&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">JobProjection&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">ID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">id&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">Status&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">JobStatusRunning&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">UpdatedAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Now&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">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">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="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">s&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Server&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">handleJobList&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">16&lt;/span>&lt;span class="cl"> &lt;span class="nx">_&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewEncoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Encode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">s&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">jobs&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式有三個問題：handler 直接改 map，map 沒有 lock，查詢直接輸出內部資料。只要另一個 goroutine 同時讀寫 &lt;code>jobs&lt;/code>，就可能產生 data race。&lt;/p>
&lt;h2 id="判讀state-owner-是唯一寫入入口">【判讀】state owner 是唯一寫入入口&lt;/h2>
&lt;p>state owner 的核心責任是擁有資料與狀態轉移規則。它可以叫 repository、store、manager；名稱不是重點，重點是所有寫入都經過同一組方法。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">JobRepository&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">mu&lt;/span> &lt;span class="nx">sync&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">RWMutex&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">jobs&lt;/span> &lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="nx">JobProjection&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">NewJobRepository&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">JobRepository&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">JobRepository&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">jobs&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="nx">JobProjection&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>handler 不再直接改 map，而是呼叫 repository 方法：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">JobRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">MarkRunning&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">id&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">now&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">TrimSpace&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">id&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Errorf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;job id is required&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&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"> 7&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"> 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="nx">job&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">jobs&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">id&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">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Status&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">JobStatusRunning&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">UpdatedAt&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">now&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">jobs&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="nx">job&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">nil&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>這個方法把「running 狀態怎麼寫入」集中起來。未來如果 running 只能從 pending 轉移，規則也加在這裡。&lt;/p>
&lt;h2 id="策略鎖保護的是不變式">【策略】鎖保護的是不變式&lt;/h2>
&lt;p>lock 的核心責任是保護完整狀態不變式，不只是保護某一行 map assignment。若一次狀態轉移要同時更新 current、history、updated time，就要在同一把鎖內完成。&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">JobRecord&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">Current&lt;/span> &lt;span class="nx">JobProjection&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">History&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="nx">JobProjection&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">JobRepository&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&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">8&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">JobRecord&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>寫入時同時更新 summary 與 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">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">JobRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Apply&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">event&lt;/span> &lt;span class="nx">JobEvent&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Lock&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Unlock&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">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">JobID&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">next&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">record&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Current&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">next&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">JobID&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">next&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">UpdatedAt&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">OccurredAt&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 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">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">11&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="s">&amp;#34;job.started&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">next&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Status&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">JobStatusRunning&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="s">&amp;#34;job.succeeded&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">next&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Status&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">JobStatusSucceeded&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nx">next&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Progress&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">100&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="s">&amp;#34;job.failed&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="nx">next&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Status&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">JobStatusFailed&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&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">19&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Errorf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;unsupported job event type %q&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Type&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="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">23&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">24&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">JobID&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">25&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">26&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式讓 current 與 history 保持一致。若分散在不同 handler 或不同鎖裡，就可能留下「current 已更新但 history 沒有記錄」的中間狀態。&lt;/p></description><content:encoded><![CDATA[<p>狀態管理重構的核心目標是集中寫入、保護 map、回傳複製資料，並避免讓 handler、背景工作或即時連線直接操作內部狀態。本章用一般 repository 範例說明如何建立安全邊界。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>辨識共享狀態外洩的程式碼壞味道</li>
<li>用 repository 或 state owner 集中寫入</li>
<li>用 <code>sync.RWMutex</code> 保護 map、slice 與狀態不變式</li>
<li>用 copy boundary 防止呼叫端修改內部資料</li>
<li>用行為測試與 <code>go test -race</code> 驗證並發狀態</li>
</ol>
<hr>
<h2 id="觀察共享狀態外洩會讓規則分散">【觀察】共享狀態外洩會讓規則分散</h2>
<p>共享狀態外洩的核心問題是多個元件可以繞過同一套規則直接修改資料。當 handler、worker、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> client manager 都能改同一個 map，狀態不一致與 data race 會變得很難追蹤。</p>
<p>重構前常見寫法：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">Server</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">jobs</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">JobProjection</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">s</span> <span class="o">*</span><span class="nx">Server</span><span class="p">)</span> <span class="nf">handleJobStarted</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"> 6</span><span class="cl">    <span class="nx">id</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">URL</span><span class="p">.</span><span class="nf">Query</span><span class="p">().</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;id&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">s</span><span class="p">.</span><span class="nx">jobs</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span> <span class="p">=</span> <span class="nx">JobProjection</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>        <span class="nx">id</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">Status</span><span class="p">:</span>    <span class="nx">JobStatusRunning</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">UpdatedAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="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">13</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">s</span> <span class="o">*</span><span class="nx">Server</span><span class="p">)</span> <span class="nf">handleJobList</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">16</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">NewEncoder</span><span class="p">(</span><span class="nx">w</span><span class="p">).</span><span class="nf">Encode</span><span class="p">(</span><span class="nx">s</span><span class="p">.</span><span class="nx">jobs</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式有三個問題：handler 直接改 map，map 沒有 lock，查詢直接輸出內部資料。只要另一個 goroutine 同時讀寫 <code>jobs</code>，就可能產生 data race。</p>
<h2 id="判讀state-owner-是唯一寫入入口">【判讀】state owner 是唯一寫入入口</h2>
<p>state owner 的核心責任是擁有資料與狀態轉移規則。它可以叫 repository、store、manager；名稱不是重點，重點是所有寫入都經過同一組方法。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">JobRepository</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">mu</span>   <span class="nx">sync</span><span class="p">.</span><span class="nx">RWMutex</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">jobs</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">JobProjection</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kd">func</span> <span class="nf">NewJobRepository</span><span class="p">()</span> <span class="o">*</span><span class="nx">JobRepository</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">return</span> <span class="o">&amp;</span><span class="nx">JobRepository</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">jobs</span><span class="p">:</span> <span class="nb">make</span><span class="p">(</span><span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">JobProjection</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>handler 不再直接改 map，而是呼叫 repository 方法：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">JobRepository</span><span class="p">)</span> <span class="nf">MarkRunning</span><span class="p">(</span><span class="nx">id</span> <span class="kt">string</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="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">if</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">id</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;job id is required&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</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"> 7</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"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">job</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">jobs</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">job</span><span class="p">.</span><span class="nx">ID</span> <span class="p">=</span> <span class="nx">id</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">job</span><span class="p">.</span><span class="nx">Status</span> <span class="p">=</span> <span class="nx">JobStatusRunning</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">job</span><span class="p">.</span><span class="nx">UpdatedAt</span> <span class="p">=</span> <span class="nx">now</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">jobs</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span> <span class="p">=</span> <span class="nx">job</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">return</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></code></pre></div><p>這個方法把「running 狀態怎麼寫入」集中起來。未來如果 running 只能從 pending 轉移，規則也加在這裡。</p>
<h2 id="策略鎖保護的是不變式">【策略】鎖保護的是不變式</h2>
<p>lock 的核心責任是保護完整狀態不變式，不只是保護某一行 map assignment。若一次狀態轉移要同時更新 current、history、updated time，就要在同一把鎖內完成。</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">JobRecord</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">Current</span> <span class="nx">JobProjection</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">History</span> <span class="p">[]</span><span class="nx">JobProjection</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="kd">type</span> <span class="nx">JobRepository</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">mu</span>      <span class="nx">sync</span><span class="p">.</span><span class="nx">RWMutex</span>
</span></span><span class="line"><span class="ln">8</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">JobRecord</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>寫入時同時更新 summary 與 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="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">JobRepository</span><span class="p">)</span> <span class="nf">Apply</span><span class="p">(</span><span class="nx">event</span> <span class="nx">JobEvent</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Lock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Unlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">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">JobID</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="o">:=</span> <span class="nx">record</span><span class="p">.</span><span class="nx">Current</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">next</span><span class="p">.</span><span class="nx">ID</span> <span class="p">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">JobID</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">next</span><span class="p">.</span><span class="nx">UpdatedAt</span> <span class="p">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">OccurredAt</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</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">11</span><span class="cl">    <span class="k">case</span> <span class="s">&#34;job.started&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">next</span><span class="p">.</span><span class="nx">Status</span> <span class="p">=</span> <span class="nx">JobStatusRunning</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">case</span> <span class="s">&#34;job.succeeded&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="nx">next</span><span class="p">.</span><span class="nx">Status</span> <span class="p">=</span> <span class="nx">JobStatusSucceeded</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="nx">next</span><span class="p">.</span><span class="nx">Progress</span> <span class="p">=</span> <span class="mi">100</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">case</span> <span class="s">&#34;job.failed&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="nx">next</span><span class="p">.</span><span class="nx">Status</span> <span class="p">=</span> <span class="nx">JobStatusFailed</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">default</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">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;unsupported job event type %q&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Type</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">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="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">23</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">24</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">JobID</span><span class="p">]</span> <span class="p">=</span> <span class="nx">record</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式讓 current 與 history 保持一致。若分散在不同 handler 或不同鎖裡，就可能留下「current 已更新但 history 沒有記錄」的中間狀態。</p>
<h2 id="執行讀取要回傳-copy">【執行】讀取要回傳 copy</h2>
<p>copy boundary 的核心目標是避免呼叫端拿到內部可變資料。鎖只保護鎖內操作；一旦把內部 slice 或 pointer 回傳出去，呼叫端就可以在鎖外修改資料。</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="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">JobRepository</span><span class="p">)</span> <span class="nf">Get</span><span class="p">(</span><span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">JobProjection</span><span class="p">,</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RLock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RUnlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">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">JobProjection</span><span class="p">{},</span> <span class="kc">false</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">return</span> <span class="nf">cloneJobProjection</span><span class="p">(</span><span class="nx">record</span><span class="p">.</span><span class="nx">Current</span><span class="p">),</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>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="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">JobRepository</span><span class="p">)</span> <span class="nf">History</span><span class="p">(</span><span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">[]</span><span class="nx">JobProjection</span> <span class="p">{</span>
</span></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">JobProjection</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="k">for</span> <span class="nx">i</span><span class="p">,</span> <span class="nx">item</span> <span class="o">:=</span> <span class="k">range</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="nx">result</span><span class="p">[</span><span class="nx">i</span><span class="p">]</span> <span class="p">=</span> <span class="nf">cloneJobProjection</span><span class="p">(</span><span class="nx">item</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">return</span> <span class="nx">result</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>clone 函式處理 pointer 欄位：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="nf">cloneJobProjection</span><span class="p">(</span><span class="nx">job</span> <span class="nx">JobProjection</span><span class="p">)</span> <span class="nx">JobProjection</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">cloned</span> <span class="o">:=</span> <span class="nx">job</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">if</span> <span class="nx">job</span><span class="p">.</span><span class="nx">FinishedAt</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="nx">finishedAt</span> <span class="o">:=</span> <span class="o">*</span><span class="nx">job</span><span class="p">.</span><span class="nx">FinishedAt</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="nx">cloned</span><span class="p">.</span><span class="nx">FinishedAt</span> <span class="p">=</span> <span class="o">&amp;</span><span class="nx">finishedAt</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="k">return</span> <span class="nx">cloned</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>如果 struct 未來新增 slice、map 或 pointer 欄位，clone 函式也要跟著更新。這是資料擁有權邊界的一部分。</p>
<h2 id="判讀state-和-projection-要分清楚">【判讀】state 和 projection 要分清楚</h2>
<p>state/<a href="/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection</a> 分離的核心原因是寫入規則與讀取需求不同。domain state 保存規則，projection 服務查詢與顯示。</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">JobState</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">ID</span>        <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">Status</span>    <span class="nx">JobStatus</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">UpdatedAt</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="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">JobProjection</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">ID</span>          <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">Status</span>      <span class="nx">JobStatus</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">Progress</span>    <span class="kt">int</span>
</span></span><span class="line"><span class="ln">11</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">12</span><span class="cl">    <span class="nx">DisplayText</span> <span class="kt">string</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>DisplayText</code> 不應參與狀態轉移，它是 response 或 <a href="/blog/backend/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="說明為查詢場景建立的讀取模型，與正式狀態的責任分離">read model</a> 的資料。若把顯示文字混進核心 state，前端文案改動就會牽動業務規則測試。</p>
<p>重構時不一定要一次拆出兩個 struct。可以先在程式碼中標記哪些欄位是 state，哪些欄位是 projection；等壓力變大，再正式拆型別。</p>
<h2 id="策略handler-只請求狀態更新">【策略】handler 只請求狀態更新</h2>
<p>handler 的核心責任是把 HTTP request 轉成狀態更新請求，而不是自己修改狀態。</p>
<p>重構後：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">JobStarter</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">MarkRunning</span><span class="p">(</span><span class="nx">id</span> <span class="kt">string</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="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">JobHandler</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">jobs</span> <span class="nx">JobStarter</span>
</span></span><span class="line"><span class="ln"> 7</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"> 8</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="nx">JobHandler</span><span class="p">)</span> <span class="nf">Start</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">11</span><span class="cl">    <span class="nx">id</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">URL</span><span class="p">.</span><span class="nf">Query</span><span class="p">().</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;id&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">h</span><span class="p">.</span><span class="nx">jobs</span><span class="p">.</span><span class="nf">MarkRunning</span><span class="p">(</span><span class="nx">id</span><span class="p">,</span> <span class="nx">h</span><span class="p">.</span><span class="nf">now</span><span class="p">());</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="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;start job&#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">14</span><span class="cl">        <span class="k">return</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="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">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>handler 不知道 repository 內部用 map、slice、mutex 還是資料庫。它只知道「可以把 job 標記為 running」。</p>
<h2 id="策略為未來資料庫保留邊界但不提前綁死">【策略】為未來資料庫保留邊界，但不提前綁死</h2>
<p><a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database</a>-ready 邊界的核心是 context、error 與一致性語意，不是提早引入 ORM。memory repository 可以先存在，但方法簽名可以保留未來 I/O 的可能。</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">JobRepositoryPort</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">JobEvent</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nf">Get</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">JobProjection</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>memory implementation 可以忽略 context：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">JobRepository</span><span class="p">)</span> <span class="nf">Get</span><span class="p">(</span><span class="nx">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">JobProjection</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">JobProjection</span><span class="p">{},</span> <span class="kc">false</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">return</span> <span class="nf">cloneJobProjection</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="kc">true</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>未來換成資料庫時，context 可以傳給 query；error 可以包上資料庫錯誤。<a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a> 則等到一個 usecase 真的需要多筆寫入一致性時再設計。</p>
<h2 id="執行state-transition-測試鎖定規則">【執行】state transition 測試鎖定規則</h2>
<p>state transition 測試的核心目標是確認事件會產生正確狀態與 history。這類測試不需要 HTTP，也不需要 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">func</span> <span class="nf">TestJobRepositoryApplyRecordsHistory</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">repo</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">JobRepository</span><span class="p">{</span><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">JobRecord</span><span class="p">)}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">startedAt</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Apply</span><span class="p">(</span><span class="nx">JobEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">JobID</span><span class="p">:</span>      <span class="s">&#34;job_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>       <span class="s">&#34;job.started&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">startedAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;apply event: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">job</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;job_1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;job should exist&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">if</span> <span class="nx">job</span><span class="p">.</span><span class="nx">Status</span> <span class="o">!=</span> <span class="nx">JobStatusRunning</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;status = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">job</span><span class="p">.</span><span class="nx">Status</span><span class="p">,</span> <span class="nx">JobStatusRunning</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="nx">history</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="s">&#34;job_1&#34;</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="nb">len</span><span class="p">(</span><span class="nx">history</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">1</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;history length = %d, want 1&#34;</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">25</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試鎖定的是狀態規則，而不是鎖本身。</p>
<h2 id="執行copy-boundary-測試要嘗試破壞資料">【執行】copy boundary 測試要嘗試破壞資料</h2>
<p>copy boundary 測試的核心目標是證明呼叫端拿到的資料不能修改 repository 內部狀態。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestJobRepositoryHistoryReturnsCopy</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">repo</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">JobRepository</span><span class="p">{</span><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">JobRecord</span><span class="p">)}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">occurredAt</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Apply</span><span class="p">(</span><span class="nx">JobEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">JobID</span><span class="p">:</span>      <span class="s">&#34;job_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>       <span class="s">&#34;job.started&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">occurredAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">history</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="s">&#34;job_1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="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">JobStatusFailed</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">again</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="s">&#34;job_1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">if</span> <span class="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">JobStatusRunning</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;repository history was modified through returned slice&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這種測試比只看程式碼更可靠。它直接模擬呼叫端拿到資料後做了危險操作。</p>
<h2 id="執行並發測試配合-race-detector">【執行】並發測試配合 race detector</h2>
<p>並發測試的核心目標是讓 race detector 執行到共享狀態路徑。測試本身可以只檢查不 panic 或基本結果，真正的 data race 由 <code>go test -race</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">TestJobRepositoryConcurrentAccess</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">repo</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">JobRepository</span><span class="p">{</span><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">JobRecord</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">wg</span> <span class="nx">sync</span><span class="p">.</span><span class="nx">WaitGroup</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">i</span> <span class="o">:=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="p">&lt;</span> <span class="mi">100</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">wg</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">go</span> <span class="kd">func</span><span class="p">(</span><span class="nx">i</span> <span class="kt">int</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="k">defer</span> <span class="nx">wg</span><span class="p">.</span><span class="nf">Done</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="nx">id</span> <span class="o">:=</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Sprintf</span><span class="p">(</span><span class="s">&#34;job_%d&#34;</span><span class="p">,</span> <span class="nx">i</span><span class="o">%</span><span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="nx">_</span> <span class="p">=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Apply</span><span class="p">(</span><span class="nx">JobEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">                <span class="nx">JobID</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="nx">Type</span><span class="p">:</span>       <span class="s">&#34;job.started&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">                <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">i</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">15</span><span class="cl">            <span class="p">})</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">            <span class="nx">_</span><span class="p">,</span> <span class="nx">_</span> <span class="p">=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="nx">id</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">            <span class="nx">_</span> <span class="p">=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">History</span><span class="p">(</span><span class="nx">id</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="p">}(</span><span class="nx">i</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="nx">wg</span><span class="p">.</span><span class="nf">Wait</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>執行：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">go <span class="nb">test</span> -race ./...</span></span></code></pre></div><p>race detector 只能檢查測試實際跑到的路徑。若並發讀寫沒有被測試覆蓋，它也不會發現問題。</p>
<h2 id="重構步驟">重構步驟</h2>
<p>從共享狀態外洩重構到安全邊界，可以按這個順序：</p>
<ol>
<li>找出所有直接讀寫 map、slice 或 projection 的地方。</li>
<li>建立 state owner 或 repository。</li>
<li>把最常用的寫入流程搬成方法。</li>
<li>在方法內加入 lock，保護完整不變式。</li>
<li>把讀取方法改成回傳 copy。</li>
<li>讓 handler、worker、publisher 改呼叫方法，不直接碰資料。</li>
<li>補 state transition 與 copy boundary 測試。</li>
<li>補並發測試並執行 <code>go test -race ./...</code>。</li>
</ol>
<p>不要一開始就重寫所有狀態模型。先把寫入集中，再逐步整理 state/projection 與資料庫邊界。</p>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="檢查一加鎖後仍要保護回傳資料">檢查一：加鎖後仍要保護回傳資料</h3>
<p>鎖只保護鎖內操作。回傳內部 map 或 slice 後，呼叫端可以在鎖外修改資料，狀態邊界仍然失效。</p>
<h3 id="檢查二讀取鎖只保護讀取">檢查二：讀取鎖只保護讀取</h3>
<p><code>RLock</code> 只適合讀取。只要會修改 map、slice、pointer 指向的值或 struct 欄位，就必須使用 <code>Lock</code>。</p>
<h3 id="檢查三狀態副本需要明確-owner">檢查三：狀態副本需要明確 owner</h3>
<p>多份狀態副本會造成 <a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth</a> 混亂。handler 應該請求同一個 state owner 更新或查詢。</p>
<h3 id="檢查四持久化替換跟著需求前進">檢查四：持久化替換跟著需求前進</h3>
<p>狀態邊界是程式碼架構的責任；資料庫只負責持久化。把 memory repository 換成 ORM 只解決「資料存在哪裡」，沒有解決「誰有權利寫、怎麼寫才一致」。</p>
<p>引入資料庫後，清楚的寫入方法、交易語意、copy/DTO 邊界與測試仍要留在程式碼設計中。這些規則決定狀態如何被修改，不能交給資料庫連線本身代勞。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理 state owner、lock boundary 與 copy boundary；資料庫 transaction 與分散式一致性，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">Go 進階：資料庫 transaction 與 schema migration</a></li>
<li><a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Go 進階：Source of Truth：狀態邊界</a></li>
<li><a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">Backend：資料庫與持久化</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 repository、read model 與 shared state 的邊界；如果你要先回看語言教材，可以讀：</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/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</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>狀態管理重構的重點是建立資料擁有者。寫入集中在 repository 或 state owner，lock 保護完整不變式，讀取回傳 copy，handler 和 worker 只請求狀態更新。當狀態邊界清楚時，race detector 才有意義，未來換成資料庫也只是 adapter 變化，不會改變核心狀態規則。</p>
]]></content:encoded></item><item><title>7.5 以 domain 重新整理 package</title><link>https://tarrragon.github.io/blog/go/07-refactoring/domain-packages/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/07-refactoring/domain-packages/</guid><description>&lt;p>以 domain 重新整理 package 的核心目標是讓程式結構反映業務語意，而不是只反映技術元件。當系統開始有 account、job、event、workflow 這些不同概念時，平面檔案會讓邊界越來越難看見。&lt;/p>
&lt;p>Go package 是語意邊界，不只是檔案分類。好的 package 名稱應該讓讀者知道這裡負責哪一組概念；如果只能命名成 &lt;code>utils&lt;/code>、&lt;code>common&lt;/code> 或 &lt;code>helpers&lt;/code>，通常代表邊界還沒有想清楚。&lt;/p>
&lt;p>這一章承接入門篇的「單檔到多檔案」路線。平面多檔案是 Go 程式自然長大的中間階段；只有當檔案切分已經無法表達業務邊界時，才需要把概念搬成更清楚的 domain package。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>判斷何時該從平面多檔案拆出 package&lt;/li>
&lt;li>用業務語意命名 package&lt;/li>
&lt;li>依照純型別、純規則、usecase/repository 的順序搬移&lt;/li>
&lt;li>避免 import cycle&lt;/li>
&lt;li>用 type alias 與測試降低搬移風險&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察平面多檔案是自然成長階段">【觀察】平面多檔案是自然成長階段&lt;/h2>
&lt;p>平面 package 的核心價值是初期簡單。服務還小時，&lt;code>main.go&lt;/code>、&lt;code>models.go&lt;/code>、&lt;code>handlers.go&lt;/code>、&lt;code>repository.go&lt;/code> 放在同一層，常常比一開始切十幾個資料夾更容易理解。&lt;/p>
&lt;p>常見中間階段：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">notify/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">├── go.mod
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">├── main.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">├── models.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">├── handlers.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">├── repository.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">├── events.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">└── worker.go&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個結構不是問題本身。真正的問題通常出現在概念開始混在一起：HTTP request struct、domain state、event type、repository model 都放在 &lt;code>models.go&lt;/code>；handler、worker、processor 都直接引用同一批可變資料。&lt;/p>
&lt;h2 id="判讀拆-package-的訊號是語意邊界變模糊">【判讀】拆 package 的訊號是語意邊界變模糊&lt;/h2>
&lt;p>拆 package 的核心判斷是讀者是否能從結構看出概念邊界。若只是檔案變多，先拆檔案即可；若業務概念混在一起，才需要拆 package。&lt;/p>
&lt;p>適合拆 package 的訊號：&lt;/p>
&lt;ul>
&lt;li>&lt;code>models.go&lt;/code> 同時包含 request DTO、domain state、response view。&lt;/li>
&lt;li>新增功能時不知道型別該放哪個檔案。&lt;/li>
&lt;li>event、job、account 規則互相 import 或互相修改。&lt;/li>
&lt;li>測試一個 domain 規則必須初始化 handler 或 server。&lt;/li>
&lt;li>package 內 unexported helper 太多，讀者很難判斷哪些屬於哪個概念。&lt;/li>
&lt;/ul>
&lt;p>不一定要拆 package 的情境：&lt;/p>
&lt;ul>
&lt;li>檔案只是稍長，但仍圍繞同一個概念。&lt;/li>
&lt;li>只有單一 main package 的小工具。&lt;/li>
&lt;li>邊界還不穩，拆完很可能馬上搬回來。&lt;/li>
&lt;li>只是為了符合某個目錄模板。&lt;/li>
&lt;/ul>
&lt;h2 id="策略package-名稱要表達業務概念">【策略】package 名稱要表達業務概念&lt;/h2>
&lt;p>domain package 的核心要求是名稱要讓讀者知道這裡負責哪組概念。&lt;code>job&lt;/code>、&lt;code>event&lt;/code>、&lt;code>account&lt;/code>、&lt;code>workflow&lt;/code> 比 &lt;code>common&lt;/code>、&lt;code>types&lt;/code>、&lt;code>utils&lt;/code> 更有語意。&lt;/p>
&lt;p>一個可能的拆分：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">notify/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">├── go.mod
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">├── main.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">├── domain/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">│ ├── account/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">│ ├── job/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">│ ├── event/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">│ └── workflow/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">├── transport/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">│ └── http/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">└── storage/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> └── memory/&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這是示範語意方向的參考結構。小型服務也可以先只拆：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">notify/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">├── main.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">├── notification/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">└── httpapi/&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Go package 不需要層數多才算成熟。好的 package 是讓 import 讀起來自然：&lt;/p>





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





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// models.go&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="kn">package&lt;/span> &lt;span class="nx">main&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">JobStatus&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="kd">const&lt;/span> &lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">JobStatusPending&lt;/span> &lt;span class="nx">JobStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;pending&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">JobStatusRunning&lt;/span> &lt;span class="nx">JobStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;running&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">JobStatusSucceeded&lt;/span> &lt;span class="nx">JobStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;succeeded&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">JobStatusFailed&lt;/span> &lt;span class="nx">JobStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;failed&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">JobProjection&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">ID&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nx">Status&lt;/span> &lt;span class="nx">JobStatus&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="nx">UpdatedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>重構後：&lt;/p></description><content:encoded><![CDATA[<p>以 domain 重新整理 package 的核心目標是讓程式結構反映業務語意，而不是只反映技術元件。當系統開始有 account、job、event、workflow 這些不同概念時，平面檔案會讓邊界越來越難看見。</p>
<p>Go package 是語意邊界，不只是檔案分類。好的 package 名稱應該讓讀者知道這裡負責哪一組概念；如果只能命名成 <code>utils</code>、<code>common</code> 或 <code>helpers</code>，通常代表邊界還沒有想清楚。</p>
<p>這一章承接入門篇的「單檔到多檔案」路線。平面多檔案是 Go 程式自然長大的中間階段；只有當檔案切分已經無法表達業務邊界時，才需要把概念搬成更清楚的 domain package。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>判斷何時該從平面多檔案拆出 package</li>
<li>用業務語意命名 package</li>
<li>依照純型別、純規則、usecase/repository 的順序搬移</li>
<li>避免 import cycle</li>
<li>用 type alias 與測試降低搬移風險</li>
</ol>
<hr>
<h2 id="觀察平面多檔案是自然成長階段">【觀察】平面多檔案是自然成長階段</h2>
<p>平面 package 的核心價值是初期簡單。服務還小時，<code>main.go</code>、<code>models.go</code>、<code>handlers.go</code>、<code>repository.go</code> 放在同一層，常常比一開始切十幾個資料夾更容易理解。</p>
<p>常見中間階段：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">notify/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── go.mod
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── main.go
</span></span><span class="line"><span class="ln">4</span><span class="cl">├── models.go
</span></span><span class="line"><span class="ln">5</span><span class="cl">├── handlers.go
</span></span><span class="line"><span class="ln">6</span><span class="cl">├── repository.go
</span></span><span class="line"><span class="ln">7</span><span class="cl">├── events.go
</span></span><span class="line"><span class="ln">8</span><span class="cl">└── worker.go</span></span></code></pre></div><p>這個結構不是問題本身。真正的問題通常出現在概念開始混在一起：HTTP request struct、domain state、event type、repository model 都放在 <code>models.go</code>；handler、worker、processor 都直接引用同一批可變資料。</p>
<h2 id="判讀拆-package-的訊號是語意邊界變模糊">【判讀】拆 package 的訊號是語意邊界變模糊</h2>
<p>拆 package 的核心判斷是讀者是否能從結構看出概念邊界。若只是檔案變多，先拆檔案即可；若業務概念混在一起，才需要拆 package。</p>
<p>適合拆 package 的訊號：</p>
<ul>
<li><code>models.go</code> 同時包含 request DTO、domain state、response view。</li>
<li>新增功能時不知道型別該放哪個檔案。</li>
<li>event、job、account 規則互相 import 或互相修改。</li>
<li>測試一個 domain 規則必須初始化 handler 或 server。</li>
<li>package 內 unexported helper 太多，讀者很難判斷哪些屬於哪個概念。</li>
</ul>
<p>不一定要拆 package 的情境：</p>
<ul>
<li>檔案只是稍長，但仍圍繞同一個概念。</li>
<li>只有單一 main package 的小工具。</li>
<li>邊界還不穩，拆完很可能馬上搬回來。</li>
<li>只是為了符合某個目錄模板。</li>
</ul>
<h2 id="策略package-名稱要表達業務概念">【策略】package 名稱要表達業務概念</h2>
<p>domain package 的核心要求是名稱要讓讀者知道這裡負責哪組概念。<code>job</code>、<code>event</code>、<code>account</code>、<code>workflow</code> 比 <code>common</code>、<code>types</code>、<code>utils</code> 更有語意。</p>
<p>一個可能的拆分：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">notify/
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">├── go.mod
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">├── main.go
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">├── domain/
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">│   ├── account/
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">│   ├── job/
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">│   ├── event/
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">│   └── workflow/
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">├── transport/
</span></span><span class="line"><span class="ln">10</span><span class="cl">│   └── http/
</span></span><span class="line"><span class="ln">11</span><span class="cl">└── storage/
</span></span><span class="line"><span class="ln">12</span><span class="cl">    └── memory/</span></span></code></pre></div><p>這是示範語意方向的參考結構。小型服務也可以先只拆：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">notify/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── main.go
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── notification/
</span></span><span class="line"><span class="ln">4</span><span class="cl">└── httpapi/</span></span></code></pre></div><p>Go package 不需要層數多才算成熟。好的 package 是讓 import 讀起來自然：</p>





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





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





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





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// domain/job/transition.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">package</span> <span class="nx">job</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="kn">import</span> <span class="s">&#34;fmt&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kd">func</span> <span class="nf">CanTransition</span><span class="p">(</span><span class="nx">from</span> <span class="nx">Status</span><span class="p">,</span> <span class="nx">to</span> <span class="nx">Status</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">switch</span> <span class="nx">from</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">case</span> <span class="nx">StatusPending</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">return</span> <span class="nx">to</span> <span class="o">==</span> <span class="nx">StatusRunning</span> <span class="o">||</span> <span class="nx">to</span> <span class="o">==</span> <span class="nx">StatusFailed</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">case</span> <span class="nx">StatusRunning</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="k">return</span> <span class="nx">to</span> <span class="o">==</span> <span class="nx">StatusSucceeded</span> <span class="o">||</span> <span class="nx">to</span> <span class="o">==</span> <span class="nx">StatusFailed</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="k">return</span> <span class="kc">false</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="kd">func</span> <span class="nf">Transition</span><span class="p">(</span><span class="nx">current</span> <span class="nx">Projection</span><span class="p">,</span> <span class="nx">next</span> <span class="nx">Status</span><span class="p">)</span> <span class="p">(</span><span class="nx">Projection</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nf">CanTransition</span><span class="p">(</span><span class="nx">current</span><span class="p">.</span><span class="nx">Status</span><span class="p">,</span> <span class="nx">next</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="k">return</span> <span class="nx">Projection</span><span class="p">{},</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;invalid job status transition: %s -&gt; %s&#34;</span><span class="p">,</span> <span class="nx">current</span><span class="p">.</span><span class="nx">Status</span><span class="p">,</span> <span class="nx">next</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="nx">current</span><span class="p">.</span><span class="nx">Status</span> <span class="p">=</span> <span class="nx">next</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">return</span> <span class="nx">current</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這些規則不應 import HTTP package，也不應知道 repository。它們是 domain 的穩定核心。</p>
<h2 id="判讀domain-不依賴-adapter">【判讀】domain 不依賴 adapter</h2>
<p>避免 import cycle 的核心規則是低層 domain 不依賴高層 adapter。domain 可以被 HTTP、worker、repository 使用；但 domain 不應 import 這些外部層。</p>
<p>不良方向：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">domain/job -&gt; transport/http -&gt; domain/job</span></span></code></pre></div><p>良好方向：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">transport/http -&gt; application -&gt; domain/job
</span></span><span class="line"><span class="ln">2</span><span class="cl">storage/memory -&gt; domain/job</span></span></code></pre></div><p>如果 <code>domain/job</code> 需要知道 HTTP request struct，代表 request DTO 沒有停在 transport layer。應把 HTTP request 轉成 command 或 domain value，再交給下層。</p>
<h2 id="執行最後搬-repositoryusecase">【執行】最後搬 repository/usecase</h2>
<p>repository 和 usecase 的核心特徵是它們開始協調多個概念，所以搬移時要更謹慎。通常先搬 domain 型別與規則，再處理 application layer。</p>
<p>可能的結構：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">notify/
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">├── domain/
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">│   ├── job/
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">│   └── event/
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">├── application/
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">│   ├── command.go
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">│   └── processor.go
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">├── transport/
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">│   └── http/
</span></span><span class="line"><span class="ln">10</span><span class="cl">└── storage/
</span></span><span class="line"><span class="ln">11</span><span class="cl">    └── memory/</span></span></code></pre></div><p>application 可以協調 domain：</p>





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestProcessorAppliesJobProjection</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">repo</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">fakeJobRepository</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">processor</span> <span class="o">:=</span> <span class="nx">application</span><span class="p">.</span><span class="nf">NewProcessor</span><span class="p">(</span><span class="nx">repo</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">err</span> <span class="o">:=</span> <span class="nx">processor</span><span class="p">.</span><span class="nf">Process</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">(),</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Event</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span> <span class="s">&#34;job_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>      <span class="nx">event</span><span class="p">.</span><span class="nx">JobStarted</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;process event: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">if</span> <span class="nx">repo</span><span class="p">.</span><span class="nx">got</span><span class="p">.</span><span class="nx">ID</span> <span class="o">!=</span> <span class="s">&#34;job_1&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;job ID = %q, want job_1&#34;</span><span class="p">,</span> <span class="nx">repo</span><span class="p">.</span><span class="nx">got</span><span class="p">.</span><span class="nx">ID</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>測試不需要關心檔案搬到哪裡，它只確認 package API 與行為仍然正確。</p>
<h2 id="重構步驟">重構步驟</h2>
<p>從平面 package 重構成 domain package，可以按這個順序：</p>
<ol>
<li>列出現有檔案中的概念：request、response、domain state、event、repository、worker。</li>
<li>找出最穩定的 domain 名稱，例如 <code>job</code>、<code>event</code>、<code>account</code>。</li>
<li>先建立一個 domain package，不要一次建立整棵架構。</li>
<li>搬純型別與 typed constant。</li>
<li>用 type alias 過渡大型呼叫端。</li>
<li>搬純規則與測試。</li>
<li>修正 import，避免 domain 依賴 adapter。</li>
<li>測試通過後，再搬下一個 domain。</li>
</ol>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="檢查一目錄跟著概念壓力成長">檢查一：目錄跟著概念壓力成長</h3>
<p>服務還小時，一次建立 <code>domain/application/infrastructure/interfaces</code> 可能只會增加跳轉成本。先拆最痛的語意邊界。</p>
<h3 id="檢查二package-名稱表達-domain-概念">檢查二：package 名稱表達 domain 概念</h3>
<p><code>models</code>、<code>types</code>、<code>helpers</code> 通常不夠好。它們說明了程式碼形狀，沒有說明業務語意。</p>
<h3 id="檢查三domain-package-保持技術無關">檢查三：domain package 保持技術無關</h3>
<p>domain 應保存業務語意，不應知道傳輸協定。若 domain import adapter，依賴方向已經反了。</p>
<h3 id="檢查四搬移和行為變更分開">檢查四：搬移和行為變更分開</h3>
<p>package 重構應先保持行為不變。若同時改規則與搬檔案，測試失敗時很難判斷是搬移錯誤還是行為改動。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理 package 結構如何反映 domain 語意；更大型的 module 拆分與 monorepo 策略，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go/01-basics/growing-files-packages/" data-link-title="1.5 從單檔到多檔案" data-link-desc="理解 Go 程式如何從 main.go 長成多檔案與多 package">Go 入門：從單檔到多檔案</a></li>
<li><a href="/blog/go/01-basics/modules/" data-link-title="1.1 Go 專案結構與 module" data-link-desc="理解 go.mod、module path 與 Go 專案的依賴邊界">Go 入門：Go 專案結構與 module</a></li>
<li><a href="/blog/go/00-philosophy/composition/" data-link-title="0.2 組合優先：小介面與明確依賴" data-link-desc="用小介面與 struct 組合取代大型繼承結構">Go 入門：composition 優先：小介面與明確依賴</a></li>
<li><a href="/blog/go/07-refactoring/hexagonal-migration/" data-link-title="7.6 逐步遷移到 ports/adapters 架構" data-link-desc="用 ports 與 adapters 控制 Go 服務的依賴方向">Go 進階：逐步遷移到 ports/adapters 架構</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 type、rule 與 package 邊界；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
<li><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go：狀態管理的安全邊界</a></li>
<li><a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go：如何新增 repository port</a></li>
<li><a href="/blog/go/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">Go：如何新增一種 domain event</a></li>
</ul>
]]></content:encoded></item><item><title>7.6 逐步遷移到 ports/adapters 架構</title><link>https://tarrragon.github.io/blog/go/07-refactoring/hexagonal-migration/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/07-refactoring/hexagonal-migration/</guid><description>&lt;p>ports/adapters 遷移的核心目標是讓 application 與 domain 不依賴外部技術細節。HTTP、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a>、callback receiver、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database&lt;/a> 都是 adapters；usecase 透過 ports 使用它們。&lt;/p>
&lt;p>這種重構不需要一次套完整架構。Go 專案更常見的做法是先把過重 handler、外部依賴與狀態寫入切開，再在壓力最大的邊界引入 port。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>用依賴方向理解 ports/adapters&lt;/li>
&lt;li>分辨 inbound adapter 與 outbound adapter&lt;/li>
&lt;li>把 usecase 從 handler、repository、publisher 中切出&lt;/li>
&lt;li>用新功能先走新架構的方式漸進遷移&lt;/li>
&lt;li>驗證 import direction、usecase test 與 adapter integration test&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察portsadapters-是依賴方向">【觀察】ports/adapters 是依賴方向&lt;/h2>
&lt;p>ports/adapters 的核心規則是外部技術依賴 application，而不是 application 依賴外部技術。HTTP、WebSocket、database、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> 都是可替換的邊界；usecase 應該依賴自己定義的 port。&lt;/p>
&lt;p>目標方向：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">transport/http ┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">transport/websocket ├─&amp;gt; application ──&amp;gt; domain
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">transport/callback ┘ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> ports defined here
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> ▲
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">storage/memory ┐ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">eventlog/memory ├───────┘
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">publisher/websocket ┘&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>資料夾名稱可以不同。真正重要的是 import direction：domain 不 import HTTP，application 不 import database implementation，adapter import application 並實作 application 需要的 port。&lt;/p>
&lt;h2 id="判讀inbound-adapter-把外部輸入轉成-command">【判讀】inbound adapter 把外部輸入轉成 command&lt;/h2>
&lt;p>inbound adapter 的核心責任是接收外部訊號，轉成 application command 或 domain event。它不應直接修改 state，也不應保存業務規則。&lt;/p>
&lt;p>常見 inbound adapter：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>adapter&lt;/th>
 &lt;th>輸入&lt;/th>
 &lt;th>轉換結果&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>HTTP handler&lt;/td>
 &lt;td>HTTP request&lt;/td>
 &lt;td>command&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>WebSocket router&lt;/td>
 &lt;td>client message&lt;/td>
 &lt;td>command&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>callback receiver&lt;/td>
 &lt;td>external callback&lt;/td>
 &lt;td>domain event&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>worker&lt;/td>
 &lt;td>timer 或 queue item&lt;/td>
 &lt;td>command/event&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>例如 HTTP adapter：&lt;/p>





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">transport/http      ┐
</span></span><span class="line"><span class="ln">2</span><span class="cl">transport/websocket ├─&gt; application ──&gt; domain
</span></span><span class="line"><span class="ln">3</span><span class="cl">transport/callback  ┘        │
</span></span><span class="line"><span class="ln">4</span><span class="cl">                             ▼
</span></span><span class="line"><span class="ln">5</span><span class="cl">                      ports defined here
</span></span><span class="line"><span class="ln">6</span><span class="cl">                             ▲
</span></span><span class="line"><span class="ln">7</span><span class="cl">storage/memory       ┐       │
</span></span><span class="line"><span class="ln">8</span><span class="cl">eventlog/memory      ├───────┘
</span></span><span class="line"><span class="ln">9</span><span class="cl">publisher/websocket  ┘</span></span></code></pre></div><p>資料夾名稱可以不同。真正重要的是 import direction：domain 不 import HTTP，application 不 import database implementation，adapter import application 並實作 application 需要的 port。</p>
<h2 id="判讀inbound-adapter-把外部輸入轉成-command">【判讀】inbound adapter 把外部輸入轉成 command</h2>
<p>inbound adapter 的核心責任是接收外部訊號，轉成 application command 或 domain event。它不應直接修改 state，也不應保存業務規則。</p>
<p>常見 inbound adapter：</p>
<table>
  <thead>
      <tr>
          <th>adapter</th>
          <th>輸入</th>
          <th>轉換結果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>HTTP handler</td>
          <td>HTTP request</td>
          <td>command</td>
      </tr>
      <tr>
          <td>WebSocket router</td>
          <td>client message</td>
          <td>command</td>
      </tr>
      <tr>
          <td>callback receiver</td>
          <td>external callback</td>
          <td>domain event</td>
      </tr>
      <tr>
          <td>worker</td>
          <td>timer 或 queue item</td>
          <td>command/event</td>
      </tr>
  </tbody>
</table>
<p>例如 HTTP adapter：</p>





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





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





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





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





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





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





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





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">domain       不 import application、transport、storage
</span></span><span class="line"><span class="ln">2</span><span class="cl">application  可以 import domain，不 import transport/storage implementation
</span></span><span class="line"><span class="ln">3</span><span class="cl">transport    可以 import application/domain
</span></span><span class="line"><span class="ln">4</span><span class="cl">storage      可以 import application/domain
</span></span><span class="line"><span class="ln">5</span><span class="cl">cmd/main     可以 import 所有 adapter 與 application 做組裝</span></span></code></pre></div><p>可以用 <code>go list</code> 觀察 package 依賴：</p>





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestIngestExternalEventAppendsAndProcesses</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">eventLog</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">fakeEventLog</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">processor</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">fakeEventProcessor</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">usecase</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">application</span><span class="p">.</span><span class="nx">IngestExternalEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">eventLog</span><span class="p">:</span>  <span class="nx">eventLog</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">processor</span><span class="p">:</span> <span class="nx">processor</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">event</span> <span class="o">:=</span> <span class="nf">validDomainEvent</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">usecase</span><span class="p">.</span><span class="nf">Execute</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">(),</span> <span class="nx">event</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;ingest event: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">eventLog</span><span class="p">.</span><span class="nx">appended</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">1</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;appended events = %d, want 1&#34;</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">eventLog</span><span class="p">.</span><span class="nx">appended</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">processor</span><span class="p">.</span><span class="nx">processed</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">1</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;processed events = %d, want 1&#34;</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">processor</span><span class="p">.</span><span class="nx">processed</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>adapter integration test 則可以用 <code>httptest</code> 驗證 request/response 與 usecase fake 是否被呼叫。兩種測試分工清楚，失敗時才知道是規則錯還是協定轉換錯。</p>
<h2 id="重構步驟">重構步驟</h2>
<p>逐步遷移到 ports/adapters，可以按這個順序：</p>
<ol>
<li>先找一條最痛的功能路徑，例如新增 notification 或 ingest external event。</li>
<li>把 handler/router 中的規則抽成 command/usecase。</li>
<li>在 application 定義 repository、event log、publisher port。</li>
<li>讓現有 memory store 或 publisher 實作 port。</li>
<li>main 組裝 concrete adapter 與 usecase。</li>
<li>新功能只走新路徑。</li>
<li>舊功能被修改時，逐步搬到同樣邊界。</li>
<li>用 import direction review 防止反向依賴。</li>
</ol>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="檢查一遷移從單一邊界開始">檢查一：遷移從單一邊界開始</h3>
<p>ports/adapters 的價值是依賴方向。若沒有先拆 usecase 與 port，只是搬資料夾，複雜度會上升但邊界不會變清楚。</p>
<h3 id="檢查二application-依賴-port">檢查二：application 依賴 port</h3>
<p>application 應依賴 port，不依賴 memory、SQLite 或 database adapter。若 application import storage，依賴方向已經反了。</p>
<h3 id="檢查三業務規則留在-application-或-domain">檢查三：業務規則留在 application 或 domain</h3>
<p>adapter 可以驗證輸入格式，但業務規則應該在 usecase 或 domain。否則 HTTP 與 WebSocket 會各自複製規則。</p>
<h3 id="檢查四port-跟著使用端分散">檢查四：port 跟著使用端分散</h3>
<p>port 應靠近使用端。把所有 interface 集中到一個大型 package，常會讓依賴重新糾纏在一起。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理 ports/adapters 的依賴方向；分散式系統、資料庫與平台 wiring，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/" data-link-title="模組七：跨節點與平台整合" data-link-desc="把單一 Go 服務延伸到資料庫、queue、跨節點 WebSocket、可觀測性與部署平台">Go 進階：跨節點與平台整合</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">Go 進階：資料庫 transaction 與 schema migration</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Go 進階：Durable queue、outbox 與 idempotency</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">Go 進階：Kubernetes、systemd 與 load balancer 合約</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 handler、repository、event 與 composition root 的遷移路線；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">Go：把 handler 邏輯拆成可測單元</a></li>
<li><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
<li><a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go：如何新增 repository port</a></li>
<li><a href="/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">Go：composition root 與依賴組裝</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>ports/adapters 遷移的重點是控制依賴方向：adapter 處理外部技術，application 定義 usecase 與 ports，domain 保存核心語意。Go 專案可以漸進式遷移，新功能先走清楚邊界，舊功能在修改時再搬。架構的價值在於測試更直接、替換更容易、核心規則不被外部技術綁住。</p>
]]></content:encoded></item><item><title>7.7 composition root 與依賴組裝</title><link>https://tarrragon.github.io/blog/go/07-refactoring/composition-root/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/07-refactoring/composition-root/</guid><description>&lt;p>composition root 的核心責任是集中建立具體依賴。domain 與 application 應依賴 port；&lt;code>main&lt;/code> 或啟動層負責讀取 config、建立 adapter、組裝 usecase、註冊 handler 與啟動 server。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>理解 composition root 為什麼要集中在啟動層&lt;/li>
&lt;li>分辨 port、adapter 與 usecase 的組裝責任&lt;/li>
&lt;li>用 typed config 讓 wiring 依賴可讀、可測、可替換&lt;/li>
&lt;li>看懂哪些依賴應在 &lt;code>main&lt;/code> 組裝，哪些不該散在 handler 裡&lt;/li>
&lt;li>讓啟動層只負責「把系統接起來」，不負責業務規則&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察composition-root-是整個應用的接線板">【觀察】composition root 是整個應用的接線板&lt;/h2>
&lt;p>composition root 的核心用途是把具體依賴集中在一個地方建立。這個地方通常是 &lt;code>main()&lt;/code>、&lt;code>cmd/.../main.go&lt;/code> 或啟動層 package。&lt;/p>
&lt;p>當讀者打開入口程式時，應該能直接看到：&lt;/p>
&lt;ul>
&lt;li>config 從哪裡來&lt;/li>
&lt;li>repository 怎麼建立&lt;/li>
&lt;li>publisher / worker / server 怎麼串起來&lt;/li>
&lt;li>哪些 dependency 是 mockable port&lt;/li>
&lt;li>哪些是明確的外部 adapter&lt;/li>
&lt;/ul>
&lt;p>這種集中式 wiring 的好處是：&lt;/p>
&lt;ul>
&lt;li>依賴方向清楚&lt;/li>
&lt;li>測試替身好替換&lt;/li>
&lt;li>啟動問題容易定位&lt;/li>
&lt;li>不會把建構邏輯散落到各個 handler 或 usecase&lt;/li>
&lt;/ul>
&lt;h2 id="判讀dependency-injection-的重點是方向">【判讀】dependency injection 的重點是方向&lt;/h2>
&lt;p>Go 的依賴注入通常不需要框架。真正的重點是：高層只依賴 port，低層在入口被組裝進來。&lt;/p>
&lt;p>例如：&lt;/p>





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





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





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





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">Config</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">HTTPAddr</span>   <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">DatabaseDSN</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">RedisAddr</span>  <span class="kt">string</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>load config 的責任是把外部輸入變成可預期的程式設定，而不是在每個 adapter 初始化時各自讀環境變數。</p>
<h2 id="執行建立-adapter-後再注入-usecase">【執行】建立 adapter 後再注入 usecase</h2>
<p>常見的組裝順序是：</p>
<ol>
<li>讀 config。</li>
<li>建立 logger / <a href="/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics</a> / tracer。</li>
<li>建立 <a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database</a> / cache / <a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a> client。</li>
<li>建立 repository 與 service。</li>
<li>建立 handler 或 server。</li>
<li>啟動背景 worker 與 HTTP server。</li>
</ol>
<p>這樣做可以讓初始化失敗在入口層就被看見，不會等到請求進來才爆。</p>
<h2 id="判讀組裝邏輯應集中在入口層">【判讀】組裝邏輯應集中在入口層</h2>
<p>如果 handler 自己 new repository、new client、new worker，就會出現這些問題：</p>
<ul>
<li>測試無法替換依賴</li>
<li>生命週期很難控制</li>
<li>每個 request 都可能建立不必要的資源</li>
<li>啟動路徑與請求路徑混在一起</li>
</ul>
<p>handler 應該只接收已組裝好的依賴，專心處理輸入和回應。</p>
<h2 id="延伸backend-教材負責具體外部服務語意">【延伸】Backend 教材負責具體外部服務語意</h2>
<p>Go 章節只需要知道依賴怎麼接，真正的外部服務語意留給 Backend 教材：</p>
<ul>
<li>database client 建立、pool 與 <a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a> 語意</li>
<li>Redis client、pipeline 與 cache 邊界</li>
<li>broker connection、[durable <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a>](/go/backend/knowledge-cards/durable-queue) 與重試</li>
<li>platform secret、runtime limit 與部署環境</li>
</ul>
<p>Go 的 composition root 不需要重複教這些技術，只要把它們正確接上即可。</p>
<h2 id="與-backend-教材的分工">與 Backend 教材的分工</h2>
<p>本章處理 Go 程式如何組裝依賴。資料庫連線池、Redis client、broker connection、container secret 與平台設定會放在 Backend 對應模組；Go 章節只保留「誰依賴誰」與「在哪裡組裝」的設計。</p>
]]></content:encoded></item><item><title>模組七：重構實戰</title><link>https://tarrragon.github.io/blog/go/07-refactoring/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/07-refactoring/</guid><description>&lt;p>Go 重構的核心目標是讓邊界更清楚、測試更直接、資料競爭更少。抽象只在它能降低耦合或保護行為時才有價值；本模組用一般 Go 服務範例說明如何在程式仍可運行的前提下，從平面檔案結構逐步走向更清楚的 package、interface、state 與 adapter 邊界。&lt;/p>
&lt;p>重構章節的主軸是「壓力出現後再拆分」。小型 Go 程式可以保持簡單；當 handler 過重、狀態外洩、測試困難、事件語意混亂或外部依賴變多時，再逐步引入 domain-oriented package 與 ports/adapters。這種順序比一開始套用完整分層架構更符合 Go 的工程習慣。&lt;/p>
&lt;h2 id="章節列表">章節列表&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>章節&lt;/th>
 &lt;th>主題&lt;/th>
 &lt;th>關鍵收穫&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">7.1&lt;/a>&lt;/td>
 &lt;td>把 handler 邏輯拆成可測單元&lt;/td>
 &lt;td>分離協定處理與業務邏輯&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">7.2&lt;/a>&lt;/td>
 &lt;td>用 interface 隔離外部依賴&lt;/td>
 &lt;td>建立小而穩定的測試替身&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/dedup-refactor/" data-link-title="7.3 事件去重邏輯的重構策略" data-link-desc="保留語義鍵並降低重複流程">7.3&lt;/a>&lt;/td>
 &lt;td>事件去重邏輯的重構策略&lt;/td>
 &lt;td>保留語義鍵，降低重複流程&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">7.4&lt;/a>&lt;/td>
 &lt;td>狀態管理的安全邊界&lt;/td>
 &lt;td>用複製與鎖保護共享資料&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/domain-packages/" data-link-title="7.5 以 domain 重新整理 package" data-link-desc="讓 account、job、event、workflow 這類領域邊界在目錄中可見">7.5&lt;/a>&lt;/td>
 &lt;td>以 domain 重新整理 package&lt;/td>
 &lt;td>讓 account、job、event、workflow 這類語意邊界可見&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/hexagonal-migration/" data-link-title="7.6 逐步遷移到 ports/adapters 架構" data-link-desc="用 ports 與 adapters 控制 Go 服務的依賴方向">7.6&lt;/a>&lt;/td>
 &lt;td>逐步遷移到 ports/adapters 架構&lt;/td>
 &lt;td>用 ports/adapters 控制依賴方向&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">7.7&lt;/a>&lt;/td>
 &lt;td>composition root 與依賴組裝&lt;/td>
 &lt;td>把具體 adapter、config 與 usecase wiring 留在入口層&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/pressure-driven-refactor/" data-link-title="7.8 壓力出現後的重構路線" data-link-desc="當 Go 服務變大時，如何按壓力逐步重構邊界">7.8&lt;/a>&lt;/td>
 &lt;td>壓力出現後的重構路線&lt;/td>
 &lt;td>按壓力逐步拆邊界，讓服務變大仍可維護&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組的重構判斷">本模組的重構判斷&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>先保行為，再搬結構&lt;/strong>：每次重構都要有測試或可觀察行為保護。&lt;/li>
&lt;li>&lt;strong>package 代表語意邊界&lt;/strong>：清楚的 domain 名稱能讓責任可見；&lt;code>utils&lt;/code>、&lt;code>common&lt;/code> 這類技術分類容易把不同概念混在一起。&lt;/li>
&lt;li>&lt;strong>interface 由使用端定義&lt;/strong>：usecase 需要什麼能力，就定義什麼 port。&lt;/li>
&lt;li>&lt;strong>state 要有擁有者&lt;/strong>：共享 map、slice、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection&lt;/a> 必須集中寫入並保護 copy boundary。&lt;/li>
&lt;li>&lt;strong>架構不是目錄模板&lt;/strong>：ports/adapters 的重點是依賴方向，不是固定資料夾名稱。&lt;/li>
&lt;/ul>
&lt;h2 id="章節粒度說明">章節粒度說明&lt;/h2>
&lt;p>重構章節刻意維持「一章一條遷移路線」。每章會先說明壓力訊號，再給局部重構策略、測試保護、設計檢查與延伸範圍。這種安排讓讀者能照著章節做小步遷移；完整架構模板應在遷移壓力明確後再引入。&lt;/p>
&lt;p>如果只想查單一概念，可以依照下列對照閱讀：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>重構問題&lt;/th>
 &lt;th>優先閱讀&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>handler 太厚&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">把 handler 邏輯拆成可測單元&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>外部依賴難測&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">用 interface 隔離外部依賴&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>事件重複或來源變多&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/dedup-refactor/" data-link-title="7.3 事件去重邏輯的重構策略" data-link-desc="保留語義鍵並降低重複流程">事件去重邏輯的重構策略&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>共享狀態外洩&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">狀態管理的安全邊界&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>檔案平面結構失去語意&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/domain-packages/" data-link-title="7.5 以 domain 重新整理 package" data-link-desc="讓 account、job、event、workflow 這類領域邊界在目錄中可見">以 domain 重新整理 package&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>依賴方向需要穩定&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/hexagonal-migration/" data-link-title="7.6 逐步遷移到 ports/adapters 架構" data-link-desc="用 ports 與 adapters 控制 Go 服務的依賴方向">逐步遷移到 ports/adapters 架構&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>抽出 port 後不知道在哪裡 new adapter&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">composition root 與依賴組裝&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組使用的範例主題">本模組使用的範例主題&lt;/h2>
&lt;ul>
&lt;li>事件處理流程重構&lt;/li>
&lt;li>共享狀態重構&lt;/li>
&lt;li>查詢介面邊界&lt;/li>
&lt;li>feature gate 邏輯&lt;/li>
&lt;li>domain package 切分&lt;/li>
&lt;li>inbound/outbound adapter 遷移&lt;/li>
&lt;li>composition root 與依賴組裝&lt;/li>
&lt;/ul>
&lt;h2 id="學習時間">學習時間&lt;/h2>
&lt;p>預計 2.5 小時&lt;/p></description><content:encoded><![CDATA[<p>Go 重構的核心目標是讓邊界更清楚、測試更直接、資料競爭更少。抽象只在它能降低耦合或保護行為時才有價值；本模組用一般 Go 服務範例說明如何在程式仍可運行的前提下，從平面檔案結構逐步走向更清楚的 package、interface、state 與 adapter 邊界。</p>
<p>重構章節的主軸是「壓力出現後再拆分」。小型 Go 程式可以保持簡單；當 handler 過重、狀態外洩、測試困難、事件語意混亂或外部依賴變多時，再逐步引入 domain-oriented package 與 ports/adapters。這種順序比一開始套用完整分層架構更符合 Go 的工程習慣。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">7.1</a></td>
          <td>把 handler 邏輯拆成可測單元</td>
          <td>分離協定處理與業務邏輯</td>
      </tr>
      <tr>
          <td><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">7.2</a></td>
          <td>用 interface 隔離外部依賴</td>
          <td>建立小而穩定的測試替身</td>
      </tr>
      <tr>
          <td><a href="/blog/go/07-refactoring/dedup-refactor/" data-link-title="7.3 事件去重邏輯的重構策略" data-link-desc="保留語義鍵並降低重複流程">7.3</a></td>
          <td>事件去重邏輯的重構策略</td>
          <td>保留語義鍵，降低重複流程</td>
      </tr>
      <tr>
          <td><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">7.4</a></td>
          <td>狀態管理的安全邊界</td>
          <td>用複製與鎖保護共享資料</td>
      </tr>
      <tr>
          <td><a href="/blog/go/07-refactoring/domain-packages/" data-link-title="7.5 以 domain 重新整理 package" data-link-desc="讓 account、job、event、workflow 這類領域邊界在目錄中可見">7.5</a></td>
          <td>以 domain 重新整理 package</td>
          <td>讓 account、job、event、workflow 這類語意邊界可見</td>
      </tr>
      <tr>
          <td><a href="/blog/go/07-refactoring/hexagonal-migration/" data-link-title="7.6 逐步遷移到 ports/adapters 架構" data-link-desc="用 ports 與 adapters 控制 Go 服務的依賴方向">7.6</a></td>
          <td>逐步遷移到 ports/adapters 架構</td>
          <td>用 ports/adapters 控制依賴方向</td>
      </tr>
      <tr>
          <td><a href="/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">7.7</a></td>
          <td>composition root 與依賴組裝</td>
          <td>把具體 adapter、config 與 usecase wiring 留在入口層</td>
      </tr>
      <tr>
          <td><a href="/blog/go/07-refactoring/pressure-driven-refactor/" data-link-title="7.8 壓力出現後的重構路線" data-link-desc="當 Go 服務變大時，如何按壓力逐步重構邊界">7.8</a></td>
          <td>壓力出現後的重構路線</td>
          <td>按壓力逐步拆邊界，讓服務變大仍可維護</td>
      </tr>
  </tbody>
</table>
<h2 id="本模組的重構判斷">本模組的重構判斷</h2>
<ul>
<li><strong>先保行為，再搬結構</strong>：每次重構都要有測試或可觀察行為保護。</li>
<li><strong>package 代表語意邊界</strong>：清楚的 domain 名稱能讓責任可見；<code>utils</code>、<code>common</code> 這類技術分類容易把不同概念混在一起。</li>
<li><strong>interface 由使用端定義</strong>：usecase 需要什麼能力，就定義什麼 port。</li>
<li><strong>state 要有擁有者</strong>：共享 map、slice、<a href="/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection</a> 必須集中寫入並保護 copy boundary。</li>
<li><strong>架構不是目錄模板</strong>：ports/adapters 的重點是依賴方向，不是固定資料夾名稱。</li>
</ul>
<h2 id="章節粒度說明">章節粒度說明</h2>
<p>重構章節刻意維持「一章一條遷移路線」。每章會先說明壓力訊號，再給局部重構策略、測試保護、設計檢查與延伸範圍。這種安排讓讀者能照著章節做小步遷移；完整架構模板應在遷移壓力明確後再引入。</p>
<p>如果只想查單一概念，可以依照下列對照閱讀：</p>
<table>
  <thead>
      <tr>
          <th>重構問題</th>
          <th>優先閱讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>handler 太厚</td>
          <td><a href="/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">把 handler 邏輯拆成可測單元</a></td>
      </tr>
      <tr>
          <td>外部依賴難測</td>
          <td><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">用 interface 隔離外部依賴</a></td>
      </tr>
      <tr>
          <td>事件重複或來源變多</td>
          <td><a href="/blog/go/07-refactoring/dedup-refactor/" data-link-title="7.3 事件去重邏輯的重構策略" data-link-desc="保留語義鍵並降低重複流程">事件去重邏輯的重構策略</a></td>
      </tr>
      <tr>
          <td>共享狀態外洩</td>
          <td><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">狀態管理的安全邊界</a></td>
      </tr>
      <tr>
          <td>檔案平面結構失去語意</td>
          <td><a href="/blog/go/07-refactoring/domain-packages/" data-link-title="7.5 以 domain 重新整理 package" data-link-desc="讓 account、job、event、workflow 這類領域邊界在目錄中可見">以 domain 重新整理 package</a></td>
      </tr>
      <tr>
          <td>依賴方向需要穩定</td>
          <td><a href="/blog/go/07-refactoring/hexagonal-migration/" data-link-title="7.6 逐步遷移到 ports/adapters 架構" data-link-desc="用 ports 與 adapters 控制 Go 服務的依賴方向">逐步遷移到 ports/adapters 架構</a></td>
      </tr>
      <tr>
          <td>抽出 port 後不知道在哪裡 new adapter</td>
          <td><a href="/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">composition root 與依賴組裝</a></td>
      </tr>
  </tbody>
</table>
<h2 id="本模組使用的範例主題">本模組使用的範例主題</h2>
<ul>
<li>事件處理流程重構</li>
<li>共享狀態重構</li>
<li>查詢介面邊界</li>
<li>feature gate 邏輯</li>
<li>domain package 切分</li>
<li>inbound/outbound adapter 遷移</li>
<li>composition root 與依賴組裝</li>
</ul>
<h2 id="學習時間">學習時間</h2>
<p>預計 2.5 小時</p>
]]></content:encoded></item><item><title>模組七：重構實戰</title><link>https://tarrragon.github.io/blog/python/07-refactoring/</link><pubDate>Tue, 20 Jan 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/python/07-refactoring/</guid><description>&lt;p>本模組基於 v0.28.0-v0.31.0 Hook 系統重構的實際經驗，教你如何識別程式碼問題並進行系統性重構。&lt;/p>
&lt;h2 id="學習目標">學習目標&lt;/h2>
&lt;ol>
&lt;li>學會識別常見的程式碼壞味道&lt;/li>
&lt;li>理解配置與程式碼分離的重要性&lt;/li>
&lt;li>掌握 DRY 原則的實踐方法&lt;/li>
&lt;li>學會消除魔法數字&lt;/li>
&lt;li>能夠進行系統性的程式碼重構&lt;/li>
&lt;li>理解變數作用域變更在重構中的風險&lt;/li>
&lt;/ol>
&lt;h2 id="章節內容">章節內容&lt;/h2>
&lt;h3 id="重構的動機與策略">&lt;a href="https://tarrragon.github.io/blog/python/07-refactoring/refactoring-strategy/" data-link-title="重構的動機與策略" data-link-desc="從 Hook 系統重構經驗出發，學習何時重構、何時不該重構，以及如何將大規模重構拆分成可管理的階段">重構的動機與策略&lt;/a>&lt;/h3>
&lt;p>量化認知負擔、制定重構策略、掌握階段分解方法。&lt;/p>
&lt;h3 id="程式碼壞味道識別">&lt;a href="https://tarrragon.github.io/blog/python/07-refactoring/code-smells/" data-link-title="程式碼壞味道偵測" data-link-desc="從三級分類系統到偵測工具鏈，建立系統化的程式碼品質防線">程式碼壞味道識別&lt;/a>&lt;/h3>
&lt;p>學習如何識別程式碼中的問題，包括 Error Patterns 系統介紹和 5 Why 分析方法。&lt;/p>
&lt;h3 id="dry-原則與共用程式庫">&lt;a href="https://tarrragon.github.io/blog/python/07-refactoring/dry-principle/" data-link-title="DRY 原則與共用程式庫" data-link-desc="學習識別重複程式碼並建立共用模組，含模組演進與漸進遷移策略">DRY 原則與共用程式庫&lt;/a>&lt;/h3>
&lt;p>基於 IMP-001 錯誤模式，學習如何識別重複程式碼並建立共用模組。&lt;/p>
&lt;h3 id="配置分離與常數管理">&lt;a href="https://tarrragon.github.io/blog/python/07-refactoring/constants-management/" data-link-title="配置分離與常數管理" data-link-desc="學習消除三種硬編碼問題：魔法數字、配置混合、散落訊息">配置分離與常數管理&lt;/a>&lt;/h3>
&lt;p>基於 IMP-002、ARCH-001 錯誤模式，學習三種硬編碼（魔法數字、配置、字串）的系統性消除。&lt;/p>
&lt;h3 id="大規模統一化重構">&lt;a href="https://tarrragon.github.io/blog/python/07-refactoring/unified-infrastructure/" data-link-title="大規模統一化重構" data-link-desc="從 44 種不同實作到統一基礎設施：日誌、訊息、風格的三階段漸進式重構">大規模統一化重構&lt;/a>&lt;/h3>
&lt;p>基於 W22-W24 統一化重構經驗，學習三階段漸進式重構方法。&lt;/p>
&lt;h3 id="重構陷阱與防護">&lt;a href="https://tarrragon.github.io/blog/python/07-refactoring/refactoring-pitfalls/" data-link-title="重構陷阱與防護" data-link-desc="三個真實重構事故的共通模式：部分更新問題與系統性防護方法">重構陷阱與防護&lt;/a>&lt;/h3>
&lt;p>基於 IMP-003、IMP-005 錯誤模式，學習重構中的常見陷阱和防護措施。&lt;/p>
&lt;h3 id="非程式碼的重構">&lt;a href="https://tarrragon.github.io/blog/python/07-refactoring/non-code-refactoring/" data-link-title="非程式碼的重構" data-link-desc="用 Progressive Disclosure 精簡膨脹的規則文件，文件重構和程式碼重構是同一套思維">非程式碼的重構&lt;/a>&lt;/h3>
&lt;p>文件壞味道識別與 Progressive Disclosure 精簡策略。&lt;/p>
&lt;h3 id="完整案例回顧">&lt;a href="https://tarrragon.github.io/blog/python/07-refactoring/case-study/" data-link-title="完整案例回顧" data-link-desc="從超過 30 個 Hook 各自為政到系統化品質工程，三個階段的完整重構復盤">完整案例回顧&lt;/a>&lt;/h3>
&lt;p>完整回顧 v0.28.0-v0.31.0 重構流程，從問題識別到最終成果。&lt;/p>
&lt;h3 id="作用域迴歸案例研究">&lt;a href="https://tarrragon.github.io/blog/python/07-refactoring/scope-regression/" data-link-title="作用域迴歸案例研究" data-link-desc="從 IMP-003 事件學習 Python 變數作用域的陷阱">作用域迴歸案例研究&lt;/a>&lt;/h3>
&lt;p>基於 IMP-003 錯誤模式，學習 Python 變數作用域在重構中的陷阱。W24 統一 logger 初始化時，7 個 Hook 因作用域變更靜默失敗的真實案例。&lt;/p>
&lt;h2 id="重構成效數據">重構成效數據&lt;/h2>
&lt;p>v0.28.0 重構的實際成果：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>指標&lt;/th>
 &lt;th>數值&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>消除重複程式碼&lt;/td>
 &lt;td>~415 行&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Hook 檔案縮減&lt;/td>
 &lt;td>38%-65%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>新增共用模組&lt;/td>
 &lt;td>7 個&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>單元測試&lt;/td>
 &lt;td>28 個（全部通過）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="資料來源">資料來源&lt;/h2>
&lt;p>本模組使用的實際案例來自：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Error Patterns&lt;/strong>: &lt;code>.claude/error-patterns/&lt;/code> 目錄下的錯誤模式文件&lt;/li>
&lt;li>&lt;strong>Git 歷史&lt;/strong>: Commit &lt;code>60f1b95&lt;/code>（v0.28.0）、&lt;code>3cf47d4e&lt;/code>（v0.31.0 W24）及相關重構提交&lt;/li>
&lt;li>&lt;strong>共用程式庫&lt;/strong>: &lt;code>.claude/lib/&lt;/code> 目錄下的模組&lt;/li>
&lt;/ul>
&lt;h2 id="先修知識">先修知識&lt;/h2>
&lt;p>建議先完成以下模組：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/python/01-basics/" data-link-title="模組一：Python 基礎概念" data-link-desc="Python 語言、script、module、package 與 import 機制的核心概念快速回顧">模組一：Python 基礎概念&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/python/04-oop/" data-link-title="模組四：物件導向設計" data-link-desc="Python 的物件導向設計與設計模式">模組四：物件導向設計&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>文件版本：v0.31.0&lt;/em>
&lt;em>最後更新：2026-03-04&lt;/em>&lt;/p></description><content:encoded><![CDATA[<p>本模組基於 v0.28.0-v0.31.0 Hook 系統重構的實際經驗，教你如何識別程式碼問題並進行系統性重構。</p>
<h2 id="學習目標">學習目標</h2>
<ol>
<li>學會識別常見的程式碼壞味道</li>
<li>理解配置與程式碼分離的重要性</li>
<li>掌握 DRY 原則的實踐方法</li>
<li>學會消除魔法數字</li>
<li>能夠進行系統性的程式碼重構</li>
<li>理解變數作用域變更在重構中的風險</li>
</ol>
<h2 id="章節內容">章節內容</h2>
<h3 id="重構的動機與策略"><a href="/blog/python/07-refactoring/refactoring-strategy/" data-link-title="重構的動機與策略" data-link-desc="從 Hook 系統重構經驗出發，學習何時重構、何時不該重構，以及如何將大規模重構拆分成可管理的階段">重構的動機與策略</a></h3>
<p>量化認知負擔、制定重構策略、掌握階段分解方法。</p>
<h3 id="程式碼壞味道識別"><a href="/blog/python/07-refactoring/code-smells/" data-link-title="程式碼壞味道偵測" data-link-desc="從三級分類系統到偵測工具鏈，建立系統化的程式碼品質防線">程式碼壞味道識別</a></h3>
<p>學習如何識別程式碼中的問題，包括 Error Patterns 系統介紹和 5 Why 分析方法。</p>
<h3 id="dry-原則與共用程式庫"><a href="/blog/python/07-refactoring/dry-principle/" data-link-title="DRY 原則與共用程式庫" data-link-desc="學習識別重複程式碼並建立共用模組，含模組演進與漸進遷移策略">DRY 原則與共用程式庫</a></h3>
<p>基於 IMP-001 錯誤模式，學習如何識別重複程式碼並建立共用模組。</p>
<h3 id="配置分離與常數管理"><a href="/blog/python/07-refactoring/constants-management/" data-link-title="配置分離與常數管理" data-link-desc="學習消除三種硬編碼問題：魔法數字、配置混合、散落訊息">配置分離與常數管理</a></h3>
<p>基於 IMP-002、ARCH-001 錯誤模式，學習三種硬編碼（魔法數字、配置、字串）的系統性消除。</p>
<h3 id="大規模統一化重構"><a href="/blog/python/07-refactoring/unified-infrastructure/" data-link-title="大規模統一化重構" data-link-desc="從 44 種不同實作到統一基礎設施：日誌、訊息、風格的三階段漸進式重構">大規模統一化重構</a></h3>
<p>基於 W22-W24 統一化重構經驗，學習三階段漸進式重構方法。</p>
<h3 id="重構陷阱與防護"><a href="/blog/python/07-refactoring/refactoring-pitfalls/" data-link-title="重構陷阱與防護" data-link-desc="三個真實重構事故的共通模式：部分更新問題與系統性防護方法">重構陷阱與防護</a></h3>
<p>基於 IMP-003、IMP-005 錯誤模式，學習重構中的常見陷阱和防護措施。</p>
<h3 id="非程式碼的重構"><a href="/blog/python/07-refactoring/non-code-refactoring/" data-link-title="非程式碼的重構" data-link-desc="用 Progressive Disclosure 精簡膨脹的規則文件，文件重構和程式碼重構是同一套思維">非程式碼的重構</a></h3>
<p>文件壞味道識別與 Progressive Disclosure 精簡策略。</p>
<h3 id="完整案例回顧"><a href="/blog/python/07-refactoring/case-study/" data-link-title="完整案例回顧" data-link-desc="從超過 30 個 Hook 各自為政到系統化品質工程，三個階段的完整重構復盤">完整案例回顧</a></h3>
<p>完整回顧 v0.28.0-v0.31.0 重構流程，從問題識別到最終成果。</p>
<h3 id="作用域迴歸案例研究"><a href="/blog/python/07-refactoring/scope-regression/" data-link-title="作用域迴歸案例研究" data-link-desc="從 IMP-003 事件學習 Python 變數作用域的陷阱">作用域迴歸案例研究</a></h3>
<p>基於 IMP-003 錯誤模式，學習 Python 變數作用域在重構中的陷阱。W24 統一 logger 初始化時，7 個 Hook 因作用域變更靜默失敗的真實案例。</p>
<h2 id="重構成效數據">重構成效數據</h2>
<p>v0.28.0 重構的實際成果：</p>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>數值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>消除重複程式碼</td>
          <td>~415 行</td>
      </tr>
      <tr>
          <td>Hook 檔案縮減</td>
          <td>38%-65%</td>
      </tr>
      <tr>
          <td>新增共用模組</td>
          <td>7 個</td>
      </tr>
      <tr>
          <td>單元測試</td>
          <td>28 個（全部通過）</td>
      </tr>
  </tbody>
</table>
<h2 id="資料來源">資料來源</h2>
<p>本模組使用的實際案例來自：</p>
<ul>
<li><strong>Error Patterns</strong>: <code>.claude/error-patterns/</code> 目錄下的錯誤模式文件</li>
<li><strong>Git 歷史</strong>: Commit <code>60f1b95</code>（v0.28.0）、<code>3cf47d4e</code>（v0.31.0 W24）及相關重構提交</li>
<li><strong>共用程式庫</strong>: <code>.claude/lib/</code> 目錄下的模組</li>
</ul>
<h2 id="先修知識">先修知識</h2>
<p>建議先完成以下模組：</p>
<ul>
<li><a href="/blog/python/01-basics/" data-link-title="模組一：Python 基礎概念" data-link-desc="Python 語言、script、module、package 與 import 機制的核心概念快速回顧">模組一：Python 基礎概念</a></li>
<li><a href="/blog/python/04-oop/" data-link-title="模組四：物件導向設計" data-link-desc="Python 的物件導向設計與設計模式">模組四：物件導向設計</a></li>
</ul>
<hr>
<p><em>文件版本：v0.31.0</em>
<em>最後更新：2026-03-04</em></p>
]]></content:encoded></item><item><title>7.8 壓力出現後的重構路線</title><link>https://tarrragon.github.io/blog/go/07-refactoring/pressure-driven-refactor/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/07-refactoring/pressure-driven-refactor/</guid><description>&lt;p>這一章補的是一個很實際的問題：服務還能跑，但已經不好讀、不好測、不好改時，應該怎麼重構。Go 的重構是先辨認壓力來源，再逐步把邊界拉開。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>辨認 handler、state、interface 與 adapter 的壓力訊號&lt;/li>
&lt;li>了解什麼時候該先拆函式，什麼時候該拆 package&lt;/li>
&lt;li>用小步遷移方式保持行為穩定&lt;/li>
&lt;li>將重構與測試保護綁在一起&lt;/li>
&lt;li>讓服務變大時仍能維持可讀性&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察壓力通常先出現在局部">【觀察】壓力通常先出現在局部&lt;/h2>
&lt;p>Go 服務變大後，最先冒出來的問題通常是局部壓力：&lt;/p>
&lt;ul>
&lt;li>handler 開始太厚&lt;/li>
&lt;li>state 太分散&lt;/li>
&lt;li>event 語意不清&lt;/li>
&lt;li>依賴越來越多&lt;/li>
&lt;li>測試開始很脆弱&lt;/li>
&lt;/ul>
&lt;p>這些訊號表示該重構，但不代表要一次重寫。&lt;/p>
&lt;h2 id="判讀先保行為再搬結構">【判讀】先保行為，再搬結構&lt;/h2>
&lt;p>重構的基本順序應該是：&lt;/p>
&lt;ol>
&lt;li>先讓行為有測試或觀察點&lt;/li>
&lt;li>再把大函式拆成小函式&lt;/li>
&lt;li>再把責任拆到 package 或 interface&lt;/li>
&lt;li>最後才引入更清楚的 adapter 邊界&lt;/li>
&lt;/ol>
&lt;p>這樣可以降低重構風險，也比較符合 Go 的漸進式習慣。&lt;/p>
&lt;h2 id="策略先拆最有壓力的邊界">【策略】先拆最有壓力的邊界&lt;/h2>
&lt;p>最值得先處理的通常是這幾種：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>壓力訊號&lt;/th>
 &lt;th>優先動作&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>handler 過重&lt;/td>
 &lt;td>拆成協定處理與業務函式&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>外部依賴難測&lt;/td>
 &lt;td>抽出小介面&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>state 外洩&lt;/td>
 &lt;td>集中擁有者並控制 copy boundary&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>事件混亂&lt;/td>
 &lt;td>先定義語意，再拆 package&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>依賴耦合太高&lt;/td>
 &lt;td>用 ports/adapters 穩定方向&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這些動作不一定同時做，而是按壓力大小逐步處理。&lt;/p>
&lt;h2 id="執行composition-root-是最後的收斂點">【執行】composition root 是最後的收斂點&lt;/h2>
&lt;p>當系統開始出現明確的 application、domain 與 adapter 時，composition root 會變成依賴組裝的收斂點。重構的目標是讓邏輯邊界與依賴方向更穩定。&lt;/p></description><content:encoded><![CDATA[<p>這一章補的是一個很實際的問題：服務還能跑，但已經不好讀、不好測、不好改時，應該怎麼重構。Go 的重構是先辨認壓力來源，再逐步把邊界拉開。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>辨認 handler、state、interface 與 adapter 的壓力訊號</li>
<li>了解什麼時候該先拆函式，什麼時候該拆 package</li>
<li>用小步遷移方式保持行為穩定</li>
<li>將重構與測試保護綁在一起</li>
<li>讓服務變大時仍能維持可讀性</li>
</ol>
<hr>
<h2 id="觀察壓力通常先出現在局部">【觀察】壓力通常先出現在局部</h2>
<p>Go 服務變大後，最先冒出來的問題通常是局部壓力：</p>
<ul>
<li>handler 開始太厚</li>
<li>state 太分散</li>
<li>event 語意不清</li>
<li>依賴越來越多</li>
<li>測試開始很脆弱</li>
</ul>
<p>這些訊號表示該重構，但不代表要一次重寫。</p>
<h2 id="判讀先保行為再搬結構">【判讀】先保行為，再搬結構</h2>
<p>重構的基本順序應該是：</p>
<ol>
<li>先讓行為有測試或觀察點</li>
<li>再把大函式拆成小函式</li>
<li>再把責任拆到 package 或 interface</li>
<li>最後才引入更清楚的 adapter 邊界</li>
</ol>
<p>這樣可以降低重構風險，也比較符合 Go 的漸進式習慣。</p>
<h2 id="策略先拆最有壓力的邊界">【策略】先拆最有壓力的邊界</h2>
<p>最值得先處理的通常是這幾種：</p>
<table>
  <thead>
      <tr>
          <th>壓力訊號</th>
          <th>優先動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>handler 過重</td>
          <td>拆成協定處理與業務函式</td>
      </tr>
      <tr>
          <td>外部依賴難測</td>
          <td>抽出小介面</td>
      </tr>
      <tr>
          <td>state 外洩</td>
          <td>集中擁有者並控制 copy boundary</td>
      </tr>
      <tr>
          <td>事件混亂</td>
          <td>先定義語意，再拆 package</td>
      </tr>
      <tr>
          <td>依賴耦合太高</td>
          <td>用 ports/adapters 穩定方向</td>
      </tr>
  </tbody>
</table>
<p>這些動作不一定同時做，而是按壓力大小逐步處理。</p>
<h2 id="執行composition-root-是最後的收斂點">【執行】composition root 是最後的收斂點</h2>
<p>當系統開始出現明確的 application、domain 與 adapter 時，composition root 會變成依賴組裝的收斂點。重構的目標是讓邏輯邊界與依賴方向更穩定。</p>
]]></content:encoded></item><item><title>重構的動機與策略</title><link>https://tarrragon.github.io/blog/python/07-refactoring/refactoring-strategy/</link><pubDate>Wed, 04 Mar 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/python/07-refactoring/refactoring-strategy/</guid><description>&lt;p>你有沒有修完一個 Bug，一週後發現另一個地方有完全一樣的問題？或者改了一個函式的行為，卻不確定還有哪些地方依賴它？這些情境的根源往往是程式碼結構讓問題難以被看見。&lt;/p>
&lt;p>重構是改變程式碼的內部結構，而不改變其外部行為。聽起來簡單，但實務上最困難的問題不是「怎麼改」，而是「為什麼改」和「什麼順序改」。&lt;/p>
&lt;p>本章從 Hook 系統兩次大規模重構（v0.28.0 和 v0.31.0）的經驗出發，討論重構的動機判斷和階段分解策略。Hook 系統是一個由數十個 Python 腳本組成的自動化系統，負責程式碼品質檢查、流程驗證和開發規範執行。&lt;/p>
&lt;h2 id="為什麼要重構">為什麼要重構&lt;/h2>
&lt;h3 id="認知負擔超載">認知負擔超載&lt;/h3>
&lt;p>重構的第一個訊號是：&lt;strong>讀程式碼時，你需要同時記住太多東西。&lt;/strong>&lt;/p>
&lt;p>v0.28.0 重構前，&lt;code>task-dispatch-readiness-check.py&lt;/code> 有 858 行。閱讀這個檔案時，你需要同時追蹤：&lt;/p>
&lt;ul>
&lt;li>15 個代理人的名稱和觸發條件&lt;/li>
&lt;li>Git 分支操作的 &lt;code>subprocess&lt;/code> 細節&lt;/li>
&lt;li>Worktree 路徑解析邏輯&lt;/li>
&lt;li>重複出現的工具函式（同一個 &lt;code>run_git_command&lt;/code> 在多個檔案中各自定義一次）&lt;/li>
&lt;/ul>
&lt;p>心理學家 George Miller 的研究指出，人類的工作記憶一次只能處理 7 加減 2 個項目。858 行的檔案遠超這個限制。你不可能在腦中同時維護這麼多上下文來理解程式碼的行為。&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">認知負擔指數 = 變數數 + 分支數 + 巢狀深度 + 依賴數&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>指數&lt;/th>
 &lt;th>評估&lt;/th>
 &lt;th>行動&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>1-5&lt;/td>
 &lt;td>優良&lt;/td>
 &lt;td>維持&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>6-10&lt;/td>
 &lt;td>可接受&lt;/td>
 &lt;td>考慮最佳化&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>11-15&lt;/td>
 &lt;td>需重構&lt;/td>
 &lt;td>排入計畫&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&amp;gt; 15&lt;/td>
 &lt;td>必須重構&lt;/td>
 &lt;td>立即處理&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>v0.28.0 重構前的 &lt;code>task-dispatch-readiness-check.py&lt;/code>，光是頂層函式就有 23 個，模組級變數超過 10 個。認知負擔指數遠超 15，屬於「必須重構」等級。&lt;/p>
&lt;h3 id="不重構的代價">不重構的代價&lt;/h3>
&lt;p>「能動就不要碰」是常見的想法，但不重構的代價會隨時間累積。&lt;/p>
&lt;p>v0.31.0 的 W24 開發週期提供了一個具體案例。任務是統一 16 個 Hook 檔案的 logger 初始化風格——看起來是一個簡單的機械性修改。但因為缺乏共用模組和清晰的模組邊界，修改引發了一個作用域問題（將全域變數移入函式後，其他引用該變數的函式失去存取權限），導致 7 個 Hook 靜默失敗，影響 41 個函式。更糟的是，這個問題至少持續了 2 個 session 才被發現。&lt;/p>
&lt;blockquote>
&lt;p>完整的作用域迴歸分析參見&lt;a href="https://tarrragon.github.io/blog/python/07-refactoring/scope-regression/" data-link-title="作用域迴歸案例研究" data-link-desc="從 IMP-003 事件學習 Python 變數作用域的陷阱">作用域迴歸案例研究&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;p>靜默失敗比直接報錯更危險。錯誤被頂層的 &lt;code>try/except&lt;/code> 吞掉，只寫入日誌檔案，而沒有人在看日誌。如果重構前就有清晰的模組邊界和完整的測試覆蓋，這個問題可以在修改當下就被偵測到。&lt;/p>
&lt;h3 id="重複程式碼的連鎖效應">重複程式碼的連鎖效應&lt;/h3>
&lt;p>另一個推動重構的因素是重複。我們用一行指令就能量化問題的嚴重程度：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 在專案根目錄執行&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">grep -h &lt;span class="s2">&amp;#34;^def &amp;#34;&lt;/span> .claude/hooks/*.py &lt;span class="p">|&lt;/span> sort &lt;span class="p">|&lt;/span> uniq -c &lt;span class="p">|&lt;/span> sort -rn &lt;span class="p">|&lt;/span> head -5&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>v0.28.0 重構前的結果顯示，&lt;code>run_git_command&lt;/code> 在多個檔案中各有一份定義。這意味著：&lt;/p>
&lt;ul>
&lt;li>修復一個 Bug 要改 4 個地方&lt;/li>
&lt;li>漏改任何一個就會產生行為不一致&lt;/li>
&lt;li>新增 Hook 時需要再複製一份&lt;/li>
&lt;/ul>
&lt;p>重複程式碼的總行數約 415 行。這 415 行不只是浪費空間——它們是 415 行的維護風險。&lt;/p>
&lt;h3 id="判斷三問">判斷三問&lt;/h3>
&lt;p>在決定是否重構之前，問自己三個問題：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>讀這段程式碼時，我需要同時記住多少東西？&lt;/strong> 超過 7 個就是警訊。&lt;/li>
&lt;li>&lt;strong>如果要修改一個行為，我需要改幾個地方？&lt;/strong> 超過 1 個就有重複的問題。&lt;/li>
&lt;li>&lt;strong>新人加入團隊後，需要多久才能理解這段程式碼？&lt;/strong> 如果答案是「需要有人口頭解釋」，那就是程式碼本身不夠清楚。&lt;/li>
&lt;/ol>
&lt;p>如果三個問題的答案都指向問題，那就該動手了。&lt;/p>
&lt;h2 id="何時不應該重構">何時不應該重構&lt;/h2>
&lt;p>不是所有情況都適合重構。以下三個時機，重構通常會帶來更多問題。&lt;/p>
&lt;h3 id="沒有測試保護時">沒有測試保護時&lt;/h3>
&lt;p>重構的前提是：你能驗證修改後的行為和修改前一致。沒有測試，你就無法確認這一點。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 假設你想把這段程式碼抽成函式&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">result&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">stdout&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">strip&lt;/span>&lt;span class="p">())&lt;/span> &lt;span class="o">&amp;gt;&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="n">branches&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">result&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">stdout&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">strip&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">split&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="s2">&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="n">branches&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">b&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">strip&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">b&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">branches&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">b&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">strip&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="n">protected&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">b&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">b&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">branches&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">b&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;main&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;master&amp;#34;&lt;/span>&lt;span class="p">]]&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段邏輯涉及空字串處理、換行符分割、空白清理。如果沒有測試覆蓋這些邊界情況，抽取函式時很容易改變行為而不自知。&lt;/p>
&lt;p>&lt;strong>原則&lt;/strong>：先寫測試，再重構。如果時間只夠做一件事，選擇寫測試。&lt;/p>
&lt;h3 id="修-bug-時順手重構">修 Bug 時順手重構&lt;/h3>
&lt;p>修 Bug 和重構是兩件不同的事。混在一起做會產生兩個問題：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>難以驗證&lt;/strong>：Bug 修好了嗎？還是被重構掩蓋了？&lt;/li>
&lt;li>&lt;strong>難以回溯&lt;/strong>：如果重構引入了新問題，&lt;code>git bisect&lt;/code> 無法區分哪些變更是修 Bug、哪些是重構&lt;/li>
&lt;/ol>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl"># 錯誤的工作流程
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">commit: &amp;#34;修復 #42 並重構 git_utils&amp;#34; ← 兩件事混在一個 commit
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"># 正確的工作流程
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">commit: &amp;#34;修復 #42：branch 名稱解析的空字串處理&amp;#34;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">commit: &amp;#34;重構 git_utils：抽取 parse_branch_name 函式&amp;#34;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>原則&lt;/strong>：先修好 Bug 並提交，確認測試通過，然後再開始重構。&lt;/p>
&lt;h3 id="時間壓力下">時間壓力下&lt;/h3>
&lt;p>v0.31.0 W24 的 logger 統一修改就是一個教訓——在時間壓力下跳過了跨函式引用的完整驗證，結果 7 個 Hook 靜默失敗，修復花費的時間遠超原本省下的。&lt;/p></description><content:encoded><![CDATA[<p>你有沒有修完一個 Bug，一週後發現另一個地方有完全一樣的問題？或者改了一個函式的行為，卻不確定還有哪些地方依賴它？這些情境的根源往往是程式碼結構讓問題難以被看見。</p>
<p>重構是改變程式碼的內部結構，而不改變其外部行為。聽起來簡單，但實務上最困難的問題不是「怎麼改」，而是「為什麼改」和「什麼順序改」。</p>
<p>本章從 Hook 系統兩次大規模重構（v0.28.0 和 v0.31.0）的經驗出發，討論重構的動機判斷和階段分解策略。Hook 系統是一個由數十個 Python 腳本組成的自動化系統，負責程式碼品質檢查、流程驗證和開發規範執行。</p>
<h2 id="為什麼要重構">為什麼要重構</h2>
<h3 id="認知負擔超載">認知負擔超載</h3>
<p>重構的第一個訊號是：<strong>讀程式碼時，你需要同時記住太多東西。</strong></p>
<p>v0.28.0 重構前，<code>task-dispatch-readiness-check.py</code> 有 858 行。閱讀這個檔案時，你需要同時追蹤：</p>
<ul>
<li>15 個代理人的名稱和觸發條件</li>
<li>Git 分支操作的 <code>subprocess</code> 細節</li>
<li>Worktree 路徑解析邏輯</li>
<li>重複出現的工具函式（同一個 <code>run_git_command</code> 在多個檔案中各自定義一次）</li>
</ul>
<p>心理學家 George Miller 的研究指出，人類的工作記憶一次只能處理 7 加減 2 個項目。858 行的檔案遠超這個限制。你不可能在腦中同時維護這麼多上下文來理解程式碼的行為。</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">認知負擔指數 = 變數數 + 分支數 + 巢狀深度 + 依賴數</span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>指數</th>
          <th>評估</th>
          <th>行動</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1-5</td>
          <td>優良</td>
          <td>維持</td>
      </tr>
      <tr>
          <td>6-10</td>
          <td>可接受</td>
          <td>考慮最佳化</td>
      </tr>
      <tr>
          <td>11-15</td>
          <td>需重構</td>
          <td>排入計畫</td>
      </tr>
      <tr>
          <td>&gt; 15</td>
          <td>必須重構</td>
          <td>立即處理</td>
      </tr>
  </tbody>
</table>
<p>v0.28.0 重構前的 <code>task-dispatch-readiness-check.py</code>，光是頂層函式就有 23 個，模組級變數超過 10 個。認知負擔指數遠超 15，屬於「必須重構」等級。</p>
<h3 id="不重構的代價">不重構的代價</h3>
<p>「能動就不要碰」是常見的想法，但不重構的代價會隨時間累積。</p>
<p>v0.31.0 的 W24 開發週期提供了一個具體案例。任務是統一 16 個 Hook 檔案的 logger 初始化風格——看起來是一個簡單的機械性修改。但因為缺乏共用模組和清晰的模組邊界，修改引發了一個作用域問題（將全域變數移入函式後，其他引用該變數的函式失去存取權限），導致 7 個 Hook 靜默失敗，影響 41 個函式。更糟的是，這個問題至少持續了 2 個 session 才被發現。</p>
<blockquote>
<p>完整的作用域迴歸分析參見<a href="/blog/python/07-refactoring/scope-regression/" data-link-title="作用域迴歸案例研究" data-link-desc="從 IMP-003 事件學習 Python 變數作用域的陷阱">作用域迴歸案例研究</a>。</p></blockquote>
<p>靜默失敗比直接報錯更危險。錯誤被頂層的 <code>try/except</code> 吞掉，只寫入日誌檔案，而沒有人在看日誌。如果重構前就有清晰的模組邊界和完整的測試覆蓋，這個問題可以在修改當下就被偵測到。</p>
<h3 id="重複程式碼的連鎖效應">重複程式碼的連鎖效應</h3>
<p>另一個推動重構的因素是重複。我們用一行指令就能量化問題的嚴重程度：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 在專案根目錄執行</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">grep -h <span class="s2">&#34;^def &#34;</span> .claude/hooks/*.py <span class="p">|</span> sort <span class="p">|</span> uniq -c <span class="p">|</span> sort -rn <span class="p">|</span> head -5</span></span></code></pre></div><p>v0.28.0 重構前的結果顯示，<code>run_git_command</code> 在多個檔案中各有一份定義。這意味著：</p>
<ul>
<li>修復一個 Bug 要改 4 個地方</li>
<li>漏改任何一個就會產生行為不一致</li>
<li>新增 Hook 時需要再複製一份</li>
</ul>
<p>重複程式碼的總行數約 415 行。這 415 行不只是浪費空間——它們是 415 行的維護風險。</p>
<h3 id="判斷三問">判斷三問</h3>
<p>在決定是否重構之前，問自己三個問題：</p>
<ol>
<li><strong>讀這段程式碼時，我需要同時記住多少東西？</strong> 超過 7 個就是警訊。</li>
<li><strong>如果要修改一個行為，我需要改幾個地方？</strong> 超過 1 個就有重複的問題。</li>
<li><strong>新人加入團隊後，需要多久才能理解這段程式碼？</strong> 如果答案是「需要有人口頭解釋」，那就是程式碼本身不夠清楚。</li>
</ol>
<p>如果三個問題的答案都指向問題，那就該動手了。</p>
<h2 id="何時不應該重構">何時不應該重構</h2>
<p>不是所有情況都適合重構。以下三個時機，重構通常會帶來更多問題。</p>
<h3 id="沒有測試保護時">沒有測試保護時</h3>
<p>重構的前提是：你能驗證修改後的行為和修改前一致。沒有測試，你就無法確認這一點。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 假設你想把這段程式碼抽成函式</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">result</span><span class="o">.</span><span class="n">stdout</span><span class="o">.</span><span class="n">strip</span><span class="p">())</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="n">branches</span> <span class="o">=</span> <span class="n">result</span><span class="o">.</span><span class="n">stdout</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="s2">&#34;</span><span class="se">\n</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="n">branches</span> <span class="o">=</span> <span class="p">[</span><span class="n">b</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span> <span class="k">for</span> <span class="n">b</span> <span class="ow">in</span> <span class="n">branches</span> <span class="k">if</span> <span class="n">b</span><span class="o">.</span><span class="n">strip</span><span class="p">()]</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="n">protected</span> <span class="o">=</span> <span class="p">[</span><span class="n">b</span> <span class="k">for</span> <span class="n">b</span> <span class="ow">in</span> <span class="n">branches</span> <span class="k">if</span> <span class="n">b</span> <span class="ow">in</span> <span class="p">[</span><span class="s2">&#34;main&#34;</span><span class="p">,</span> <span class="s2">&#34;master&#34;</span><span class="p">]]</span></span></span></code></pre></div><p>這段邏輯涉及空字串處理、換行符分割、空白清理。如果沒有測試覆蓋這些邊界情況，抽取函式時很容易改變行為而不自知。</p>
<p><strong>原則</strong>：先寫測試，再重構。如果時間只夠做一件事，選擇寫測試。</p>
<h3 id="修-bug-時順手重構">修 Bug 時順手重構</h3>
<p>修 Bug 和重構是兩件不同的事。混在一起做會產生兩個問題：</p>
<ol>
<li><strong>難以驗證</strong>：Bug 修好了嗎？還是被重構掩蓋了？</li>
<li><strong>難以回溯</strong>：如果重構引入了新問題，<code>git bisect</code> 無法區分哪些變更是修 Bug、哪些是重構</li>
</ol>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl"># 錯誤的工作流程
</span></span><span class="line"><span class="ln">2</span><span class="cl">commit: &#34;修復 #42 並重構 git_utils&#34;  ← 兩件事混在一個 commit
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"># 正確的工作流程
</span></span><span class="line"><span class="ln">5</span><span class="cl">commit: &#34;修復 #42：branch 名稱解析的空字串處理&#34;
</span></span><span class="line"><span class="ln">6</span><span class="cl">commit: &#34;重構 git_utils：抽取 parse_branch_name 函式&#34;</span></span></code></pre></div><p><strong>原則</strong>：先修好 Bug 並提交，確認測試通過，然後再開始重構。</p>
<h3 id="時間壓力下">時間壓力下</h3>
<p>v0.31.0 W24 的 logger 統一修改就是一個教訓——在時間壓力下跳過了跨函式引用的完整驗證，結果 7 個 Hook 靜默失敗，修復花費的時間遠超原本省下的。</p>
<p>重構需要完整的注意力。趕進度時進行重構，容易在壓力下跳過驗證步驟（「測試之後再補」），反而製造更多技術債務。</p>
<p><strong>原則</strong>：記錄下需要重構的地方，排入後續計畫。不要在時間壓力下動手。</p>
<h3 id="判斷清單">判斷清單</h3>
<p>把上述三個情境整理成一個快速檢查清單：</p>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>是</th>
          <th>否</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>有測試覆蓋修改區域？</td>
          <td>可以繼續</td>
          <td>先寫測試</td>
      </tr>
      <tr>
          <td>修改範圍只有重構？</td>
          <td>可以繼續</td>
          <td>先分開 Bug 修復和重構</td>
      </tr>
      <tr>
          <td>有足夠的時間完成驗證？</td>
          <td>可以繼續</td>
          <td>記錄後排入計畫</td>
      </tr>
  </tbody>
</table>
<p>三個問題都回答「是」，才開始動手。</p>
<h2 id="wave-分解策略">Wave 分解策略</h2>
<p>大規模重構最容易失敗的原因是：試圖一次做完所有事情。</p>
<blockquote>
<p><strong>Wave</strong>：一個有明確目標和驗證點的重構階段。每完成一個 Wave，程式碼都必須處於可用狀態。</p></blockquote>
<p>Wave 分解的核心思想是：<strong>把重構拆成多個有序的 Wave，確保每一步都可驗證、可回退。</strong></p>
<h3 id="v0280基礎架構重構">v0.28.0：基礎架構重構</h3>
<p>v0.28.0 將 Hook 系統從「各自為政」重構為「共用模組 + 配置分離」的架構。拆分為 4 個 Wave：</p>
<h4 id="wave-1建立共用程式庫">Wave 1：建立共用程式庫</h4>
<p>先建立共用模組的介面和測試，不改動任何現有 Hook。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">建立模組    →  寫測試    →  確認測試通過
</span></span><span class="line"><span class="ln">2</span><span class="cl">config_loader    4 個測試
</span></span><span class="line"><span class="ln">3</span><span class="cl">git_utils        6 個測試
</span></span><span class="line"><span class="ln">4</span><span class="cl">hook_io          3 個測試
</span></span><span class="line"><span class="ln">5</span><span class="cl">hook_logging     2 個測試</span></span></code></pre></div><p>為什麼先做這步？因為後續 Wave 需要依賴這些模組。如果跳過這步直接改 Hook，會遇到「要用的函式還不存在」的問題。</p>
<h4 id="wave-2配置分離">Wave 2：配置分離</h4>
<p>把 task-dispatch 中的硬編碼清單（代理人名稱、品質規則、指令對應表）抽到 YAML 配置檔。這是行數最多、修改範圍最大的階段。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 重構前：硬編碼在 Python 中</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">AGENTS</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s2">&#34;parsley-flutter-developer&#34;</span><span class="p">:</span> <span class="p">{</span><span class="s2">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;implementation&#34;</span><span class="p">,</span> <span class="s2">&#34;lang&#34;</span><span class="p">:</span> <span class="s2">&#34;dart&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="s2">&#34;thyme-python-developer&#34;</span><span class="p">:</span> <span class="p">{</span><span class="s2">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;implementation&#34;</span><span class="p">,</span> <span class="s2">&#34;lang&#34;</span><span class="p">:</span> <span class="s2">&#34;python&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="c1"># ... 15 個代理人定義散落在程式碼中</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="c1"># 重構後：讀取 YAML 配置</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="n">agents</span> <span class="o">=</span> <span class="n">config_loader</span><span class="o">.</span><span class="n">load_config</span><span class="p">(</span><span class="s2">&#34;agents.yaml&#34;</span><span class="p">)</span></span></span></code></pre></div><p>分離後，新增代理人只需要編輯 YAML 檔案，不需要動 Python 程式碼。</p>
<h4 id="wave-3逐檔重構">Wave 3：逐檔重構</h4>
<p>有了共用程式庫和配置檔，逐一修改 Hook 檔案。每改完一個檔案就執行測試，確保沒改壞東西。這個階段的關鍵是<strong>紀律</strong>：每次只改一個檔案，改完就跑測試，不要累積多個修改後一起驗證。</p>
<h4 id="wave-4驗證與清理">Wave 4：驗證與清理</h4>
<p>28 個單元測試全部通過。移除不再需要的重複程式碼。檢查是否有遺漏的相依性。</p>
<h3 id="v0310風格統一與防護強化">v0.31.0：風格統一與防護強化</h3>
<p>v0.31.0 的重構是在既有架構上統一風格和強化防護，規模與性質都與 v0.28.0 不同。拆分為 4 個 Wave（W22-W25）：</p>
<table>
  <thead>
      <tr>
          <th>Wave</th>
          <th>目標</th>
          <th>性質</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>W22</td>
          <td>日誌系統統一</td>
          <td>機械性修改</td>
      </tr>
      <tr>
          <td>W23</td>
          <td>訊息常數抽取</td>
          <td>機械性修改</td>
      </tr>
      <tr>
          <td>W24</td>
          <td>程式碼風格統一</td>
          <td>機械性，但觸發了作用域問題</td>
      </tr>
      <tr>
          <td>W25</td>
          <td>修復 W24 問題 + 防護機制</td>
          <td>修復 + 新功能</td>
      </tr>
  </tbody>
</table>
<p>注意 W25 的存在。它不在原始計畫中，而是 W24 出問題後臨時新增的。<strong>好的分解策略要預留處理意外的空間。</strong></p>
<p>兩次重構的對比：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>v0.28.0</th>
          <th>v0.31.0</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>性質</td>
          <td>建立新架構</td>
          <td>統一既有架構的風格</td>
      </tr>
      <tr>
          <td>風險來源</td>
          <td>介面設計是否正確</td>
          <td>機械性修改是否有隱含的邏輯變更</td>
      </tr>
      <tr>
          <td>Wave 數</td>
          <td>4（全部計畫內）</td>
          <td>4（第 4 個是意外新增）</td>
      </tr>
      <tr>
          <td>教訓</td>
          <td>先建基礎設施再動手</td>
          <td>機械性修改也可能觸發邏輯問題</td>
      </tr>
  </tbody>
</table>
<h3 id="wave-分解的原則">Wave 分解的原則</h3>
<p>從兩次重構經驗中，可以歸納出三個分解原則：</p>
<h4 id="原則-1依賴方向決定順序">原則 1：依賴方向決定順序</h4>
<p>被依賴的模組先做。v0.28.0 先建共用程式庫（Wave 1），因為後續所有 Wave 都會用到它。</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">Wave 1: 共用模組（被所有 Hook 依賴）
</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">Wave 2: 配置檔（被 task-dispatch 依賴）
</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">Wave 3: Hook 檔案（依賴共用模組和配置檔）
</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">Wave 4: 驗證（依賴所有修改完成）</span></span></code></pre></div><h4 id="原則-2機械性修改和邏輯修改分開">原則 2：機械性修改和邏輯修改分開</h4>
<p>機械性修改（如統一命名風格、統一匯入路徑）和邏輯修改（如改變函式行為、改變變數作用域）不該放在同一個 Wave。</p>
<p>v0.31.0 的 W24 表面上是機械性修改（統一 logger 初始化位置），但實際上涉及了作用域的邏輯變更——把全域變數移入函式，會影響所有引用它的其他函式。如果在規劃時就識別出這一點，應該把「移動 logger 位置」和「修改函式簽名以傳遞 logger 參數」拆成兩個步驟。</p>
<p>怎麼區分？問自己：<strong>這個修改會不會改變任何函式的可見變數？</strong> 如果會，就不是純機械性修改。</p>
<h4 id="原則-3每個-wave-結束時程式碼必須可用">原則 3：每個 Wave 結束時程式碼必須可用</h4>
<p>不能出現「改到一半，程式跑不起來」的狀態。每個 Wave 完成後：</p>
<ul>
<li>所有測試通過</li>
<li>程式碼可正常執行</li>
<li>可以安全地提交</li>
</ul>
<p>這保證了即使中途需要停下來處理其他事情，程式碼也不會處於損壞狀態。</p>
<h3 id="分解流程">分解流程</h3>
<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">Step 1: 畫出依賴關係圖
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    → 哪些模組被其他模組依賴？（先做）
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    → 哪些模組互相獨立？（可以並行）
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">Step 2: 分類修改類型
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    → 機械性修改：統一命名、統一格式、統一匯入
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    → 邏輯修改：改變函式行為、改變資料流、改變作用域
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">Step 3: 分配到 Wave
</span></span><span class="line"><span class="ln">10</span><span class="cl">    → Wave N: 基礎設施（被依賴的、獨立的）
</span></span><span class="line"><span class="ln">11</span><span class="cl">    → Wave N+1: 機械性修改（依賴基礎設施）
</span></span><span class="line"><span class="ln">12</span><span class="cl">    → Wave N+2: 邏輯修改（依賴前面的修改）
</span></span><span class="line"><span class="ln">13</span><span class="cl">    → Wave N+3: 驗證與清理
</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">Step 4: 每個 Wave 定義驗證標準
</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></span><span class="line"><span class="ln">18</span><span class="cl">    → 可以安全提交嗎？</span></span></code></pre></div><h2 id="度量表">度量表</h2>
<p>用量化指標驗證重構是否達到目標：</p>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>v0.28.0 前</th>
          <th>v0.28.0 後</th>
          <th>v0.31.0 後</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>共用模組數</td>
          <td>0</td>
          <td>4</td>
          <td>7+</td>
      </tr>
      <tr>
          <td>重複程式碼行數</td>
          <td>~415 行</td>
          <td>~0</td>
          <td>~0</td>
      </tr>
      <tr>
          <td>新增 Hook 的樣板行數</td>
          <td>~15 行</td>
          <td>~10 行</td>
          <td>~5 行（核心呼叫 1 行）</td>
      </tr>
      <tr>
          <td>Error Patterns 記錄數</td>
          <td>4</td>
          <td>8</td>
          <td>19</td>
      </tr>
      <tr>
          <td>task-dispatch 行數</td>
          <td>858</td>
          <td>296</td>
          <td>267</td>
      </tr>
  </tbody>
</table>
<p>幾個值得注意的趨勢：</p>
<ul>
<li><strong>共用模組數</strong>從 0 成長到 7+。這代表重複程式碼有了歸屬，不再散落各處。</li>
<li><strong>新增 Hook 的樣板行數</strong>從 15 行降到約 5 行（核心只需 1 行匯入呼叫）。新增一個 Hook 從「複製一堆工具函式」變成「匯入共用模組」。</li>
<li><strong>Error Patterns</strong> 從 4 成長到 19。這不是壞事——它代表團隊開始系統性地記錄和傳承經驗，而不是每個人各自出問題。</li>
</ul>
<h2 id="小結">小結</h2>
<p>重構的決策可以歸納為三個問題：</p>
<ol>
<li><strong>該不該做？</strong> 認知負擔超載、重複程式碼累積、維護成本持續上升，就該做。</li>
<li><strong>現在能做嗎？</strong> 有測試保護、不在修 Bug、時間充裕，才能做。</li>
<li><strong>怎麼拆分？</strong> 按依賴順序，機械和邏輯分開，每步結束都可用。</li>
</ol>
<p>後續章節會深入每個具體的重構技巧：如何識別壞味道、如何抽取配置、如何消除重複、如何處理作用域陷阱。</p>
<h2 id="思考題">思考題</h2>
<ol>
<li>
<p>你目前的專案中，有哪些檔案的行數超過 200 行？列出前三名，分析它們為什麼會這麼長——是職責太多、重複程式碼、還是配置和邏輯混在一起？</p>
</li>
<li>
<p>回想一次你在修 Bug 時順手重構的經驗。事後回頭看 <code>git log</code>，能清楚區分哪些變更是修 Bug、哪些是重構嗎？</p>
</li>
<li>
<p>如果你的專案完全沒有測試，但認知負擔已經很高，你會怎麼規劃「補測試」和「重構」的先後順序？</p>
</li>
</ol>
<h2 id="實作練習">實作練習</h2>
<ol>
<li>
<p>用以下指令掃描你的專案，找出重複定義的函式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 在專案根目錄執行</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">grep -h <span class="s2">&#34;^def &#34;</span> your_project/*.py <span class="p">|</span> sort <span class="p">|</span> uniq -c <span class="p">|</span> sort -rn <span class="p">|</span> head -10</span></span></code></pre></div><p>分析結果：哪些函式被重複定義了？它們應該被抽到哪個共用模組？</p>
</li>
<li>
<p>選一個超過 200 行的檔案，嘗試畫出它的 Wave 分解計畫。回答以下問題：</p>
<ul>
<li>哪些部分被其他部分依賴？（先做）</li>
<li>哪些修改是機械性的？（可以批量處理）</li>
<li>每個 Wave 完成後，程式碼能正常執行嗎？</li>
</ul>
</li>
<li>
<p>計算你選定檔案的認知負擔指數（變數數 + 分支數 + 巢狀深度 + 依賴數）。找出指數最高的函式，思考如何將它拆分到指數低於 10。</p>
</li>
</ol>
<hr>
<p>下一章：<a href="/blog/python/07-refactoring/code-smells/" data-link-title="程式碼壞味道偵測" data-link-desc="從三級分類系統到偵測工具鏈，建立系統化的程式碼品質防線">程式碼壞味道偵測</a></p>
<p><em>文件版本：v0.31.0</em>
<em>最後更新：2026-03-04</em></p>
]]></content:encoded></item><item><title>程式碼壞味道偵測</title><link>https://tarrragon.github.io/blog/python/07-refactoring/code-smells/</link><pubDate>Wed, 04 Mar 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/python/07-refactoring/code-smells/</guid><description>&lt;p>上一章：&lt;a href="https://tarrragon.github.io/blog/python/07-refactoring/refactoring-strategy/" data-link-title="重構的動機與策略" data-link-desc="從 Hook 系統重構經驗出發，學習何時重構、何時不該重構，以及如何將大規模重構拆分成可管理的階段">重構的動機與策略&lt;/a>&lt;/p>
&lt;p>「程式碼壞味道」(Code Smell) 是 Martin Fowler 在《Refactoring》中提出的概念：程式碼中暗示深層問題的表面跡象。壞味道不是 Bug，程式仍然能正常執行，但它們預告了維護成本的攀升。上一章介紹了認知負擔指數——重複程式碼和難以理解的結構是指數升高的主要原因。本章把這些讓認知負擔上升的具體模式系統化，稱為「壞味道」。&lt;/p>
&lt;p>本章建立一套從「識別」到「行動」的完整流程：先以三級分類理解問題的嚴重程度，再以工具鏈偵測，最後透過 5 Why 分析找到根本原因。&lt;/p>
&lt;h2 id="壞味道三級分類">壞味道三級分類&lt;/h2>
&lt;p>不是所有壞味道都一樣嚴重。依照影響範圍和修復成本，分成三個等級：&lt;/p>
&lt;h3 id="第一級實作級--單一檔案內的問題">第一級：實作級 &amp;ndash; 單一檔案內的問題&lt;/h3>
&lt;p>影響範圍最小，通常改一個檔案就能解決。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Pattern ID&lt;/th>
 &lt;th>壞味道&lt;/th>
 &lt;th>典型症狀&lt;/th>
 &lt;th>風險&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>IMP-001&lt;/td>
 &lt;td>重複程式碼散落各處&lt;/td>
 &lt;td>同一個函式在 4 個檔案各寫一次&lt;/td>
 &lt;td>中&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>IMP-002&lt;/td>
 &lt;td>魔法數字&lt;/td>
 &lt;td>&lt;code>line[9:]&lt;/code> &amp;ndash; 為什麼是 9？&lt;/td>
 &lt;td>低&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h4 id="imp-001-範例四份一模一樣的函式">IMP-001 範例：四份一模一樣的函式&lt;/h4>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># hooks/pre_commit.py&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">def&lt;/span> &lt;span class="nf">run_git_command&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">cmd&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">subprocess&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">run&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">cmd&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">capture_output&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">text&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&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="n">result&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">stdout&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">strip&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1"># hooks/post_merge.py -- 完全相同的程式碼&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">def&lt;/span> &lt;span class="nf">run_git_command&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">cmd&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">subprocess&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">run&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">cmd&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">capture_output&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">text&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">result&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">stdout&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">strip&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1"># hooks/branch_check.py -- 又是一模一樣&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="c1"># hooks/worktree_guardian.py -- 第四份...&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>問題在於當你需要加入錯誤處理時，要改四個地方，漏掉一個就是 Bug。&lt;/p>
&lt;h4 id="imp-002-範例沒人記得的數字">IMP-002 範例：沒人記得的數字&lt;/h4>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">parse_worktree_line&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">line&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">line&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">startswith&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;worktree &amp;#34;&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">line&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">9&lt;/span>&lt;span class="p">:]&lt;/span> &lt;span class="c1"># 三個月後，你還記得 9 是什麼嗎？&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="第二級架構級--跨模組的結構問題">第二級：架構級 &amp;ndash; 跨模組的結構問題&lt;/h3>
&lt;p>影響多個檔案的互動方式，需要架構層面的重新設計。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Pattern ID&lt;/th>
 &lt;th>壞味道&lt;/th>
 &lt;th>典型症狀&lt;/th>
 &lt;th>風險&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>ARCH-001&lt;/td>
 &lt;td>配置與程式碼混合&lt;/td>
 &lt;td>800 行的檔案，一半是配置資料&lt;/td>
 &lt;td>高&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h4 id="arch-001-範例被配置淹沒的邏輯">ARCH-001 範例：被配置淹沒的邏輯&lt;/h4>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 一個 800+ 行的 Hook 檔案&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="n">PROTECTED_BRANCHES&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;main&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;master&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;develop&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="n">ALLOWED_PATTERNS&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;feat/*&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;fix/*&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;chore/*&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="n">ERROR_MESSAGES&lt;/span> &lt;span class="o">=&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="s2">&amp;#34;branch_not_allowed&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&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="s2">&amp;#34;missing_ticket&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;缺少 Ticket 引用&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="c1"># ... 數十行配置繼續&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="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">def&lt;/span> &lt;span class="nf">check_branch&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="c1"># 真正的邏輯只有幾十行，卻埋在幾百行配置之下&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">pass&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>修改一條錯誤訊息就要打開整個程式碼檔案，負責配置的人被迫閱讀程式邏輯，負責邏輯的人被迫捲過數百行配置——兩者都承受了不必要的負擔。&lt;/p>
&lt;h3 id="第三級遷移級--重構過程中引入的問題">第三級：遷移級 &amp;ndash; 重構過程中引入的問題&lt;/h3>
&lt;p>最危險的一類。它們是在修復其他壞味道時「創造」出來的新問題。遷移級問題在 Error Pattern 系統中仍使用 IMP 前綴，因為它們本質上是實作層面的作用域和 Import 問題——只是發生在重構過程中，因此格外危險。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Pattern ID&lt;/th>
 &lt;th>壞味道&lt;/th>
 &lt;th>典型症狀&lt;/th>
 &lt;th>風險&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>IMP-003&lt;/td>
 &lt;td>重構作用域迴歸&lt;/td>
 &lt;td>變數移入函式後，其他函式找不到&lt;/td>
 &lt;td>高&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>IMP-005&lt;/td>
 &lt;td>模組遷移 Import 斷裂&lt;/td>
 &lt;td>檔案搬家後，Import 路徑沒跟著改&lt;/td>
 &lt;td>高&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h4 id="imp-003-範例搬家沒留新地址">IMP-003 範例：搬家沒留新地址&lt;/h4>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 修正前：logger 是全域變數，所有函式都看得到&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="n">logger&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">setup_hook_logging&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;hook-name&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">helper_function&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="n">logger&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;doing something&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># OK，全域可見&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">main&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">helper_function&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c1"># 修正後：logger 搬進 main()，但 helper 沒收到通知&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">def&lt;/span> &lt;span class="nf">helper_function&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="n">logger&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;doing something&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># NameError! logger 不見了&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">main&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="n">logger&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">setup_hook_logging&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;hook-name&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># 現在是區域變數&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">helper_function&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="c1"># helper 找不到 logger&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個 Bug 在真實專案中影響了 7 個 Hook、41 個函式。更危險的是，例外捕捉機制將錯誤靜默吞掉，直到開發者主動翻查日誌才發現。在事件發生當時，錯誤被靜默吞掉。此問題後來已修復，現在 Hook 失敗會輸出到 stderr 確保開發者可見。&lt;/p></description><content:encoded><![CDATA[<p>上一章：<a href="/blog/python/07-refactoring/refactoring-strategy/" data-link-title="重構的動機與策略" data-link-desc="從 Hook 系統重構經驗出發，學習何時重構、何時不該重構，以及如何將大規模重構拆分成可管理的階段">重構的動機與策略</a></p>
<p>「程式碼壞味道」(Code Smell) 是 Martin Fowler 在《Refactoring》中提出的概念：程式碼中暗示深層問題的表面跡象。壞味道不是 Bug，程式仍然能正常執行，但它們預告了維護成本的攀升。上一章介紹了認知負擔指數——重複程式碼和難以理解的結構是指數升高的主要原因。本章把這些讓認知負擔上升的具體模式系統化，稱為「壞味道」。</p>
<p>本章建立一套從「識別」到「行動」的完整流程：先以三級分類理解問題的嚴重程度，再以工具鏈偵測，最後透過 5 Why 分析找到根本原因。</p>
<h2 id="壞味道三級分類">壞味道三級分類</h2>
<p>不是所有壞味道都一樣嚴重。依照影響範圍和修復成本，分成三個等級：</p>
<h3 id="第一級實作級--單一檔案內的問題">第一級：實作級 &ndash; 單一檔案內的問題</h3>
<p>影響範圍最小，通常改一個檔案就能解決。</p>
<table>
  <thead>
      <tr>
          <th>Pattern ID</th>
          <th>壞味道</th>
          <th>典型症狀</th>
          <th>風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>IMP-001</td>
          <td>重複程式碼散落各處</td>
          <td>同一個函式在 4 個檔案各寫一次</td>
          <td>中</td>
      </tr>
      <tr>
          <td>IMP-002</td>
          <td>魔法數字</td>
          <td><code>line[9:]</code> &ndash; 為什麼是 9？</td>
          <td>低</td>
      </tr>
  </tbody>
</table>
<h4 id="imp-001-範例四份一模一樣的函式">IMP-001 範例：四份一模一樣的函式</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># hooks/pre_commit.py</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">def</span> <span class="nf">run_git_command</span><span class="p">(</span><span class="n">cmd</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="n">result</span> <span class="o">=</span> <span class="n">subprocess</span><span class="o">.</span><span class="n">run</span><span class="p">(</span><span class="n">cmd</span><span class="p">,</span> <span class="n">capture_output</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">text</span><span class="o">=</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="k">return</span> <span class="n">result</span><span class="o">.</span><span class="n">stdout</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># hooks/post_merge.py -- 完全相同的程式碼</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">def</span> <span class="nf">run_git_command</span><span class="p">(</span><span class="n">cmd</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="n">result</span> <span class="o">=</span> <span class="n">subprocess</span><span class="o">.</span><span class="n">run</span><span class="p">(</span><span class="n">cmd</span><span class="p">,</span> <span class="n">capture_output</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">text</span><span class="o">=</span><span class="kc">True</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="n">result</span><span class="o">.</span><span class="n">stdout</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># hooks/branch_check.py -- 又是一模一樣</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># hooks/worktree_guardian.py -- 第四份...</span></span></span></code></pre></div><p>問題在於當你需要加入錯誤處理時，要改四個地方，漏掉一個就是 Bug。</p>
<h4 id="imp-002-範例沒人記得的數字">IMP-002 範例：沒人記得的數字</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">def</span> <span class="nf">parse_worktree_line</span><span class="p">(</span><span class="n">line</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">if</span> <span class="n">line</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;worktree &#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="k">return</span> <span class="n">line</span><span class="p">[</span><span class="mi">9</span><span class="p">:]</span>  <span class="c1"># 三個月後，你還記得 9 是什麼嗎？</span></span></span></code></pre></div><h3 id="第二級架構級--跨模組的結構問題">第二級：架構級 &ndash; 跨模組的結構問題</h3>
<p>影響多個檔案的互動方式，需要架構層面的重新設計。</p>
<table>
  <thead>
      <tr>
          <th>Pattern ID</th>
          <th>壞味道</th>
          <th>典型症狀</th>
          <th>風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>ARCH-001</td>
          <td>配置與程式碼混合</td>
          <td>800 行的檔案，一半是配置資料</td>
          <td>高</td>
      </tr>
  </tbody>
</table>
<h4 id="arch-001-範例被配置淹沒的邏輯">ARCH-001 範例：被配置淹沒的邏輯</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 一個 800+ 行的 Hook 檔案</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">PROTECTED_BRANCHES</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;main&#34;</span><span class="p">,</span> <span class="s2">&#34;master&#34;</span><span class="p">,</span> <span class="s2">&#34;develop&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">ALLOWED_PATTERNS</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;feat/*&#34;</span><span class="p">,</span> <span class="s2">&#34;fix/*&#34;</span><span class="p">,</span> <span class="s2">&#34;chore/*&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">ERROR_MESSAGES</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="s2">&#34;branch_not_allowed&#34;</span><span class="p">:</span> <span class="s2">&#34;分支名稱不符合規範&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="s2">&#34;missing_ticket&#34;</span><span class="p">:</span> <span class="s2">&#34;缺少 Ticket 引用&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="c1"># ... 數十行配置繼續</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="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">def</span> <span class="nf">check_branch</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="c1"># 真正的邏輯只有幾十行，卻埋在幾百行配置之下</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">pass</span></span></span></code></pre></div><p>修改一條錯誤訊息就要打開整個程式碼檔案，負責配置的人被迫閱讀程式邏輯，負責邏輯的人被迫捲過數百行配置——兩者都承受了不必要的負擔。</p>
<h3 id="第三級遷移級--重構過程中引入的問題">第三級：遷移級 &ndash; 重構過程中引入的問題</h3>
<p>最危險的一類。它們是在修復其他壞味道時「創造」出來的新問題。遷移級問題在 Error Pattern 系統中仍使用 IMP 前綴，因為它們本質上是實作層面的作用域和 Import 問題——只是發生在重構過程中，因此格外危險。</p>
<table>
  <thead>
      <tr>
          <th>Pattern ID</th>
          <th>壞味道</th>
          <th>典型症狀</th>
          <th>風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>IMP-003</td>
          <td>重構作用域迴歸</td>
          <td>變數移入函式後，其他函式找不到</td>
          <td>高</td>
      </tr>
      <tr>
          <td>IMP-005</td>
          <td>模組遷移 Import 斷裂</td>
          <td>檔案搬家後，Import 路徑沒跟著改</td>
          <td>高</td>
      </tr>
  </tbody>
</table>
<h4 id="imp-003-範例搬家沒留新地址">IMP-003 範例：搬家沒留新地址</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 修正前：logger 是全域變數，所有函式都看得到</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">logger</span> <span class="o">=</span> <span class="n">setup_hook_logging</span><span class="p">(</span><span class="s2">&#34;hook-name&#34;</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="k">def</span> <span class="nf">helper_function</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="s2">&#34;doing something&#34;</span><span class="p">)</span>  <span class="c1"># OK，全域可見</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="n">result</span> <span class="o">=</span> <span class="n">helper_function</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"># 修正後：logger 搬進 main()，但 helper 沒收到通知</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="k">def</span> <span class="nf">helper_function</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="n">logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="s2">&#34;doing something&#34;</span><span class="p">)</span>  <span class="c1"># NameError! logger 不見了</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="n">logger</span> <span class="o">=</span> <span class="n">setup_hook_logging</span><span class="p">(</span><span class="s2">&#34;hook-name&#34;</span><span class="p">)</span>  <span class="c1"># 現在是區域變數</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="n">result</span> <span class="o">=</span> <span class="n">helper_function</span><span class="p">()</span>  <span class="c1"># helper 找不到 logger</span></span></span></code></pre></div><p>這個 Bug 在真實專案中影響了 7 個 Hook、41 個函式。更危險的是，例外捕捉機制將錯誤靜默吞掉，直到開發者主動翻查日誌才發現。在事件發生當時，錯誤被靜默吞掉。此問題後來已修復，現在 Hook 失敗會輸出到 stderr 確保開發者可見。</p>
<h3 id="三級分類速查表">三級分類速查表</h3>
<table>
  <thead>
      <tr>
          <th>級別</th>
          <th>影響範圍</th>
          <th>修復成本</th>
          <th>偵測難度</th>
          <th>典型 Pattern</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>實作級</td>
          <td>單一檔案</td>
          <td>低</td>
          <td>容易</td>
          <td>IMP-001, IMP-002</td>
      </tr>
      <tr>
          <td>架構級</td>
          <td>跨模組</td>
          <td>中-高</td>
          <td>中等</td>
          <td>ARCH-001</td>
      </tr>
      <tr>
          <td>遷移級</td>
          <td>重構過程</td>
          <td>高</td>
          <td>困難（可能靜默）</td>
          <td>IMP-003, IMP-005</td>
      </tr>
  </tbody>
</table>
<h2 id="偵測工具鏈">偵測工具鏈</h2>
<p>識別壞味道不能只靠肉眼。以下工具從簡單到進階，組成完整的偵測鏈。</p>
<h3 id="第一層grep-模式掃描">第一層：grep 模式掃描</h3>
<p>最快的初步篩檢，幾秒鐘就能掃完整個專案。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 偵測 IMP-001：找出重複的函式定義</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">grep -rh <span class="s2">&#34;^def &#34;</span> hooks/*.py <span class="p">|</span> sort <span class="p">|</span> uniq -c <span class="p">|</span> sort -rn <span class="p">|</span> head -10
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1">#  4 def run_git_command(cmd):    &lt;-- 出現 4 次，高度疑似重複</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1">#  2 def parse_output(line):      &lt;-- 出現 2 次，需要確認</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># 偵測 IMP-002：找出魔法數字</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">grep -rn -E <span class="s2">&#34;\[[0-9]+:\]&#34;</span> hooks/*.py      <span class="c1"># 數字切片 [9:]</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">grep -rn <span class="s2">&#34;sleep([0-9]&#34;</span> hooks/*.py        <span class="c1"># 硬編碼的等待時間</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">grep -rn <span class="s2">&#34;range([0-9]&#34;</span> hooks/*.py        <span class="c1"># 硬編碼的迴圈次數</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># 偵測 ARCH-001：找出超長檔案</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">find hooks/ -name <span class="s2">&#34;*.py&#34;</span> -exec wc -l <span class="o">{}</span> <span class="se">\;</span> <span class="p">|</span> awk <span class="s1">&#39;$1 &gt; 500&#39;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"># 847 hooks/user_prompt_submit.py    &lt;-- 紅色警報</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="c1"># 偵測 IMP-005：模組遷移後殘留的舊 Import</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">grep -rn <span class="s2">&#34;from common_functions import&#34;</span> hooks/*.py
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1"># 如果 common_functions.py 已經搬到 lib/，這些都是未更新的引用</span></span></span></code></pre></div><p><strong>grep 的限制</strong>：只做文字比對，無法理解程式碼結構。<code>line[9:]</code> 會被抓到，但 <code>offset = 9; line[offset:]</code> 就抓不到了。</p>
<h3 id="第二層ast-分析">第二層：AST 分析</h3>
<p>Python 的 <code>ast</code> 模組能解析程式碼結構，做到 grep 做不到的事。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">import</span> <span class="nn">ast</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">import</span> <span class="nn">sys</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="k">def</span> <span class="nf">find_scope_references</span><span class="p">(</span><span class="n">filename</span><span class="p">,</span> <span class="n">variable_name</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="s2">&#34;&#34;&#34;找出所有在非 main 函式中引用特定變數的位置。
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s2">    限制：此函式只做名稱比對，無法追蹤賦值或閉包捕獲。&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="n">filename</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="n">tree</span> <span class="o">=</span> <span class="n">ast</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="n">f</span><span class="o">.</span><span class="n">read</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="n">issues</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">for</span> <span class="n">node</span> <span class="ow">in</span> <span class="n">ast</span><span class="o">.</span><span class="n">walk</span><span class="p">(</span><span class="n">tree</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">node</span><span class="p">,</span> <span class="n">ast</span><span class="o">.</span><span class="n">FunctionDef</span><span class="p">)</span> <span class="ow">and</span> <span class="n">node</span><span class="o">.</span><span class="n">name</span> <span class="o">!=</span> <span class="s2">&#34;main&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="n">param_names</span> <span class="o">=</span> <span class="p">{</span><span class="n">arg</span><span class="o">.</span><span class="n">arg</span> <span class="k">for</span> <span class="n">arg</span> <span class="ow">in</span> <span class="n">node</span><span class="o">.</span><span class="n">args</span><span class="o">.</span><span class="n">args</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="n">variable_name</span> <span class="ow">in</span> <span class="n">param_names</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">                <span class="k">continue</span>  <span class="c1"># 函式已接收此變數為參數，非問題</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">            <span class="k">for</span> <span class="n">child</span> <span class="ow">in</span> <span class="n">ast</span><span class="o">.</span><span class="n">walk</span><span class="p">(</span><span class="n">node</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">                <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">child</span><span class="p">,</span> <span class="n">ast</span><span class="o">.</span><span class="n">Name</span><span class="p">)</span> <span class="ow">and</span> <span class="n">child</span><span class="o">.</span><span class="n">id</span> <span class="o">==</span> <span class="n">variable_name</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">                    <span class="n">issues</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;  </span><span class="si">{</span><span class="n">node</span><span class="o">.</span><span class="n">name</span><span class="si">}</span><span class="s2">() 在第 </span><span class="si">{</span><span class="n">child</span><span class="o">.</span><span class="n">lineno</span><span class="si">}</span><span class="s2"> 行引用 </span><span class="si">{</span><span class="n">variable_name</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">                    <span class="k">break</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">return</span> <span class="n">issues</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="c1"># 使用方式</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="n">issues</span> <span class="o">=</span> <span class="n">find_scope_references</span><span class="p">(</span><span class="s2">&#34;hooks/pre_commit.py&#34;</span><span class="p">,</span> <span class="s2">&#34;logger&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="k">for</span> <span class="n">issue</span> <span class="ow">in</span> <span class="n">issues</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="n">issue</span><span class="p">)</span></span></span></code></pre></div><p><strong>AST 能做而 grep 做不到的事</strong>：</p>
<table>
  <thead>
      <tr>
          <th>能力</th>
          <th>grep</th>
          <th>AST</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>找出字面上的文字模式</td>
          <td>可以</td>
          <td>可以</td>
      </tr>
      <tr>
          <td>區分變數定義和使用</td>
          <td>不行</td>
          <td>可以</td>
      </tr>
      <tr>
          <td>分析函式的參數列表</td>
          <td>不行</td>
          <td>可以</td>
      </tr>
      <tr>
          <td>偵測作用域問題</td>
          <td>不行</td>
          <td>可以</td>
      </tr>
      <tr>
          <td>計算巢狀深度</td>
          <td>不行</td>
          <td>可以</td>
      </tr>
  </tbody>
</table>
<p>自己撰寫 AST 腳本適合針對特定問題的精確偵測。但對於更廣泛的靜態分析需求，現成工具能用更低的成本涵蓋更多場景。</p>
<h3 id="第三層靜態分析工具比較">第三層：靜態分析工具比較</h3>
<p>不同工具的偵測能力差異很大，選錯工具會漏掉關鍵問題。</p>
<table>
  <thead>
      <tr>
          <th>偵測能力</th>
          <th>py_compile</th>
          <th>pylint</th>
          <th>mypy</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>語法錯誤</td>
          <td>可以</td>
          <td>可以</td>
          <td>可以</td>
      </tr>
      <tr>
          <td>未使用的變數</td>
          <td>不行</td>
          <td>可以</td>
          <td>不行</td>
      </tr>
      <tr>
          <td>作用域問題 (IMP-003)</td>
          <td><strong>不行</strong></td>
          <td>可以</td>
          <td>部分</td>
      </tr>
      <tr>
          <td>Import 路徑錯誤 (IMP-005)</td>
          <td><strong>不行</strong></td>
          <td>可以</td>
          <td>部分*</td>
      </tr>
      <tr>
          <td>型別錯誤</td>
          <td>不行</td>
          <td>部分</td>
          <td>可以</td>
      </tr>
      <tr>
          <td>程式碼風格</td>
          <td>不行</td>
          <td>可以</td>
          <td>不行</td>
      </tr>
      <tr>
          <td>執行速度</td>
          <td>最快</td>
          <td>中等</td>
          <td>較慢</td>
      </tr>
  </tbody>
</table>
<p>*mypy 偵測 Import 路徑錯誤需正確設定 MYPYPATH 或 mypy.ini，對動態 <code>sys.path</code> 無效。</p>
<p><code>py_compile</code> 只檢查語法是否合法。<code>logger</code> 變數不存在是執行期錯誤，不是語法錯誤。這就是為什麼 IMP-003 能通過 <code>py_compile</code> 的檢查，卻在執行時爆炸。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># py_compile：語法 OK 不代表能跑</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">python3 -m py_compile hooks/pre_commit.py  <span class="c1"># 通過！但 logger 根本找不到</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># pylint：能抓到更多問題</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">pylint hooks/pre_commit.py
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># E0602: Undefined variable &#39;logger&#39; (undefined-variable)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"># 實際執行：最可靠的驗證</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">python3 hooks/pre_commit.py &lt; /dev/null
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"># NameError: name &#39;logger&#39; is not defined</span></span></span></code></pre></div><p><strong>建議的偵測策略</strong>：先用 grep 做快速掃描，對疑似問題用 AST 確認，重構後用 pylint 或實際執行做最終驗證。</p>
<table>
  <thead>
      <tr>
          <th>使用場景</th>
          <th>推薦工具</th>
          <th>適用理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>快速掃描重複模式</td>
          <td>grep</td>
          <td>速度最快，適合初篩</td>
      </tr>
      <tr>
          <td>確認特定函式結構問題</td>
          <td>AST 分析</td>
          <td>精確到語法層級，無正規表達式偽陽性</td>
      </tr>
      <tr>
          <td>重構後整體品質驗證</td>
          <td>pylint / mypy</td>
          <td>涵蓋面廣，可持續整合</td>
      </tr>
      <tr>
          <td>作用域和型別問題</td>
          <td>實際執行</td>
          <td>py_compile 不夠，需 pytest 或直接執行</td>
      </tr>
  </tbody>
</table>
<h2 id="5-why-根因分析">5 Why 根因分析</h2>
<p>找到壞味道只是起點；若要防止問題再次出現，必須找到根本原因。</p>
<h3 id="完整範例arch-001-配置與程式碼混合">完整範例：ARCH-001 配置與程式碼混合</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">問題：單一 Hook 檔案超過 800 行，其中約一半是硬編碼的配置資料
</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">Why 1: 為什麼檔案會有 800 行？
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">--&gt; 因為配置資料（分支規則、錯誤訊息、檔案模式）和程式邏輯
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    全部寫在同一個檔案中
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">Why 2: 為什麼配置和邏輯放在一起？
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">--&gt; 因為開發時為求快速，直接在程式碼中定義配置常數
</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">Why 3: 為什麼選擇快速做法而非分離？
</span></span><span class="line"><span class="ln">11</span><span class="cl">--&gt; 因為缺乏配置管理策略，沒有標準化的做法可以遵循
</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">Why 4: 為什麼沒有配置管理策略？
</span></span><span class="line"><span class="ln">14</span><span class="cl">--&gt; 因為 Hook 系統初期設計時，只考慮了功能實現，
</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></span><span class="line"><span class="ln">17</span><span class="cl">Why 5: 為什麼初期設計沒考慮配置增長？
</span></span><span class="line"><span class="ln">18</span><span class="cl">--&gt; 【根本原因】缺乏明確的架構原則指導配置與程式碼分離</span></span></code></pre></div><p><strong>根因指向的行動</strong>：制定架構原則，明確規定什麼放在 YAML、什麼留在程式碼中。</p>
<table>
  <thead>
      <tr>
          <th>資料類型</th>
          <th>正確位置</th>
          <th>判斷依據</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>業務規則配置</td>
          <td>YAML 檔案</td>
          <td>會隨環境改變嗎？非工程師可能修改嗎？</td>
      </tr>
      <tr>
          <td>錯誤訊息</td>
          <td>YAML 或 i18n</td>
          <td>需要多語言嗎？</td>
      </tr>
      <tr>
          <td>常數定義</td>
          <td>Python 常數檔</td>
          <td>與程式邏輯緊密耦合嗎？</td>
      </tr>
      <tr>
          <td>程式邏輯</td>
          <td>Python 檔案</td>
          <td>是演算法或流程控制嗎？</td>
      </tr>
  </tbody>
</table>
<h3 id="5-why-的技巧">5 Why 的技巧</h3>
<ol>
<li><strong>持續追問</strong>：第一個「為什麼」幾乎永遠不是根本原因</li>
<li><strong>客觀描述</strong>：寫「缺乏審查機制」而不是「某人偷懶」</li>
<li><strong>可驗證</strong>：每一層的回答都應該可以被事實確認</li>
<li><strong>可行動</strong>：最終原因必須能轉化成具體的改善措施</li>
<li><strong>停止條件</strong>：當答案指向「流程或規範的缺失」時，通常就是根因</li>
</ol>
<h2 id="error-patterns-經驗傳承系統">Error Patterns 經驗傳承系統</h2>
<p>個人發現壞味道是一次性的收穫；將其記錄為 Error Pattern，才能讓整個團隊持續受益。</p>
<h3 id="目錄結構">目錄結構</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">.claude/error-patterns/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── README.md              # 系統說明與索引
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── test/                  # 測試相關：TEST-001, TEST-002, ...
</span></span><span class="line"><span class="ln">4</span><span class="cl">├── documentation/         # 文件相關：DOC-001, DOC-002, ...
</span></span><span class="line"><span class="ln">5</span><span class="cl">├── architecture/          # 架構相關：ARCH-001, ARCH-002, ...
</span></span><span class="line"><span class="ln">6</span><span class="cl">└── implementation/        # 實作相關：IMP-001, IMP-002, ...</span></span></code></pre></div><h3 id="pattern-文件模板">Pattern 文件模板</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="gh"># [Pattern ID]: [簡短標題]
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="gh"></span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="gu">## 基本資訊
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="gu"></span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="k">-</span> **Pattern ID**: {CATEGORY}-{NNN}
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="k">-</span> **風險等級**: 高/中/低
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">-</span> **發現日期**: YYYY-MM-DD
</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="gu">## 問題描述
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="gu"></span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="gu">### 症狀
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="gu"></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></span><span class="line"><span class="ln">15</span><span class="cl"><span class="gu">### 根本原因 (5 Why 分析)
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="gu"></span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="k">1.</span> Why 1: ...
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="k">2.</span> Why 2: ...
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="k">3.</span> Why 3: ...
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="k">4.</span> Why 4: ...
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="k">5.</span> Why 5: (根本原因)
</span></span><span class="line"><span class="ln">22</span><span class="cl">
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="gu">## 解決方案
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="gu"></span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="gu">### 正確做法
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="gu"></span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">[程式碼範例]
</span></span><span class="line"><span class="ln">28</span><span class="cl">
</span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="gu">### 錯誤做法 (避免)
</span></span></span><span class="line"><span class="ln">30</span><span class="cl"><span class="gu"></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></span><span class="line"><span class="ln">33</span><span class="cl"><span class="gu">## 檢測方法
</span></span></span><span class="line"><span class="ln">34</span><span class="cl"><span class="gu"></span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">[grep 指令、AST 腳本或工具配置]</span></span></code></pre></div><h3 id="建立流程">建立流程</h3>
<ol>
<li><strong>識別模式</strong>：確認問題確實重複出現（至少 2 次）</li>
<li><strong>分類歸檔</strong>：選擇 TEST / DOC / ARCH / IMP</li>
<li><strong>5 Why 分析</strong>：找出根本原因</li>
<li><strong>記錄方案</strong>：寫下正確和錯誤做法的對比</li>
<li><strong>加入偵測</strong>：提供 grep 或 AST 的偵測指令</li>
</ol>
<h2 id="從識別到行動的決策流程">從識別到行動的決策流程</h2>
<p>找到壞味道之後，不是每個都要立刻修。用這個流程判斷優先級：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">發現壞味道
</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">    v
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">影響正確性嗎？（會導致 Bug）
</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">    +-- 是 --&gt; 立即修復，建立 Ticket
</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">    +-- 否 --&gt; 影響多個檔案嗎？
</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">                +-- 是 --&gt; 記錄 Error Pattern + 建立 Ticket
</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">                +-- 否 --&gt; 認知負擔高嗎？（函式超長、巢狀太深）
</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">                            +-- 是 --&gt; 排入下次重構
</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">                            +-- 否 --&gt; 記錄，暫不處理</span></span></code></pre></div><p><strong>關鍵原則</strong>：遷移級壞味道（IMP-003、IMP-005）幾乎都會影響正確性，必須立即處理。實作級壞味道（IMP-001、IMP-002）通常不影響正確性，可以排入重構計畫。</p>
<h2 id="實作練習">實作練習</h2>
<h3 id="練習-1分類壞味道">練習 1：分類壞味道</h3>
<p>以下程式碼有哪些壞味道？各屬於哪一級？</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">BRANCH_RULES</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="s2">&#34;protected&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;main&#34;</span><span class="p">,</span> <span class="s2">&#34;master&#34;</span><span class="p">],</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="s2">&#34;max_length&#34;</span><span class="p">:</span> <span class="mi">50</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="s2">&#34;patterns&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;feat/*&#34;</span><span class="p">,</span> <span class="s2">&#34;fix/*&#34;</span><span class="p">,</span> <span class="s2">&#34;chore/*&#34;</span><span class="p">],</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">def</span> <span class="nf">check</span><span class="p">(</span><span class="n">data</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="n">res</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">data</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="n">data</span><span class="p">[</span><span class="n">i</span><span class="p">][</span><span class="s2">&#34;type&#34;</span><span class="p">]</span> <span class="o">==</span> <span class="s2">&#34;A&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="k">if</span> <span class="n">data</span><span class="p">[</span><span class="n">i</span><span class="p">][</span><span class="s2">&#34;status&#34;</span><span class="p">]</span> <span class="o">==</span> <span class="mi">1</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">                <span class="k">if</span> <span class="n">data</span><span class="p">[</span><span class="n">i</span><span class="p">][</span><span class="s2">&#34;value&#34;</span><span class="p">]</span> <span class="o">&gt;</span> <span class="mi">100</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">                    <span class="n">res</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">data</span><span class="p">[</span><span class="n">i</span><span class="p">][</span><span class="s2">&#34;name&#34;</span><span class="p">][</span><span class="mi">5</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="n">res</span></span></span></code></pre></div><details>
<summary>參考答案</summary>
<p><strong>實作級壞味道</strong>：</p>
<ol>
<li><strong>重複程式碼散落各處</strong> (IMP-001) &ndash; <code>data[i]</code> 在迴圈中重複出現 5 次，應提取為區域變數</li>
<li><strong>魔法數字</strong> (IMP-002) &ndash; <code>1</code>、<code>100</code>、<code>[5:]</code> 含義不明</li>
<li><strong>巢狀過深</strong> &ndash; 三層 if 應該用 Guard Clause 攤平</li>
<li><strong>使用 range(len())</strong> &ndash; 應該直接迭代集合</li>
</ol>
<p><strong>架構級壞味道</strong>：5. <strong>配置與程式碼混合</strong> (ARCH-001) &ndash; <code>BRANCH_RULES</code> 字典直接寫在程式碼中</p>
</details>
<h3 id="練習-2設計偵測指令">練習 2：設計偵測指令</h3>
<p>針對以下壞味道，各寫一條 grep 指令來偵測：</p>
<ol>
<li>在 <code>src/</code> 目錄下找出所有超過 3 層巢狀的 if 語句</li>
<li>找出可能的重複函式定義</li>
<li>找出所有引用已遷移模組 <code>old_utils</code> 的檔案</li>
</ol>
<details>
<summary>參考答案</summary>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 1. 找出深層巢狀（透過縮排層級近似偵測，偵測第 4 層起始）</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">grep -rn <span class="s2">&#34;^                if &#34;</span> src/*.py  <span class="c1"># 16 個空格 = 第四層（超過 3 層）</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"># 這個方法假設每層縮排使用 4 個空格。如果專案使用 2 格縮排，對應數字應改為 8。</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># 更可靠的做法是使用 AST 分析計算實際巢狀深度。</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># 2. 找出重複的函式定義</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">grep -rh <span class="s2">&#34;^def &#34;</span> src/*.py <span class="p">|</span> sort <span class="p">|</span> uniq -c <span class="p">|</span> sort -rn <span class="p">|</span> head -10
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"># 3. 找出未更新的舊 import</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">grep -rn <span class="s2">&#34;from old_utils import&#34;</span> src/*.py
</span></span><span class="line"><span class="ln">11</span><span class="cl">grep -rn <span class="s2">&#34;import old_utils&#34;</span> src/*.py</span></span></code></pre></div></details>
<h3 id="練習-35-why-分析">練習 3：5 Why 分析</h3>
<p>對以下問題進行 5 Why 分析：「重構時把 <code>logger</code> 從全域移到 <code>main()</code> 內部，導致 7 個 Hook 靜默失敗」。</p>
<details>
<summary>參考答案</summary>





<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">Why 1: 為什麼 7 個 Hook 會失敗？
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">--&gt; 因為 helper 函式引用了 logger，但 logger 已不在全域作用域
</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">Why 2: 為什麼 logger 不在全域了？
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">--&gt; 因為重構要求統一 logger 初始化風格為「main() 內部」
</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">Why 3: 為什麼只移動了 logger，沒有更新引用？
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">--&gt; 因為執行重構時沒有先列出所有引用 logger 的函式
</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">Why 4: 為什麼沒有做引用分析？
</span></span><span class="line"><span class="ln">11</span><span class="cl">--&gt; 因為缺乏「作用域變更檢查清單」的標準步驟
</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">Why 5: 為什麼沒有這個檢查清單？
</span></span><span class="line"><span class="ln">14</span><span class="cl">--&gt; 【根本原因】重構流程缺乏「影響範圍分析」的強制步驟</span></span></code></pre></div><p><strong>行動</strong>：在每次變更變數作用域前，強制執行 grep 或 AST 分析列出所有引用。</p>
</details>
<h2 id="小結">小結</h2>
<ul>
<li>壞味道分三級：實作級影響單一檔案，架構級需跨模組重新設計，遷移級最危險——它是重構過程中創造出來的新問題</li>
<li>偵測工具鏈由淺入深：grep 快速掃描、AST 結構分析、pylint/mypy 靜態檢查</li>
<li><code>py_compile</code> 只檢查語法，無法偵測作用域問題和 Import 錯誤</li>
<li>5 Why 分析追問到「流程或規範的缺失」才是根因</li>
<li>Error Patterns 把個人經驗變成團隊資產</li>
</ul>
<p>下一章：<a href="/blog/python/07-refactoring/dry-principle/" data-link-title="DRY 原則與共用程式庫" data-link-desc="學習識別重複程式碼並建立共用模組，含模組演進與漸進遷移策略">DRY 原則與共用程式庫</a></p>
]]></content:encoded></item><item><title>DRY 原則與共用程式庫</title><link>https://tarrragon.github.io/blog/python/07-refactoring/dry-principle/</link><pubDate>Wed, 04 Mar 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/python/07-refactoring/dry-principle/</guid><description>&lt;p>上一章：&lt;a href="https://tarrragon.github.io/blog/python/07-refactoring/code-smells/" data-link-title="程式碼壞味道偵測" data-link-desc="從三級分類系統到偵測工具鏈，建立系統化的程式碼品質防線">程式碼壞味道偵測&lt;/a>&lt;/p>
&lt;p>DRY (Don&amp;rsquo;t Repeat Yourself) 是軟體開發的核心原則之一。本章基於 Error Pattern IMP-001，學習如何識別重複程式碼並建立共用模組。後半部分以 v0.31.0 的模組演進和遷移實戰為例，示範共用庫如何隨系統成長持續演進。&lt;/p>
&lt;h2 id="問題背景">問題背景&lt;/h2>
&lt;h3 id="症狀">症狀&lt;/h3>
&lt;p>相同功能在多個檔案中重複實作：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># hooks/pre_commit.py&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">def&lt;/span> &lt;span class="nf">run_git_command&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">cmd&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">subprocess&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">run&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">cmd&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">capture_output&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">text&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&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="n">result&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">stdout&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">strip&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>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1"># hooks/post_merge.py -- 完全相同&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="c1"># hooks/branch_check.py -- 完全相同&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="c1"># hooks/worktree_guardian.py -- 完全相同&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>四個檔案中存在完全相同的函式定義。&lt;/p>
&lt;h3 id="5-why-分析">5 Why 分析&lt;/h3>
&lt;ol>
&lt;li>Why 1: 相同的 run_git_command 函式在 4 個檔案中重複&lt;/li>
&lt;li>Why 2: 每個 Hook 獨立開發，沒有共用模組&lt;/li>
&lt;li>Why 3: 缺乏 Hook 系統的架構設計和共用程式庫規劃&lt;/li>
&lt;li>Why 4: 快速開發時複製貼上最快&lt;/li>
&lt;li>Why 5: &lt;strong>缺乏 DRY 原則的強制檢查機制&lt;/strong>&lt;/li>
&lt;/ol>
&lt;h2 id="dry-原則核心">DRY 原則核心&lt;/h2>
&lt;p>重複程式碼的四大壞處：&lt;strong>修改需改多處&lt;/strong>、&lt;strong>容易不一致&lt;/strong>、&lt;strong>增加維護成本&lt;/strong>、&lt;strong>測試困難&lt;/strong>。&lt;/p>
&lt;p>DRY 的完整含義不只是「不要複製貼上」：&lt;/p>
&lt;blockquote>
&lt;p>Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.&lt;/p>
&lt;p>&amp;ndash; Andy Hunt &amp;amp; Dave Thomas, &lt;em>The Pragmatic Programmer&lt;/em>&lt;/p>&lt;/blockquote>
&lt;p>這意味著不只是程式碼，還包括業務邏輯、資料定義、設定內容。&lt;/p>
&lt;h2 id="識別重複程式碼">識別重複程式碼&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 找出重複的函式定義&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">grep -rh &lt;span class="s2">&amp;#34;^def &amp;#34;&lt;/span> .claude/hooks/*.py &lt;span class="p">|&lt;/span> sort &lt;span class="p">|&lt;/span> uniq -c &lt;span class="p">|&lt;/span> sort -rn &lt;span class="p">|&lt;/span> head -20
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># 範例輸出：&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1"># 4 def run_git_command(cmd):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1"># 3 def get_current_branch():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="c1"># 2 def parse_worktree_line(line):&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>重複類型&lt;/th>
 &lt;th>範例&lt;/th>
 &lt;th>處理方式&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>完全相同&lt;/td>
 &lt;td>複製貼上的程式碼&lt;/td>
 &lt;td>抽取到共用模組&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>結構相同&lt;/td>
 &lt;td>相似但參數不同&lt;/td>
 &lt;td>抽取並參數化&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>概念相同&lt;/td>
 &lt;td>做同樣的事但實作不同&lt;/td>
 &lt;td>統一介面&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="建立共用程式庫">建立共用程式庫&lt;/h2>
&lt;h3 id="模組結構">模組結構&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">.claude/lib/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">├── __init__.py # 公開介面
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">├── git_utils.py # Git 操作
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">├── config_loader.py # 配置載入
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">├── hook_io.py # 輸入輸出
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">└── hook_logging.py # 日誌系統&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="抽取共用函式">抽取共用函式&lt;/h3>
&lt;p>從重複程式碼中抽取，加上完整的型別標註和 docstring：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># lib/git_utils.py&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;Git 操作工具模組。&amp;#34;&amp;#34;&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">subprocess&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">pathlib&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Path&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">typing&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">List&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Optional&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="k">def&lt;/span> &lt;span class="nf">run_git_command&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="n">cmd&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">List&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">str&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="n">cwd&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Optional&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Path&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">None&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="n">check&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">bool&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">False&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 class="o">-&amp;gt;&lt;/span> &lt;span class="nb">str&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="s2">&amp;#34;&amp;#34;&amp;#34;執行 Git 命令並回傳輸出。
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="s2">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="s2"> Args:
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="s2"> cmd: Git 命令列表，例如 [&amp;#34;git&amp;#34;, &amp;#34;status&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="s2"> cwd: 工作目錄，預設為當前目錄
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="s2"> check: 是否在命令失敗時拋出異常
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">&lt;span class="s2"> &amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">subprocess&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">run&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="n">cmd&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">capture_output&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">text&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">cwd&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cwd&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">check&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">check&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 class="k">return&lt;/span> &lt;span class="n">result&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">stdout&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">strip&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">get_current_branch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">cwd&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Optional&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Path&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;取得當前分支名稱。&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">run_git_command&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="s2">&amp;#34;git&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;branch&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;--show-current&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">cwd&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cwd&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="更新使用處">更新使用處&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># hooks/pre_commit.py（重構後）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">lib.git_utils&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">run_git_command&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">get_current_branch&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">check_branch&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="n">current_branch&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">get_current_branch&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="c1"># 使用共用函式，不再重複定義&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="抽取技巧">抽取技巧&lt;/h2>
&lt;h3 id="處理微小差異">處理微小差異&lt;/h3>
&lt;p>當重複程式碼有微小差異時，使用參數化：&lt;/p></description><content:encoded><![CDATA[<p>上一章：<a href="/blog/python/07-refactoring/code-smells/" data-link-title="程式碼壞味道偵測" data-link-desc="從三級分類系統到偵測工具鏈，建立系統化的程式碼品質防線">程式碼壞味道偵測</a></p>
<p>DRY (Don&rsquo;t Repeat Yourself) 是軟體開發的核心原則之一。本章基於 Error Pattern IMP-001，學習如何識別重複程式碼並建立共用模組。後半部分以 v0.31.0 的模組演進和遷移實戰為例，示範共用庫如何隨系統成長持續演進。</p>
<h2 id="問題背景">問題背景</h2>
<h3 id="症狀">症狀</h3>
<p>相同功能在多個檔案中重複實作：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># hooks/pre_commit.py</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">def</span> <span class="nf">run_git_command</span><span class="p">(</span><span class="n">cmd</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="n">result</span> <span class="o">=</span> <span class="n">subprocess</span><span class="o">.</span><span class="n">run</span><span class="p">(</span><span class="n">cmd</span><span class="p">,</span> <span class="n">capture_output</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">text</span><span class="o">=</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="k">return</span> <span class="n">result</span><span class="o">.</span><span class="n">stdout</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"># hooks/post_merge.py  -- 完全相同</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># hooks/branch_check.py  -- 完全相同</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"># hooks/worktree_guardian.py  -- 完全相同</span></span></span></code></pre></div><p>四個檔案中存在完全相同的函式定義。</p>
<h3 id="5-why-分析">5 Why 分析</h3>
<ol>
<li>Why 1: 相同的 run_git_command 函式在 4 個檔案中重複</li>
<li>Why 2: 每個 Hook 獨立開發，沒有共用模組</li>
<li>Why 3: 缺乏 Hook 系統的架構設計和共用程式庫規劃</li>
<li>Why 4: 快速開發時複製貼上最快</li>
<li>Why 5: <strong>缺乏 DRY 原則的強制檢查機制</strong></li>
</ol>
<h2 id="dry-原則核心">DRY 原則核心</h2>
<p>重複程式碼的四大壞處：<strong>修改需改多處</strong>、<strong>容易不一致</strong>、<strong>增加維護成本</strong>、<strong>測試困難</strong>。</p>
<p>DRY 的完整含義不只是「不要複製貼上」：</p>
<blockquote>
<p>Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.</p>
<p>&ndash; Andy Hunt &amp; Dave Thomas, <em>The Pragmatic Programmer</em></p></blockquote>
<p>這意味著不只是程式碼，還包括業務邏輯、資料定義、設定內容。</p>
<h2 id="識別重複程式碼">識別重複程式碼</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 找出重複的函式定義</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">grep -rh <span class="s2">&#34;^def &#34;</span> .claude/hooks/*.py <span class="p">|</span> sort <span class="p">|</span> uniq -c <span class="p">|</span> sort -rn <span class="p">|</span> head -20
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 範例輸出：</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">#    4 def run_git_command(cmd):</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">#    3 def get_current_branch():</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1">#    2 def parse_worktree_line(line):</span></span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>重複類型</th>
          <th>範例</th>
          <th>處理方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>完全相同</td>
          <td>複製貼上的程式碼</td>
          <td>抽取到共用模組</td>
      </tr>
      <tr>
          <td>結構相同</td>
          <td>相似但參數不同</td>
          <td>抽取並參數化</td>
      </tr>
      <tr>
          <td>概念相同</td>
          <td>做同樣的事但實作不同</td>
          <td>統一介面</td>
      </tr>
  </tbody>
</table>
<h2 id="建立共用程式庫">建立共用程式庫</h2>
<h3 id="模組結構">模組結構</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">.claude/lib/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── __init__.py           # 公開介面
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── git_utils.py          # Git 操作
</span></span><span class="line"><span class="ln">4</span><span class="cl">├── config_loader.py      # 配置載入
</span></span><span class="line"><span class="ln">5</span><span class="cl">├── hook_io.py            # 輸入輸出
</span></span><span class="line"><span class="ln">6</span><span class="cl">└── hook_logging.py       # 日誌系統</span></span></code></pre></div><h3 id="抽取共用函式">抽取共用函式</h3>
<p>從重複程式碼中抽取，加上完整的型別標註和 docstring：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># lib/git_utils.py</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="s2">&#34;&#34;&#34;Git 操作工具模組。&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="kn">import</span> <span class="nn">subprocess</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kn">from</span> <span class="nn">pathlib</span> <span class="kn">import</span> <span class="n">Path</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kn">from</span> <span class="nn">typing</span> <span class="kn">import</span> <span class="n">List</span><span class="p">,</span> <span class="n">Optional</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="k">def</span> <span class="nf">run_git_command</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="n">cmd</span><span class="p">:</span> <span class="n">List</span><span class="p">[</span><span class="nb">str</span><span class="p">],</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="n">cwd</span><span class="p">:</span> <span class="n">Optional</span><span class="p">[</span><span class="n">Path</span><span class="p">]</span> <span class="o">=</span> <span class="kc">None</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="n">check</span><span class="p">:</span> <span class="nb">bool</span> <span class="o">=</span> <span class="kc">False</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="s2">&#34;&#34;&#34;執行 Git 命令並回傳輸出。
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="s2">    Args:
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="s2">        cmd: Git 命令列表，例如 [&#34;git&#34;, &#34;status&#34;]
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="s2">        cwd: 工作目錄，預設為當前目錄
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="s2">        check: 是否在命令失敗時拋出異常
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="s2">    &#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="n">result</span> <span class="o">=</span> <span class="n">subprocess</span><span class="o">.</span><span class="n">run</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="n">cmd</span><span class="p">,</span> <span class="n">capture_output</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">text</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">cwd</span><span class="o">=</span><span class="n">cwd</span><span class="p">,</span> <span class="n">check</span><span class="o">=</span><span class="n">check</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="k">return</span> <span class="n">result</span><span class="o">.</span><span class="n">stdout</span><span class="o">.</span><span class="n">strip</span><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="k">def</span> <span class="nf">get_current_branch</span><span class="p">(</span><span class="n">cwd</span><span class="p">:</span> <span class="n">Optional</span><span class="p">[</span><span class="n">Path</span><span class="p">]</span> <span class="o">=</span> <span class="kc">None</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="s2">&#34;&#34;&#34;取得當前分支名稱。&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="k">return</span> <span class="n">run_git_command</span><span class="p">([</span><span class="s2">&#34;git&#34;</span><span class="p">,</span> <span class="s2">&#34;branch&#34;</span><span class="p">,</span> <span class="s2">&#34;--show-current&#34;</span><span class="p">],</span> <span class="n">cwd</span><span class="o">=</span><span class="n">cwd</span><span class="p">)</span></span></span></code></pre></div><h3 id="更新使用處">更新使用處</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># hooks/pre_commit.py（重構後）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kn">from</span> <span class="nn">lib.git_utils</span> <span class="kn">import</span> <span class="n">run_git_command</span><span class="p">,</span> <span class="n">get_current_branch</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="k">def</span> <span class="nf">check_branch</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="n">current_branch</span> <span class="o">=</span> <span class="n">get_current_branch</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="c1"># 使用共用函式，不再重複定義</span></span></span></code></pre></div><h2 id="抽取技巧">抽取技巧</h2>
<h3 id="處理微小差異">處理微小差異</h3>
<p>當重複程式碼有微小差異時，使用參數化：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 重構前：三個檔案各自的版本</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"># hooks/file_a.py</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="k">def</span> <span class="nf">parse_worktree_line</span><span class="p">(</span><span class="n">line</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="n">line</span><span class="p">[</span><span class="mi">9</span><span class="p">:]</span>                        <span class="c1"># 不 strip</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># hooks/file_b.py</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">def</span> <span class="nf">parse_worktree_line</span><span class="p">(</span><span class="n">line</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="n">line</span><span class="p">[</span><span class="mi">9</span><span class="p">:]</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span>                <span class="c1"># 有 strip</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"># hooks/file_c.py</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="k">def</span> <span class="nf">parse_worktree_line</span><span class="p">(</span><span class="n">line</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="n">line</span><span class="o">.</span><span class="n">removeprefix</span><span class="p">(</span><span class="s2">&#34;worktree &#34;</span><span class="p">)</span>  <span class="c1"># 用 Python 3.9+ API</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"># 重構後：統一實作，支援選項</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">WORKTREE_PREFIX</span> <span class="o">=</span> <span class="s2">&#34;worktree &#34;</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">def</span> <span class="nf">parse_worktree_line</span><span class="p">(</span><span class="n">line</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">strip</span><span class="p">:</span> <span class="nb">bool</span> <span class="o">=</span> <span class="kc">True</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="s2">&#34;&#34;&#34;解析 worktree 輸出行。&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="n">result</span> <span class="o">=</span> <span class="n">line</span><span class="o">.</span><span class="n">removeprefix</span><span class="p">(</span><span class="n">WORKTREE_PREFIX</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">return</span> <span class="n">result</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span> <span class="k">if</span> <span class="n">strip</span> <span class="k">else</span> <span class="n">result</span></span></span></code></pre></div><h3 id="使用高階函式">使用高階函式</h3>
<p>當邏輯結構相同但操作不同時：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">from</span> <span class="nn">pathlib</span> <span class="kn">import</span> <span class="n">Path</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">from</span> <span class="nn">typing</span> <span class="kn">import</span> <span class="n">Callable</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># 重構前</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="k">def</span> <span class="nf">check_all_python_files</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">for</span> <span class="n">file</span> <span class="ow">in</span> <span class="n">Path</span><span class="p">(</span><span class="s2">&#34;.&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">glob</span><span class="p">(</span><span class="s2">&#34;**/*.py&#34;</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="n">validate_python</span><span class="p">(</span><span class="n">file</span><span class="p">):</span> <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;OK: </span><span class="si">{</span><span class="n">file</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="k">def</span> <span class="nf">check_all_yaml_files</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">for</span> <span class="n">file</span> <span class="ow">in</span> <span class="n">Path</span><span class="p">(</span><span class="s2">&#34;.&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">glob</span><span class="p">(</span><span class="s2">&#34;**/*.yaml&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="k">if</span> <span class="n">validate_yaml</span><span class="p">(</span><span class="n">file</span><span class="p">):</span> <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;OK: </span><span class="si">{</span><span class="n">file</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"># 重構後</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="k">def</span> <span class="nf">check_files</span><span class="p">(</span><span class="n">pattern</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">validator</span><span class="p">:</span> <span class="n">Callable</span><span class="p">[[</span><span class="n">Path</span><span class="p">],</span> <span class="nb">bool</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">for</span> <span class="n">file</span> <span class="ow">in</span> <span class="n">Path</span><span class="p">(</span><span class="s2">&#34;.&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">glob</span><span class="p">(</span><span class="n">pattern</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="n">validator</span><span class="p">(</span><span class="n">file</span><span class="p">):</span> <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;OK: </span><span class="si">{</span><span class="n">file</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="n">check_files</span><span class="p">(</span><span class="s2">&#34;**/*.py&#34;</span><span class="p">,</span> <span class="n">validate_python</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="n">check_files</span><span class="p">(</span><span class="s2">&#34;**/*.yaml&#34;</span><span class="p">,</span> <span class="n">validate_yaml</span><span class="p">)</span></span></span></code></pre></div><h2 id="共用模組設計原則">共用模組設計原則</h2>
<table>
  <thead>
      <tr>
          <th>原則</th>
          <th>做法</th>
          <th>反面教材</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>單一職責</strong></td>
          <td><code>git_utils.py</code>（Git 操作）、<code>config_loader.py</code>（配置載入）。模組名稱即可看出職責</td>
          <td><code>utils.py</code>（什麼都放，職責不明確）</td>
      </tr>
      <tr>
          <td><strong>穩定的介面</strong></td>
          <td>透過 <code>__init__.py</code> 定義公開 API，內部可自由重構</td>
          <td>讓使用者直接 import 內部實作細節</td>
      </tr>
      <tr>
          <td><strong>完整的 docstring</strong></td>
          <td>每個公開函式都要有 docstring（Args/Returns/Raises）</td>
          <td>只有程式碼，沒有使用說明</td>
      </tr>
      <tr>
          <td><strong>充分的測試</strong></td>
          <td>每個共用函式都要有對應的單元測試</td>
          <td>重構後不跑測試就上線</td>
      </tr>
  </tbody>
</table>
<h2 id="模組演進從-4-個到-7-個">模組演進：從 4 個到 7+ 個</h2>
<p>共用程式庫隨著系統成長持續演進。</p>
<h3 id="模組演進表">模組演進表</h3>
<table>
  <thead>
      <tr>
          <th>版本</th>
          <th>模組</th>
          <th>職責</th>
          <th>說明</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>v0.28.0</td>
          <td><code>git_utils.py</code></td>
          <td>Git 命令執行、分支管理</td>
          <td>消除 4 處 run_git_command 重複</td>
      </tr>
      <tr>
          <td>v0.28.0</td>
          <td><code>hook_io.py</code></td>
          <td>Hook JSON 輸入讀取、輸出生成</td>
          <td>統一 stdin/stdout 處理</td>
      </tr>
      <tr>
          <td>v0.28.0</td>
          <td><code>config_loader.py</code></td>
          <td>YAML 配置檔案載入</td>
          <td>支援 PyYAML fallback JSON</td>
      </tr>
      <tr>
          <td>v0.28.0</td>
          <td><code>hook_logging.py</code></td>
          <td>日誌設定</td>
          <td>統一日誌格式</td>
      </tr>
      <tr>
          <td>v0.31.0</td>
          <td><code>hook_utils.py</code></td>
          <td>統一日誌 + 頂層例外處理</td>
          <td>取代分散的兩套日誌系統</td>
      </tr>
      <tr>
          <td>v0.31.0</td>
          <td><code>hook_messages.py</code></td>
          <td>訊息常數集中管理</td>
          <td>消除 19 個 Hook 的硬編碼訊息</td>
      </tr>
      <tr>
          <td>v0.31.0</td>
          <td><code>hook_validator.py</code></td>
          <td>Hook 健康檢查</td>
          <td>驗證 import 和執行狀態</td>
      </tr>
  </tbody>
</table>
<h3 id="演進的驅動力">演進的驅動力</h3>
<p>每次新增模組都有明確的驅動力，而非預先設計：</p>
<p><strong>v0.28.0（初建期）</strong>：四個函式重複 → 建立四個共用模組。</p>
<p><strong>v0.31.0（成熟期）</strong>：Hook 數量從 7 個成長到 40+ 個，新的重複模式浮現：</p>
<ol>
<li><strong>日誌系統分裂</strong>：<code>hook_logging.py</code> 和 <code>common_functions.setup_hook_logging</code> 兩套實作並存，40+ 個 Hook 各自選用。最終建立 <code>hook_utils.py</code> 統一取代</li>
<li><strong>訊息散落各處</strong>：19 個 Hook 各自硬編碼使用者訊息 → 建立 <code>hook_messages.py</code> 集中管理</li>
</ol>
<p>這驗證了「至少重複兩次再抽取」的 Rule of Three 原則：模組是在真實需求驅動下自然長出來的。</p>
<h2 id="漸進遷移策略">漸進遷移策略</h2>
<p>共用庫建立後，需要將現有使用者逐步遷移。「一次全改」風險太高，以下是 W22 遷移 40+ 個 Hook 到新日誌系統的實戰策略。</p>
<h3 id="分批遷移計畫">分批遷移計畫</h3>
<table>
  <thead>
      <tr>
          <th>批次</th>
          <th>範圍</th>
          <th>檔案數</th>
          <th>策略</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>W22-001.2</td>
          <td>主力遷移</td>
          <td>14 個</td>
          <td>按 Hook 事件類型分組遷移</td>
      </tr>
      <tr>
          <td>W22-001.3</td>
          <td>補漏</td>
          <td>3 個</td>
          <td>掃描殘留的舊 import</td>
      </tr>
  </tbody>
</table>
<h3 id="每個-hook-的遷移步驟">每個 Hook 的遷移步驟</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># === 步驟 1：替換 import ===</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"># 遷移前</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kn">from</span> <span class="nn">lib.common_functions</span> <span class="kn">import</span> <span class="n">setup_hook_logging</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># 遷移後</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kn">from</span> <span class="nn">hook_utils</span> <span class="kn">import</span> <span class="n">setup_hook_logging</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"># === 步驟 2：包裹主函式 ===</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"># 遷移前</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="k">if</span> <span class="vm">__name__</span> <span class="o">==</span> <span class="s2">&#34;__main__&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="n">main</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">except</span> <span class="ne">Exception</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="n">logger</span><span class="o">.</span><span class="n">error</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;執行失敗: </span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="n">sys</span><span class="o">.</span><span class="n">exit</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1"># 遷移後</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="kn">from</span> <span class="nn">hook_utils</span> <span class="kn">import</span> <span class="n">run_hook_safely</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="k">if</span> <span class="vm">__name__</span> <span class="o">==</span> <span class="s2">&#34;__main__&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="n">sys</span><span class="o">.</span><span class="n">exit</span><span class="p">(</span><span class="n">run_hook_safely</span><span class="p">(</span><span class="n">main</span><span class="p">,</span> <span class="s2">&#34;my-hook&#34;</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="c1"># === 步驟 3：驗證 ===</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="n">uv</span> <span class="n">run</span> <span class="n">python</span> <span class="n">hook</span><span class="o">-</span><span class="n">name</span><span class="o">.</span><span class="n">py</span> <span class="o">&lt;</span> <span class="o">/</span><span class="n">dev</span><span class="o">/</span><span class="n">null</span></span></span></code></pre></div><h3 id="為什麼分批而非一次全改">為什麼分批而非一次全改</h3>
<table>
  <thead>
      <tr>
          <th>一次全改</th>
          <th>分批遷移</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>改動 40+ 個檔案，review 困難</td>
          <td>每批 14-3 個，可仔細確認</td>
      </tr>
      <tr>
          <td>一個錯誤影響所有 Hook</td>
          <td>錯誤影響範圍有限</td>
      </tr>
      <tr>
          <td>無法中途暫停</td>
          <td>每批獨立可交付</td>
      </tr>
      <tr>
          <td>回滾等於全部回滾</td>
          <td>只回滾出問題的批次</td>
      </tr>
  </tbody>
</table>
<h2 id="遷移陷阱imp-005">遷移陷阱：IMP-005</h2>
<p>模組遷移最常見的陷阱是 <strong>import 路徑未同步更新</strong>。這個問題在系統中發生過兩次，我們將其記錄為 Error Pattern IMP-005。</p>
<h3 id="症狀-1">症狀</h3>
<p>模組從目錄 A 移到目錄 B 後，部分使用者的 import 忘記更新：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 遷移前（同目錄）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kn">from</span> <span class="nn">common_functions</span> <span class="kn">import</span> <span class="n">hook_output</span>  <span class="c1"># OK</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 遷移後（模組移到 lib/，但 import 未更新）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="kn">from</span> <span class="nn">common_functions</span> <span class="kn">import</span> <span class="n">hook_output</span>  <span class="c1"># ModuleNotFoundError!</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># 正確的遷移後 import</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="kn">from</span> <span class="nn">lib.common_functions</span> <span class="kn">import</span> <span class="n">hook_output</span>  <span class="c1"># OK</span></span></span></code></pre></div><h3 id="為什麼容易遺漏">為什麼容易遺漏</h3>
<ol>
<li><strong>py_compile 不偵測 import 問題</strong>：只檢查語法，不解析模組路徑</li>
<li><strong>部分 Hook 不常觸發</strong>：SessionStart Hook 只在啟動時執行，測試不容易覆蓋</li>
<li><strong>多源錯誤疊加</strong>：多個 Hook 同時報錯，修完幾個就以為全部修好</li>
</ol>
<h3 id="遷移前強制檢查清單">遷移前強制檢查清單</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 1. 列出所有引用舊路徑的檔案</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">grep -r <span class="s2">&#34;from common_functions import&#34;</span> .claude/hooks/*.py
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 2. 逐一更新每個引用者的 import 路徑</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"># 3. 逐一驗證（不能只跑其中幾個！）</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="k">for</span> f in .claude/hooks/*.py<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    uv run python <span class="s2">&#34;</span><span class="nv">$f</span><span class="s2">&#34;</span> &lt; /dev/null 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">|</span> grep -q <span class="s2">&#34;Error&#34;</span> <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">&#34;FAIL: </span><span class="nv">$f</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="k">done</span></span></span></code></pre></div><h3 id="import-防護機制">Import 防護機制</h3>
<p>在 Hook 入口加 try-except，讓 import 失敗時顯示具體原因：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="kn">from</span> <span class="nn">hook_utils</span> <span class="kn">import</span> <span class="n">setup_hook_logging</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">except</span> <span class="ne">ImportError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;[Hook Import Error] </span><span class="si">{</span><span class="n">Path</span><span class="p">(</span><span class="vm">__file__</span><span class="p">)</span><span class="o">.</span><span class="n">name</span><span class="si">}</span><span class="s2">: </span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="n">file</span><span class="o">=</span><span class="n">sys</span><span class="o">.</span><span class="n">stderr</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="n">sys</span><span class="o">.</span><span class="n">exit</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span></span></span></code></pre></div><h2 id="實際案例統計">實際案例統計</h2>
<p>v0.28.0 初建共用庫：</p>
<table>
  <thead>
      <tr>
          <th>函式</th>
          <th>重複次數</th>
          <th>重構後</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>run_git_command</td>
          <td>4</td>
          <td>1 (git_utils.py)</td>
      </tr>
      <tr>
          <td>get_current_branch</td>
          <td>3</td>
          <td>1 (git_utils.py)</td>
      </tr>
      <tr>
          <td>parse_worktree_line</td>
          <td>2</td>
          <td>1 (git_utils.py)</td>
      </tr>
      <tr>
          <td>load_json</td>
          <td>2</td>
          <td>1 (hook_io.py)</td>
      </tr>
  </tbody>
</table>
<p>總計消除數百行重複程式碼。</p>
<p>v0.31.0 持續演進：</p>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>重複次數</th>
          <th>重構後</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>setup_hook_logging</td>
          <td>2 套系統</td>
          <td>1 (hook_utils.py)</td>
      </tr>
      <tr>
          <td>run_hook_safely</td>
          <td>40+ 處 try-except</td>
          <td>1 (hook_utils.py)</td>
      </tr>
      <tr>
          <td>使用者訊息字串</td>
          <td>19 個 Hook 散落</td>
          <td>1 (hook_messages.py)</td>
      </tr>
  </tbody>
</table>
<h2 id="常見錯誤">常見錯誤</h2>
<h3 id="錯誤-1過早抽象">錯誤 1：過早抽象</h3>
<p>只用一次就抽出去是過度抽象。<strong>原則</strong>：至少重複兩次再抽取（Rule of Three）。</p>
<h3 id="錯誤-2強行統一">錯誤 2：強行統一</h3>
<p>不同概念硬塞進同一個函式（靠 mode 參數切換）。<strong>解決</strong>：不同概念應該是不同的函式。</p>
<h3 id="錯誤-3忽略測試">錯誤 3：忽略測試</h3>
<p>重構時沒有先寫測試，導致引入新 bug。<strong>原則</strong>：先寫測試，確保重構不改變行為。</p>
<h3 id="錯誤-4遷移不徹底">錯誤 4：遷移不徹底</h3>
<p>模組搬家後只更新「自己知道的」使用處。<strong>原則</strong>：用 grep 列出所有引用，逐一更新並驗證（詳見 IMP-005）。</p>
<h2 id="實作練習">實作練習</h2>
<h3 id="練習-1識別重複">練習 1：識別重複</h3>
<p>找出以下程式碼的可抽取重複：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># file1.py</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">def</span> <span class="nf">process_user_data</span><span class="p">(</span><span class="n">user</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="ow">not</span> <span class="n">user</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;name&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">return</span> <span class="p">{</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;缺少姓名&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">user</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;email&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">return</span> <span class="p">{</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;缺少信箱&#34;</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="p">{</span><span class="s2">&#34;success&#34;</span><span class="p">:</span> <span class="kc">True</span><span class="p">,</span> <span class="s2">&#34;data&#34;</span><span class="p">:</span> <span class="n">user</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"># file2.py</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="k">def</span> <span class="nf">process_order_data</span><span class="p">(</span><span class="n">order</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">order</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;product&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">return</span> <span class="p">{</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;缺少商品&#34;</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="ow">not</span> <span class="n">order</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;quantity&#34;</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="p">{</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;缺少數量&#34;</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="p">{</span><span class="s2">&#34;success&#34;</span><span class="p">:</span> <span class="kc">True</span><span class="p">,</span> <span class="s2">&#34;data&#34;</span><span class="p">:</span> <span class="n">order</span><span class="p">}</span></span></span></code></pre></div><details>
<summary>參考答案</summary>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">def</span> <span class="nf">validate_required_fields</span><span class="p">(</span><span class="n">data</span><span class="p">:</span> <span class="nb">dict</span><span class="p">,</span> <span class="n">required_fields</span><span class="p">:</span> <span class="nb">list</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">dict</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="s2">&#34;&#34;&#34;驗證必填欄位。&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">for</span> <span class="n">field</span> <span class="ow">in</span> <span class="n">required_fields</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">if</span> <span class="ow">not</span> <span class="n">data</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">field</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="k">return</span> <span class="p">{</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="sa">f</span><span class="s2">&#34;缺少</span><span class="si">{</span><span class="n">field</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">return</span> <span class="p">{</span><span class="s2">&#34;success&#34;</span><span class="p">:</span> <span class="kc">True</span><span class="p">,</span> <span class="s2">&#34;data&#34;</span><span class="p">:</span> <span class="n">data</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="k">def</span> <span class="nf">process_user_data</span><span class="p">(</span><span class="n">user</span><span class="p">:</span> <span class="nb">dict</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">dict</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="n">validate_required_fields</span><span class="p">(</span><span class="n">user</span><span class="p">,</span> <span class="p">[</span><span class="s2">&#34;name&#34;</span><span class="p">,</span> <span class="s2">&#34;email&#34;</span><span class="p">])</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="k">def</span> <span class="nf">process_order_data</span><span class="p">(</span><span class="n">order</span><span class="p">:</span> <span class="nb">dict</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">dict</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="n">validate_required_fields</span><span class="p">(</span><span class="n">order</span><span class="p">,</span> <span class="p">[</span><span class="s2">&#34;product&#34;</span><span class="p">,</span> <span class="s2">&#34;quantity&#34;</span><span class="p">])</span></span></span></code></pre></div></details>
<h3 id="練習-2規劃遷移策略">練習 2：規劃遷移策略</h3>
<p>20 個 Hook 要從 <code>from common_functions import setup_logging</code> 遷移到 <code>from hook_utils import setup_hook_logging</code>，請規劃遷移策略。</p>
<details>
<summary>參考答案</summary>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 1. 盤點</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">grep -rl <span class="s2">&#34;from common_functions import&#34;</span> .claude/hooks/*.py <span class="p">|</span> wc -l
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># 2. 分批（按事件類型）</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"># 第一批：SessionStart hooks（啟動就能看到）</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># 第二批：UserPromptSubmit hooks</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"># 第三批：PreToolUse / PostToolUse hooks</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"># 3. 逐批執行，每批完成後 commit</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># 4. 全量掃描（不可省略！防止 IMP-005）</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">grep -r <span class="s2">&#34;from common_functions import&#34;</span> .claude/hooks/*.py
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"># 預期輸出：空</span></span></span></code></pre></div></details>
<h2 id="小結">小結</h2>
<ul>
<li>DRY 原則要求每個知識只有單一權威來源，用 <code>grep</code> 識別重複的函式定義</li>
<li>不要過早抽象，至少重複兩次再抽取（Rule of Three）</li>
<li>建立結構清晰的共用程式庫，重構前先寫測試確保行為不變</li>
<li>共用庫隨系統成長持續演進，大規模遷移採用分批策略</li>
<li>模組搬家後必須全量 <code>grep</code> 引用並逐一驗證，防止 IMP-005 陷阱</li>
</ul>
<p>下一章：<a href="/blog/python/07-refactoring/constants-management/" data-link-title="配置分離與常數管理" data-link-desc="學習消除三種硬編碼問題：魔法數字、配置混合、散落訊息">配置分離與常數管理</a></p>
<hr>
<p><em>文件版本：v0.31.1</em>
<em>建立日期：2026-03-04</em></p>
]]></content:encoded></item><item><title>配置分離與常數管理</title><link>https://tarrragon.github.io/blog/python/07-refactoring/constants-management/</link><pubDate>Wed, 04 Mar 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/python/07-refactoring/constants-management/</guid><description>&lt;p>上一章：&lt;a href="https://tarrragon.github.io/blog/python/07-refactoring/dry-principle/" data-link-title="DRY 原則與共用程式庫" data-link-desc="學習識別重複程式碼並建立共用模組，含模組演進與漸進遷移策略">DRY 原則與共用程式庫&lt;/a>&lt;/p>
&lt;p>硬編碼問題不只是魔法數字。當專案成長到數十個模組時，三種不同形態的硬編碼會同時出現：看不懂的數字、混在邏輯裡的配置資料、散落各處的使用者訊息。本章整合 Error Pattern IMP-002（魔法數字）和 ARCH-001（配置與邏輯混合）的實戰經驗，並加入 W23 訊息集中化的完整案例。&lt;/p>
&lt;hr>
&lt;h2 id="三種硬編碼問題">三種硬編碼問題&lt;/h2>
&lt;p>在維護 19 個 Hook 模組的過程中，我們遇到了三種不同但相關的硬編碼問題：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>類型&lt;/th>
 &lt;th>Error Pattern&lt;/th>
 &lt;th>典型症狀&lt;/th>
 &lt;th>危害&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>魔法數字&lt;/td>
 &lt;td>IMP-002&lt;/td>
 &lt;td>&lt;code>line[9:]&lt;/code>、&lt;code>sleep(3)&lt;/code>、&lt;code>range(5)&lt;/code>&lt;/td>
 &lt;td>無法理解數字含義，修改時容易遺漏&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>配置混合&lt;/td>
 &lt;td>ARCH-001&lt;/td>
 &lt;td>800 行檔案中 400 行是配置資料&lt;/td>
 &lt;td>配置散落各處，同一資料有多個版本&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>散落訊息&lt;/td>
 &lt;td>W23 發現&lt;/td>
 &lt;td>57+ 個硬編碼中文字串散落在 19 個檔案中&lt;/td>
 &lt;td>訊息不一致，無法統一維護&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>三種問題的共同根因：&lt;strong>開發時為求快速，把應該集中管理的資料直接寫在邏輯程式碼裡。&lt;/strong>&lt;/p>
&lt;hr>
&lt;h2 id="一消除魔法數字-imp-002">一、消除魔法數字 (IMP-002)&lt;/h2>
&lt;p>魔法數字是程式碼中無法理解含義的字面值：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">parse_worktree_line&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">line&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">line&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">startswith&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;worktree &amp;#34;&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">line&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">9&lt;/span>&lt;span class="p">:]&lt;/span> &lt;span class="c1"># 為什麼是 9？&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="n">line&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="k">if&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">branch&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">&amp;gt;&lt;/span> &lt;span class="mi">50&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="c1"># 為什麼是 50？&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">raise&lt;/span> &lt;span class="n">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;分支名稱過長&amp;#34;&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>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="n">time&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">sleep&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># 為什麼等 3 秒？&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>問題不只是可讀性。當前綴改成 &lt;code>&amp;quot;work tree &amp;quot;&lt;/code> 時，&lt;code>line[9:]&lt;/code> 不會自動更新，產生隱蔽的 bug。&lt;/p>
&lt;h3 id="三種消除方法">三種消除方法&lt;/h3>
&lt;h4 id="方法-1len-動態計算最安全">方法 1：len() 動態計算（最安全）&lt;/h4>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">WORKTREE_PREFIX&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;worktree &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">parse_worktree_line&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">line&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">str&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">if&lt;/span> &lt;span class="n">line&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">startswith&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">WORKTREE_PREFIX&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="k">return&lt;/span> &lt;span class="n">line&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">WORKTREE_PREFIX&lt;/span>&lt;span class="p">):]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">line&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>前綴改變時切片自動正確，不需要同步更新數字。&lt;/p>
&lt;h4 id="方法-2removeprefix最簡潔python-39">方法 2：removeprefix（最簡潔，Python 3.9+）&lt;/h4>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">WORKTREE_PREFIX&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;worktree &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">parse_worktree_line&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">line&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">str&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="n">line&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">removeprefix&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">WORKTREE_PREFIX&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>不需要先檢查 &lt;code>startswith&lt;/code>，沒有前綴時安全返回原字串。&lt;/p>
&lt;h4 id="方法-3intenum-管理相關常數群組">方法 3：IntEnum 管理相關常數群組&lt;/h4>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">enum&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">IntEnum&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="k">class&lt;/span> &lt;span class="nc">Limits&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">IntEnum&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="n">MAX_BRANCH_LENGTH&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">50&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="n">MAX_COMMIT_MSG_LENGTH&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">72&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="n">MAX_RETRIES&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">3&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="n">TIMEOUT_SECONDS&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">30&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">branch&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">Limits&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">MAX_BRANCH_LENGTH&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">raise&lt;/span> &lt;span class="ne">ValueError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;分支名稱過長&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="常見處理對照">常見處理對照&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>場景&lt;/th>
 &lt;th>壞&lt;/th>
 &lt;th>好&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>字串切片&lt;/td>
 &lt;td>&lt;code>line[7:]&lt;/code>&lt;/td>
 &lt;td>&lt;code>line.removeprefix(PREFIX)&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>時間限制&lt;/td>
 &lt;td>&lt;code>sleep(3)&lt;/code>&lt;/td>
 &lt;td>&lt;code>sleep(RETRY_DELAY_SECONDS)&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>大小限制&lt;/td>
 &lt;td>&lt;code>len(x) &amp;gt; 50&lt;/code>&lt;/td>
 &lt;td>&lt;code>len(x) &amp;gt; MAX_BRANCH_LENGTH&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>重試次數&lt;/td>
 &lt;td>&lt;code>range(5)&lt;/code>&lt;/td>
 &lt;td>&lt;code>range(MAX_RETRIES)&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="可接受的例外">可接受的例外&lt;/h3>
&lt;p>不是所有數字都需要命名：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="n">count&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="c1"># 可接受：0 在布林邏輯中&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="n">text&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;key&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="o">-&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="c1"># 可接受：-1 作為找不到的標記&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="n">half&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">total&lt;/span> &lt;span class="o">/&lt;/span> &lt;span class="mi">2&lt;/span> &lt;span class="c1"># 可接受：明顯的數學常數&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>判斷標準：&lt;strong>如果閱讀者需要思考「這個數字為什麼是這個值」，就應該命名。&lt;/strong>&lt;/p>
&lt;hr>
&lt;h2 id="二yaml-配置分離-arch-001">二、YAML 配置分離 (ARCH-001)&lt;/h2>
&lt;h3 id="問題識別">問題識別&lt;/h3>
&lt;p>單一 Hook 檔案超過 800 行，其中約一半是硬編碼的配置資料：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># user_prompt_submit.py (847 行，配置佔 400+)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="n">PROTECTED_BRANCHES&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;main&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;master&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;develop&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="n">ALLOWED_PATTERNS&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;feat/*&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;fix/*&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;chore/*&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="n">ERROR_MESSAGES&lt;/span> &lt;span class="o">=&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="s2">&amp;#34;branch_not_allowed&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&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="s2">&amp;#34;missing_ticket&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;缺少 Ticket 引用&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="c1"># ... 數百行配置&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="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">def&lt;/span> &lt;span class="nf">main&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="c1"># 實際邏輯只有 200 行&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">pass&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>更嚴重的是，同一份配置在多個檔案中各自定義，彼此不一致：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># file1.py&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="n">PROTECTED_BRANCHES&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;main&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;master&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="c1"># file2.py&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="n">PROTECTED_BRANCHES&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;main&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;master&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;develop&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="c1"># 多了 develop！&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="判斷標準">判斷標準&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>問題&lt;/th>
 &lt;th>若答「是」&lt;/th>
 &lt;th>放置位置&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>會隨環境改變？&lt;/td>
 &lt;td>是&lt;/td>
 &lt;td>YAML 配置檔&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>非工程師可能修改？&lt;/td>
 &lt;td>是&lt;/td>
 &lt;td>YAML 配置檔&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>是業務規則？&lt;/td>
 &lt;td>是&lt;/td>
 &lt;td>程式碼常數檔（附註解）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>與程式邏輯緊密耦合？&lt;/td>
 &lt;td>是&lt;/td>
 &lt;td>程式碼內常數&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>簡單記憶：&lt;strong>資料放配置，邏輯留程式碼。&lt;/strong>&lt;/p></description><content:encoded><![CDATA[<p>上一章：<a href="/blog/python/07-refactoring/dry-principle/" data-link-title="DRY 原則與共用程式庫" data-link-desc="學習識別重複程式碼並建立共用模組，含模組演進與漸進遷移策略">DRY 原則與共用程式庫</a></p>
<p>硬編碼問題不只是魔法數字。當專案成長到數十個模組時，三種不同形態的硬編碼會同時出現：看不懂的數字、混在邏輯裡的配置資料、散落各處的使用者訊息。本章整合 Error Pattern IMP-002（魔法數字）和 ARCH-001（配置與邏輯混合）的實戰經驗，並加入 W23 訊息集中化的完整案例。</p>
<hr>
<h2 id="三種硬編碼問題">三種硬編碼問題</h2>
<p>在維護 19 個 Hook 模組的過程中，我們遇到了三種不同但相關的硬編碼問題：</p>
<table>
  <thead>
      <tr>
          <th>類型</th>
          <th>Error Pattern</th>
          <th>典型症狀</th>
          <th>危害</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>魔法數字</td>
          <td>IMP-002</td>
          <td><code>line[9:]</code>、<code>sleep(3)</code>、<code>range(5)</code></td>
          <td>無法理解數字含義，修改時容易遺漏</td>
      </tr>
      <tr>
          <td>配置混合</td>
          <td>ARCH-001</td>
          <td>800 行檔案中 400 行是配置資料</td>
          <td>配置散落各處，同一資料有多個版本</td>
      </tr>
      <tr>
          <td>散落訊息</td>
          <td>W23 發現</td>
          <td>57+ 個硬編碼中文字串散落在 19 個檔案中</td>
          <td>訊息不一致，無法統一維護</td>
      </tr>
  </tbody>
</table>
<p>三種問題的共同根因：<strong>開發時為求快速，把應該集中管理的資料直接寫在邏輯程式碼裡。</strong></p>
<hr>
<h2 id="一消除魔法數字-imp-002">一、消除魔法數字 (IMP-002)</h2>
<p>魔法數字是程式碼中無法理解含義的字面值：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">def</span> <span class="nf">parse_worktree_line</span><span class="p">(</span><span class="n">line</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">if</span> <span class="n">line</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;worktree &#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="k">return</span> <span class="n">line</span><span class="p">[</span><span class="mi">9</span><span class="p">:]</span>  <span class="c1"># 為什麼是 9？</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="k">return</span> <span class="n">line</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">branch</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mi">50</span><span class="p">:</span>    <span class="c1"># 為什麼是 50？</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="k">raise</span> <span class="n">Error</span><span class="p">(</span><span class="s2">&#34;分支名稱過長&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="n">time</span><span class="o">.</span><span class="n">sleep</span><span class="p">(</span><span class="mi">3</span><span class="p">)</span>           <span class="c1"># 為什麼等 3 秒？</span></span></span></code></pre></div><p>問題不只是可讀性。當前綴改成 <code>&quot;work tree &quot;</code> 時，<code>line[9:]</code> 不會自動更新，產生隱蔽的 bug。</p>
<h3 id="三種消除方法">三種消除方法</h3>
<h4 id="方法-1len-動態計算最安全">方法 1：len() 動態計算（最安全）</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">WORKTREE_PREFIX</span> <span class="o">=</span> <span class="s2">&#34;worktree &#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">def</span> <span class="nf">parse_worktree_line</span><span class="p">(</span><span class="n">line</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="k">if</span> <span class="n">line</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="n">WORKTREE_PREFIX</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="k">return</span> <span class="n">line</span><span class="p">[</span><span class="nb">len</span><span class="p">(</span><span class="n">WORKTREE_PREFIX</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="n">line</span></span></span></code></pre></div><p>前綴改變時切片自動正確，不需要同步更新數字。</p>
<h4 id="方法-2removeprefix最簡潔python-39">方法 2：removeprefix（最簡潔，Python 3.9+）</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">WORKTREE_PREFIX</span> <span class="o">=</span> <span class="s2">&#34;worktree &#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">def</span> <span class="nf">parse_worktree_line</span><span class="p">(</span><span class="n">line</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</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="n">line</span><span class="o">.</span><span class="n">removeprefix</span><span class="p">(</span><span class="n">WORKTREE_PREFIX</span><span class="p">)</span></span></span></code></pre></div><p>不需要先檢查 <code>startswith</code>，沒有前綴時安全返回原字串。</p>
<h4 id="方法-3intenum-管理相關常數群組">方法 3：IntEnum 管理相關常數群組</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">from</span> <span class="nn">enum</span> <span class="kn">import</span> <span class="n">IntEnum</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="k">class</span> <span class="nc">Limits</span><span class="p">(</span><span class="n">IntEnum</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">MAX_BRANCH_LENGTH</span> <span class="o">=</span> <span class="mi">50</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">MAX_COMMIT_MSG_LENGTH</span> <span class="o">=</span> <span class="mi">72</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="n">MAX_RETRIES</span> <span class="o">=</span> <span class="mi">3</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="n">TIMEOUT_SECONDS</span> <span class="o">=</span> <span class="mi">30</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">branch</span><span class="p">)</span> <span class="o">&gt;</span> <span class="n">Limits</span><span class="o">.</span><span class="n">MAX_BRANCH_LENGTH</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">raise</span> <span class="ne">ValueError</span><span class="p">(</span><span class="s2">&#34;分支名稱過長&#34;</span><span class="p">)</span></span></span></code></pre></div><h3 id="常見處理對照">常見處理對照</h3>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>壞</th>
          <th>好</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>字串切片</td>
          <td><code>line[7:]</code></td>
          <td><code>line.removeprefix(PREFIX)</code></td>
      </tr>
      <tr>
          <td>時間限制</td>
          <td><code>sleep(3)</code></td>
          <td><code>sleep(RETRY_DELAY_SECONDS)</code></td>
      </tr>
      <tr>
          <td>大小限制</td>
          <td><code>len(x) &gt; 50</code></td>
          <td><code>len(x) &gt; MAX_BRANCH_LENGTH</code></td>
      </tr>
      <tr>
          <td>重試次數</td>
          <td><code>range(5)</code></td>
          <td><code>range(MAX_RETRIES)</code></td>
      </tr>
  </tbody>
</table>
<h3 id="可接受的例外">可接受的例外</h3>
<p>不是所有數字都需要命名：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">if</span> <span class="n">count</span> <span class="o">==</span> <span class="mi">0</span><span class="p">:</span>               <span class="c1"># 可接受：0 在布林邏輯中</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">if</span> <span class="n">text</span><span class="o">.</span><span class="n">find</span><span class="p">(</span><span class="s2">&#34;key&#34;</span><span class="p">)</span> <span class="o">==</span> <span class="o">-</span><span class="mi">1</span><span class="p">:</span>   <span class="c1"># 可接受：-1 作為找不到的標記</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">half</span> <span class="o">=</span> <span class="n">total</span> <span class="o">/</span> <span class="mi">2</span>              <span class="c1"># 可接受：明顯的數學常數</span></span></span></code></pre></div><p>判斷標準：<strong>如果閱讀者需要思考「這個數字為什麼是這個值」，就應該命名。</strong></p>
<hr>
<h2 id="二yaml-配置分離-arch-001">二、YAML 配置分離 (ARCH-001)</h2>
<h3 id="問題識別">問題識別</h3>
<p>單一 Hook 檔案超過 800 行，其中約一半是硬編碼的配置資料：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># user_prompt_submit.py (847 行，配置佔 400+)</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">PROTECTED_BRANCHES</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;main&#34;</span><span class="p">,</span> <span class="s2">&#34;master&#34;</span><span class="p">,</span> <span class="s2">&#34;develop&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">ALLOWED_PATTERNS</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;feat/*&#34;</span><span class="p">,</span> <span class="s2">&#34;fix/*&#34;</span><span class="p">,</span> <span class="s2">&#34;chore/*&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">ERROR_MESSAGES</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="s2">&#34;branch_not_allowed&#34;</span><span class="p">:</span> <span class="s2">&#34;分支名稱不符合規範&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="s2">&#34;missing_ticket&#34;</span><span class="p">:</span> <span class="s2">&#34;缺少 Ticket 引用&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="c1"># ... 數百行配置</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="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">def</span> <span class="nf">main</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="c1"># 實際邏輯只有 200 行</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">pass</span></span></span></code></pre></div><p>更嚴重的是，同一份配置在多個檔案中各自定義，彼此不一致：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># file1.py</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">PROTECTED_BRANCHES</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;main&#34;</span><span class="p">,</span> <span class="s2">&#34;master&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># file2.py</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">PROTECTED_BRANCHES</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;main&#34;</span><span class="p">,</span> <span class="s2">&#34;master&#34;</span><span class="p">,</span> <span class="s2">&#34;develop&#34;</span><span class="p">]</span>  <span class="c1"># 多了 develop！</span></span></span></code></pre></div><h3 id="判斷標準">判斷標準</h3>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>若答「是」</th>
          <th>放置位置</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>會隨環境改變？</td>
          <td>是</td>
          <td>YAML 配置檔</td>
      </tr>
      <tr>
          <td>非工程師可能修改？</td>
          <td>是</td>
          <td>YAML 配置檔</td>
      </tr>
      <tr>
          <td>是業務規則？</td>
          <td>是</td>
          <td>程式碼常數檔（附註解）</td>
      </tr>
      <tr>
          <td>與程式邏輯緊密耦合？</td>
          <td>是</td>
          <td>程式碼內常數</td>
      </tr>
  </tbody>
</table>
<p>簡單記憶：<strong>資料放配置，邏輯留程式碼。</strong></p>
<h3 id="實作config_loader-模式">實作：config_loader 模式</h3>
<h4 id="步驟-1抽離配置到-yaml">步驟 1：抽離配置到 YAML</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># config/branch_rules.yaml</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">protected_branches</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span>- <span class="l">main</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">  </span>- <span class="l">master</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">  </span>- <span class="l">develop</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="nt">allowed_patterns</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">  </span>- <span class="s2">&#34;feat/*&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">  </span>- <span class="s2">&#34;fix/*&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">  </span>- <span class="s2">&#34;chore/*&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w"></span><span class="nt">error_messages</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">  </span><span class="nt">branch_not_allowed</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;分支名稱不符合規範&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">  </span><span class="nt">missing_ticket</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;缺少 Ticket 引用&#34;</span></span></span></code></pre></div><h4 id="步驟-2建立載入器含快取">步驟 2：建立載入器（含快取）</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># lib/config_loader.py</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">from</span> <span class="nn">pathlib</span> <span class="kn">import</span> <span class="n">Path</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kn">from</span> <span class="nn">typing</span> <span class="kn">import</span> <span class="n">Any</span><span class="p">,</span> <span class="n">Dict</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="kn">import</span> <span class="nn">yaml</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="n">_config_cache</span><span class="p">:</span> <span class="n">Dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">]</span> <span class="o">=</span> <span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="k">def</span> <span class="nf">load_config</span><span class="p">(</span><span class="n">filename</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="s2">&#34;&#34;&#34;載入 YAML 配置檔案（含快取）。&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="n">filename</span> <span class="ow">in</span> <span class="n">_config_cache</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="n">_config_cache</span><span class="p">[</span><span class="n">filename</span><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="n">config_path</span> <span class="o">=</span> <span class="n">Path</span><span class="p">(</span><span class="vm">__file__</span><span class="p">)</span><span class="o">.</span><span class="n">parent</span><span class="o">.</span><span class="n">parent</span> <span class="o">/</span> <span class="s2">&#34;config&#34;</span> <span class="o">/</span> <span class="n">filename</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">config_path</span><span class="o">.</span><span class="n">exists</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="k">raise</span> <span class="ne">FileNotFoundError</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;配置檔案不存在: </span><span class="si">{</span><span class="n">config_path</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="n">config_path</span><span class="p">,</span> <span class="s2">&#34;r&#34;</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s2">&#34;utf-8&#34;</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="n">config</span> <span class="o">=</span> <span class="n">yaml</span><span class="o">.</span><span class="n">safe_load</span><span class="p">(</span><span class="n">f</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="n">_config_cache</span><span class="p">[</span><span class="n">filename</span><span class="p">]</span> <span class="o">=</span> <span class="n">config</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="k">return</span> <span class="n">config</span></span></span></code></pre></div><h4 id="步驟-3在-hook-中使用">步驟 3：在 Hook 中使用</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="kn">from</span> <span class="nn">lib.config_loader</span> <span class="kn">import</span> <span class="n">load_config</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">def</span> <span class="nf">check_branch</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="n">config</span> <span class="o">=</span> <span class="n">load_config</span><span class="p">(</span><span class="s2">&#34;branch_rules.yaml&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">if</span> <span class="n">current_branch</span> <span class="ow">in</span> <span class="n">config</span><span class="p">[</span><span class="s2">&#34;protected_branches&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;錯誤: </span><span class="si">{</span><span class="n">config</span><span class="p">[</span><span class="s1">&#39;error_messages&#39;</span><span class="p">][</span><span class="s1">&#39;branch_not_allowed&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">        <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="k">return</span> <span class="kc">True</span></span></span></code></pre></div><p>重構後結構：847 行的單一檔案拆成約 200 行純邏輯 + <code>config/</code> 目錄的 YAML 檔 + 共用的 <code>config_loader.py</code>。</p>
<h3 id="常見錯誤">常見錯誤</h3>
<p><strong>過度配置化</strong> &ndash; 把程式邏輯也放進配置檔：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl"><span class="c"># 錯誤：這是邏輯，不是資料</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="nt">process_steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">  </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;validate&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">    </span><span class="nt">function</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;validate_input&#34;</span></span></span></code></pre></div><p><strong>缺乏預設值</strong> &ndash; 沒有處理配置缺失：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">timeout</span> <span class="o">=</span> <span class="n">config</span><span class="p">[</span><span class="s2">&#34;timeout&#34;</span><span class="p">]</span>        <span class="c1"># KeyError!</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">timeout</span> <span class="o">=</span> <span class="n">config</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;timeout&#34;</span><span class="p">,</span> <span class="mi">30</span><span class="p">)</span>  <span class="c1"># 正確</span></span></span></code></pre></div><hr>
<h2 id="三訊息集中化-w23">三、訊息集中化 (W23)</h2>
<p>消除魔法數字和分離配置後，還有一種硬編碼藏在邏輯裡：使用者訊息字串。</p>
<p>W23 審計發現 19 個 Hook 中散落了 57+ 個硬編碼中文字串：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># hook_a.py</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="s2">&#34;錯誤：未找到待處理的 Ticket&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="s2">&#34;建議執行 /ticket create 建立新 Ticket&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># hook_b.py</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="s2">&#34;錯誤：未找到待處理的 Ticket&#34;</span><span class="p">)</span>  <span class="c1"># 同一訊息，略有不同</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="s2">&#34;請先建立 Ticket 再執行&#34;</span><span class="p">)</span></span></span></code></pre></div><p>同一個錯誤概念有 2-3 種不同措辭，修改一則訊息需要搜尋所有檔案。</p>
<h3 id="messages-類別模式">Messages 類別模式</h3>
<p>解決方案：建立 <code>hook_messages.py</code>，用類別分組管理所有訊息常數。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># lib/hook_messages.py</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">class</span> <span class="nc">CoreMessages</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="s2">&#34;&#34;&#34;Hook 執行通用訊息 - 所有 Hook 共用&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">HOOK_START</span> <span class="o">=</span> <span class="s2">&#34;</span><span class="si">{hook_name}</span><span class="s2"> 啟動&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">INPUT_EMPTY</span> <span class="o">=</span> <span class="s2">&#34;輸入為空，預設允許&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="n">JSON_PARSE_ERROR</span> <span class="o">=</span> <span class="s2">&#34;JSON 解析錯誤，預設允許: </span><span class="si">{error}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="k">class</span> <span class="nc">GateMessages</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="s2">&#34;&#34;&#34;Gate Hook 阻擋訊息 - 5 個 gate hooks 使用&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="n">TICKET_NOT_FOUND_ERROR</span> <span class="o">=</span> <span class="s2">&#34;&#34;&#34;錯誤：未找到待處理的 Ticket
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="s2">建議: 執行 /ticket create 建立新 Ticket&#34;&#34;&#34;</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="n">TICKET_NOT_CLAIMED_ERROR</span> <span class="o">=</span> <span class="s2">&#34;&#34;&#34;錯誤：Ticket </span><span class="si">{ticket_id}</span><span class="s2"> 尚未認領
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="s2">建議: 執行 /ticket track claim </span><span class="si">{ticket_id}</span><span class="s2"> 認領&#34;&#34;&#34;</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">class</span> <span class="nc">WorkflowMessages</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="s2">&#34;&#34;&#34;工作流指導訊息 - 5 個工作流 hooks 使用&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="n">PRE_FIX_EVAL_REQUIRED</span> <span class="o">=</span> <span class="s2">&#34;&#34;&#34;[強制] 修復前評估
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="s2">  1. 執行 /pre-fix-eval
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="s2">  2. 派發 incident-responder 分析&#34;&#34;&#34;</span></span></span></code></pre></div><p>最終產出 7 個 Messages 類別，管理約 45 個訊息常數。</p>
<h3 id="使用方式">使用方式</h3>
<p>Hook 中引用常數，使用 <code>.format()</code> 填入動態值：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="kn">from</span> <span class="nn">lib.hook_messages</span> <span class="kn">import</span> <span class="n">GateMessages</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="k">def</span> <span class="nf">validate_ticket</span><span class="p">(</span><span class="n">ticket_id</span><span class="p">:</span> <span class="nb">str</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">is_claimed</span><span class="p">(</span><span class="n">ticket_id</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="n">GateMessages</span><span class="o">.</span><span class="n">TICKET_NOT_CLAIMED_ERROR</span><span class="o">.</span><span class="n">format</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">            <span class="n">ticket_id</span><span class="o">=</span><span class="n">ticket_id</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">        <span class="p">))</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">        <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl">    <span class="k">return</span> <span class="kc">True</span></span></span></code></pre></div><h3 id="組織原則">組織原則</h3>
<table>
  <thead>
      <tr>
          <th>分類依據</th>
          <th>類別名稱</th>
          <th>涵蓋範圍</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>核心通用</td>
          <td><code>CoreMessages</code></td>
          <td>所有 Hook 共用的啟動、錯誤訊息</td>
      </tr>
      <tr>
          <td>阻擋訊息</td>
          <td><code>GateMessages</code></td>
          <td>5 個 Gate Hook 的阻止原因和建議</td>
      </tr>
      <tr>
          <td>工作流指導</td>
          <td><code>WorkflowMessages</code></td>
          <td>5 個工作流 Hook 的流程提示</td>
      </tr>
      <tr>
          <td>品質檢查</td>
          <td><code>QualityMessages</code></td>
          <td>5 個品質 Hook 的檢查結果</td>
      </tr>
      <tr>
          <td>驗證相關</td>
          <td><code>ValidationMessages</code></td>
          <td>驗證 Hook 的成功/失敗訊息</td>
      </tr>
  </tbody>
</table>
<p>分類原則：<strong>按使用者角色和觸發情境分組，而不是按技術功能。</strong></p>
<h3 id="命名規範">命名規範</h3>
<table>
  <thead>
      <tr>
          <th>常數類型</th>
          <th>命名規則</th>
          <th>範例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>訊息常數</td>
          <td>大寫蛇形</td>
          <td><code>TICKET_NOT_FOUND_ERROR</code></td>
      </tr>
      <tr>
          <td>Messages 類別</td>
          <td>PascalCase + Messages</td>
          <td><code>GateMessages</code></td>
      </tr>
      <tr>
          <td>格式化佔位符</td>
          <td><code>{variable_name}</code></td>
          <td><code>&quot;Ticket {ticket_id} 尚未認領&quot;</code></td>
      </tr>
  </tbody>
</table>
<h3 id="w23-實際數據">W23 實際數據</h3>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>重構前</th>
          <th>重構後</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>硬編碼訊息位置</td>
          <td>散落 19 個檔案</td>
          <td>集中 1 個檔案</td>
      </tr>
      <tr>
          <td>訊息總數</td>
          <td>57+ 個（含重複）</td>
          <td>45 個（去重後）</td>
      </tr>
      <tr>
          <td>修改訊息需搜尋</td>
          <td>所有 Hook 檔案</td>
          <td>只需 hook_messages.py</td>
      </tr>
      <tr>
          <td>訊息一致性</td>
          <td>同概念 2-3 種措辭</td>
          <td>每個概念一個定義</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="決策框架">決策框架</h2>
<p>遇到硬編碼時，用這張表判斷該怎麼處理：</p>
<table>
  <thead>
      <tr>
          <th>硬編碼類型</th>
          <th>識別特徵</th>
          <th>處理方式</th>
          <th>存放位置</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>魔法數字</td>
          <td>裸露的數字或字串切片</td>
          <td>具名常數、<code>len()</code>、<code>removeprefix()</code></td>
          <td>同檔案頂部或常數模組</td>
      </tr>
      <tr>
          <td>配置資料</td>
          <td>清單、規則表、業務參數</td>
          <td>抽離到 YAML 配置檔</td>
          <td><code>config/</code> 目錄</td>
      </tr>
      <tr>
          <td>使用者訊息</td>
          <td>字串直接嵌入邏輯</td>
          <td>提取到 Messages 類別</td>
          <td><code>lib/*_messages.py</code></td>
      </tr>
      <tr>
          <td>程式邏輯常數</td>
          <td>與邏輯緊密耦合的值</td>
          <td>具名常數，保留在程式碼</td>
          <td>檔案頂部</td>
      </tr>
  </tbody>
</table>
<h3 id="決策流程">決策流程</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">發現硬編碼
</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">    v
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">會隨環境改變？ ─是→ YAML 配置檔
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    |
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    否
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    v
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">是使用者看到的文字？ ─是→ Messages 類別
</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></span><span class="line"><span class="ln">11</span><span class="cl">    v
</span></span><span class="line"><span class="ln">12</span><span class="cl">是無法理解的數字？ ─是→ 具名常數 / len() / removeprefix()
</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></span><span class="line"><span class="ln">15</span><span class="cl">    v
</span></span><span class="line"><span class="ln">16</span><span class="cl">保留原樣（程式邏輯的一部分）</span></span></code></pre></div><hr>
<h2 id="完整重構範例">完整重構範例</h2>
<h3 id="重構前">重構前</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">def</span> <span class="nf">validate_branch</span><span class="p">(</span><span class="n">branch</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">branch</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mi">50</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="k">if</span> <span class="n">branch</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;refs/heads/&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="n">branch</span> <span class="o">=</span> <span class="n">branch</span><span class="p">[</span><span class="mi">11</span><span class="p">:]</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">3</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="n">check_remote</span><span class="p">(</span><span class="n">branch</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="kc">True</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="n">time</span><span class="o">.</span><span class="n">sleep</span><span class="p">(</span><span class="mi">2</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="kc">False</span></span></span></code></pre></div><h3 id="重構後">重構後</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">MAX_BRANCH_LENGTH</span> <span class="o">=</span> <span class="mi">50</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">REFS_HEADS_PREFIX</span> <span class="o">=</span> <span class="s2">&#34;refs/heads/&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">MAX_RETRIES</span> <span class="o">=</span> <span class="mi">3</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">RETRY_DELAY_SECONDS</span> <span class="o">=</span> <span class="mi">2</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="k">def</span> <span class="nf">validate_branch</span><span class="p">(</span><span class="n">branch</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="s2">&#34;&#34;&#34;驗證分支名稱。&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">branch</span><span class="p">)</span> <span class="o">&gt;</span> <span class="n">MAX_BRANCH_LENGTH</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="kc">False</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="n">branch</span> <span class="o">=</span> <span class="n">branch</span><span class="o">.</span><span class="n">removeprefix</span><span class="p">(</span><span class="n">REFS_HEADS_PREFIX</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">for</span> <span class="n">attempt</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">MAX_RETRIES</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">if</span> <span class="n">check_remote</span><span class="p">(</span><span class="n">branch</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="k">return</span> <span class="kc">True</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="n">time</span><span class="o">.</span><span class="n">sleep</span><span class="p">(</span><span class="n">RETRY_DELAY_SECONDS</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="kc">False</span></span></span></code></pre></div><p>四個魔法數字全部消除，每個值的含義一目了然。</p>
<hr>
<h2 id="檢測方法">檢測方法</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 找出數字切片（潛在魔法數字）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">grep -rn <span class="s2">&#34;\[[0-9]*:\]&#34;</span> hooks/*.py
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 找出 sleep 和 range 中的硬編碼</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">grep -rn <span class="s2">&#34;sleep([0-9]&#34;</span> hooks/*.py
</span></span><span class="line"><span class="ln">6</span><span class="cl">grep -rn <span class="s2">&#34;range([0-9]&#34;</span> hooks/*.py
</span></span><span class="line"><span class="ln">7</span><span class="cl">
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"># 找出硬編碼中文字串（潛在散落訊息）</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl">grep -rn <span class="s1">&#39;[一-龥]&#39;</span> hooks/*.py</span></span></code></pre></div><hr>
<h2 id="實作練習">實作練習</h2>
<p>找出以下程式碼中的三種硬編碼問題，並提出修正方案：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">def</span> <span class="nf">process_hook_result</span><span class="p">(</span><span class="n">result_line</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">if</span> <span class="n">result_line</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;status: &#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="n">status</span> <span class="o">=</span> <span class="n">result_line</span><span class="p">[</span><span class="mi">8</span><span class="p">:]</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="n">status</span> <span class="o">=</span> <span class="s2">&#34;unknown&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">status</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mi">100</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="s2">&#34;狀態文字過長，已截斷&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="n">status</span> <span class="o">=</span> <span class="n">status</span><span class="p">[:</span><span class="mi">97</span><span class="p">]</span> <span class="o">+</span> <span class="s2">&#34;...&#34;</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="n">VALID_STATUSES</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;pass&#34;</span><span class="p">,</span> <span class="s2">&#34;fail&#34;</span><span class="p">,</span> <span class="s2">&#34;skip&#34;</span><span class="p">,</span> <span class="s2">&#34;error&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">if</span> <span class="n">status</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">VALID_STATUSES</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="s2">&#34;無效的狀態值: &#34;</span> <span class="o">+</span> <span class="n">status</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="kc">None</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">return</span> <span class="n">status</span></span></span></code></pre></div><details>
<summary>參考答案</summary>
<p>三種硬編碼問題：</p>
<ol>
<li><strong>魔法數字</strong>：<code>result_line[8:]</code>、<code>100</code>、<code>97</code></li>
<li><strong>配置資料</strong>：<code>VALID_STATUSES</code> 清單應該可配置</li>
<li><strong>散落訊息</strong>：<code>&quot;狀態文字過長，已截斷&quot;</code>、<code>&quot;無效的狀態值: &quot;</code></li>
</ol>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">from</span> <span class="nn">lib.config_loader</span> <span class="kn">import</span> <span class="n">load_config</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">STATUS_PREFIX</span> <span class="o">=</span> <span class="s2">&#34;status: &#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">MAX_STATUS_LENGTH</span> <span class="o">=</span> <span class="mi">100</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">ELLIPSIS</span> <span class="o">=</span> <span class="s2">&#34;...&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">class</span> <span class="nc">HookResultMessages</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="n">STATUS_TRUNCATED</span> <span class="o">=</span> <span class="s2">&#34;狀態文字過長，已截斷&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="n">INVALID_STATUS</span> <span class="o">=</span> <span class="s2">&#34;無效的狀態值: </span><span class="si">{status}</span><span class="s2">&#34;</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">def</span> <span class="nf">process_hook_result</span><span class="p">(</span><span class="n">result_line</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="n">status</span> <span class="o">=</span> <span class="n">result_line</span><span class="o">.</span><span class="n">removeprefix</span><span class="p">(</span><span class="n">STATUS_PREFIX</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="n">status</span> <span class="o">==</span> <span class="n">result_line</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="n">status</span> <span class="o">=</span> <span class="s2">&#34;unknown&#34;</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="nb">len</span><span class="p">(</span><span class="n">status</span><span class="p">)</span> <span class="o">&gt;</span> <span class="n">MAX_STATUS_LENGTH</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="n">HookResultMessages</span><span class="o">.</span><span class="n">STATUS_TRUNCATED</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="n">truncate_at</span> <span class="o">=</span> <span class="n">MAX_STATUS_LENGTH</span> <span class="o">-</span> <span class="nb">len</span><span class="p">(</span><span class="n">ELLIPSIS</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="n">status</span> <span class="o">=</span> <span class="n">status</span><span class="p">[:</span><span class="n">truncate_at</span><span class="p">]</span> <span class="o">+</span> <span class="n">ELLIPSIS</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="n">config</span> <span class="o">=</span> <span class="n">load_config</span><span class="p">(</span><span class="s2">&#34;hook_rules.yaml&#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="n">status</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">config</span><span class="p">[</span><span class="s2">&#34;valid_statuses&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="n">HookResultMessages</span><span class="o">.</span><span class="n">INVALID_STATUS</span><span class="o">.</span><span class="n">format</span><span class="p">(</span><span class="n">status</span><span class="o">=</span><span class="n">status</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">        <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="k">return</span> <span class="n">status</span></span></span></code></pre></div></details>
<hr>
<h2 id="小結">小結</h2>
<ul>
<li>硬編碼問題有三種形態：魔法數字、配置混合、散落訊息</li>
<li>魔法數字用 <code>len()</code>、<code>removeprefix()</code>、<code>IntEnum</code> 消除</li>
<li>配置資料用 YAML 檔案集中管理，透過 <code>config_loader</code> 載入</li>
<li>使用者訊息用 Messages 類別集中化，按角色和情境分組</li>
<li>決策關鍵：<strong>會隨環境改變 → 配置檔；是使用者文字 → Messages；是裸露數字 → 常數</strong></li>
</ul>
<p>下一章：<a href="/blog/python/07-refactoring/unified-infrastructure/" data-link-title="大規模統一化重構" data-link-desc="從 44 種不同實作到統一基礎設施：日誌、訊息、風格的三階段漸進式重構">大規模統一化重構</a></p>
<hr>
<p><em>文件版本：v0.31.1</em>
<em>建立日期：2026-03-04</em></p>
]]></content:encoded></item><item><title>大規模統一化重構</title><link>https://tarrragon.github.io/blog/python/07-refactoring/unified-infrastructure/</link><pubDate>Wed, 04 Mar 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/python/07-refactoring/unified-infrastructure/</guid><description>&lt;p>前面幾章的重構案例都在解決局部問題：提取常數、分離配置、消除重複。本章探討一個更大的挑戰：當系統中有 &lt;strong>44 個獨立腳本&lt;/strong>，各自發展出不同的基礎設施實作時，如何系統性地統一它們？&lt;/p>
&lt;p>這是 W22-W24 開發週期中實際執行的三階段統一化重構。每個階段解決一個維度的分歧，最終讓所有 Hook 共享同一套基礎設施。&lt;/p>
&lt;h2 id="問題全貌">問題全貌&lt;/h2>
&lt;h3 id="44-個-hookn-種實作">44 個 Hook，N 種實作&lt;/h3>
&lt;p>Hook 系統經過數個版本的有機成長，累積了大量不一致：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># Hook A：用 common_functions 的 setup_hook_logging&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">lib.common_functions&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">setup_hook_logging&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="n">logger&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">setup_hook_logging&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;hook-a&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="c1"># Hook B：用 hook_logging 的 setup_hook_logging（不同模組，同名函式）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">lib.hook_logging&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">setup_hook_logging&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="n">logger&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">setup_hook_logging&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;hook-b&amp;#34;&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="c1"># Hook C：直接用 logging 模組&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">logging&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="n">logging&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">basicConfig&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">level&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">logging&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">INFO&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="n">logger&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">logging&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">getLogger&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="vm">__name__&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>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="c1"># Hook D：print 大法&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">def&lt;/span> &lt;span class="nf">log&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">msg&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="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;[hook-d] &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">msg&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>不只日誌，訊息和錯誤處理也是如此：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>分歧數量&lt;/th>
 &lt;th>常見變體&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>日誌初始化&lt;/td>
 &lt;td>3 種&lt;/td>
 &lt;td>common_functions / hook_logging / 直接 logging&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>錯誤處理&lt;/td>
 &lt;td>3 種&lt;/td>
 &lt;td>try-except 包 main / 不處理 / 自訂裝飾器&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>使用者訊息&lt;/td>
 &lt;td>19 個檔案各自定義&lt;/td>
 &lt;td>每個 Hook 硬編碼自己的字串（共 57+ 個）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>logger 作用域&lt;/td>
 &lt;td>2 種&lt;/td>
 &lt;td>模組級全域 / main() 內區域&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="為什麼要統一">為什麼要統一&lt;/h3>
&lt;p>分歧帶來的實際問題：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>修改成本倍增&lt;/strong>：改一個日誌格式，要改 44 個檔案&lt;/li>
&lt;li>&lt;strong>行為不一致&lt;/strong>：有的 Hook 失敗時靜默，有的會 crash&lt;/li>
&lt;li>&lt;strong>難以排查問題&lt;/strong>：每個 Hook 的日誌格式不同，無法統一搜尋&lt;/li>
&lt;li>&lt;strong>新 Hook 沒有範本&lt;/strong>：寫新 Hook 時不知道該參考哪個&lt;/li>
&lt;/ol>
&lt;h2 id="統一化模式">統一化模式&lt;/h2>
&lt;p>三階段統一化遵循一個共同的模式：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">1. 建立統一介面 → 寫一個所有人都要用的模組
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">2. 漸進式遷移 → 逐批將現有 Hook 切換到新介面
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">3. 驗證 → 確認行為一致
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">4. 處理例外 → 處理少數無法直接遷移的情況&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個模式的關鍵在於&lt;strong>不一次改完&lt;/strong>。每個階段只統一一個維度，確認穩定後再進入下一個。&lt;/p>
&lt;h2 id="第一階段統一日誌w22">第一階段：統一日誌（W22）&lt;/h2>
&lt;h3 id="設計統一介面">設計統一介面&lt;/h3>
&lt;p>目標是用一個模組取代三套日誌實作。核心 API 只有兩個函式：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># hook_utils.py — 統一日誌模組&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="k">def&lt;/span> &lt;span class="nf">setup_hook_logging&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">hook_name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="n">logging&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Logger&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="s2">&amp;#34;&amp;#34;&amp;#34;建立並設定 Hook 日誌系統
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="s2">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="s2"> - 建立日誌目錄 .claude/hook-logs/&lt;/span>&lt;span class="si">{hook_name}&lt;/span>&lt;span class="s2">/
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="s2"> - 建立帶時間戳的日誌檔案
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="s2"> - 配置 FileHandler + StreamHandler
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="s2"> &amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">run_hook_safely&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">main_func&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Callable&lt;/span>&lt;span class="p">[[],&lt;/span> &lt;span class="nb">int&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">hook_name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">int&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="s2">&amp;#34;&amp;#34;&amp;#34;安全執行 Hook 函式，頂層例外處理
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="s2">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="s2"> - 呼叫 setup_hook_logging 取得 logger
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="s2"> - 執行 main_func，捕獲所有 Exception
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="s2"> - 異常時記錄完整 traceback，返回 1
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="s2"> &amp;#34;&amp;#34;&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>setup_hook_logging&lt;/code> 封裝了所有日誌配置細節：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">setup_hook_logging&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">hook_name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="n">logging&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Logger&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="n">sanitized_name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">_sanitize_hook_name&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">hook_name&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="n">root_dir&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">_find_project_root&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="n">log_base_dir&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">root_dir&lt;/span> &lt;span class="o">/&lt;/span> &lt;span class="s2">&amp;#34;.claude&amp;#34;&lt;/span> &lt;span class="o">/&lt;/span> &lt;span class="s2">&amp;#34;hook-logs&amp;#34;&lt;/span> &lt;span class="o">/&lt;/span> &lt;span class="n">sanitized_name&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="k">try&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="n">log_base_dir&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">mkdir&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">parents&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">exist_ok&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&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">except&lt;/span> &lt;span class="ne">OSError&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">_create_fallback_logger&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">hook_name&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="n">logger&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">logging&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">getLogger&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">hook_name&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="n">_clear_logger_handlers&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">logger&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="n">logger&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">setLevel&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">logging&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">DEBUG&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>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="n">is_debug&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">os&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">getenv&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;HOOK_DEBUG&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">lower&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;true&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="n">_setup_logger_handlers&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">logger&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">log_base_dir&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">sanitized_name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">is_debug&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>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">logger&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>幾個設計決策值得注意：&lt;/p></description><content:encoded><![CDATA[<p>前面幾章的重構案例都在解決局部問題：提取常數、分離配置、消除重複。本章探討一個更大的挑戰：當系統中有 <strong>44 個獨立腳本</strong>，各自發展出不同的基礎設施實作時，如何系統性地統一它們？</p>
<p>這是 W22-W24 開發週期中實際執行的三階段統一化重構。每個階段解決一個維度的分歧，最終讓所有 Hook 共享同一套基礎設施。</p>
<h2 id="問題全貌">問題全貌</h2>
<h3 id="44-個-hookn-種實作">44 個 Hook，N 種實作</h3>
<p>Hook 系統經過數個版本的有機成長，累積了大量不一致：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># Hook A：用 common_functions 的 setup_hook_logging</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">from</span> <span class="nn">lib.common_functions</span> <span class="kn">import</span> <span class="n">setup_hook_logging</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">logger</span> <span class="o">=</span> <span class="n">setup_hook_logging</span><span class="p">(</span><span class="s2">&#34;hook-a&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"># Hook B：用 hook_logging 的 setup_hook_logging（不同模組，同名函式）</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kn">from</span> <span class="nn">lib.hook_logging</span> <span class="kn">import</span> <span class="n">setup_hook_logging</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">logger</span> <span class="o">=</span> <span class="n">setup_hook_logging</span><span class="p">(</span><span class="s2">&#34;hook-b&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"># Hook C：直接用 logging 模組</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="kn">import</span> <span class="nn">logging</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">logging</span><span class="o">.</span><span class="n">basicConfig</span><span class="p">(</span><span class="n">level</span><span class="o">=</span><span class="n">logging</span><span class="o">.</span><span class="n">INFO</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">logger</span> <span class="o">=</span> <span class="n">logging</span><span class="o">.</span><span class="n">getLogger</span><span class="p">(</span><span class="vm">__name__</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"># Hook D：print 大法</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="k">def</span> <span class="nf">log</span><span class="p">(</span><span class="n">msg</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;[hook-d] </span><span class="si">{</span><span class="n">msg</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span></span></span></code></pre></div><p>不只日誌，訊息和錯誤處理也是如此：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>分歧數量</th>
          <th>常見變體</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>日誌初始化</td>
          <td>3 種</td>
          <td>common_functions / hook_logging / 直接 logging</td>
      </tr>
      <tr>
          <td>錯誤處理</td>
          <td>3 種</td>
          <td>try-except 包 main / 不處理 / 自訂裝飾器</td>
      </tr>
      <tr>
          <td>使用者訊息</td>
          <td>19 個檔案各自定義</td>
          <td>每個 Hook 硬編碼自己的字串（共 57+ 個）</td>
      </tr>
      <tr>
          <td>logger 作用域</td>
          <td>2 種</td>
          <td>模組級全域 / main() 內區域</td>
      </tr>
  </tbody>
</table>
<h3 id="為什麼要統一">為什麼要統一</h3>
<p>分歧帶來的實際問題：</p>
<ol>
<li><strong>修改成本倍增</strong>：改一個日誌格式，要改 44 個檔案</li>
<li><strong>行為不一致</strong>：有的 Hook 失敗時靜默，有的會 crash</li>
<li><strong>難以排查問題</strong>：每個 Hook 的日誌格式不同，無法統一搜尋</li>
<li><strong>新 Hook 沒有範本</strong>：寫新 Hook 時不知道該參考哪個</li>
</ol>
<h2 id="統一化模式">統一化模式</h2>
<p>三階段統一化遵循一個共同的模式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">1. 建立統一介面    → 寫一個所有人都要用的模組
</span></span><span class="line"><span class="ln">2</span><span class="cl">2. 漸進式遷移      → 逐批將現有 Hook 切換到新介面
</span></span><span class="line"><span class="ln">3</span><span class="cl">3. 驗證           → 確認行為一致
</span></span><span class="line"><span class="ln">4</span><span class="cl">4. 處理例外       → 處理少數無法直接遷移的情況</span></span></code></pre></div><p>這個模式的關鍵在於<strong>不一次改完</strong>。每個階段只統一一個維度，確認穩定後再進入下一個。</p>
<h2 id="第一階段統一日誌w22">第一階段：統一日誌（W22）</h2>
<h3 id="設計統一介面">設計統一介面</h3>
<p>目標是用一個模組取代三套日誌實作。核心 API 只有兩個函式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># hook_utils.py — 統一日誌模組</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="k">def</span> <span class="nf">setup_hook_logging</span><span class="p">(</span><span class="n">hook_name</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">logging</span><span class="o">.</span><span class="n">Logger</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="s2">&#34;&#34;&#34;建立並設定 Hook 日誌系統
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s2">    - 建立日誌目錄 .claude/hook-logs/</span><span class="si">{hook_name}</span><span class="s2">/
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s2">    - 建立帶時間戳的日誌檔案
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s2">    - 配置 FileHandler + StreamHandler
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s2">    &#34;&#34;&#34;</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">def</span> <span class="nf">run_hook_safely</span><span class="p">(</span><span class="n">main_func</span><span class="p">:</span> <span class="n">Callable</span><span class="p">[[],</span> <span class="nb">int</span><span class="p">],</span> <span class="n">hook_name</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">int</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="s2">&#34;&#34;&#34;安全執行 Hook 函式，頂層例外處理
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="s2">    - 呼叫 setup_hook_logging 取得 logger
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="s2">    - 執行 main_func，捕獲所有 Exception
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="s2">    - 異常時記錄完整 traceback，返回 1
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="s2">    &#34;&#34;&#34;</span></span></span></code></pre></div><p><code>setup_hook_logging</code> 封裝了所有日誌配置細節：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">def</span> <span class="nf">setup_hook_logging</span><span class="p">(</span><span class="n">hook_name</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">logging</span><span class="o">.</span><span class="n">Logger</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="n">sanitized_name</span> <span class="o">=</span> <span class="n">_sanitize_hook_name</span><span class="p">(</span><span class="n">hook_name</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="n">root_dir</span> <span class="o">=</span> <span class="n">_find_project_root</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">log_base_dir</span> <span class="o">=</span> <span class="n">root_dir</span> <span class="o">/</span> <span class="s2">&#34;.claude&#34;</span> <span class="o">/</span> <span class="s2">&#34;hook-logs&#34;</span> <span class="o">/</span> <span class="n">sanitized_name</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="n">log_base_dir</span><span class="o">.</span><span class="n">mkdir</span><span class="p">(</span><span class="n">parents</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">exist_ok</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">except</span> <span class="ne">OSError</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="n">_create_fallback_logger</span><span class="p">(</span><span class="n">hook_name</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="n">logger</span> <span class="o">=</span> <span class="n">logging</span><span class="o">.</span><span class="n">getLogger</span><span class="p">(</span><span class="n">hook_name</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="n">_clear_logger_handlers</span><span class="p">(</span><span class="n">logger</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="n">logger</span><span class="o">.</span><span class="n">setLevel</span><span class="p">(</span><span class="n">logging</span><span class="o">.</span><span class="n">DEBUG</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="n">is_debug</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;HOOK_DEBUG&#34;</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">lower</span><span class="p">()</span> <span class="o">==</span> <span class="s2">&#34;true&#34;</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="n">_setup_logger_handlers</span><span class="p">(</span><span class="n">logger</span><span class="p">,</span> <span class="n">log_base_dir</span><span class="p">,</span> <span class="n">sanitized_name</span><span class="p">,</span> <span class="n">is_debug</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">return</span> <span class="n">logger</span></span></span></code></pre></div><p>幾個設計決策值得注意：</p>
<table>
  <thead>
      <tr>
          <th>決策</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>_sanitize_hook_name</code></td>
          <td>Hook 名稱可能包含 <code>/</code> 等特殊字元，不能直接用作目錄名</td>
      </tr>
      <tr>
          <td><code>_clear_logger_handlers</code></td>
          <td>避免重複呼叫時 handler 累加</td>
      </tr>
      <tr>
          <td>Fallback logger</td>
          <td>目錄建立失敗時仍可輸出到 stdout，不會 crash</td>
      </tr>
      <tr>
          <td><code>HOOK_DEBUG</code> 環境變數</td>
          <td>開發時可開啟 DEBUG 級別的 stream 輸出</td>
      </tr>
  </tbody>
</table>
<h3 id="run_hook_safely一行搞定錯誤處理"><code>run_hook_safely</code>：一行搞定錯誤處理</h3>
<p>這是統一化的核心武器。原本每個 Hook 自己寫 try-except：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 重構前：每個 Hook 自己處理</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">if</span> <span class="vm">__name__</span> <span class="o">==</span> <span class="s2">&#34;__main__&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="n">result</span> <span class="o">=</span> <span class="n">main</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="n">sys</span><span class="o">.</span><span class="n">exit</span><span class="p">(</span><span class="n">result</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="k">except</span> <span class="ne">Exception</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">        <span class="c1"># 有的寫日誌，有的 print，有的什麼都不做</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;Error: </span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl">        <span class="n">sys</span><span class="o">.</span><span class="n">exit</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span></span></span></code></pre></div><p>統一後：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 重構後：一行搞定</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">if</span> <span class="vm">__name__</span> <span class="o">==</span> <span class="s2">&#34;__main__&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="n">sys</span><span class="o">.</span><span class="n">exit</span><span class="p">(</span><span class="n">run_hook_safely</span><span class="p">(</span><span class="n">main</span><span class="p">,</span> <span class="s2">&#34;acceptance-gate&#34;</span><span class="p">))</span></span></span></code></pre></div><p><code>run_hook_safely</code> 內部處理三個邊界：</p>
<ul>
<li><strong>返回值驗證</strong>：<code>main()</code> 可能回傳 <code>None</code> 或布林值，<code>run_hook_safely</code> 會將非整數返回值轉換為 <code>0</code>（成功）或 <code>1</code>（失敗），確保 <code>sys.exit</code> 收到合法的退出碼</li>
<li><strong>不攔截 <code>SystemExit</code></strong>：刻意的 <code>sys.exit()</code> 呼叫不該被吃掉</li>
<li><strong>不攔截 <code>KeyboardInterrupt</code></strong>：Ctrl+C 中斷不該被捕獲</li>
</ul>
<p>所有其他 <code>Exception</code> 子類別都被捕獲、記錄到日誌、返回錯誤碼 1。</p>
<h3 id="遷移策略">遷移策略</h3>
<p>不可能一次改完 44 個檔案。按風險分批：</p>
<table>
  <thead>
      <tr>
          <th>批次</th>
          <th>範圍</th>
          <th>策略</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>第 1 批</td>
          <td>5 個低風險 Hook</td>
          <td>驗證新模組行為正確</td>
      </tr>
      <tr>
          <td>第 2 批</td>
          <td>15 個中等複雜度</td>
          <td>建立遷移信心</td>
      </tr>
      <tr>
          <td>第 3 批</td>
          <td>剩餘所有 Hook</td>
          <td>批量遷移</td>
      </tr>
  </tbody>
</table>
<p>每批遷移後執行全量測試，確認無迴歸。</p>
<h2 id="第二階段統一訊息w23">第二階段：統一訊息（W23）</h2>
<h3 id="問題硬編碼訊息散落各處">問題：硬編碼訊息散落各處</h3>
<p>日誌統一後，下一個問題浮現：每個 Hook 的使用者訊息各自定義。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># command-entrance-gate-hook.py</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="s2">&#34;錯誤：未找到待處理的 Ticket</span><span class="se">\n</span><span class="s2">建議操作: 執行 /ticket create&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># acceptance-gate-hook.py</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="s2">&#34;[ERROR] 子任務未全部完成</span><span class="se">\n</span><span class="s2">Ticket: </span><span class="si">{}</span><span class="se">\n</span><span class="s2">請先完成所有子任務&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># main-thread-edit-restriction-hook.py</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="s2">&#34;編輯操作受限&#34;</span><span class="p">)</span></span></span></code></pre></div><p>同樣的問題：改一個訊息格式要翻遍所有 Hook。訊息重複時會出現不一致。</p>
<h3 id="集中管理hook_messagespy">集中管理：hook_messages.py</h3>
<p>建立一個訊息常數模組，按職責分類：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># lib/hook_messages.py</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="k">class</span> <span class="nc">CoreMessages</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="s2">&#34;&#34;&#34;所有 Hook 共用的通用訊息&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">HOOK_START</span> <span class="o">=</span> <span class="s2">&#34;</span><span class="si">{hook_name}</span><span class="s2"> 啟動&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="n">INPUT_EMPTY</span> <span class="o">=</span> <span class="s2">&#34;輸入為空，預設允許&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="n">JSON_PARSE_ERROR</span> <span class="o">=</span> <span class="s2">&#34;JSON 解析錯誤，預設允許: </span><span class="si">{error}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="k">class</span> <span class="nc">GateMessages</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="s2">&#34;&#34;&#34;5 個 Gate Hook 的阻擋/警告訊息&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="n">TICKET_NOT_FOUND_ERROR</span> <span class="o">=</span> <span class="s2">&#34;&#34;&#34;錯誤：未找到待處理的 Ticket
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="s2">    為什麼阻止執行：
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="s2">      開發命令必須有對應的 Ticket，確保工作可追蹤和驗收。
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="s2">    建議操作:
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="s2">      1. 執行 /ticket create 建立新 Ticket
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="s2">      2. 或執行 /ticket track claim </span><span class="si">{id}</span><span class="s2"> 認領現有 Ticket&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="k">class</span> <span class="nc">WorkflowMessages</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="s2">&#34;&#34;&#34;工作流指導 Hook 的訊息&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="n">EXTERNAL_QUERY_DETECTED</span> <span class="o">=</span> <span class="s2">&#34;檢測到 </span><span class="si">{tool_name}</span><span class="s2"> 調用&#34;</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">class</span> <span class="nc">QualityMessages</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="s2">&#34;&#34;&#34;品質檢查 Hook 的訊息&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="c1"># ...</span></span></span></code></pre></div><h3 id="分類原則">分類原則</h3>
<table>
  <thead>
      <tr>
          <th>類別</th>
          <th>包含的 Hook</th>
          <th>訊息特徵</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CoreMessages</td>
          <td>所有 Hook</td>
          <td>啟動、錯誤、預設行為</td>
      </tr>
      <tr>
          <td>GateMessages</td>
          <td>5 個 Gate Hook</td>
          <td>阻擋、警告、建議操作</td>
      </tr>
      <tr>
          <td>WorkflowMessages</td>
          <td>5 個工作流 Hook</td>
          <td>流程指導、步驟說明</td>
      </tr>
      <tr>
          <td>QualityMessages</td>
          <td>品質檢查 Hook</td>
          <td>掃描結果、改善建議</td>
      </tr>
      <tr>
          <td>ValidationMessages</td>
          <td>驗證 Hook</td>
          <td>格式檢查、合規結果</td>
      </tr>
  </tbody>
</table>
<p>使用方式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 重構前</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="s2">&#34;錯誤：未找到待處理的 Ticket</span><span class="se">\n</span><span class="s2">建議操作: ...&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 重構後</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="kn">from</span> <span class="nn">lib.hook_messages</span> <span class="kn">import</span> <span class="n">GateMessages</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">GateMessages</span><span class="o">.</span><span class="n">TICKET_NOT_FOUND_ERROR</span><span class="p">)</span></span></span></code></pre></div><p>參數化的訊息用 <code>format()</code> ：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 帶參數的訊息</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">GateMessages</span><span class="o">.</span><span class="n">TICKET_NOT_CLAIMED_ERROR</span><span class="o">.</span><span class="n">format</span><span class="p">(</span><span class="n">ticket_id</span><span class="o">=</span><span class="s2">&#34;0.31.0-W2-001&#34;</span><span class="p">))</span></span></span></code></pre></div><h3 id="效果">效果</h3>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>重構前</th>
          <th>重構後</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>訊息定義位置</td>
          <td>散落 19 個檔案（57+ 個硬編碼字串）</td>
          <td>集中 1 個模組（45 個常數）</td>
      </tr>
      <tr>
          <td>修改訊息格式</td>
          <td>逐檔搜尋修改</td>
          <td>改一處生效</td>
      </tr>
      <tr>
          <td>訊息一致性</td>
          <td>同概念 2-3 種措辭</td>
          <td>每個概念一個定義</td>
      </tr>
      <tr>
          <td>新 Hook 訊息</td>
          <td>自行發明</td>
          <td>複用現有類別</td>
      </tr>
  </tbody>
</table>
<h2 id="第三階段統一風格w24">第三階段：統一風格（W24）</h2>
<h3 id="問題logger-初始化位置不一致">問題：logger 初始化位置不一致</h3>
<p>日誌模組和訊息常數統一後，16 個 Hook 的 logger 初始化位置仍然不一致：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 風格 A：模組級初始化（13 個 Hook）</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">logger</span> <span class="o">=</span> <span class="n">setup_hook_logging</span><span class="p">(</span><span class="s2">&#34;my-hook&#34;</span><span class="p">)</span>  <span class="c1"># 最外層</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="k">def</span> <span class="nf">helper</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="s2">&#34;working...&#34;</span><span class="p">)</span>           <span class="c1"># 引用全域 logger</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="n">helper</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="mi">0</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># 風格 B：main() 內初始化（3 個 Hook）</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="k">def</span> <span class="nf">helper</span><span class="p">(</span><span class="n">logger</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="n">logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="s2">&#34;working...&#34;</span><span class="p">)</span>           <span class="c1"># 接收 logger 參數</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">def</span> <span class="nf">main</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="n">logger</span> <span class="o">=</span> <span class="n">setup_hook_logging</span><span class="p">(</span><span class="s2">&#34;my-hook&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="n">helper</span><span class="p">(</span><span class="n">logger</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">return</span> <span class="mi">0</span></span></span></code></pre></div><p>目標是統一為風格 B。理由是：模組級初始化的 <code>logger</code> 會在 <code>import</code> 時立即建立日誌目錄和檔案，即使這個模組只是被其他工具引用而不是作為 Hook 執行。將 <code>logger</code> 移入 <code>main()</code> 可以確保只有<strong>真正執行</strong>時才初始化日誌系統。</p>
<h3 id="事故7-個-hook-靜默失敗">事故：7 個 Hook 靜默失敗</h3>
<p>統一風格的過程中發生了一個典型的作用域迴歸 bug。把 <code>logger</code> 從模組級移到 <code>main()</code> 內部後，引用全域 <code>logger</code> 的 helper 函式觸發了 <code>NameError</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 修改後（有 bug）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">def</span> <span class="nf">check_acceptance_criteria</span><span class="p">(</span><span class="n">ticket_path</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="n">logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;Checking </span><span class="si">{</span><span class="n">ticket_path</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>  <span class="c1"># NameError!</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">def</span> <span class="nf">main</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="n">logger</span> <span class="o">=</span> <span class="n">setup_hook_logging</span><span class="p">(</span><span class="s2">&#34;acceptance-gate-hook&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="n">result</span> <span class="o">=</span> <span class="n">check_acceptance_criteria</span><span class="p">(</span><span class="n">path</span><span class="p">)</span></span></span></code></pre></div><p>更危險的是，<code>run_hook_safely</code> 的頂層 try-except 捕獲了 <code>NameError</code>（它是 <code>Exception</code> 的子類別），寫入日誌檔案，返回錯誤碼。用戶完全看不到任何異常。<strong>7 個 Hook 在至少 2 個 session 中靜默失敗</strong>。</p>
<blockquote>
<p>這個事故的完整分析見下一章：<a href="/blog/python/07-refactoring/refactoring-pitfalls/" data-link-title="重構陷阱與防護" data-link-desc="三個真實重構事故的共通模式：部分更新問題與系統性防護方法">重構陷阱與防護</a></p></blockquote>
<h3 id="修正逐一分析影響範圍">修正：逐一分析影響範圍</h3>
<p>正確的做法是在修改作用域<strong>之前</strong>，用 AST 分析或 grep 找出所有引用 <code>logger</code> 的非 main 函式，然後為每個函式加入 <code>logger</code> 參數：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">def</span> <span class="nf">check_acceptance_criteria</span><span class="p">(</span><span class="n">ticket_path</span><span class="p">,</span> <span class="n">logger</span><span class="p">):</span>  <span class="c1"># 加入參數</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="n">logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;Checking </span><span class="si">{</span><span class="n">ticket_path</span><span class="si">}</span><span class="s2">&#34;</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="k">def</span> <span class="nf">main</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="n">logger</span> <span class="o">=</span> <span class="n">setup_hook_logging</span><span class="p">(</span><span class="s2">&#34;acceptance-gate-hook&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="n">result</span> <span class="o">=</span> <span class="n">check_acceptance_criteria</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="n">logger</span><span class="p">)</span>  <span class="c1"># 傳遞 logger</span></span></span></code></pre></div><p>修正規模：7 個 Hook、41 個函式、+143/-81 行。</p>
<h3 id="事故後的改善">事故後的改善</h3>
<p>這次事故直接促成了 <code>_log_exception</code> 的 stderr 輸出改善（W25-005）：在寫入日誌檔案之外，額外輸出一行到 <code>sys.stderr</code>，確保即使 <code>run_hook_safely</code> 捕獲了異常，用戶也能在終端看到 <code>[Hook Error]</code> 提示。</p>
<h2 id="重構後的標準樣板">重構後的標準樣板</h2>
<p>三階段統一完成後，每個 Hook 的結構變得極為一致：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="ch">#!/usr/bin/env python3</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="s2">&#34;&#34;&#34;Hook 說明文件&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="kn">import</span> <span class="nn">sys</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kn">import</span> <span class="nn">json</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kn">from</span> <span class="nn">pathlib</span> <span class="kn">import</span> <span class="n">Path</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"># 引入統一基礎設施</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"># Hook 不是安裝的套件，需要手動把 hooks/ 目錄加入 Python 搜尋路徑</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"># 這樣才能 import 同目錄下的 hook_utils 和 lib/ 子模組</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">_hooks_dir</span> <span class="o">=</span> <span class="n">Path</span><span class="p">(</span><span class="vm">__file__</span><span class="p">)</span><span class="o">.</span><span class="n">parent</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="k">if</span> <span class="n">_hooks_dir</span> <span class="ow">not</span> <span class="ow">in</span> <span class="p">[</span><span class="n">p</span> <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="n">sys</span><span class="o">.</span><span class="n">path</span> <span class="k">if</span> <span class="n">Path</span><span class="p">(</span><span class="n">p</span><span class="p">)</span> <span class="o">==</span> <span class="n">_hooks_dir</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="n">sys</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">insert</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="nb">str</span><span class="p">(</span><span class="n">_hooks_dir</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="kn">from</span> <span class="nn">hook_utils</span> <span class="kn">import</span> <span class="n">run_hook_safely</span><span class="p">,</span> <span class="n">setup_hook_logging</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="kn">from</span> <span class="nn">lib.hook_messages</span> <span class="kn">import</span> <span class="n">GateMessages</span><span class="p">,</span> <span class="n">CoreMessages</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="c1"># 常數定義</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="n">EXIT_SUCCESS</span> <span class="o">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="n">EXIT_BLOCK</span> <span class="o">=</span> <span class="mi">2</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="c1"># ---- 業務邏輯 ----</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">def</span> <span class="nf">check_something</span><span class="p">(</span><span class="n">data</span><span class="p">,</span> <span class="n">logger</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="s2">&#34;&#34;&#34;每個 helper 都接收 logger 參數&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="n">logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="n">CoreMessages</span><span class="o">.</span><span class="n">HOOK_START</span><span class="o">.</span><span class="n">format</span><span class="p">(</span><span class="n">hook_name</span><span class="o">=</span><span class="s2">&#34;my-hook&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="c1"># ...</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">
</span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="n">logger</span> <span class="o">=</span> <span class="n">setup_hook_logging</span><span class="p">(</span><span class="s2">&#34;my-hook&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">    <span class="c1"># 讀取輸入、執行檢查、輸出結果</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">    <span class="k">return</span> <span class="n">EXIT_SUCCESS</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">
</span></span><span class="line"><span class="ln">34</span><span class="cl"><span class="c1"># ---- 入口 ----</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl"><span class="k">if</span> <span class="vm">__name__</span> <span class="o">==</span> <span class="s2">&#34;__main__&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">    <span class="n">sys</span><span class="o">.</span><span class="n">exit</span><span class="p">(</span><span class="n">run_hook_safely</span><span class="p">(</span><span class="n">main</span><span class="p">,</span> <span class="s2">&#34;my-hook&#34;</span><span class="p">))</span></span></span></code></pre></div><p>對比重構前後：</p>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>重構前</th>
          <th>重構後</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>日誌初始化</td>
          <td>3 種模組 + 散裝 logging</td>
          <td><code>setup_hook_logging</code> 一行</td>
      </tr>
      <tr>
          <td>錯誤處理</td>
          <td>自寫 try-except 或不處理</td>
          <td><code>run_hook_safely</code> 一行</td>
      </tr>
      <tr>
          <td>使用者訊息</td>
          <td>硬編碼在各檔案</td>
          <td>引用 <code>hook_messages</code> 常數</td>
      </tr>
      <tr>
          <td>logger 傳遞</td>
          <td>全域變數</td>
          <td>參數傳遞</td>
      </tr>
      <tr>
          <td>入口點</td>
          <td>5-15 行樣板</td>
          <td>1 行</td>
      </tr>
      <tr>
          <td>新 Hook 開發</td>
          <td>參考哪個都不確定</td>
          <td>複製標準樣板</td>
      </tr>
  </tbody>
</table>
<h2 id="統一化的通用教訓">統一化的通用教訓</h2>
<h3 id="教訓-1先建介面再遷移">教訓 1：先建介面，再遷移</h3>
<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">錯誤路徑：邊改邊用 → 半成品狀態 → 新舊混合更亂
</span></span><span class="line"><span class="ln">2</span><span class="cl">正確路徑：新模組獨立完成 → 逐批遷移 → 舊模組標記棄用</span></span></code></pre></div><h3 id="教訓-2分批遷移每批驗證">教訓 2：分批遷移，每批驗證</h3>
<p>44 個 Hook 一次改完的風險太高。分批的目的不只是降低風險，更是建立信心。第一批 5 個成功後，第二批 15 個就能更快。</p>
<h3 id="教訓-3統一風格是最危險的一步">教訓 3：統一風格是最危險的一步</h3>
<p>統一「介面」（W22 日誌、W23 訊息）相對安全，因為是新增模組再切換引用。統一「風格」（W24 作用域）涉及修改現有程式碼的結構，牽一髮動全身。</p>
<table>
  <thead>
      <tr>
          <th>風險等級</th>
          <th>操作類型</th>
          <th>範例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>低</td>
          <td>新增模組 + 替換 import</td>
          <td>W22 新增 hook_utils.py</td>
      </tr>
      <tr>
          <td>中</td>
          <td>替換訊息字串</td>
          <td>W23 硬編碼 → 常數引用</td>
      </tr>
      <tr>
          <td>高</td>
          <td>修改變數作用域</td>
          <td>W24 全域 logger → 參數傳遞</td>
      </tr>
  </tbody>
</table>
<h3 id="教訓-4安全網要先到位">教訓 4：安全網要先到位</h3>
<p>W24 的事故之所以嚴重，是因為安全網（stderr 輸出）在事故<strong>之後</strong>才補上。正確的順序應該是：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">1. 先確認安全網（stderr 輸出、測試覆蓋）
</span></span><span class="line"><span class="ln">2</span><span class="cl">2. 再執行風險操作（作用域修改）
</span></span><span class="line"><span class="ln">3</span><span class="cl">3. 最後清理（移除棄用程式碼）</span></span></code></pre></div><h2 id="量化成果">量化成果</h2>
<p>三階段統一化的最終成果：</p>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>統一前</th>
          <th>統一後</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>日誌模組</td>
          <td>3 個</td>
          <td>1 個 (hook_utils.py)</td>
      </tr>
      <tr>
          <td>錯誤處理模式</td>
          <td>3 種</td>
          <td>1 種 (run_hook_safely)</td>
      </tr>
      <tr>
          <td>訊息定義位置</td>
          <td>19 個檔案（57+ 個字串）</td>
          <td>1 個 (hook_messages.py)</td>
      </tr>
      <tr>
          <td>logger 初始化風格</td>
          <td>2 種</td>
          <td>1 種 (main 內 + 參數傳遞)</td>
      </tr>
      <tr>
          <td>新 Hook 開發時間</td>
          <td>~30 分鐘</td>
          <td>~10 分鐘</td>
      </tr>
      <tr>
          <td>Hook 入口樣板</td>
          <td>5-15 行</td>
          <td>1 行</td>
      </tr>
  </tbody>
</table>
<h2 id="思考題">思考題</h2>
<ol>
<li>如果你的系統有 100 個腳本而不是 44 個，統一化策略會有什麼不同？</li>
<li><code>run_hook_safely</code> 選擇返回錯誤碼而不是重新拋出異常，這個設計在什麼情境下會是錯誤的？</li>
<li>訊息常數用 class 分類（<code>GateMessages</code>、<code>WorkflowMessages</code>）而不是單一字典，有什麼優缺點？</li>
</ol>
<h2 id="實作練習">實作練習</h2>
<ol>
<li>為一組 3 個以上的腳本設計統一日誌模組，包含 <code>setup_logging</code> 和 <code>run_safely</code> 兩個函式</li>
<li>掃描一個多檔案專案，找出所有硬編碼的使用者訊息字串，規劃集中管理方案</li>
<li>嘗試用本章的分批遷移策略，將練習 2 的訊息逐批遷移到常數模組</li>
</ol>
<h2 id="小結">小結</h2>
<ul>
<li>大規模統一化的核心模式：<strong>建立統一介面 -&gt; 分批遷移 -&gt; 驗證 -&gt; 處理例外</strong></li>
<li>統一「介面」（新增模組 + 替換引用）風險低，統一「風格」（修改現有結構）風險高</li>
<li><code>run_hook_safely</code> 一行取代 44 套自寫的錯誤處理，確保行為一致</li>
<li>訊息集中化用 Messages 類別按使用者角色分組，消除散落的硬編碼字串</li>
<li>分批遷移不只降低風險，更是建立信心的過程</li>
<li>安全網（stderr 輸出、測試覆蓋）必須在風險操作<strong>之前</strong>到位</li>
</ul>
<hr>
<p><em>上一章：<a href="/blog/python/07-refactoring/constants-management/" data-link-title="配置分離與常數管理" data-link-desc="學習消除三種硬編碼問題：魔法數字、配置混合、散落訊息">配置分離與常數管理</a></em>
<em>下一章：<a href="/blog/python/07-refactoring/refactoring-pitfalls/" data-link-title="重構陷阱與防護" data-link-desc="三個真實重構事故的共通模式：部分更新問題與系統性防護方法">重構陷阱與防護</a></em>
<em>相關：<a href="/blog/python/05-error-testing/error-infrastructure/" data-link-title="5.5 頂層例外處理機制" data-link-desc="run_hook_safely 與統一錯誤基礎設施">5.5 頂層例外處理機制</a></em></p>
]]></content:encoded></item><item><title>作用域迴歸案例研究</title><link>https://tarrragon.github.io/blog/python/07-refactoring/scope-regression/</link><pubDate>Wed, 04 Mar 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/python/07-refactoring/scope-regression/</guid><description>&lt;p>本章記錄 W24 開發週期中發生的一個真實 bug：在統一 16 個 Hook 的 logger 初始化風格時，7 個 Hook 因為&lt;strong>變數作用域變更&lt;/strong>而靜默失敗，影響 41 個函式。&lt;/p>
&lt;p>這個案例的價值在於：bug 本身很簡單（&lt;code>NameError&lt;/code>），但它暴露了重構時一個容易被忽略的系統性風險。&lt;/p>
&lt;h2 id="背景">背景&lt;/h2>
&lt;p>W24 的任務是統一所有 Hook 的 logger 初始化風格。原本各 Hook 的 logger 初始化位置不一致：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 風格 A：模組級初始化（13 個 Hook 使用）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="n">logger&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">setup_hook_logging&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;my-hook&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># 在最外層&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">helper&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="n">logger&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;working...&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># OK：logger 是全域變數&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">main&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="n">helper&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="n">logger&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;done&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 class="mi">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="c1"># 風格 B：main() 內初始化（已有部分 Hook 使用）&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">def&lt;/span> &lt;span class="nf">helper&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">logger&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="n">logger&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;working...&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># OK：logger 是參數&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">main&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="n">logger&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">setup_hook_logging&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;my-hook&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># 在 main() 內&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="n">helper&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">logger&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="n">logger&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;done&amp;#34;&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="k">return&lt;/span> &lt;span class="mi">0&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>統一目標：全部改為&lt;strong>風格 B&lt;/strong>（&lt;code>main()&lt;/code> 內初始化），理由是：&lt;/p>
&lt;ul>
&lt;li>logger 不該在模組被 import 時就建立&lt;/li>
&lt;li>&lt;code>main()&lt;/code> 內初始化更明確，生命週期更可控&lt;/li>
&lt;/ul>
&lt;h2 id="出了什麼問題">出了什麼問題&lt;/h2>
&lt;p>修改時只做了一件事：把 &lt;code>logger = setup_hook_logging(...)&lt;/code> 從模組級移到 &lt;code>main()&lt;/code> 內部。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 修改前&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="n">logger&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">setup_hook_logging&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;acceptance-gate-hook&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">check_acceptance_criteria&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ticket_path&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="n">logger&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Checking &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">ticket_path&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># OK&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="c1"># ...&lt;/span>
&lt;/span>&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="k">def&lt;/span> &lt;span class="nf">validate_ticket_format&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">content&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="n">logger&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;Validating format&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># OK&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="c1"># ...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">main&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="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">check_acceptance_criteria&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">path&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="c1"># ...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="c1"># 修改後（有 bug）&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">def&lt;/span> &lt;span class="nf">check_acceptance_criteria&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ticket_path&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="n">logger&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Checking &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">ticket_path&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># NameError!&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="c1"># ...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">validate_ticket_format&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">content&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="n">logger&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;Validating format&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># NameError!&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl"> &lt;span class="c1"># ...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">main&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl"> &lt;span class="n">logger&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">setup_hook_logging&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;acceptance-gate-hook&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># 區域變數&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl"> &lt;span class="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">check_acceptance_criteria&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">path&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="c1"># ...&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>logger&lt;/code> 從全域變數變成了 &lt;code>main()&lt;/code> 的區域變數。但 &lt;code>check_acceptance_criteria&lt;/code> 和 &lt;code>validate_ticket_format&lt;/code> 仍然以全域方式引用 &lt;code>logger&lt;/code>——它們不知道 &lt;code>logger&lt;/code> 已經不在全域作用域了。&lt;/p>
&lt;h2 id="python-作用域規則回顧">Python 作用域規則回顧&lt;/h2>
&lt;p>Python 的變數查找遵循 &lt;strong>LEGB 規則&lt;/strong>：&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">L - Local : 函式內部
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">E - Enclosing : 外層函式（閉包）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">G - Global : 模組級
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">B - Built-in : Python 內建&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 修改前：logger 在 G（Global）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="n">logger&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">setup_hook_logging&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;hook&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># Global scope&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">helper&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="n">logger&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;...&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># L 找不到 → E 找不到 → G 找到了&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="c1"># 修改後：logger 在 main 的 L（Local）&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">def&lt;/span> &lt;span class="nf">helper&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="n">logger&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;...&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># L 找不到 → E 找不到 → G 找不到 → NameError!&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">main&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="n">logger&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">setup_hook_logging&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;hook&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># main 的 Local scope&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="n">helper&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="c1"># helper 無法存取 main 的 Local&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>main()&lt;/code> 的區域變數對 &lt;code>helper()&lt;/code> 來說是&lt;strong>不可見的&lt;/strong>。&lt;code>helper()&lt;/code> 不是定義在 &lt;code>main()&lt;/code> 內部（不是閉包），所以 Enclosing scope 也找不到。&lt;/p></description><content:encoded><![CDATA[<p>本章記錄 W24 開發週期中發生的一個真實 bug：在統一 16 個 Hook 的 logger 初始化風格時，7 個 Hook 因為<strong>變數作用域變更</strong>而靜默失敗，影響 41 個函式。</p>
<p>這個案例的價值在於：bug 本身很簡單（<code>NameError</code>），但它暴露了重構時一個容易被忽略的系統性風險。</p>
<h2 id="背景">背景</h2>
<p>W24 的任務是統一所有 Hook 的 logger 初始化風格。原本各 Hook 的 logger 初始化位置不一致：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 風格 A：模組級初始化（13 個 Hook 使用）</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">logger</span> <span class="o">=</span> <span class="n">setup_hook_logging</span><span class="p">(</span><span class="s2">&#34;my-hook&#34;</span><span class="p">)</span>  <span class="c1"># 在最外層</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="k">def</span> <span class="nf">helper</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="s2">&#34;working...&#34;</span><span class="p">)</span>  <span class="c1"># OK：logger 是全域變數</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="n">helper</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="n">logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="s2">&#34;done&#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 class="mi">0</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># 風格 B：main() 內初始化（已有部分 Hook 使用）</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="k">def</span> <span class="nf">helper</span><span class="p">(</span><span class="n">logger</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="n">logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="s2">&#34;working...&#34;</span><span class="p">)</span>  <span class="c1"># OK：logger 是參數</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">def</span> <span class="nf">main</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="n">logger</span> <span class="o">=</span> <span class="n">setup_hook_logging</span><span class="p">(</span><span class="s2">&#34;my-hook&#34;</span><span class="p">)</span>  <span class="c1"># 在 main() 內</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="n">helper</span><span class="p">(</span><span class="n">logger</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="n">logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="s2">&#34;done&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">return</span> <span class="mi">0</span></span></span></code></pre></div><p>統一目標：全部改為<strong>風格 B</strong>（<code>main()</code> 內初始化），理由是：</p>
<ul>
<li>logger 不該在模組被 import 時就建立</li>
<li><code>main()</code> 內初始化更明確，生命週期更可控</li>
</ul>
<h2 id="出了什麼問題">出了什麼問題</h2>
<p>修改時只做了一件事：把 <code>logger = setup_hook_logging(...)</code> 從模組級移到 <code>main()</code> 內部。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 修改前</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">logger</span> <span class="o">=</span> <span class="n">setup_hook_logging</span><span class="p">(</span><span class="s2">&#34;acceptance-gate-hook&#34;</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="k">def</span> <span class="nf">check_acceptance_criteria</span><span class="p">(</span><span class="n">ticket_path</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;Checking </span><span class="si">{</span><span class="n">ticket_path</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>  <span class="c1"># OK</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="c1"># ...</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="k">def</span> <span class="nf">validate_ticket_format</span><span class="p">(</span><span class="n">content</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="n">logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="s2">&#34;Validating format&#34;</span><span class="p">)</span>  <span class="c1"># OK</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="c1"># ...</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="n">result</span> <span class="o">=</span> <span class="n">check_acceptance_criteria</span><span class="p">(</span><span class="n">path</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="c1"># ...</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="c1"># 修改後（有 bug）</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="k">def</span> <span class="nf">check_acceptance_criteria</span><span class="p">(</span><span class="n">ticket_path</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="n">logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;Checking </span><span class="si">{</span><span class="n">ticket_path</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>  <span class="c1"># NameError!</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="c1"># ...</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="k">def</span> <span class="nf">validate_ticket_format</span><span class="p">(</span><span class="n">content</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="n">logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="s2">&#34;Validating format&#34;</span><span class="p">)</span>  <span class="c1"># NameError!</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="c1"># ...</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="k">def</span> <span class="nf">main</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="n">logger</span> <span class="o">=</span> <span class="n">setup_hook_logging</span><span class="p">(</span><span class="s2">&#34;acceptance-gate-hook&#34;</span><span class="p">)</span>  <span class="c1"># 區域變數</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="n">result</span> <span class="o">=</span> <span class="n">check_acceptance_criteria</span><span class="p">(</span><span class="n">path</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="c1"># ...</span></span></span></code></pre></div><p><code>logger</code> 從全域變數變成了 <code>main()</code> 的區域變數。但 <code>check_acceptance_criteria</code> 和 <code>validate_ticket_format</code> 仍然以全域方式引用 <code>logger</code>——它們不知道 <code>logger</code> 已經不在全域作用域了。</p>
<h2 id="python-作用域規則回顧">Python 作用域規則回顧</h2>
<p>Python 的變數查找遵循 <strong>LEGB 規則</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">L - Local      : 函式內部
</span></span><span class="line"><span class="ln">2</span><span class="cl">E - Enclosing  : 外層函式（閉包）
</span></span><span class="line"><span class="ln">3</span><span class="cl">G - Global     : 模組級
</span></span><span class="line"><span class="ln">4</span><span class="cl">B - Built-in   : Python 內建</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 修改前：logger 在 G（Global）</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">logger</span> <span class="o">=</span> <span class="n">setup_hook_logging</span><span class="p">(</span><span class="s2">&#34;hook&#34;</span><span class="p">)</span>  <span class="c1"># Global scope</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="k">def</span> <span class="nf">helper</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="s2">&#34;...&#34;</span><span class="p">)</span>  <span class="c1"># L 找不到 → E 找不到 → G 找到了</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"># 修改後：logger 在 main 的 L（Local）</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="k">def</span> <span class="nf">helper</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="n">logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="s2">&#34;...&#34;</span><span class="p">)</span>  <span class="c1"># L 找不到 → E 找不到 → G 找不到 → NameError!</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">def</span> <span class="nf">main</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="n">logger</span> <span class="o">=</span> <span class="n">setup_hook_logging</span><span class="p">(</span><span class="s2">&#34;hook&#34;</span><span class="p">)</span>  <span class="c1"># main 的 Local scope</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="n">helper</span><span class="p">()</span>  <span class="c1"># helper 無法存取 main 的 Local</span></span></span></code></pre></div><p><code>main()</code> 的區域變數對 <code>helper()</code> 來說是<strong>不可見的</strong>。<code>helper()</code> 不是定義在 <code>main()</code> 內部（不是閉包），所以 Enclosing scope 也找不到。</p>
<h2 id="為什麼沒被立刻發現">為什麼沒被立刻發現</h2>
<p>這個 bug 最危險的地方是<strong>靜默失敗</strong>。原因是 <code>run_hook_safely</code> 的頂層例外處理：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">def</span> <span class="nf">run_hook_safely</span><span class="p">(</span><span class="n">main_func</span><span class="p">,</span> <span class="n">hook_name</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="n">logger</span> <span class="o">=</span> <span class="n">setup_hook_logging</span><span class="p">(</span><span class="n">hook_name</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="n">exit_code</span> <span class="o">=</span> <span class="n">main_func</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">except</span> <span class="p">(</span><span class="ne">KeyboardInterrupt</span><span class="p">,</span> <span class="ne">SystemExit</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">raise</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">except</span> <span class="ne">Exception</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="n">tb_str</span> <span class="o">=</span> <span class="n">traceback</span><span class="o">.</span><span class="n">format_exc</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="n">_log_exception</span><span class="p">(</span><span class="n">logger</span><span class="p">,</span> <span class="n">hook_name</span><span class="p">,</span> <span class="n">tb_str</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="n">EXIT_ERROR</span>  <span class="c1"># 返回錯誤碼，但不會 crash</span></span></span></code></pre></div><p>流程是這樣的：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">1. main() 被 run_hook_safely 呼叫
</span></span><span class="line"><span class="ln">2</span><span class="cl">2. main() 內呼叫 check_acceptance_criteria()
</span></span><span class="line"><span class="ln">3</span><span class="cl">3. check_acceptance_criteria() 引用 logger → NameError
</span></span><span class="line"><span class="ln">4</span><span class="cl">4. NameError 是 Exception 的子類別
</span></span><span class="line"><span class="ln">5</span><span class="cl">5. run_hook_safely 捕獲，寫入日誌檔案
</span></span><span class="line"><span class="ln">6</span><span class="cl">6. 返回 EXIT_ERROR（整數 1）
</span></span><span class="line"><span class="ln">7</span><span class="cl">7. Hook 系統收到非零退出碼 → 顯示 &#34;hook success&#34;（suppressOutput）
</span></span><span class="line"><span class="ln">8</span><span class="cl">8. 用戶看不到任何異常</span></span></code></pre></div><p>7 個 Hook 就這樣在至少 2 個 session 中靜默失敗。直到有人手動觸發了一個受影響的 Hook 並檢查日誌，才發現問題。</p>
<blockquote>
<p>這也是為什麼 W25-005 後來在 <code>_log_exception</code> 加入了 stderr 輸出。詳見 <a href="/blog/python/05-error-testing/error-infrastructure/" data-link-title="5.5 頂層例外處理機制" data-link-desc="run_hook_safely 與統一錯誤基礎設施">5.5 頂層例外處理機制</a>。</p></blockquote>
<h2 id="正確的修正方式">正確的修正方式</h2>
<h3 id="step-1影響範圍分析">Step 1：影響範圍分析</h3>
<p>修改變數作用域<strong>之前</strong>，先列出所有引用該變數的函式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 用 AST 分析找出所有引用 logger 的非 main 函式</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">python3 -c <span class="s2">&#34;
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="s2">import ast, sys
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s2">tree = ast.parse(open(sys.argv[1]).read())
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s2">for node in ast.walk(tree):
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s2">    if isinstance(node, ast.FunctionDef) and node.name != &#39;main&#39;:
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s2">        for child in ast.walk(node):
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s2">            if isinstance(child, ast.Name) and child.id == &#39;logger&#39;:
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s2">                print(f&#39;  {node.name}() references logger&#39;)
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="s2">                break
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="s2">&#34;</span> acceptance-gate-hook.py</span></span></code></pre></div><p>輸出：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">  check_acceptance_criteria() references logger
</span></span><span class="line"><span class="ln">2</span><span class="cl">  validate_ticket_format() references logger
</span></span><span class="line"><span class="ln">3</span><span class="cl">  check_worklog_sections() references logger
</span></span><span class="line"><span class="ln">4</span><span class="cl">  ... (共 11 個函式)</span></span></code></pre></div><h3 id="step-2修改函式簽名">Step 2：修改函式簽名</h3>
<p>每個引用 <code>logger</code> 的函式都必須接收 <code>logger</code> 作為參數：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">def</span> <span class="nf">check_acceptance_criteria</span><span class="p">(</span><span class="n">ticket_path</span><span class="p">,</span> <span class="n">logger</span><span class="p">):</span>  <span class="c1"># 加入 logger 參數</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="n">logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;Checking </span><span class="si">{</span><span class="n">ticket_path</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="c1"># ...</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="k">def</span> <span class="nf">validate_ticket_format</span><span class="p">(</span><span class="n">content</span><span class="p">,</span> <span class="n">logger</span><span class="p">):</span>  <span class="c1"># 加入 logger 參數</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="n">logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="s2">&#34;Validating format&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="c1"># ...</span></span></span></code></pre></div><h3 id="step-3更新所有呼叫端">Step 3：更新所有呼叫端</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="n">logger</span> <span class="o">=</span> <span class="n">setup_hook_logging</span><span class="p">(</span><span class="s2">&#34;acceptance-gate-hook&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="n">result</span> <span class="o">=</span> <span class="n">check_acceptance_criteria</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="n">logger</span><span class="p">)</span>  <span class="c1"># 傳遞 logger</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="n">validate_ticket_format</span><span class="p">(</span><span class="n">content</span><span class="p">,</span> <span class="n">logger</span><span class="p">)</span>            <span class="c1"># 傳遞 logger</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">return</span> <span class="mi">0</span></span></span></code></pre></div><h3 id="step-4驗證">Step 4：驗證</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># AST 驗證：確認沒有函式在引用全域 logger</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">python3 -c <span class="s2">&#34;
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="s2">import ast, sys
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s2">tree = ast.parse(open(sys.argv[1]).read())
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s2">issues = []
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s2">for node in ast.walk(tree):
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s2">    if isinstance(node, ast.FunctionDef) and node.name != &#39;main&#39;:
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s2">        params = {arg.arg for arg in node.args.args}
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s2">        if &#39;logger&#39; not in params:
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="s2">            for child in ast.walk(node):
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="s2">                if isinstance(child, ast.Name) and child.id == &#39;logger&#39;:
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="s2">                    issues.append(node.name)
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="s2">                    break
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="s2">if issues:
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="s2">    print(f&#39;FAIL: {issues} still reference global logger&#39;)
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="s2">else:
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="s2">    print(&#39;PASS: all functions receive logger as parameter&#39;)
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="s2">&#34;</span> acceptance-gate-hook.py</span></span></code></pre></div><h2 id="修正規模">修正規模</h2>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>數值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>受影響 Hook</td>
          <td>7 個</td>
      </tr>
      <tr>
          <td>受影響函式</td>
          <td>41 個</td>
      </tr>
      <tr>
          <td>修正行數</td>
          <td>+143 / -81</td>
      </tr>
      <tr>
          <td>靜默失敗持續時間</td>
          <td>至少 2 個 session</td>
      </tr>
  </tbody>
</table>
<h2 id="為什麼-py_compile-抓不到這個-bug">為什麼 py_compile 抓不到這個 bug</h2>
<p>你可能會想：修改後跑一下語法檢查不就好了？</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">python3 -m py_compile acceptance-gate-hook.py
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 通過！沒有任何錯誤</span></span></span></code></pre></div><p><code>py_compile</code> 只檢查<strong>語法</strong>（syntax），不檢查<strong>作用域</strong>（scope）。<code>logger.info(&quot;...&quot;)</code> 在語法上完全正確——它是一個合法的「存取名稱 logger 的 info 屬性並呼叫」。只有在<strong>執行時</strong>，Python 才會查找 <code>logger</code> 這個名稱，發現找不到，拋出 <code>NameError</code>。</p>
<h3 id="驗證工具的能力比較">驗證工具的能力比較</h3>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>能否偵測此 bug</th>
          <th>原因</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>py_compile</code></td>
          <td>否</td>
          <td>只檢查語法</td>
      </tr>
      <tr>
          <td><code>mypy</code></td>
          <td>可能</td>
          <td>型別檢查會分析名稱可見性</td>
      </tr>
      <tr>
          <td>AST 分析</td>
          <td>是</td>
          <td>可以追蹤名稱引用和定義</td>
      </tr>
      <tr>
          <td>實際執行</td>
          <td>是</td>
          <td>直接觸發 <code>NameError</code></td>
      </tr>
      <tr>
          <td><code>pylint</code></td>
          <td>是</td>
          <td>會警告 <code>undefined-variable</code></td>
      </tr>
  </tbody>
</table>
<h2 id="教訓作用域變更的強制檢查清單">教訓：作用域變更的強制檢查清單</h2>
<p>任何涉及<strong>變數作用域變更</strong>的重構（全域 → 區域、模組級 → 函式內、類別屬性 → 方法參數），都必須執行：</p>
<table>
  <thead>
      <tr>
          <th>步驟</th>
          <th>動作</th>
          <th>驗證方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>列出所有引用該變數的函式</td>
          <td><code>grep</code> 或 AST 分析</td>
      </tr>
      <tr>
          <td>2</td>
          <td>每個函式確認：透過參數接收還是依賴全域？</td>
          <td>逐一檢查函式簽名</td>
      </tr>
      <tr>
          <td>3</td>
          <td>依賴全域的函式必須新增參數</td>
          <td>修改函式簽名</td>
      </tr>
      <tr>
          <td>4</td>
          <td>所有呼叫端必須傳遞新參數</td>
          <td>修改所有 call site</td>
      </tr>
      <tr>
          <td>5</td>
          <td>驗證</td>
          <td>AST 分析或實際執行（不要只用 py_compile）</td>
      </tr>
  </tbody>
</table>
<h2 id="更廣泛的啟示">更廣泛的啟示</h2>
<p>這個案例不只適用於 <code>logger</code>。任何「移動變數定義位置」的重構都有同樣的風險：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 範例：將資料庫連線從全域移入函式</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"># 修改前</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">db</span> <span class="o">=</span> <span class="n">connect_database</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">def</span> <span class="nf">get_user</span><span class="p">(</span><span class="n">user_id</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="n">db</span><span class="o">.</span><span class="n">query</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;SELECT * FROM users WHERE id = </span><span class="si">{</span><span class="n">user_id</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"># 修改後（有 bug）</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="k">def</span> <span class="nf">get_user</span><span class="p">(</span><span class="n">user_id</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="n">db</span><span class="o">.</span><span class="n">query</span><span class="p">(</span><span class="o">...</span><span class="p">)</span>  <span class="c1"># NameError: db 不再是全域</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="n">db</span> <span class="o">=</span> <span class="n">connect_database</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="n">user</span> <span class="o">=</span> <span class="n">get_user</span><span class="p">(</span><span class="mi">123</span><span class="p">)</span></span></span></code></pre></div><p>同樣的模式，同樣的陷阱。解決方式也一樣：分析引用 → 修改簽名 → 傳遞參數 → 驗證。</p>
<h2 id="思考題">思考題</h2>
<ol>
<li>如果使用 <code>global logger</code> 宣告，能否解決這個問題？為什麼不推薦這種做法？</li>
<li>閉包（closure）能否解決這個問題？把 <code>helper</code> 定義在 <code>main()</code> 內部會怎樣？</li>
<li>這個 bug 在什麼條件下才會被發現？（提示：考慮測試覆蓋率和 Hook 觸發時機）</li>
</ol>
<h2 id="實作練習">實作練習</h2>
<ol>
<li>找一段使用全域變數的程式碼，嘗試將變數移入函式內部，並用 AST 分析驗證所有引用</li>
<li>寫一個腳本，掃描指定的 Python 檔案，找出所有「函式內引用但未定義、也不在參數中」的名稱</li>
<li>設計一個 pre-commit hook，在 <code>git diff</code> 中偵測「變數定義位置改變」的情況</li>
</ol>
<hr>
<p><em>上一章：<a href="/blog/python/07-refactoring/case-study/" data-link-title="完整案例回顧" data-link-desc="從超過 30 個 Hook 各自為政到系統化品質工程，三個階段的完整重構復盤">重構案例研究</a></em>
<em>相關：<a href="/blog/python/05-error-testing/error-infrastructure/" data-link-title="5.5 頂層例外處理機制" data-link-desc="run_hook_safely 與統一錯誤基礎設施">5.5 頂層例外處理機制</a> — 本案例中 bug 被靜默吞掉的機制分析</em></p>
]]></content:encoded></item><item><title>重構陷阱與防護</title><link>https://tarrragon.github.io/blog/python/07-refactoring/refactoring-pitfalls/</link><pubDate>Wed, 04 Mar 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/python/07-refactoring/refactoring-pitfalls/</guid><description>&lt;p>「只是把變數移個位置」「只是搬個檔案」「只是加個參數」——這些聽起來無害的操作，在我們的專案中分別造成了 7 個 Hook 靜默失敗、5 個 Hook 啟動崩潰、以及使用者看到莫名其妙的 &amp;ldquo;hook error&amp;rdquo; 訊息。&lt;/p>
&lt;p>本章整合三個真實事故（IMP-003、IMP-005、IMP-006），分析它們的共通模式，並建立一套防護方法。如果你只帶走一句話，請記住：&lt;strong>修改了定義，就必須更新所有引用&lt;/strong>。&lt;/p>
&lt;h2 id="三個陷阱的概覽">三個陷阱的概覽&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>陷阱&lt;/th>
 &lt;th>重構類型&lt;/th>
 &lt;th>遺漏&lt;/th>
 &lt;th>靜默時間&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>作用域迴歸 (IMP-003)&lt;/td>
 &lt;td>變數從全域移入函式&lt;/td>
 &lt;td>引用該變數的函式未更新&lt;/td>
 &lt;td>2+ sessions&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Import 未同步 (IMP-005)&lt;/td>
 &lt;td>模組搬遷至子目錄&lt;/td>
 &lt;td>引用該模組的檔案未更新&lt;/td>
 &lt;td>直到下次啟動&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>靜默故障 (IMP-006)&lt;/td>
 &lt;td>函式簽名變更&lt;/td>
 &lt;td>部分 call site 未更新&lt;/td>
 &lt;td>直到該路徑被執行&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>三者看似不同，但根本原因完全一致：&lt;strong>修改了定義，但沒有更新所有引用&lt;/strong>。&lt;/p>
&lt;hr>
&lt;h2 id="陷阱一作用域迴歸">陷阱一：作用域迴歸&lt;/h2>
&lt;blockquote>
&lt;p>本節是概要。IMP-003 的完整分析（含 LEGB 規則詳解、AST 修正腳本）請見&lt;a href="https://tarrragon.github.io/blog/python/07-refactoring/scope-regression/" data-link-title="作用域迴歸案例研究" data-link-desc="從 IMP-003 事件學習 Python 變數作用域的陷阱">作用域迴歸案例研究&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;h3 id="事件摘要">事件摘要&lt;/h3>
&lt;p>W24 的任務是統一 16 個 Hook 的 logger 初始化風格：從模組級初始化（全域變數）改為 &lt;code>main()&lt;/code> 內初始化（區域變數）。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 修改前：logger 是全域變數，所有函式可存取&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="n">logger&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">setup_hook_logging&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;my-hook&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">helper&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="n">logger&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;working...&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># OK：LEGB 在 Global 層找到&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="c1"># 修改後：logger 變成 main() 的區域變數&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">def&lt;/span> &lt;span class="nf">helper&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="n">logger&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;working...&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># NameError! helper 看不到 main 的區域變數&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">main&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="n">logger&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">setup_hook_logging&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;my-hook&amp;#34;&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="n">helper&lt;/span>&lt;span class="p">()&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="為什麼危險">為什麼危險&lt;/h3>
&lt;p>兩個因素疊加讓這個 bug 特別難發現：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>&lt;code>py_compile&lt;/code> 抓不到&lt;/strong>：&lt;code>logger.info(...)&lt;/code> 語法完全合法，名稱解析要到執行時才發生&lt;/li>
&lt;li>&lt;strong>頂層例外處理吞掉了 &lt;code>NameError&lt;/code>&lt;/strong>：&lt;code>run_hook_safely()&lt;/code> 捕捉所有 &lt;code>Exception&lt;/code>，Hook 靜默失敗而非 crash（詳見 &lt;a href="https://tarrragon.github.io/blog/python/05-error-testing/error-infrastructure/" data-link-title="5.5 頂層例外處理機制" data-link-desc="run_hook_safely 與統一錯誤基礎設施">5.5 頂層例外處理機制&lt;/a>）&lt;/li>
&lt;/ol>
&lt;p>結果：7 個 Hook 在至少 2 個 session 中靜默失敗，41 個函式需要修正，+143/-81 行修改——全部源自一個「只是移動定義位置」的操作。&lt;/p>
&lt;h3 id="正確做法">正確做法&lt;/h3>
&lt;p>修改前用 grep 或 AST 列出所有引用，逐一加入 &lt;code>logger&lt;/code> 參數，再用 AST 驗證無遺漏。完整的四步修正流程見&lt;a href="https://tarrragon.github.io/blog/python/07-refactoring/scope-regression/" data-link-title="作用域迴歸案例研究" data-link-desc="從 IMP-003 事件學習 Python 變數作用域的陷阱">作用域迴歸案例研究&lt;/a>。&lt;/p>
&lt;hr>
&lt;h2 id="陷阱二import-未同步">陷阱二：Import 未同步&lt;/h2>
&lt;h3 id="背景">背景&lt;/h3>
&lt;p>W22 重構將 &lt;code>common_functions.py&lt;/code> 從 &lt;code>.claude/hooks/&lt;/code> 遷移至 &lt;code>.claude/hooks/lib/&lt;/code>。但只更新了部分 Hook 的 import 路徑。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 遷移前（模組在同目錄）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="n">sys&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">path&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">insert&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Path&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="vm">__file__&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">parent&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="kn">from&lt;/span> &lt;span class="nn">common_functions&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">hook_output&lt;/span> &lt;span class="c1"># OK&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="c1"># 遷移後（模組移到 lib/，但 import 未更新）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="n">sys&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">path&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">insert&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Path&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="vm">__file__&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">parent&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="kn">from&lt;/span> &lt;span class="nn">common_functions&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">hook_output&lt;/span> &lt;span class="c1"># ModuleNotFoundError!&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="c1"># 正確的遷移後寫法&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">lib.common_functions&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">hook_output&lt;/span> &lt;span class="c1"># OK&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="5-why-分析">5 Why 分析&lt;/h3>
&lt;ol>
&lt;li>Hook 啟動時拋出 &lt;code>ModuleNotFoundError&lt;/code>&lt;/li>
&lt;li>&lt;code>from common_functions import ...&lt;/code> 找不到模組&lt;/li>
&lt;li>&lt;code>common_functions.py&lt;/code> 已遷移至 &lt;code>lib/&lt;/code> 子目錄&lt;/li>
&lt;li>遷移時只更新了&lt;strong>部分&lt;/strong> Hook 的 import 路徑&lt;/li>
&lt;li>&lt;strong>根本原因&lt;/strong>：模組遷移後缺乏「全量引用更新」步驟&lt;/li>
&lt;/ol>
&lt;p>5 個 Hook 受影響，涵蓋 SessionStart、PostToolUse、UserPromptSubmit 三種事件類型。&lt;/p></description><content:encoded><![CDATA[<p>「只是把變數移個位置」「只是搬個檔案」「只是加個參數」——這些聽起來無害的操作，在我們的專案中分別造成了 7 個 Hook 靜默失敗、5 個 Hook 啟動崩潰、以及使用者看到莫名其妙的 &ldquo;hook error&rdquo; 訊息。</p>
<p>本章整合三個真實事故（IMP-003、IMP-005、IMP-006），分析它們的共通模式，並建立一套防護方法。如果你只帶走一句話，請記住：<strong>修改了定義，就必須更新所有引用</strong>。</p>
<h2 id="三個陷阱的概覽">三個陷阱的概覽</h2>
<table>
  <thead>
      <tr>
          <th>陷阱</th>
          <th>重構類型</th>
          <th>遺漏</th>
          <th>靜默時間</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>作用域迴歸 (IMP-003)</td>
          <td>變數從全域移入函式</td>
          <td>引用該變數的函式未更新</td>
          <td>2+ sessions</td>
      </tr>
      <tr>
          <td>Import 未同步 (IMP-005)</td>
          <td>模組搬遷至子目錄</td>
          <td>引用該模組的檔案未更新</td>
          <td>直到下次啟動</td>
      </tr>
      <tr>
          <td>靜默故障 (IMP-006)</td>
          <td>函式簽名變更</td>
          <td>部分 call site 未更新</td>
          <td>直到該路徑被執行</td>
      </tr>
  </tbody>
</table>
<p>三者看似不同，但根本原因完全一致：<strong>修改了定義，但沒有更新所有引用</strong>。</p>
<hr>
<h2 id="陷阱一作用域迴歸">陷阱一：作用域迴歸</h2>
<blockquote>
<p>本節是概要。IMP-003 的完整分析（含 LEGB 規則詳解、AST 修正腳本）請見<a href="/blog/python/07-refactoring/scope-regression/" data-link-title="作用域迴歸案例研究" data-link-desc="從 IMP-003 事件學習 Python 變數作用域的陷阱">作用域迴歸案例研究</a>。</p></blockquote>
<h3 id="事件摘要">事件摘要</h3>
<p>W24 的任務是統一 16 個 Hook 的 logger 初始化風格：從模組級初始化（全域變數）改為 <code>main()</code> 內初始化（區域變數）。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 修改前：logger 是全域變數，所有函式可存取</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">logger</span> <span class="o">=</span> <span class="n">setup_hook_logging</span><span class="p">(</span><span class="s2">&#34;my-hook&#34;</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="k">def</span> <span class="nf">helper</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="s2">&#34;working...&#34;</span><span class="p">)</span>  <span class="c1"># OK：LEGB 在 Global 層找到</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"># 修改後：logger 變成 main() 的區域變數</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="k">def</span> <span class="nf">helper</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="n">logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="s2">&#34;working...&#34;</span><span class="p">)</span>  <span class="c1"># NameError! helper 看不到 main 的區域變數</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">def</span> <span class="nf">main</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="n">logger</span> <span class="o">=</span> <span class="n">setup_hook_logging</span><span class="p">(</span><span class="s2">&#34;my-hook&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="n">helper</span><span class="p">()</span></span></span></code></pre></div><h3 id="為什麼危險">為什麼危險</h3>
<p>兩個因素疊加讓這個 bug 特別難發現：</p>
<ol>
<li><strong><code>py_compile</code> 抓不到</strong>：<code>logger.info(...)</code> 語法完全合法，名稱解析要到執行時才發生</li>
<li><strong>頂層例外處理吞掉了 <code>NameError</code></strong>：<code>run_hook_safely()</code> 捕捉所有 <code>Exception</code>，Hook 靜默失敗而非 crash（詳見 <a href="/blog/python/05-error-testing/error-infrastructure/" data-link-title="5.5 頂層例外處理機制" data-link-desc="run_hook_safely 與統一錯誤基礎設施">5.5 頂層例外處理機制</a>）</li>
</ol>
<p>結果：7 個 Hook 在至少 2 個 session 中靜默失敗，41 個函式需要修正，+143/-81 行修改——全部源自一個「只是移動定義位置」的操作。</p>
<h3 id="正確做法">正確做法</h3>
<p>修改前用 grep 或 AST 列出所有引用，逐一加入 <code>logger</code> 參數，再用 AST 驗證無遺漏。完整的四步修正流程見<a href="/blog/python/07-refactoring/scope-regression/" data-link-title="作用域迴歸案例研究" data-link-desc="從 IMP-003 事件學習 Python 變數作用域的陷阱">作用域迴歸案例研究</a>。</p>
<hr>
<h2 id="陷阱二import-未同步">陷阱二：Import 未同步</h2>
<h3 id="背景">背景</h3>
<p>W22 重構將 <code>common_functions.py</code> 從 <code>.claude/hooks/</code> 遷移至 <code>.claude/hooks/lib/</code>。但只更新了部分 Hook 的 import 路徑。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 遷移前（模組在同目錄）</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">sys</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">insert</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="nb">str</span><span class="p">(</span><span class="n">Path</span><span class="p">(</span><span class="vm">__file__</span><span class="p">)</span><span class="o">.</span><span class="n">parent</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kn">from</span> <span class="nn">common_functions</span> <span class="kn">import</span> <span class="n">hook_output</span>  <span class="c1"># OK</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"># 遷移後（模組移到 lib/，但 import 未更新）</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">sys</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">insert</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="nb">str</span><span class="p">(</span><span class="n">Path</span><span class="p">(</span><span class="vm">__file__</span><span class="p">)</span><span class="o">.</span><span class="n">parent</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="kn">from</span> <span class="nn">common_functions</span> <span class="kn">import</span> <span class="n">hook_output</span>  <span class="c1"># ModuleNotFoundError!</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"># 正確的遷移後寫法</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="kn">from</span> <span class="nn">lib.common_functions</span> <span class="kn">import</span> <span class="n">hook_output</span>  <span class="c1"># OK</span></span></span></code></pre></div><h3 id="5-why-分析">5 Why 分析</h3>
<ol>
<li>Hook 啟動時拋出 <code>ModuleNotFoundError</code></li>
<li><code>from common_functions import ...</code> 找不到模組</li>
<li><code>common_functions.py</code> 已遷移至 <code>lib/</code> 子目錄</li>
<li>遷移時只更新了<strong>部分</strong> Hook 的 import 路徑</li>
<li><strong>根本原因</strong>：模組遷移後缺乏「全量引用更新」步驟</li>
</ol>
<p>5 個 Hook 受影響，涵蓋 SessionStart、PostToolUse、UserPromptSubmit 三種事件類型。</p>
<h3 id="第二次發生">第二次發生</h3>
<p>同一個模式在後續又發生了一次。W24 統一 <code>sys.path</code> 風格時，<code>task-dispatch-readiness-check.py</code> 的 <code>sys.path</code> 只包含 <code>.claude/hooks/</code>，缺少 <code>.claude/lib/</code>。</p>
<p>更危險的是，這次的 error 與另一個 Hook 的 error（plugin timeout）同時出現。移除 plugin 後以為問題解決了，實際上只消除了其中一個來源。</p>
<p><strong>教訓</strong>：多個不同來源的 error 同時存在時，修一個後不能假設全部修好了——必須逐一驗證每一個。</p>
<h3 id="正確的遷移步驟">正確的遷移步驟</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># Step 1：列出所有引用</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">grep -r <span class="s2">&#34;from common_functions import&#34;</span> .claude/hooks/*.py
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># Step 2：列出所有直接 import</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">grep -r <span class="s2">&#34;import common_functions&#34;</span> .claude/hooks/*.py
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"># Step 3：逐一更新 import 路徑（根據 Step 1-2 的清單）</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"># Step 4：逐一驗證</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="k">for</span> f in .claude/hooks/*.py<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;Testing </span><span class="nv">$f</span><span class="s2">...&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nb">echo</span> <span class="s1">&#39;{}&#39;</span> <span class="p">|</span> python3 <span class="s2">&#34;</span><span class="nv">$f</span><span class="s2">&#34;</span> 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">|</span> head -5
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="k">done</span></span></span></code></pre></div><h3 id="與陷阱一的對比">與陷阱一的對比</h3>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>陷阱一（作用域）</th>
          <th>陷阱二（Import）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>修改了什麼</td>
          <td>變數定義的位置</td>
          <td>模組檔案的位置</td>
      </tr>
      <tr>
          <td>遺漏了什麼</td>
          <td>引用該變數的函式</td>
          <td>引用該模組的檔案</td>
      </tr>
      <tr>
          <td>py_compile 能偵測？</td>
          <td>否</td>
          <td>否</td>
      </tr>
      <tr>
          <td>grep 能找出？</td>
          <td>是</td>
          <td>是</td>
      </tr>
  </tbody>
</table>
<p>根本結構完全相同：<strong>移動了定義，沒有追蹤引用</strong>。</p>
<hr>
<h2 id="陷阱三靜默故障">陷阱三：靜默故障</h2>
<p>IMP-006 收錄了四個 Hook 隱性故障案例。這裡選取三個，分別代表不同的「部分更新」變體。</p>
<h3 id="案例-a函式參數遺漏">案例 A：函式參數遺漏</h3>
<p><code>save_check_log()</code> 需要 5 個參數，但某個 call site 只傳了 4 個：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 第 471 行（正確）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">save_check_log</span><span class="p">(</span><span class="n">prompt</span><span class="p">,</span> <span class="n">result</span><span class="p">,</span> <span class="n">is_dev</span><span class="p">,</span> <span class="n">count</span><span class="p">,</span> <span class="n">logger</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 第 453 行（早期返回路徑，遺漏 logger）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="n">save_check_log</span><span class="p">(</span><span class="n">prompt</span><span class="p">,</span> <span class="kc">None</span><span class="p">,</span> <span class="kc">False</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>  <span class="c1"># TypeError: missing argument</span></span></span></code></pre></div><p>同一個函式在同一個檔案中呼叫了兩次。第二次是在早期返回（early return）路徑上，開發者 copy-paste 後漏掉了最後一個參數。</p>
<p>這跟陷阱一本質相同——函式簽名變更後（加入 <code>logger</code> 參數），沒有更新<strong>所有</strong> call site。</p>
<h3 id="案例-b語義分類錯誤">案例 B：語義分類錯誤</h3>
<p><code>command-entrance-gate-hook.py</code> 將「分析、調查、研究」等關鍵字歸入 <code>DEVELOPMENT_KEYWORDS</code>，導致分析命令被當作開發命令處理，被要求先建立 Ticket 才能執行。</p>
<p>但根據決策樹，分析類命令走「問題處理流程」，不需要 Ticket。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 錯誤：ANALYSIS_KEYWORDS 被放進 DEVELOPMENT_KEYWORDS</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">DEVELOPMENT_KEYWORDS</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s2">&#34;implement&#34;</span><span class="p">,</span> <span class="s2">&#34;create&#34;</span><span class="p">,</span> <span class="s2">&#34;fix&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="s2">&#34;analyze&#34;</span><span class="p">,</span> <span class="s2">&#34;investigate&#34;</span><span class="p">,</span> <span class="s2">&#34;research&#34;</span><span class="p">,</span>  <span class="c1"># 這些不是開發命令！</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="c1"># 正確：分析類關鍵字應在白名單中</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="n">exploration_patterns</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;analyze&#34;</span><span class="p">,</span> <span class="s2">&#34;investigate&#34;</span><span class="p">,</span> <span class="s2">&#34;research&#34;</span><span class="p">,</span> <span class="s2">&#34;trace&#34;</span><span class="p">]</span></span></span></code></pre></div><p>這不是典型的「引用未更新」，但仍屬於<strong>部分更新</strong>問題：Hook 的語義分類與決策樹的語義定義不同步。修改了決策樹的行為分類，但沒有同步更新 Hook 的關鍵字分類。</p>
<h3 id="案例-c多路徑覆蓋不完整">案例 C：多路徑覆蓋不完整</h3>
<p><code>agent-ticket-validation-hook.py</code> 有兩條錯誤路徑：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 路徑 1：未預期異常（已有 stderr 輸出）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">except</span> <span class="ne">Exception</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;[Error] </span><span class="si">{</span><span class="n">traceback</span><span class="o">.</span><span class="n">format_exc</span><span class="p">()</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="n">file</span><span class="o">=</span><span class="n">sys</span><span class="o">.</span><span class="n">stderr</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># 路徑 2：有意阻止（遺漏 stderr 輸出）</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="k">if</span> <span class="ow">not</span> <span class="n">valid</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="mi">2</span>  <span class="c1"># exit code 2，但沒有 stderr 告訴使用者為什麼</span></span></span></code></pre></div><p>開發者只覆蓋了第一條路徑。第二條路徑（業務邏輯拒絕）執行時，使用者只看到 &ldquo;hook error&rdquo; 和 &ldquo;No stderr output&rdquo;，無法得知被拒絕的原因。</p>
<p><strong>教訓</strong>：一個函式的所有非成功路徑都需要相同等級的錯誤報告，不能只覆蓋 exception 路徑。</p>
<hr>
<h2 id="共通模式部分更新">共通模式：部分更新</h2>
<p>三個陷阱的根本結構完全相同：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">          修改了 A
</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">    A 有 N 個引用/依賴
</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">     只更新了其中 M 個
</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">       N - M 個壞掉了
</span></span><span class="line"><span class="ln">8</span><span class="cl">              |
</span></span><span class="line"><span class="ln">9</span><span class="cl">         靜默失敗</span></span></code></pre></div><p>不管 A 是變數定義（陷阱一）、模組路徑（陷阱二）、還是函式簽名（陷阱三），模式一致：</p>
<ol>
<li>修改了某個「被依賴的東西」</li>
<li>沒有找出<strong>所有</strong>依賴它的地方</li>
<li>遺漏的部分在<strong>執行時</strong>才爆炸</li>
<li>由於例外處理或 UI 限制，爆炸被吞掉</li>
</ol>
<h3 id="防護公式">防護公式</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">安全的重構 = 修改定義 + 列出全部引用 + 逐一更新 + 逐一驗證</span></span></code></pre></div><p>四步少一步都會出事：</p>
<ul>
<li>少了「列出全部引用」 &ndash; 你不知道影響範圍（三個陷阱的共通原因）</li>
<li>少了「逐一更新」 &ndash; 知道但沒做完（陷阱二的第二次發生）</li>
<li>少了「逐一驗證」 &ndash; 做了但不確定對不對（陷阱一用 py_compile 驗證的盲點）</li>
</ul>
<h3 id="grep防護公式的第一步">grep：防護公式的第一步</h3>
<p>「列出全部引用」聽起來很簡單，但容易被跳過。以下是每種重構類型對應的 grep 命令：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 變數作用域變更：找出所有引用某變數的位置</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">grep -rn <span class="s2">&#34;logger&#34;</span> hooks/*.py <span class="p">|</span> grep -v <span class="s2">&#34;def.*logger&#34;</span> <span class="p">|</span> grep -v <span class="s2">&#34;^#&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 模組遷移：找出所有 import 語句</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">grep -rn <span class="s2">&#34;from common_functions import&#34;</span> .claude/hooks/*.py
</span></span><span class="line"><span class="ln">6</span><span class="cl">grep -rn <span class="s2">&#34;import common_functions&#34;</span> .claude/hooks/*.py
</span></span><span class="line"><span class="ln">7</span><span class="cl">
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"># 函式簽名變更：找出所有呼叫端</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl">grep -rn <span class="s2">&#34;save_check_log(&#34;</span> .claude/hooks/*.py</span></span></code></pre></div><p>重點是<strong>養成習慣</strong>：修改定義之前，先跑一次搜尋，看看這個名稱出現在哪些地方。這一步花不到 30 秒，但能避免幾小時的除錯。</p>
<hr>
<h2 id="防護工具箱">防護工具箱</h2>
<p>不同的驗證工具能偵測不同層級的問題。沒有銀彈，但可以根據重構類型選擇正確的工具組合。</p>
<h3 id="工具能力對照表">工具能力對照表</h3>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>語法錯誤</th>
          <th>作用域問題</th>
          <th>Import 問題</th>
          <th>參數數量</th>
          <th>語義正確性</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>py_compile</code></td>
          <td>是</td>
          <td><strong>否</strong></td>
          <td><strong>否</strong></td>
          <td><strong>否</strong></td>
          <td><strong>否</strong></td>
      </tr>
      <tr>
          <td><code>grep</code> / 文字搜尋</td>
          <td>&ndash;</td>
          <td>找出引用</td>
          <td>找出引用</td>
          <td>找出 call site</td>
          <td>&ndash;</td>
      </tr>
      <tr>
          <td>AST 分析</td>
          <td>是</td>
          <td><strong>是</strong></td>
          <td>部分</td>
          <td><strong>是</strong></td>
          <td><strong>否</strong></td>
      </tr>
      <tr>
          <td><code>pylint</code></td>
          <td>是</td>
          <td><strong>是</strong></td>
          <td><strong>是</strong></td>
          <td><strong>是</strong></td>
          <td><strong>否</strong></td>
      </tr>
      <tr>
          <td><code>mypy</code></td>
          <td>是</td>
          <td><strong>是</strong></td>
          <td><strong>是</strong></td>
          <td><strong>是</strong></td>
          <td><strong>否</strong></td>
      </tr>
      <tr>
          <td>實際執行</td>
          <td>是</td>
          <td><strong>是</strong></td>
          <td><strong>是</strong></td>
          <td><strong>是</strong></td>
          <td><strong>是</strong></td>
      </tr>
  </tbody>
</table>
<h3 id="關鍵發現">關鍵發現</h3>
<p><strong>py_compile 是必要但不充分的</strong>。它能確認「Python 能讀懂這個檔案」，但不能確認「這個檔案能正確執行」。三個陷阱中沒有一個能被 py_compile 偵測到。</p>
<p><strong>grep 是最可靠的第一步</strong>。不管是變數引用、import 路徑還是函式呼叫，文字搜尋都能找出所有使用處。它不聰明，但不會遺漏。</p>
<p><strong>實際執行是唯一能驗證語義的工具</strong>。案例 B 的語義分類錯誤，靜態工具全部無法偵測——因為程式碼邏輯上沒錯，錯的是<strong>業務語義</strong>。</p>
<h3 id="按重構類型選擇工具">按重構類型選擇工具</h3>
<table>
  <thead>
      <tr>
          <th>重構類型</th>
          <th>最低要求</th>
          <th>建議</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>移動變數定義</td>
          <td>grep + AST 分析</td>
          <td>+ 實際執行覆蓋所有路徑</td>
      </tr>
      <tr>
          <td>移動模組檔案</td>
          <td>grep + 逐一 import 驗證</td>
          <td>+ <code>echo '{}' | python3 file.py</code></td>
      </tr>
      <tr>
          <td>修改函式簽名</td>
          <td>grep + AST 參數檢查</td>
          <td>+ pylint + 測試覆蓋</td>
      </tr>
      <tr>
          <td>修改關鍵字/分類</td>
          <td>與設計文件交叉比對</td>
          <td>+ 手動場景測試</td>
      </tr>
      <tr>
          <td>統一風格（批量）</td>
          <td>先 grep 建立完整清單</td>
          <td>+ 逐一驗證，不依賴數量判斷</td>
      </tr>
  </tbody>
</table>
<h3 id="批量重構的特殊風險">批量重構的特殊風險</h3>
<p>統一化重構（如「把 16 個 Hook 的 logger 風格統一」）比單一檔案的修改危險得多，因為：</p>
<ol>
<li><strong>數量產生虛假信心</strong>：改了 13 個成功了，容易假設剩下 3 個也沒問題</li>
<li><strong>機械性動作降低警覺</strong>：重複相同操作 16 次，注意力會下降</li>
<li><strong>驗證疲勞</strong>：逐一驗證 16 個檔案很煩，容易偷懶跳過</li>
</ol>
<p>對策：建立完整清單，逐一打勾，用腳本自動化驗證。</p>
<hr>
<h2 id="建立自己的重構檢查清單">建立自己的重構檢查清單</h2>
<p>根據本章三個陷阱的經驗，任何涉及「移動或修改被引用物件」的重構，都應該執行以下清單：</p>
<h3 id="修改前強制">修改前（強制）</h3>
<ul>
<li><input disabled="" type="checkbox"> 用 <code>grep</code> 列出所有引用/使用處，建立完整清單</li>
<li><input disabled="" type="checkbox"> 評估每個引用是否需要同步更新</li>
<li><input disabled="" type="checkbox"> 確認驗證方法（不能只用 py_compile）</li>
</ul>
<h3 id="修改中">修改中</h3>
<ul>
<li><input disabled="" type="checkbox"> 按清單逐一更新每個引用</li>
<li><input disabled="" type="checkbox"> 每更新一個就在清單打勾，不跳過</li>
</ul>
<h3 id="修改後強制">修改後（強制）</h3>
<ul>
<li><input disabled="" type="checkbox"> 用 AST 分析或 pylint 驗證作用域和參數</li>
<li><input disabled="" type="checkbox"> 實際執行（或測試）覆蓋所有修改過的檔案</li>
<li><input disabled="" type="checkbox"> 如果是批量修改，逐一驗證每個檔案，不依賴數量判斷</li>
</ul>
<hr>
<h2 id="思考題">思考題</h2>
<ol>
<li>
<p>為什麼動態語言（Python、JavaScript）比靜態語言（Java、Dart）更容易出現這類問題？靜態語言的什麼機制能在編譯期偵測到陷阱一和陷阱二？</p>
</li>
<li>
<p>陷阱三案例 B（語義分類錯誤）無法被任何靜態工具偵測。你會如何設計一個測試來防護這類問題？</p>
</li>
<li>
<p>「頂層例外處理吞掉錯誤」既是安全機制（防止 crash），也是風險（隱藏 bug）。如何在這兩個需求之間取得平衡？（可參考 <a href="/blog/python/05-error-testing/error-infrastructure/" data-link-title="5.5 頂層例外處理機制" data-link-desc="run_hook_safely 與統一錯誤基礎設施">5.5 頂層例外處理機制</a> 的設計方案）</p>
</li>
</ol>
<h2 id="實作練習">實作練習</h2>
<ol>
<li>
<p>選擇一個使用全域變數的 Python 專案，嘗試將一個全域變數移入函式內部。在修改前後分別用 py_compile、AST 分析、pylint 驗證，比較各工具的偵測能力。</p>
</li>
<li>
<p>寫一個 Python 腳本，接受一個 Python 檔案和一個變數名稱作為輸入，輸出「所有引用該變數但沒有在參數中接收它的函式」清單。</p>
</li>
<li>
<p>設計一個模組遷移的自動化腳本：接受舊路徑和新路徑，自動搜尋所有 <code>import</code> 語句並更新，最後逐一驗證每個修改過的檔案是否能成功 import。</p>
</li>
</ol>
<hr>
<p><em>上一章：<a href="/blog/python/07-refactoring/unified-infrastructure/" data-link-title="大規模統一化重構" data-link-desc="從 44 種不同實作到統一基礎設施：日誌、訊息、風格的三階段漸進式重構">大規模統一化重構</a></em>
<em>下一章：<a href="/blog/python/07-refactoring/non-code-refactoring/" data-link-title="非程式碼的重構" data-link-desc="用 Progressive Disclosure 精簡膨脹的規則文件，文件重構和程式碼重構是同一套思維">非程式碼的重構</a></em>
<em>相關：<a href="/blog/python/07-refactoring/scope-regression/" data-link-title="作用域迴歸案例研究" data-link-desc="從 IMP-003 事件學習 Python 變數作用域的陷阱">作用域迴歸案例研究</a> &ndash; 陷阱一的完整深入分析</em>
<em>相關：<a href="/blog/python/05-error-testing/error-infrastructure/" data-link-title="5.5 頂層例外處理機制" data-link-desc="run_hook_safely 與統一錯誤基礎設施">5.5 頂層例外處理機制</a> &ndash; 例外處理如何隱藏 bug 的機制分析</em></p>
]]></content:encoded></item><item><title>非程式碼的重構</title><link>https://tarrragon.github.io/blog/python/07-refactoring/non-code-refactoring/</link><pubDate>Wed, 04 Mar 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/python/07-refactoring/non-code-refactoring/</guid><description>&lt;p>前面幾章我們重構的對象都是程式碼：提取函式、消除魔法數字、分離配置。但你有沒有想過，&lt;strong>文件也會腐敗&lt;/strong>？&lt;/p>
&lt;p>當一份規則文件從 50 行長到 450 行，閱讀者要在腦中同時追蹤的概念數量跟&lt;a href="https://tarrragon.github.io/blog/python/07-refactoring/refactoring-strategy/" data-link-title="重構的動機與策略" data-link-desc="從 Hook 系統重構經驗出發，學習何時重構、何時不該重構，以及如何將大規模重構拆分成可管理的階段">第一章&lt;/a>提到的那個 858 行 Python 檔案沒有本質區別。認知負擔不只存在於程式碼中——任何需要人類閱讀和理解的東西都受它影響。&lt;/p>
&lt;h2 id="問題文件膨脹">問題：文件膨脹&lt;/h2>
&lt;p>v0.28.0 到 v0.31.0 之間，專案的規則文件經歷了 9 個版本的迭代。每次迭代都在解決真實的問題：補充遺漏的邊界情況、新增流程步驟、記錄決策理由。每一次修改都合理，但累積的結果是：&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">parallel-dispatch.md 280 行 ← 原本是「什麼時候可以並行」的簡單指南
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">task-splitting.md 230 行 ← 原本是「怎麼拆任務」的清單
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">ticket-lifecycle.md 220 行 ← 原本是「Ticket 狀態怎麼轉」的流程
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">version-progression.md 180 行 ← 原本是「什麼時候推進版本」的判斷
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">incident-response.md 170 行 ← 原本是「出錯了怎麼辦」的流程
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">query-vs-research.md 130 行 ← 原本是「查資料要不要派人」的二選一
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">plan-to-ticket.md 100 行 ← 原本是「計畫怎麼變成 Ticket」的流程
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">decision-tree.md 452 行 ← 核心決策樹，每次都在「補一個分支」&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>問題不是內容不正確——每一行都有存在的理由。問題是&lt;strong>讀不動&lt;/strong>。&lt;/p>
&lt;p>一個新加入的代理人需要閱讀 &lt;code>parallel-dispatch.md&lt;/code> 來決定能不能並行派發任務。它真正需要的資訊是：觸發條件（5 行）、安全檢查清單（6 行）、決策流程圖（5 行）。但它必須在 280 行中找到這些——剩下的 264 行是 5W1H 格式範例、分析任務的並行原則、Agent Teams 場景表、進度追蹤模板。&lt;/p>
&lt;p>這就像在一個 500 行的函式裡找那 20 行核心邏輯。&lt;/p>
&lt;h3 id="膨脹的過程">膨脹的過程&lt;/h3>
&lt;p>文件膨脹的方式和程式碼膨脹幾乎一模一樣。回顧 &lt;code>parallel-dispatch.md&lt;/code> 的成長軌跡：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>版本&lt;/th>
 &lt;th>行數&lt;/th>
 &lt;th>新增原因&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>v1.0&lt;/td>
 &lt;td>60 行&lt;/td>
 &lt;td>初始版本：觸發條件 + 決策流程&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>v1.1&lt;/td>
 &lt;td>90 行&lt;/td>
 &lt;td>補充：安全檢查清單（因為有人忘記檢查檔案衝突）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>v1.2&lt;/td>
 &lt;td>130 行&lt;/td>
 &lt;td>新增：Agent Teams 派發方式（新功能）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>v2.0&lt;/td>
 &lt;td>180 行&lt;/td>
 &lt;td>新增：5W1H 格式範例（因為有人不知道怎麼寫）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>v2.3&lt;/td>
 &lt;td>230 行&lt;/td>
 &lt;td>新增：分析任務並行原則、進度追蹤模板&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>v2.5&lt;/td>
 &lt;td>280 行&lt;/td>
 &lt;td>新增：並行派發後驗證流程（因為有人忘記驗證）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每一次新增都在解決真實問題。沒有人故意讓文件變長。但六個版本之後，一個簡單的「能不能並行」判斷指南變成了包羅萬象的操作手冊。&lt;/p>
&lt;p>這和程式碼中的「上帝函式」成因一樣：每次「加一點」都合理，但沒有人停下來問「這個函式是不是該拆了」。&lt;/p>
&lt;h2 id="解決方案progressive-disclosure">解決方案：Progressive Disclosure&lt;/h2>
&lt;p>程式碼重構有 Extract Method——把函式內部的細節提取到獨立函式中。文件重構有對應的技巧：&lt;strong>Progressive Disclosure&lt;/strong>（漸進式揭露）。&lt;/p>
&lt;p>核心思想：&lt;strong>常駐只保留決策入口和強制規則，細節放到參考文件中按需載入。&lt;/strong>&lt;/p>
&lt;p>這和函式設計的道理一樣。你不會把排序演算法的完整實作放在 &lt;code>main()&lt;/code> 裡面，你會呼叫 &lt;code>sort()&lt;/code>。同樣地，規則文件的讀者不需要在判斷「能不能並行」時看到「Agent Teams 的 3-4x 成本計算方式」。&lt;/p>
&lt;h3 id="精簡原則">精簡原則&lt;/h3>
&lt;p>我們制定了四條原則來指導文件重構，和程式碼重構的原則一一對應：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>程式碼原則&lt;/th>
 &lt;th>文件原則&lt;/th>
 &lt;th>說明&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>單一責任&lt;/td>
 &lt;td>一個文件回答一個問題&lt;/td>
 &lt;td>常駐文件只回答「怎麼判斷」，不回答「細節怎麼做」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>資訊層級&lt;/td>
 &lt;td>決策入口 → 強制規則 → 參考細節&lt;/td>
 &lt;td>讀者按需深入，不強制閱讀全部&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>DRY&lt;/td>
 &lt;td>細節只寫一次，放在 references/&lt;/td>
 &lt;td>多個文件引用同一份參考，不複製貼上&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>量化驗證&lt;/td>
 &lt;td>精簡前後行數對比&lt;/td>
 &lt;td>有數字才知道改善了多少&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="具體做法">具體做法&lt;/h3>
&lt;p>以 &lt;code>parallel-dispatch.md&lt;/code> 為例，精簡過程分三步：&lt;/p>
&lt;h4 id="step-1識別決策入口和參考細節">Step 1：識別「決策入口」和「參考細節」&lt;/h4>
&lt;p>閱讀 280 行內容，對每一段問：「讀者在做『能不能並行』這個決策時需要這段嗎？」&lt;/p>
&lt;ul>
&lt;li>觸發條件表格 → 需要（決策入口）&lt;/li>
&lt;li>安全檢查清單 → 需要（強制規則）&lt;/li>
&lt;li>決策流程圖 → 需要（快查表）&lt;/li>
&lt;li>數量原則 → 需要（簡短規則）&lt;/li>
&lt;li>不適用場景 → 需要（負面清單）&lt;/li>
&lt;li>5W1H 格式範例 → 不需要（移出）&lt;/li>
&lt;li>Agent Teams 場景表 → 不需要（移出）&lt;/li>
&lt;li>進度追蹤模板 → 不需要（移出）&lt;/li>
&lt;/ul>
&lt;h4 id="step-2提取到參考文件">Step 2：提取到參考文件&lt;/h4>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl"># 精簡前
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">parallel-dispatch.md (280 行，所有內容混在一起)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"># 精簡後
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">parallel-dispatch.md (98 行，決策入口 + 強制規則)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> └→ references/parallel-dispatch-details.md (剩餘細節，按需查閱)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h4 id="step-3加入連結">Step 3：加入連結&lt;/h4>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-markdown" data-lang="markdown">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="gu">## 並行派發後驗證（強制）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="gu">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">所有並行代理人回報完成後，**必須**執行 &lt;span class="sb">`git diff --stat`&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">&amp;gt; 詳細驗證步驟和常見原因：.claude/references/parallel-dispatch-details.md&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>讀者看到這段就知道：「驗證是強制的，具體步驟在那個連結裡。」它可以選擇深入閱讀，也可以先完成手邊的決策。&lt;/p></description><content:encoded><![CDATA[<p>前面幾章我們重構的對象都是程式碼：提取函式、消除魔法數字、分離配置。但你有沒有想過，<strong>文件也會腐敗</strong>？</p>
<p>當一份規則文件從 50 行長到 450 行，閱讀者要在腦中同時追蹤的概念數量跟<a href="/blog/python/07-refactoring/refactoring-strategy/" data-link-title="重構的動機與策略" data-link-desc="從 Hook 系統重構經驗出發，學習何時重構、何時不該重構，以及如何將大規模重構拆分成可管理的階段">第一章</a>提到的那個 858 行 Python 檔案沒有本質區別。認知負擔不只存在於程式碼中——任何需要人類閱讀和理解的東西都受它影響。</p>
<h2 id="問題文件膨脹">問題：文件膨脹</h2>
<p>v0.28.0 到 v0.31.0 之間，專案的規則文件經歷了 9 個版本的迭代。每次迭代都在解決真實的問題：補充遺漏的邊界情況、新增流程步驟、記錄決策理由。每一次修改都合理，但累積的結果是：</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">parallel-dispatch.md     280 行  ← 原本是「什麼時候可以並行」的簡單指南
</span></span><span class="line"><span class="ln">2</span><span class="cl">task-splitting.md        230 行  ← 原本是「怎麼拆任務」的清單
</span></span><span class="line"><span class="ln">3</span><span class="cl">ticket-lifecycle.md      220 行  ← 原本是「Ticket 狀態怎麼轉」的流程
</span></span><span class="line"><span class="ln">4</span><span class="cl">version-progression.md   180 行  ← 原本是「什麼時候推進版本」的判斷
</span></span><span class="line"><span class="ln">5</span><span class="cl">incident-response.md     170 行  ← 原本是「出錯了怎麼辦」的流程
</span></span><span class="line"><span class="ln">6</span><span class="cl">query-vs-research.md     130 行  ← 原本是「查資料要不要派人」的二選一
</span></span><span class="line"><span class="ln">7</span><span class="cl">plan-to-ticket.md        100 行  ← 原本是「計畫怎麼變成 Ticket」的流程
</span></span><span class="line"><span class="ln">8</span><span class="cl">decision-tree.md         452 行  ← 核心決策樹，每次都在「補一個分支」</span></span></code></pre></div><p>問題不是內容不正確——每一行都有存在的理由。問題是<strong>讀不動</strong>。</p>
<p>一個新加入的代理人需要閱讀 <code>parallel-dispatch.md</code> 來決定能不能並行派發任務。它真正需要的資訊是：觸發條件（5 行）、安全檢查清單（6 行）、決策流程圖（5 行）。但它必須在 280 行中找到這些——剩下的 264 行是 5W1H 格式範例、分析任務的並行原則、Agent Teams 場景表、進度追蹤模板。</p>
<p>這就像在一個 500 行的函式裡找那 20 行核心邏輯。</p>
<h3 id="膨脹的過程">膨脹的過程</h3>
<p>文件膨脹的方式和程式碼膨脹幾乎一模一樣。回顧 <code>parallel-dispatch.md</code> 的成長軌跡：</p>
<table>
  <thead>
      <tr>
          <th>版本</th>
          <th>行數</th>
          <th>新增原因</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>v1.0</td>
          <td>60 行</td>
          <td>初始版本：觸發條件 + 決策流程</td>
      </tr>
      <tr>
          <td>v1.1</td>
          <td>90 行</td>
          <td>補充：安全檢查清單（因為有人忘記檢查檔案衝突）</td>
      </tr>
      <tr>
          <td>v1.2</td>
          <td>130 行</td>
          <td>新增：Agent Teams 派發方式（新功能）</td>
      </tr>
      <tr>
          <td>v2.0</td>
          <td>180 行</td>
          <td>新增：5W1H 格式範例（因為有人不知道怎麼寫）</td>
      </tr>
      <tr>
          <td>v2.3</td>
          <td>230 行</td>
          <td>新增：分析任務並行原則、進度追蹤模板</td>
      </tr>
      <tr>
          <td>v2.5</td>
          <td>280 行</td>
          <td>新增：並行派發後驗證流程（因為有人忘記驗證）</td>
      </tr>
  </tbody>
</table>
<p>每一次新增都在解決真實問題。沒有人故意讓文件變長。但六個版本之後，一個簡單的「能不能並行」判斷指南變成了包羅萬象的操作手冊。</p>
<p>這和程式碼中的「上帝函式」成因一樣：每次「加一點」都合理，但沒有人停下來問「這個函式是不是該拆了」。</p>
<h2 id="解決方案progressive-disclosure">解決方案：Progressive Disclosure</h2>
<p>程式碼重構有 Extract Method——把函式內部的細節提取到獨立函式中。文件重構有對應的技巧：<strong>Progressive Disclosure</strong>（漸進式揭露）。</p>
<p>核心思想：<strong>常駐只保留決策入口和強制規則，細節放到參考文件中按需載入。</strong></p>
<p>這和函式設計的道理一樣。你不會把排序演算法的完整實作放在 <code>main()</code> 裡面，你會呼叫 <code>sort()</code>。同樣地，規則文件的讀者不需要在判斷「能不能並行」時看到「Agent Teams 的 3-4x 成本計算方式」。</p>
<h3 id="精簡原則">精簡原則</h3>
<p>我們制定了四條原則來指導文件重構，和程式碼重構的原則一一對應：</p>
<table>
  <thead>
      <tr>
          <th>程式碼原則</th>
          <th>文件原則</th>
          <th>說明</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>單一責任</td>
          <td>一個文件回答一個問題</td>
          <td>常駐文件只回答「怎麼判斷」，不回答「細節怎麼做」</td>
      </tr>
      <tr>
          <td>資訊層級</td>
          <td>決策入口 → 強制規則 → 參考細節</td>
          <td>讀者按需深入，不強制閱讀全部</td>
      </tr>
      <tr>
          <td>DRY</td>
          <td>細節只寫一次，放在 references/</td>
          <td>多個文件引用同一份參考，不複製貼上</td>
      </tr>
      <tr>
          <td>量化驗證</td>
          <td>精簡前後行數對比</td>
          <td>有數字才知道改善了多少</td>
      </tr>
  </tbody>
</table>
<h3 id="具體做法">具體做法</h3>
<p>以 <code>parallel-dispatch.md</code> 為例，精簡過程分三步：</p>
<h4 id="step-1識別決策入口和參考細節">Step 1：識別「決策入口」和「參考細節」</h4>
<p>閱讀 280 行內容，對每一段問：「讀者在做『能不能並行』這個決策時需要這段嗎？」</p>
<ul>
<li>觸發條件表格 → 需要（決策入口）</li>
<li>安全檢查清單 → 需要（強制規則）</li>
<li>決策流程圖 → 需要（快查表）</li>
<li>數量原則 → 需要（簡短規則）</li>
<li>不適用場景 → 需要（負面清單）</li>
<li>5W1H 格式範例 → 不需要（移出）</li>
<li>Agent Teams 場景表 → 不需要（移出）</li>
<li>進度追蹤模板 → 不需要（移出）</li>
</ul>
<h4 id="step-2提取到參考文件">Step 2：提取到參考文件</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl"># 精簡前
</span></span><span class="line"><span class="ln">2</span><span class="cl">parallel-dispatch.md (280 行，所有內容混在一起)
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"># 精簡後
</span></span><span class="line"><span class="ln">5</span><span class="cl">parallel-dispatch.md (98 行，決策入口 + 強制規則)
</span></span><span class="line"><span class="ln">6</span><span class="cl">  └→ references/parallel-dispatch-details.md (剩餘細節，按需查閱)</span></span></code></pre></div><h4 id="step-3加入連結">Step 3：加入連結</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="ln">1</span><span class="cl"><span class="gu">## 並行派發後驗證（強制）
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="gu"></span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">所有並行代理人回報完成後，**必須**執行 <span class="sb">`git diff --stat`</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">&gt; 詳細驗證步驟和常見原因：.claude/references/parallel-dispatch-details.md</span></span></code></pre></div><p>讀者看到這段就知道：「驗證是強制的，具體步驟在那個連結裡。」它可以選擇深入閱讀，也可以先完成手邊的決策。</p>
<h3 id="精簡前後對比">精簡前後對比</h3>
<p>看看 <code>incident-response.md</code> 精簡前後的結構差異：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="gh"># 精簡前 (170 行)
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="gh"></span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="gu">## 強制流程
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="gu"></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="gu">## 強制觸發條件
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="gu"></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="gu">## 派發對應表
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="gu"></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="gu">## 多視角分析原則             ← Level 3
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="gu"></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="gu">## 安全等級分類               ← Level 3
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="gu"></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="gu">## 報告格式範例               ← Level 3
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="gu"></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="gu">## 禁止行為
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="gu"></span>（禁止清單）</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="gh"># 精簡後 (64 行)
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="gh"></span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="gu">## 強制流程
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="gu"></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="gu">## 強制觸發條件
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="gu"></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="gu">## 派發對應表
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="gu"></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="gu">## 禁止行為
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="gu"></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="gu">## 相關文件
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="gu"></span>&gt; 詳細規則：.claude/references/incident-response-details.md</span></span></code></pre></div><p>Level 3 的內容（多視角分析、安全等級、報告格式）整段移到 <code>references/</code> 目錄。常駐文件只剩下做決策需要的資訊。</p>
<h2 id="實際成果">實際成果</h2>
<p>7 個規則文件的精簡結果：</p>
<table>
  <thead>
      <tr>
          <th>文件</th>
          <th>精簡前</th>
          <th>精簡後</th>
          <th>縮減</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>parallel-dispatch.md</td>
          <td>280 行</td>
          <td>98 行</td>
          <td>-65%</td>
      </tr>
      <tr>
          <td>task-splitting.md</td>
          <td>230 行</td>
          <td>93 行</td>
          <td>-60%</td>
      </tr>
      <tr>
          <td>ticket-lifecycle.md</td>
          <td>220 行</td>
          <td>70 行</td>
          <td>-68%</td>
      </tr>
      <tr>
          <td>version-progression.md</td>
          <td>180 行</td>
          <td>69 行</td>
          <td>-62%</td>
      </tr>
      <tr>
          <td>incident-response.md</td>
          <td>170 行</td>
          <td>64 行</td>
          <td>-62%</td>
      </tr>
      <tr>
          <td>query-vs-research.md</td>
          <td>130 行</td>
          <td>46 行</td>
          <td>-65%</td>
      </tr>
      <tr>
          <td>plan-to-ticket.md</td>
          <td>100 行</td>
          <td>43 行</td>
          <td>-57%</td>
      </tr>
      <tr>
          <td><strong>合計</strong></td>
          <td><strong>1310 行</strong></td>
          <td><strong>483 行</strong></td>
          <td><strong>-63%</strong></td>
      </tr>
  </tbody>
</table>
<p>核心決策樹 <code>decision-tree.md</code> 也從 452 行精簡到 286 行（-37%）。它的縮減幅度較小，因為決策樹本身就是「決策入口」——大部分內容都是必要的分支判斷。</p>
<blockquote>
<p><strong>後記</strong>：上面的數字是 v3.0.0 精簡完成時的快照。精簡後不到兩週，<code>plan-to-ticket.md</code> 因為新增「執行中額外發現」流程從 43 行長回 87 行，<code>decision-tree.md</code> 因為新增 TDD Phase 路由從 286 行長回 434 行。這不代表精簡失敗——新增的內容都是必要的新功能。但它提醒我們：<strong>文件重構和程式碼重構一樣，是持續的紀律。</strong></p></blockquote>
<h2 id="通用原則">通用原則</h2>
<p>從這次文件重構中，我們提煉出四個可複用的原則：</p>
<h3 id="原則-1單一責任">原則 1：單一責任</h3>
<p>每份文件應該只回答一個核心問題。</p>
<table>
  <thead>
      <tr>
          <th>文件</th>
          <th>核心問題</th>
          <th>不該包含</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>parallel-dispatch.md</td>
          <td>能不能並行？</td>
          <td>Agent Teams 的完整操作手冊</td>
      </tr>
      <tr>
          <td>incident-response.md</td>
          <td>出錯了怎麼處理？</td>
          <td>每種錯誤的詳細分析範例</td>
      </tr>
      <tr>
          <td>ticket-lifecycle.md</td>
          <td>Ticket 狀態怎麼轉？</td>
          <td>Hook 的技術實作細節</td>
      </tr>
  </tbody>
</table>
<p>判斷方式和函式一樣：如果描述這份文件的用途需要「和」這個字，它可能需要拆分。</p>
<h3 id="原則-2資訊層級">原則 2：資訊層級</h3>
<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">Level 0: 快查表（一眼就能找到答案）
</span></span><span class="line"><span class="ln">2</span><span class="cl">Level 1: 強制規則（必須遵守的約束）
</span></span><span class="line"><span class="ln">3</span><span class="cl">Level 2: 決策流程（判斷邏輯）
</span></span><span class="line"><span class="ln">4</span><span class="cl">Level 3: 參考細節（範例、模板、歷史記錄）</span></span></code></pre></div><p>常駐文件只包含 Level 0-2，Level 3 放在 <code>references/</code> 目錄下。</p>
<p>對比程式碼的資訊層級：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># Level 0: 函式簽名（一眼就知道做什麼）</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">def</span> <span class="nf">can_dispatch_parallel</span><span class="p">(</span><span class="n">tasks</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="n">Task</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="c1"># Level 1: Guard clauses（強制規則）</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">tasks</span><span class="p">)</span> <span class="o">&lt;</span> <span class="mi">2</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">if</span> <span class="n">has_dependency</span><span class="p">(</span><span class="n">tasks</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="c1"># Level 2: 核心邏輯（判斷邏輯）</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">return</span> <span class="ow">not</span> <span class="n">has_file_overlap</span><span class="p">(</span><span class="n">tasks</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># Level 3: 實作細節（在被呼叫的函式裡）</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="k">def</span> <span class="nf">has_file_overlap</span><span class="p">(</span><span class="n">tasks</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="c1"># 50 行的具體實作...</span></span></span></code></pre></div><p>同一個概念，同一個結構。</p>
<h3 id="原則-3dry">原則 3：DRY</h3>
<p>文件之間也會出現重複。同一段「Wave 獨立性原則」如果在 <code>parallel-dispatch.md</code>、<code>task-splitting.md</code>、<code>version-progression.md</code> 三個地方都寫了，修改時就要改三處。</p>
<p>解決方式和程式碼一樣：抽到共用的參考文件，其他文件用連結引用。</p>
<h3 id="原則-4量化驗證">原則 4：量化驗證</h3>
<p>沒有數字的重構是自我感覺良好。精簡前後一定要量化比較：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 文件行數統計</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">wc -l .claude/rules/flows/*.md .claude/rules/guides/*.md
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 前後對比</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;精簡前: 1310 行&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;精簡後:  483 行&#34;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;縮減率: 63%&#34;</span></span></span></code></pre></div><p>和程式碼重構的 <code>-65%</code> 行數對比一樣，行數本身不是目標，但它是認知負擔降低的代理指標。</p>
<h2 id="常見錯誤">常見錯誤</h2>
<p>文件重構也有自己的陷阱：</p>
<h3 id="錯誤-1過度精簡">錯誤 1：過度精簡</h3>
<p>把所有細節都移走，常駐文件只剩下標題和連結。讀者每做一個決策都要點開參考文件，跳轉次數太多反而增加認知負擔。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="ln">1</span><span class="cl"><span class="gh"># 錯誤：過度精簡
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="gh"></span><span class="gu">## 並行派發
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="gu"></span><span class="k">&gt; </span><span class="ge">詳見：references/parallel-dispatch-details.md
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="ge"></span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="gu">## 任務拆分
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="gu"></span>&gt; 詳見：references/task-splitting-details.md</span></span></code></pre></div><p>這等於一個函式裡全是 <code>call_other_function()</code>，讀者什麼都看不到。常駐文件至少要包含決策入口（判斷邏輯）和強制規則（不可違反的約束）。</p>
<h3 id="錯誤-2按章節拆分而非按層級拆分">錯誤 2：按「章節」拆分而非按「層級」拆分</h3>
<p>把文件按目錄拆成多個小文件，但每個小文件仍然混合了決策入口和參考細節。這只是把一個大問題變成了多個小問題。</p>
<p>正確的拆分維度是<strong>資訊層級</strong>，不是<strong>主題章節</strong>。</p>
<h3 id="錯誤-3沒有更新連結">錯誤 3：沒有更新連結</h3>
<p>精簡後忘記在常駐文件中加入參考文件的連結。讀者需要細節時找不到入口。這和重構後忘記更新 import 一樣危險。</p>
<h2 id="程式碼-vs-文件重構對照表">程式碼 vs 文件重構對照表</h2>
<p>程式碼重構和文件重構的手法其實是同一套思維的不同實踐：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>程式碼重構</th>
          <th>文件重構</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>壞味道</td>
          <td>函式太長、巢狀過深</td>
          <td>文件太長、層級不分</td>
      </tr>
      <tr>
          <td>核心手法</td>
          <td>Extract Method</td>
          <td>Progressive Disclosure</td>
      </tr>
      <tr>
          <td>單一責任</td>
          <td>一個函式做一件事</td>
          <td>一份文件回答一個問題</td>
      </tr>
      <tr>
          <td>DRY</td>
          <td>提取共用模組</td>
          <td>提取共用參考文件</td>
      </tr>
      <tr>
          <td>量化指標</td>
          <td>行數、認知負擔指數</td>
          <td>行數、常駐 vs 參考比例</td>
      </tr>
      <tr>
          <td>驗證方式</td>
          <td>測試通過</td>
          <td>讀者能在 30 秒內找到答案</td>
      </tr>
      <tr>
          <td>失敗的重構</td>
          <td>過度拆分導致跳轉太多</td>
          <td>過度精簡導致資訊不足</td>
      </tr>
      <tr>
          <td>觸發條件</td>
          <td>函式超過 30 行</td>
          <td>文件超過 100 行且混合多個層級</td>
      </tr>
      <tr>
          <td>典型比例</td>
          <td>858 行 → 296 行 (-65%)</td>
          <td>280 行 → 98 行 (-65%)</td>
      </tr>
  </tbody>
</table>
<p>最後一行不是巧合。兩個案例的縮減率接近，是因為底層原理相同：大約 1/3 的內容是核心邏輯（決策入口），2/3 的內容是支撐細節（參考資料）。</p>
<h2 id="什麼時候該重構文件">什麼時候該重構文件</h2>
<p>和程式碼一樣，不是所有文件都需要重構。觸發條件：</p>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>閾值</th>
          <th>行動</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>文件長度</td>
          <td>&gt; 100 行且混合多層級</td>
          <td>評估是否需要分離</td>
      </tr>
      <tr>
          <td>讀者反饋</td>
          <td>「找不到需要的資訊」</td>
          <td>重新組織資訊層級</td>
      </tr>
      <tr>
          <td>更新頻率</td>
          <td>每次更新都在不同段落</td>
          <td>考慮按變更頻率拆分</td>
      </tr>
      <tr>
          <td>重複內容</td>
          <td>同一段話出現在 2+ 份文件</td>
          <td>提取到共用參考</td>
      </tr>
  </tbody>
</table>
<h2 id="思考題">思考題</h2>
<ol>
<li>你的專案有沒有「什麼都寫在 README」的情況？如果有，試著用 Progressive Disclosure 原則拆分它。</li>
<li>為什麼 <code>decision-tree.md</code> 的縮減幅度（-37%）比其他文件（-57% 到 -68%）小？這說明了什麼？</li>
<li>文件重構有一個程式碼重構沒有的風險：「過度精簡導致讀者找不到需要的資訊」。你會怎麼驗證精簡後的文件仍然足夠完整？</li>
</ol>
<h2 id="實作練習">實作練習</h2>
<h3 id="練習-1評估文件健康度">練習 1：評估文件健康度</h3>
<p>找一份超過 150 行的文件（你自己的專案 README、API 文件、團隊 Wiki），回答以下問題：</p>
<ul>
<li>這份文件回答幾個核心問題？（如果超過 1 個，考慮拆分）</li>
<li>讀者能在 30 秒內找到最常需要的資訊嗎？</li>
<li>有沒有段落只在特定情境下才需要閱讀？</li>
</ul>
<h3 id="練習-2執行-progressive-disclosure">練習 2：執行 Progressive Disclosure</h3>
<p>對那份文件執行三步精簡：</p>
<ol>
<li>標記每一段的資訊層級（Level 0-3）</li>
<li>將 Level 3 的內容提取到獨立的參考文件</li>
<li>在原文件中加入連結</li>
</ol>
<h3 id="練習-3量化驗證">練習 3：量化驗證</h3>
<p>計算精簡前後的行數和縮減率。如果縮減率低於 30%，思考是否原本的結構就已經不錯。如果超過 70%，檢查是否過度精簡了。</p>
<details>
<summary>參考範圍</summary>
<p>根據本章的實際案例，健康的縮減率大約在 55%-68% 之間。核心決策類文件（如 decision-tree）的縮減率通常較低（30%-40%），因為它本身就是決策入口。</p>
</details>
<h2 id="小結">小結</h2>
<p>重構不只是程式碼的事。任何需要人類閱讀的東西——規則文件、操作手冊、架構文件——都會隨著時間膨脹，累積認知負擔。</p>
<p>核心手法是 Progressive Disclosure：常駐只保留讀者<strong>當下需要</strong>的資訊，細節放到參考文件中<strong>按需載入</strong>。這和 Extract Method 的道理完全一樣：呼叫端只需要知道函式名稱和參數，不需要看到完整實作。</p>
<p>程式碼壞味道有 code smell，文件壞味道也有——只是比較少人談論。</p>
<hr>
<p><em>上一章：<a href="/blog/python/07-refactoring/refactoring-pitfalls/" data-link-title="重構陷阱與防護" data-link-desc="三個真實重構事故的共通模式：部分更新問題與系統性防護方法">重構陷阱與防護</a></em>
<em>下一章：<a href="/blog/python/07-refactoring/case-study/" data-link-title="完整案例回顧" data-link-desc="從超過 30 個 Hook 各自為政到系統化品質工程，三個階段的完整重構復盤">完整案例回顧</a></em></p>
]]></content:encoded></item><item><title>完整案例回顧</title><link>https://tarrragon.github.io/blog/python/07-refactoring/case-study/</link><pubDate>Wed, 04 Mar 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/python/07-refactoring/case-study/</guid><description>&lt;p>本章是模組七的總結。前面九章從動機判斷開始，依序教了壞味道識別、配置分離、DRY 原則、常數管理、消除魔法數字、大規模統一化、重構陷阱防護、作用域迴歸、以及非程式碼的重構——這些都是從同一個真實專案提煉的。現在把時間線拉開，看看這些技術在三個階段中如何逐步應用，以及過程中犯了哪些錯。&lt;/p>
&lt;h2 id="起點超過-30-個-hook-各自為政">起點：超過 30 個 Hook 各自為政&lt;/h2>
&lt;p>v0.28.0 之前的 Hook 系統是「有機生長」的典型案例。32 個 Hook 各自獨立開發，沒有共用程式庫、沒有統一風格、沒有測試。&lt;/p>
&lt;p>具體問題：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>問題&lt;/th>
 &lt;th>嚴重度&lt;/th>
 &lt;th>量化&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>單檔過大&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>task-dispatch 達 858 行&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>函式重複&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>&lt;code>run_git_command&lt;/code> 等函式在多個檔案中複製貼上&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>配置硬編碼&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>代理人清單散落在程式碼各處&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>魔法數字&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>&lt;code>line[9:]&lt;/code> 這類寫法隨處可見&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>無測試&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>任何修改都是盲改&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這些問題不是一天造成的。每次新增 Hook 時，最快的做法就是從現有 Hook 複製一份再改。沒人想著「先建共用模組」，因為每次都只是「加一個小功能」。第一個 Hook 50 行，第二個 80 行，到第十個 Hook 時，已經有三四份 &lt;code>run_git_command&lt;/code> 的副本了。但每次都覺得「下次再整理」。&lt;/p>
&lt;p>用 &lt;a href="https://tarrragon.github.io/blog/python/07-refactoring/code-smells/" data-link-title="程式碼壞味道偵測" data-link-desc="從三級分類系統到偵測工具鏈，建立系統化的程式碼品質防線">程式碼壞味道識別&lt;/a> 中教的 grep 方法掃描一次：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">grep -rh &lt;span class="s2">&amp;#34;^def &amp;#34;&lt;/span> .claude/hooks/*.py &lt;span class="p">|&lt;/span> sort &lt;span class="p">|&lt;/span> uniq -c &lt;span class="p">|&lt;/span> sort -rn &lt;span class="p">|&lt;/span> head -5&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>掃描後會發現同樣的函式定義散落在多個檔案中。&lt;code>run_git_command&lt;/code> 出現在 2 個以上的 Hook 裡，&lt;code>get_current_branch&lt;/code>、&lt;code>read_yaml_file&lt;/code> 等也有各自的副本。如果修復 &lt;code>run_git_command&lt;/code> 的一個 bug，你需要同時改每個有副本的檔案，而且不能漏掉任何一個。&lt;/p>
&lt;p>累積到 32 個 Hook 時，技術債務已經大到無法忽視。&lt;/p>
&lt;h2 id="第一階段v0280-結構性重構">第一階段：v0.28.0 結構性重構&lt;/h2>
&lt;p>第一階段的目標很明確：消除重複、建立結構。我們把工作拆成四個 Wave，每個 Wave 有獨立的交付物和驗證點。&lt;/p>
&lt;h3 id="wave-1建立共用程式庫">Wave 1：建立共用程式庫&lt;/h3>
&lt;p>先不動任何 Hook 檔案。第一步是把散落各處的重複邏輯抽取到獨立模組，並為每個模組寫測試。&lt;/p>
&lt;p>建立的模組：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>模組&lt;/th>
 &lt;th>職責&lt;/th>
 &lt;th>對應的壞味道&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>config_loader&lt;/td>
 &lt;td>讀取 YAML 配置&lt;/td>
 &lt;td>ARCH-001 硬編碼配置&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>git_utils&lt;/td>
 &lt;td>封裝 Git 命令&lt;/td>
 &lt;td>IMP-001 重複程式碼&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>hook_io&lt;/td>
 &lt;td>統一 Hook I/O 處理&lt;/td>
 &lt;td>IMP-001 重複程式碼&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>hook_logging&lt;/td>
 &lt;td>統一日誌設定&lt;/td>
 &lt;td>IMP-001 重複程式碼&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>為什麼先建程式庫而不是先改 Hook？因為如果直接改 Hook，會遇到雞生蛋問題——Hook A 需要共用函式，但共用函式還沒建立。先建好程式庫並通過測試，後續每改一個 Hook 都有安全網。&lt;/p>
&lt;p>建立模組時的關鍵決策：介面設計先於實作。我們先定義每個模組的公開函式簽名，寫測試驗證這些簽名的行為，最後才把各 Hook 中的重複邏輯搬進來。這確保了模組的介面是「為使用者設計」的，而不是「照搬原始碼」的。&lt;/p>
&lt;p>這個順序的思考方式在 &lt;a href="https://tarrragon.github.io/blog/python/07-refactoring/dry-principle/" data-link-title="DRY 原則與共用程式庫" data-link-desc="學習識別重複程式碼並建立共用模組，含模組演進與漸進遷移策略">DRY 原則與共用程式庫&lt;/a> 中有詳細說明。&lt;/p>
&lt;h3 id="wave-2配置分離">Wave 2：配置分離&lt;/h3>
&lt;p>把 task-dispatch 中的硬編碼清單抽到 YAML 檔案：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c"># agents.yaml（節錄）&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">agents&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">incident-responder&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">triggers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;test failed&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;compile error&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;runtime error&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">priority&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">system-analyst&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">triggers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;架構&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;設計&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;需求&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">priority&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這一步的行數縮減最明顯。task-dispatch 中原本有大量的 if-elif 鏈在比對代理人名稱和觸發條件，類似這樣：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 修改前：60+ 行的 if-elif 鏈&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="s2">&amp;#34;test failed&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">message&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="s2">&amp;#34;compile error&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">message&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="n">agent&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;incident-responder&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="k">elif&lt;/span> &lt;span class="s2">&amp;#34;架構&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">message&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="s2">&amp;#34;設計&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">message&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="n">agent&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;system-analyst&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="k">elif&lt;/span> &lt;span class="s2">&amp;#34;安全&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">message&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="s2">&amp;#34;auth&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">message&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="n">agent&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;security-reviewer&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="c1"># ... 還有 20 幾個 elif&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>全部變成配置查表後，程式碼只剩下查表邏輯本身：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 修改後：配置驅動&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="n">agents&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">config_loader&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">load&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;agents.yaml&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="k">for&lt;/span> &lt;span class="n">agent_name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">config&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">agents&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">items&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">if&lt;/span> &lt;span class="nb">any&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">trigger&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">message&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">trigger&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">config&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;triggers&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="k">return&lt;/span> &lt;span class="n">agent_name&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>詳細的抽取過程在&lt;a href="https://tarrragon.github.io/blog/python/07-refactoring/constants-management/" data-link-title="配置分離與常數管理" data-link-desc="學習消除三種硬編碼問題：魔法數字、配置混合、散落訊息">配置分離與常數管理&lt;/a>中說明。&lt;/p></description><content:encoded><![CDATA[<p>本章是模組七的總結。前面九章從動機判斷開始，依序教了壞味道識別、配置分離、DRY 原則、常數管理、消除魔法數字、大規模統一化、重構陷阱防護、作用域迴歸、以及非程式碼的重構——這些都是從同一個真實專案提煉的。現在把時間線拉開，看看這些技術在三個階段中如何逐步應用，以及過程中犯了哪些錯。</p>
<h2 id="起點超過-30-個-hook-各自為政">起點：超過 30 個 Hook 各自為政</h2>
<p>v0.28.0 之前的 Hook 系統是「有機生長」的典型案例。32 個 Hook 各自獨立開發，沒有共用程式庫、沒有統一風格、沒有測試。</p>
<p>具體問題：</p>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>嚴重度</th>
          <th>量化</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>單檔過大</td>
          <td>高</td>
          <td>task-dispatch 達 858 行</td>
      </tr>
      <tr>
          <td>函式重複</td>
          <td>高</td>
          <td><code>run_git_command</code> 等函式在多個檔案中複製貼上</td>
      </tr>
      <tr>
          <td>配置硬編碼</td>
          <td>中</td>
          <td>代理人清單散落在程式碼各處</td>
      </tr>
      <tr>
          <td>魔法數字</td>
          <td>中</td>
          <td><code>line[9:]</code> 這類寫法隨處可見</td>
      </tr>
      <tr>
          <td>無測試</td>
          <td>高</td>
          <td>任何修改都是盲改</td>
      </tr>
  </tbody>
</table>
<p>這些問題不是一天造成的。每次新增 Hook 時，最快的做法就是從現有 Hook 複製一份再改。沒人想著「先建共用模組」，因為每次都只是「加一個小功能」。第一個 Hook 50 行，第二個 80 行，到第十個 Hook 時，已經有三四份 <code>run_git_command</code> 的副本了。但每次都覺得「下次再整理」。</p>
<p>用 <a href="/blog/python/07-refactoring/code-smells/" data-link-title="程式碼壞味道偵測" data-link-desc="從三級分類系統到偵測工具鏈，建立系統化的程式碼品質防線">程式碼壞味道識別</a> 中教的 grep 方法掃描一次：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">grep -rh <span class="s2">&#34;^def &#34;</span> .claude/hooks/*.py <span class="p">|</span> sort <span class="p">|</span> uniq -c <span class="p">|</span> sort -rn <span class="p">|</span> head -5</span></span></code></pre></div><p>掃描後會發現同樣的函式定義散落在多個檔案中。<code>run_git_command</code> 出現在 2 個以上的 Hook 裡，<code>get_current_branch</code>、<code>read_yaml_file</code> 等也有各自的副本。如果修復 <code>run_git_command</code> 的一個 bug，你需要同時改每個有副本的檔案，而且不能漏掉任何一個。</p>
<p>累積到 32 個 Hook 時，技術債務已經大到無法忽視。</p>
<h2 id="第一階段v0280-結構性重構">第一階段：v0.28.0 結構性重構</h2>
<p>第一階段的目標很明確：消除重複、建立結構。我們把工作拆成四個 Wave，每個 Wave 有獨立的交付物和驗證點。</p>
<h3 id="wave-1建立共用程式庫">Wave 1：建立共用程式庫</h3>
<p>先不動任何 Hook 檔案。第一步是把散落各處的重複邏輯抽取到獨立模組，並為每個模組寫測試。</p>
<p>建立的模組：</p>
<table>
  <thead>
      <tr>
          <th>模組</th>
          <th>職責</th>
          <th>對應的壞味道</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>config_loader</td>
          <td>讀取 YAML 配置</td>
          <td>ARCH-001 硬編碼配置</td>
      </tr>
      <tr>
          <td>git_utils</td>
          <td>封裝 Git 命令</td>
          <td>IMP-001 重複程式碼</td>
      </tr>
      <tr>
          <td>hook_io</td>
          <td>統一 Hook I/O 處理</td>
          <td>IMP-001 重複程式碼</td>
      </tr>
      <tr>
          <td>hook_logging</td>
          <td>統一日誌設定</td>
          <td>IMP-001 重複程式碼</td>
      </tr>
  </tbody>
</table>
<p>為什麼先建程式庫而不是先改 Hook？因為如果直接改 Hook，會遇到雞生蛋問題——Hook A 需要共用函式，但共用函式還沒建立。先建好程式庫並通過測試，後續每改一個 Hook 都有安全網。</p>
<p>建立模組時的關鍵決策：介面設計先於實作。我們先定義每個模組的公開函式簽名，寫測試驗證這些簽名的行為，最後才把各 Hook 中的重複邏輯搬進來。這確保了模組的介面是「為使用者設計」的，而不是「照搬原始碼」的。</p>
<p>這個順序的思考方式在 <a href="/blog/python/07-refactoring/dry-principle/" data-link-title="DRY 原則與共用程式庫" data-link-desc="學習識別重複程式碼並建立共用模組，含模組演進與漸進遷移策略">DRY 原則與共用程式庫</a> 中有詳細說明。</p>
<h3 id="wave-2配置分離">Wave 2：配置分離</h3>
<p>把 task-dispatch 中的硬編碼清單抽到 YAML 檔案：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl"><span class="c"># agents.yaml（節錄）</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="nt">agents</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">  </span><span class="nt">incident-responder</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">    </span><span class="nt">triggers</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;test failed&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;compile error&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;runtime error&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">    </span><span class="nt">priority</span><span class="p">:</span><span class="w"> </span><span class="m">1</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w">  </span><span class="nt">system-analyst</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w">    </span><span class="nt">triggers</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;架構&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;設計&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;需求&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w">    </span><span class="nt">priority</span><span class="p">:</span><span class="w"> </span><span class="m">2</span></span></span></code></pre></div><p>這一步的行數縮減最明顯。task-dispatch 中原本有大量的 if-elif 鏈在比對代理人名稱和觸發條件，類似這樣：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 修改前：60+ 行的 if-elif 鏈</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">if</span> <span class="s2">&#34;test failed&#34;</span> <span class="ow">in</span> <span class="n">message</span> <span class="ow">or</span> <span class="s2">&#34;compile error&#34;</span> <span class="ow">in</span> <span class="n">message</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="n">agent</span> <span class="o">=</span> <span class="s2">&#34;incident-responder&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="k">elif</span> <span class="s2">&#34;架構&#34;</span> <span class="ow">in</span> <span class="n">message</span> <span class="ow">or</span> <span class="s2">&#34;設計&#34;</span> <span class="ow">in</span> <span class="n">message</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="n">agent</span> <span class="o">=</span> <span class="s2">&#34;system-analyst&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="k">elif</span> <span class="s2">&#34;安全&#34;</span> <span class="ow">in</span> <span class="n">message</span> <span class="ow">or</span> <span class="s2">&#34;auth&#34;</span> <span class="ow">in</span> <span class="n">message</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="n">agent</span> <span class="o">=</span> <span class="s2">&#34;security-reviewer&#34;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"># ... 還有 20 幾個 elif</span></span></span></code></pre></div><p>全部變成配置查表後，程式碼只剩下查表邏輯本身：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 修改後：配置驅動</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">agents</span> <span class="o">=</span> <span class="n">config_loader</span><span class="o">.</span><span class="n">load</span><span class="p">(</span><span class="s2">&#34;agents.yaml&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">for</span> <span class="n">agent_name</span><span class="p">,</span> <span class="n">config</span> <span class="ow">in</span> <span class="n">agents</span><span class="o">.</span><span class="n">items</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="k">if</span> <span class="nb">any</span><span class="p">(</span><span class="n">trigger</span> <span class="ow">in</span> <span class="n">message</span> <span class="k">for</span> <span class="n">trigger</span> <span class="ow">in</span> <span class="n">config</span><span class="p">[</span><span class="s2">&#34;triggers&#34;</span><span class="p">]):</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="k">return</span> <span class="n">agent_name</span></span></span></code></pre></div><p>詳細的抽取過程在<a href="/blog/python/07-refactoring/constants-management/" data-link-title="配置分離與常數管理" data-link-desc="學習消除三種硬編碼問題：魔法數字、配置混合、散落訊息">配置分離與常數管理</a>中說明。</p>
<h3 id="wave-3逐檔重構">Wave 3：逐檔重構</h3>
<p>有了共用程式庫和配置檔，開始逐一重構 Hook 檔案。策略是：</p>
<ol>
<li>選擇一個 Hook 檔案</li>
<li>用 <code>from lib.xxx import yyy</code> 替換重複程式碼</li>
<li>跑測試確認行為不變</li>
<li>確認通過後繼續下一個檔案</li>
</ol>
<p>逐檔處理的好處是認知負擔可控——每次只需要理解一個 Hook 的邏輯，不需要同時在腦中處理所有修改。即使最後在同一個 commit 中提交，工作過程中仍然是一個一個檔案獨立驗證的。這就是 Wave 作為安全網的具體體現。</p>
<h3 id="wave-4驗證與收尾">Wave 4：驗證與收尾</h3>
<p>28 個單元測試全部通過。主要檔案的變化：</p>
<table>
  <thead>
      <tr>
          <th>檔案</th>
          <th>重構前</th>
          <th>重構後</th>
          <th>縮減</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>task-dispatch-readiness-check.py</td>
          <td>858 行</td>
          <td>296 行</td>
          <td>-65%</td>
      </tr>
      <tr>
          <td>branch-verify-hook.py</td>
          <td>238 行</td>
          <td>109 行</td>
          <td>-54%</td>
      </tr>
      <tr>
          <td>branch-status-reminder.py</td>
          <td>167 行</td>
          <td>103 行</td>
          <td>-38%</td>
      </tr>
  </tbody>
</table>
<h3 id="第一階段成果">第一階段成果</h3>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>數值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>消除重複程式碼</td>
          <td>約 415 行</td>
      </tr>
      <tr>
          <td>新增共用模組</td>
          <td>4 個</td>
      </tr>
      <tr>
          <td>新增單元測試</td>
          <td>28 個</td>
      </tr>
      <tr>
          <td>建立 Error Patterns</td>
          <td>3 個（ARCH-001、IMP-001、IMP-002）</td>
      </tr>
  </tbody>
</table>
<p>第一階段解決了最顯眼的問題：重複和膨脹。但還有更深層的問題藏在下面。</p>
<h2 id="第二階段v0310-品質深化">第二階段：v0.31.0 品質深化</h2>
<p>第一階段建立了結構，但結構內部的品質仍然參差不齊。v0.31.0 的四個連續 Wave 處理的是「統一風格」這個看似簡單但實際上充滿陷阱的任務。</p>
<h3 id="w22統一日誌格式">W22：統一日誌格式</h3>
<p>Hook 的日誌格式不一致。有的用 <code>print</code>，有的用 <code>logging</code>，有的用自訂格式。同樣是輸出一行日誌，你可能看到三種寫法：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 風格 A：直接 print</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;[</span><span class="si">{</span><span class="n">hook_name</span><span class="si">}</span><span class="s2">] Processing ticket </span><span class="si">{</span><span class="n">tid</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 風格 B：logging 模組</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="n">logging</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;Processing ticket </span><span class="si">{</span><span class="n">tid</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># 風格 C：自訂 logger</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="n">logger</span> <span class="o">=</span> <span class="n">setup_hook_logging</span><span class="p">(</span><span class="n">hook_name</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="n">logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;Processing ticket </span><span class="si">{</span><span class="n">tid</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span></span></span></code></pre></div><p>W22 統一為風格 C，讓所有 Hook 的日誌都通過 <code>hook_logging</code> 模組。這個 Wave 相對順利，因為只涉及輸出格式的統一，不改變程式邏輯。日誌行為改變不影響 Hook 的核心功能。</p>
<h3 id="w23統一錯誤訊息">W23：統一錯誤訊息</h3>
<p>把散落在各 Hook 中的硬編碼錯誤訊息提取到集中的 messages 模組。這對應的是 <a href="/blog/python/07-refactoring/constants-management/" data-link-title="配置分離與常數管理" data-link-desc="學習消除三種硬編碼問題：魔法數字、配置混合、散落訊息">配置分離與常數管理</a> 中「禁止硬編碼字串」的原則。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 修改前：訊息散落各處，同一個概念有不同的說法</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="s2">&#34;Error: ticket not found&#34;</span><span class="p">)</span>       <span class="c1"># Hook A</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="s2">&#34;找不到 ticket&#34;</span><span class="p">)</span>                  <span class="c1"># Hook B</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="s2">&#34;Ticket does not exist&#34;</span><span class="p">)</span>          <span class="c1"># Hook C</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"># 修改後：集中管理，一個概念一種說法</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="kn">from</span> <span class="nn">lib.messages</span> <span class="kn">import</span> <span class="n">HookMessages</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">HookMessages</span><span class="o">.</span><span class="n">TICKET_NOT_FOUND</span><span class="p">)</span></span></span></code></pre></div><p>集中管理的好處不只是一致性。如果需要把所有訊息改成中文，只需要改 messages 模組，不需要搜尋散落在幾十個檔案中的字串。</p>
<h3 id="w24統一-logger-初始化風格imp-003-事故">W24：統一 Logger 初始化風格——IMP-003 事故</h3>
<p>這是第二階段最慘痛的一課。</p>
<p>目標很簡單：把所有 Hook 的 <code>logger = setup_hook_logging(...)</code> 從模組級移到 <code>main()</code> 內部。理由是 logger 不該在模組被 import 時就建立——這是 Python 社群的通用最佳實踐。</p>
<p>結果：<strong>7 個 Hook 靜默失敗，41 個函式受影響，至少 2 個 session 沒人發現。</strong></p>
<p>根本原因是<strong>作用域變更</strong>：<code>logger</code> 從全域變數變成 <code>main()</code> 的區域變數後，其他函式無法存取它。Python 的 LEGB 規則決定了 <code>main()</code> 的區域變數對同級的其他函式是不可見的。</p>
<p>更危險的是，<code>run_hook_safely</code> 的頂層例外處理把 <code>NameError</code> 吞掉了——它捕獲所有 <code>Exception</code>，只寫入檔案日誌而不輸出到 stderr 或 stdout。於是使用者端完全看不到任何異常。</p>
<p>這個事故的完整分析在<a href="/blog/python/07-refactoring/scope-regression/" data-link-title="作用域迴歸案例研究" data-link-desc="從 IMP-003 事件學習 Python 變數作用域的陷阱">作用域迴歸案例研究</a>中。</p>
<p>IMP-003 帶來兩個直接改善：</p>
<ol>
<li><strong>作用域變更檢查清單</strong>：任何涉及變數作用域變更的重構，都必須先用 AST 分析列出所有引用，逐一確認每個函式的存取方式</li>
<li><strong>stderr 輸出</strong>：<code>_log_exception</code> 在寫入檔案日誌後，額外輸出到 stderr，確保 Hook 失敗對使用者可見——再也不會有「靜默失敗」的情況</li>
</ol>
<h3 id="w25修復連鎖問題imp-005">W25：修復連鎖問題——IMP-005</h3>
<p>W22 的模組遷移留下了另一個隱患。把 <code>common_functions.py</code> 從 <code>hooks/</code> 遷移到 <code>hooks/lib/</code> 時，部分 Hook 的 import 路徑沒有同步更新，導致 <code>ModuleNotFoundError</code>。這就是 IMP-005（模組遷移後 Import 路徑未同步更新），影響了 5 個 Hook。</p>
<p>修正流程本身不複雜：更新 import 路徑就好。但問題是遷移時沒有系統性地掃描所有引用——只改了「知道有引用的」檔案，漏掉了幾個不常觸發的 Hook。</p>
<p>這裡學到的教訓是：<strong>批量修正必須機械化</strong>。應該用 grep 或 AST 分析列出所有引用點，再逐一修改並驗證，確認沒有遺漏。手動作業的錯誤率和修改數量成正比。</p>
<h3 id="第二階段成果">第二階段成果</h3>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>數值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>統一的風格規範</td>
          <td>日誌格式、錯誤訊息、初始化方式</td>
      </tr>
      <tr>
          <td>新增 Error Patterns</td>
          <td>2 個（IMP-003、IMP-005）</td>
      </tr>
      <tr>
          <td>受影響的事故</td>
          <td>7 Hook 靜默失敗（IMP-003）</td>
      </tr>
      <tr>
          <td>新增防護機制</td>
          <td>stderr 輸出、作用域檢查清單、AST 驗證</td>
      </tr>
  </tbody>
</table>
<p>第二階段的教訓比第一階段更有價值。結構性重構（消除重複、建立模組）相對直接，風險可控。但風格統一涉及的是「改變現有能運作的程式碼」，任何疏忽都可能引入迴歸。</p>
<h2 id="第三階段系統級改善">第三階段：系統級改善</h2>
<p>前兩個階段解決了程式碼層面的問題。第三階段的視角拉高到「系統如何自我保護」。以下按主題分組，部分改善與第二階段穿插進行。</p>
<h3 id="w9progressive-disclosure-精簡">W9：Progressive Disclosure 精簡</h3>
<p>隨著規則、方法論、指南越寫越多，文件系統本身的認知負擔也在增加。一份「並行派發指南」原本 200 行，因為不斷補充場景表、案例、FAQ，膨脹到 600 行。讀者只想知道「怎麼判斷能不能並行」，卻被淹沒在細節中。</p>
<p>W9 做了一次系統性的文件瘦身：</p>
<ul>
<li>主文件只保留核心規則和決策邏輯（判斷標準、流程圖、檢查清單）</li>
<li>詳細說明、範例、模板移到 <code>references/</code> 子目錄</li>
<li>每份主文件的 token 數量縮減 20-40%</li>
<li>需要深入了解時，透過連結跳到 references</li>
</ul>
<p>這不是程式碼重構，但思考方式完全一樣：識別膨脹 → 分析哪些是核心哪些是細節 → 職責分離 → 驗證可讀性。重構的對象不只是程式碼，任何隨時間膨脹的結構化資訊都適用同樣的方法。</p>
<h3 id="w28一致性審查">W28：一致性審查</h3>
<p>對所有 Hook、規則、方法論進行一致性審查。檢查項目包括：</p>
<ul>
<li>命名是否遵循統一規範（例如 Hook 檔名的 kebab-case）</li>
<li>錯誤處理是否都通過 <code>run_hook_safely</code></li>
<li>日誌格式是否統一（W22 的成果是否被維持）</li>
<li>配置是否都從 YAML 讀取（W2 的成果是否被維持）</li>
<li>新 Hook 是否使用共用程式庫（W1 的成果是否被維持）</li>
</ul>
<p>審查的結果是發現了幾個遺漏：v0.28.0 之後新建的 Hook 有部分沒有使用共用程式庫，而是又開始「複製貼上」。開發者說：「我只是從隔壁 Hook 複製了幾行，沒必要引入整個模組。」這正是 v0.28.0 之前所有問題的起點。</p>
<p>這說明<strong>制度化</strong>比一次性重構更重要。如果沒有持續的品質檢查，程式碼會自然退化回混亂狀態。一致性審查不是做一次就結束，它需要成為定期的衛生檢查。</p>
<h2 id="量化總結階段對比">量化總結：階段對比</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>第一階段 (v0.28.0)</th>
          <th>第二階段 (v0.31.0)</th>
          <th>第三階段 (系統級)</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>目標</td>
          <td>消除重複、建立結構</td>
          <td>統一風格、深化品質</td>
          <td>系統自我保護</td>
      </tr>
      <tr>
          <td>方法</td>
          <td>抽取模組、配置分離</td>
          <td>風格統一、訊息集中</td>
          <td>文件精簡、一致性審查</td>
      </tr>
      <tr>
          <td>工作量</td>
          <td>Wave 1-4</td>
          <td>W22-W25</td>
          <td>W9、W28</td>
      </tr>
      <tr>
          <td>主要產出</td>
          <td>4 模組、28 測試</td>
          <td>統一風格、2 Error Patterns</td>
          <td>文件瘦身、檢查機制</td>
      </tr>
      <tr>
          <td>事故</td>
          <td>無</td>
          <td>IMP-003（7 Hook 靜默失敗）</td>
          <td>無</td>
      </tr>
      <tr>
          <td>認知負擔變化</td>
          <td>大幅降低（檔案縮減 38-65%）</td>
          <td>中度降低（風格一致）</td>
          <td>間接降低（文件可讀性）</td>
      </tr>
      <tr>
          <td>風險等級</td>
          <td>低（新建模組不影響現有）</td>
          <td>高（修改現有能運作的程式碼）</td>
          <td>低（不涉及程式邏輯）</td>
      </tr>
  </tbody>
</table>
<p>一個重要觀察：<strong>第二階段的風險最高</strong>。第一階段是「加法」（新增模組），第三階段是「非程式碼」（文件調整），而第二階段是「改動現有程式碼」。這正是 IMP-003 發生的背景。</p>
<p>重構的風險不取決於修改的「量」，而取決於修改的「性質」。W24 的每個修改都很小（移動一行 <code>logger = ...</code>），但每個修改都觸及了 Python 作用域這個容易被忽略的基礎機制。</p>
<h2 id="教訓">教訓</h2>
<h3 id="重構是持續過程不是一次性事件">重構是持續過程，不是一次性事件</h3>
<p>v0.28.0 做完時，我們以為重構結束了。結果 v0.31.0 又花了四個 Wave 處理品質問題，後面還有系統級的調整。</p>
<p>程式碼會自然退化。每次新增功能、修復 bug、趕進度，都可能引入新的技術債務。重構不是「做完就好」的專案，而是持續進行的衛生習慣——就像每天刷牙，不是做一次根管治療就可以不刷了。</p>
<h3 id="error-patterns-是知識累積">Error Patterns 是知識累積</h3>
<p>五個 Error Patterns（ARCH-001、IMP-001、IMP-002、IMP-003、IMP-005）不只是問題記錄。它們是團隊的「免疫記憶」：</p>
<ul>
<li><strong>ARCH-001</strong>（硬編碼配置）→ 以後新增配置時，自動想到用 YAML</li>
<li><strong>IMP-001</strong>（重複程式碼）→ 發現重複時，自動想到抽取模組</li>
<li><strong>IMP-002</strong>（魔法數字）→ 看到裸數字時，自動想到具名常數</li>
<li><strong>IMP-003</strong>（作用域迴歸）→ 移動變數定義時，自動想到影響範圍分析</li>
<li><strong>IMP-005</strong>（模組遷移後 Import 路徑未同步更新）→ 搬移模組時，自動想到掃描所有引用點</li>
</ul>
<p>每個 Error Pattern 都有明確的結構：觸發條件、根本原因、檢查清單、防護措施。新成員不需要親身經歷這些事故，讀文件就能獲得防護。這比「口耳相傳」可靠得多——口頭經驗會隨著人員流動而消失，文件化的 Error Pattern 是永久的。</p>
<h3 id="wave-是安全網">Wave 是安全網</h3>
<p>把大型重構拆成 Wave 的好處：</p>
<ol>
<li><strong>獨立驗證</strong>：每個 Wave 結束時都跑完整測試，確認沒改壞東西</li>
<li><strong>可回滾</strong>：如果 Wave 3 出問題，Wave 1 和 2 的成果不受影響</li>
<li><strong>認知管理</strong>：每次只需要理解一個 Wave 的範圍，不需要在腦中同時處理所有修改</li>
<li><strong>進度可見</strong>：每完成一個 Wave 就有具體的交付物，而不是「重構了三天但還沒完成」</li>
</ol>
<p>Wave 2（配置分離）如果和 Wave 3（逐檔重構）合併，認知負擔會超過上限——需要同時思考「配置怎麼設計」和「Hook 怎麼改」。拆開後每次只需要想一件事。</p>
<p>反過來說，Wave 也防止了「過度設計」。如果一開始就試圖設計完美的共用程式庫，可能會花太多時間在抽象設計上。Wave 1 先建立「夠用」的模組，Wave 3 在實際使用時再調整介面。實踐中的回饋比預先設計更可靠。</p>
<h3 id="風格統一比結構重構危險">風格統一比結構重構危險</h3>
<p>第一階段（結構重構）幾乎沒有事故。第二階段（風格統一）出了 IMP-003。</p>
<p>原因是：結構重構主要是「加法」——建立新模組、新測試。現有程式碼的修改量小，改壞的機率低。而風格統一是在「改動能運作的程式碼」，每一行修改都可能引入迴歸。</p>
<p>如果重來，W24 的做法會改成：</p>
<ol>
<li>先寫自動化腳本做 AST 分析，列出每個 Hook 的所有 logger 引用關係</li>
<li>用腳本自動修改函式簽名和呼叫端，而不是手動逐檔改</li>
<li>修改後立刻對每個 Hook 做隔離測試，不是改完全部再測</li>
<li>每改完一個 Hook 就提交一次，而不是改完全部再提交</li>
</ol>
<p>這些改善措施的共同主題是：<strong>縮小每次改動的影響範圍</strong>。一次改一個 Hook 比一次改 16 個 Hook 安全得多。</p>
<h2 id="章節知識地圖">章節知識地圖</h2>
<p>本模組各章對應到重構過程的哪個環節：</p>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>對應的壞味道</th>
          <th>對應的階段</th>
          <th>核心技能</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/python/07-refactoring/refactoring-strategy/" data-link-title="重構的動機與策略" data-link-desc="從 Hook 系統重構經驗出發，學習何時重構、何時不該重構，以及如何將大規模重構拆分成可管理的階段">重構的動機與策略</a></td>
          <td>全部</td>
          <td>起點（為什麼重構）</td>
          <td>認知負擔量化、階段分解</td>
      </tr>
      <tr>
          <td><a href="/blog/python/07-refactoring/code-smells/" data-link-title="程式碼壞味道偵測" data-link-desc="從三級分類系統到偵測工具鏈，建立系統化的程式碼品質防線">程式碼壞味道識別</a></td>
          <td>所有</td>
          <td>起點（識別問題）</td>
          <td>grep 分析、5 Why</td>
      </tr>
      <tr>
          <td><a href="/blog/python/07-refactoring/dry-principle/" data-link-title="DRY 原則與共用程式庫" data-link-desc="學習識別重複程式碼並建立共用模組，含模組演進與漸進遷移策略">DRY 原則與共用程式庫</a></td>
          <td>IMP-001</td>
          <td>第一階段 W1</td>
          <td>模組抽取、介面設計</td>
      </tr>
      <tr>
          <td><a href="/blog/python/07-refactoring/constants-management/" data-link-title="配置分離與常數管理" data-link-desc="學習消除三種硬編碼問題：魔法數字、配置混合、散落訊息">配置分離與常數管理</a></td>
          <td>IMP-002, ARCH-001</td>
          <td>第一階段 W2 + 第二階段 W23</td>
          <td>三種硬編碼的系統性消除</td>
      </tr>
      <tr>
          <td><a href="/blog/python/07-refactoring/unified-infrastructure/" data-link-title="大規模統一化重構" data-link-desc="從 44 種不同實作到統一基礎設施：日誌、訊息、風格的三階段漸進式重構">大規模統一化重構</a></td>
          <td>IMP-001, ARCH-001</td>
          <td>第二階段 W22-W24</td>
          <td>三階段統一化、漸進式重構</td>
      </tr>
      <tr>
          <td><a href="/blog/python/07-refactoring/refactoring-pitfalls/" data-link-title="重構陷阱與防護" data-link-desc="三個真實重構事故的共通模式：部分更新問題與系統性防護方法">重構陷阱與防護</a></td>
          <td>IMP-003, IMP-005</td>
          <td>第二階段 W24-W25</td>
          <td>部分更新防護、AST 驗證</td>
      </tr>
      <tr>
          <td><a href="/blog/python/07-refactoring/scope-regression/" data-link-title="作用域迴歸案例研究" data-link-desc="從 IMP-003 事件學習 Python 變數作用域的陷阱">作用域迴歸案例研究</a></td>
          <td>IMP-003</td>
          <td>第二階段 W24</td>
          <td>AST 分析、作用域規則</td>
      </tr>
      <tr>
          <td><a href="/blog/python/07-refactoring/non-code-refactoring/" data-link-title="非程式碼的重構" data-link-desc="用 Progressive Disclosure 精簡膨脹的規則文件，文件重構和程式碼重構是同一套思維">非程式碼的重構</a></td>
          <td>文件壞味道</td>
          <td>第三階段 W9</td>
          <td>Progressive Disclosure、文件精簡</td>
      </tr>
      <tr>
          <td>本章（完整案例回顧）</td>
          <td>全部</td>
          <td>全部</td>
          <td>系統性思考、Wave 規劃</td>
      </tr>
  </tbody>
</table>
<p>每一章都可以獨立閱讀，但它們來自同一個持續演進的真實專案。單獨學會「怎麼消除魔法數字」是基礎能力；理解「什麼時候該做、以什麼順序做、做的時候可能出什麼事故」才是重構的完整技能。</p>
<h2 id="重構前後的程式碼對比">重構前後的程式碼對比</h2>
<p>用一段典型的程式碼說明三個階段的累積效果：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># === 起點：v0.28.0 之前 ===</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"># 直接使用 subprocess，硬編碼分支名稱，魔法數字</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">result</span> <span class="o">=</span> <span class="n">subprocess</span><span class="o">.</span><span class="n">run</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="p">[</span><span class="s2">&#34;git&#34;</span><span class="p">,</span> <span class="s2">&#34;branch&#34;</span><span class="p">,</span> <span class="s2">&#34;--show-current&#34;</span><span class="p">],</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">capture_output</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">text</span><span class="o">=</span><span class="kc">True</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">branch</span> <span class="o">=</span> <span class="n">result</span><span class="o">.</span><span class="n">stdout</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="k">if</span> <span class="n">branch</span> <span class="ow">in</span> <span class="p">[</span><span class="s2">&#34;main&#34;</span><span class="p">,</span> <span class="s2">&#34;master&#34;</span><span class="p">,</span> <span class="s2">&#34;develop&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="s2">&#34;Error: protected branch&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="n">sys</span><span class="o">.</span><span class="n">exit</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># === 第一階段後：v0.28.0 ===</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"># 使用共用模組，但訊息仍硬編碼</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="kn">from</span> <span class="nn">lib.git_utils</span> <span class="kn">import</span> <span class="n">get_current_branch</span><span class="p">,</span> <span class="n">is_protected_branch</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="n">branch</span> <span class="o">=</span> <span class="n">get_current_branch</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="k">if</span> <span class="n">is_protected_branch</span><span class="p">(</span><span class="n">branch</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="s2">&#34;Error: protected branch&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="n">sys</span><span class="o">.</span><span class="n">exit</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="c1"># === 第二階段後：v0.31.0 ===</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="c1"># 訊息集中管理，logger 正確初始化</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="kn">from</span> <span class="nn">lib.git_utils</span> <span class="kn">import</span> <span class="n">get_current_branch</span><span class="p">,</span> <span class="n">is_protected_branch</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="kn">from</span> <span class="nn">lib.messages</span> <span class="kn">import</span> <span class="n">BranchMessages</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">def</span> <span class="nf">check_branch</span><span class="p">(</span><span class="n">logger</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="n">branch</span> <span class="o">=</span> <span class="n">get_current_branch</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="k">if</span> <span class="n">is_protected_branch</span><span class="p">(</span><span class="n">branch</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">        <span class="n">logger</span><span class="o">.</span><span class="n">warning</span><span class="p">(</span><span class="n">BranchMessages</span><span class="o">.</span><span class="n">PROTECTED_BRANCH</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="n">EXIT_ERROR</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">    <span class="k">return</span> <span class="n">EXIT_SUCCESS</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">
</span></span><span class="line"><span class="ln">33</span><span class="cl"><span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">    <span class="n">logger</span> <span class="o">=</span> <span class="n">setup_hook_logging</span><span class="p">(</span><span class="s2">&#34;branch-verify&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">    <span class="k">return</span> <span class="n">check_branch</span><span class="p">(</span><span class="n">logger</span><span class="p">)</span></span></span></code></pre></div><p>認知負擔的變化：</p>
<table>
  <thead>
      <tr>
          <th>版本</th>
          <th>需要同時理解的概念</th>
          <th>認知負擔指數</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>起點</td>
          <td>subprocess API、Git 命令語法、分支名稱列表、退出碼</td>
          <td>8</td>
      </tr>
      <tr>
          <td>第一階段後</td>
          <td>共用函式名稱、退出碼</td>
          <td>4</td>
      </tr>
      <tr>
          <td>第二階段後</td>
          <td>共用函式名稱、訊息常數名稱</td>
          <td>3</td>
      </tr>
  </tbody>
</table>
<p>每一階段都在降低閱讀者需要同時記住的東西。這就是 <a href="/blog/python/00-philosophy/cognitive-load/" data-link-title="認知負擔：程式碼設計的核心目的" data-link-desc="所有設計原則的統一視角：降低閱讀者的認知負擔">序章的認知負擔理論</a> 在實踐中的應用。</p>
<h2 id="小結">小結</h2>
<p>回顧整個過程，重構的節奏是：</p>
<ol>
<li><strong>先解決最痛的問題</strong>（第一階段：重複和膨脹）</li>
<li><strong>再提升內部品質</strong>（第二階段：風格和一致性）</li>
<li><strong>最後建立保護機制</strong>（第三階段：系統級防護）</li>
</ol>
<p>每個階段都需要前一個階段的基礎。沒有共用模組就無法統一風格，沒有統一風格就無法做一致性審查。</p>
<p>而貫穿三個階段的不變原則只有一個：<strong>這段程式碼讓讀者需要同時記住多少東西？</strong> 如果太多，就需要重構。不管是 858 行的單檔、散落各處的錯誤訊息、還是膨脹到 600 行的文件——認知負擔就是重構的指北針。</p>
<hr>
<p>上一章：<a href="/blog/python/07-refactoring/non-code-refactoring/" data-link-title="非程式碼的重構" data-link-desc="用 Progressive Disclosure 精簡膨脹的規則文件，文件重構和程式碼重構是同一套思維">非程式碼的重構</a></p>
<p>回到模組總覽：<a href="/blog/python/07-refactoring/" data-link-title="模組七：重構實戰" data-link-desc="基於 v0.28.0-v0.31.0 重構經驗的程式碼品質改善指南">模組七：重構實戰</a></p>
]]></content:encoded></item></channel></rss>