<?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>Over-Engineering on Tarragon</title><link>https://tarrragon.github.io/blog/tags/over-engineering/</link><description>Recent content in Over-Engineering on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 10 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/over-engineering/index.xml" rel="self" type="application/rss+xml"/><item><title>1101 行自建測試基礎設施、重構刪掉 82.5% — 過度工程的三種形態</title><link>https://tarrragon.github.io/blog/work-log/flutter_mock_infrastructure_overengineering_deleted/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_mock_infrastructure_overengineering_deleted/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter 書籍管理 App 的 Widget 測試基礎設施，設計階段產出 783 行的完整規格、實作 1101 行；下一個 Phase 的重構審查判定整套重做，刪到剩 193 行（-82.5%）、其中核心 helper 62 行
&lt;strong>疑問來源&lt;/strong>：一套經過完整設計流程、有架構圖有依賴規範的基礎設施，為什麼是該刪的？審查依據是什麼？
&lt;strong>整理目的&lt;/strong>：把這次刪除拆成三種可辨識的過度工程形態、以及「寫測試工具之前」的檢查點
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.7.0 的 Phase 1 設計文件與 Phase 4 重構記錄——同一個東西的誕生與死亡、對照完整&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="被刪掉的是什麼">被刪掉的是什麼&lt;/h2>
&lt;p>四個檔案、一個目錄：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>檔案&lt;/th>
 &lt;th>行數&lt;/th>
 &lt;th>職責&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>lib/mocks/widget_test_mocks.dart&lt;/code>&lt;/td>
 &lt;td>389&lt;/td>
 &lt;td>Mock 架構（含 &lt;code>MockBook&lt;/code>、Mock Provider 群）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>lib/helpers/multi_language_widget_test_helper.dart&lt;/code>&lt;/td>
 &lt;td>388&lt;/td>
 &lt;td>多語系測試工具（Mutex 狀態鎖、記憶體洩漏防護、三類溢位檢測）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>lib/helpers/widget_test_helper.dart&lt;/code>&lt;/td>
 &lt;td>193&lt;/td>
 &lt;td>自建測試環境建立工具&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>lib/helpers/test_logger.dart&lt;/code>&lt;/td>
 &lt;td>131&lt;/td>
 &lt;td>自製測試日誌系統&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>取代它們的是 62 行的 helper 加標準 Riverpod 模式：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">WidgetTestHelper&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">createFullTestApp&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">const&lt;/span> &lt;span class="n">LibraryDisplayPage&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="p">[&lt;/span>&lt;span class="n">libraryDisplayViewModelProvider&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">overrideWith&lt;/span>&lt;span class="p">(()&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="n">LibraryDisplayViewModelForTest&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">LibraryDisplayState&lt;/span>&lt;span class="p">()))],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>功能沒有變少——變少的是「為了用這套工具而要學的東西」。&lt;/p>
&lt;h2 id="形態一重新發明框架已有的輪子">形態一：重新發明框架已有的輪子&lt;/h2>
&lt;p>整套 Mock Provider 架構要解的問題——「測試時把真實依賴換成受控替身」——Riverpod 內建的 &lt;code>overrideWith&lt;/code> 一行就是官方解。389 行的 Mock 架構等於是在框架旁邊蓋了一座平行的依賴注入系統，每個新測試都得學它、每次框架升級它都可能斷。&lt;/p>
&lt;p>寫測試工具前的第一個檢查點就是這條：&lt;strong>先問「這個框架 / 生態的標準做法是什麼」、再問「標準做法哪裡不夠」&lt;/strong>。找不出第二題的答案，自建工具的每一行都是純負債——它不是產品碼、卻要跟產品碼一樣被維護。&lt;/p>
&lt;h2 id="形態二解決不存在的問題">形態二：解決不存在的問題&lt;/h2>
&lt;p>388 行的多語系測試 helper 是精緻度的巔峰：Mutex 狀態鎖、記憶體洩漏防護機制、三類版面溢位檢測（RenderFlex、文字截斷、螢幕邊界）。每個機能單獨看都「很完備」——但當時的 Widget 測試需求是「讓測試能編譯、能跑」，多語系切換測試根本還不在任何 use case 裡。重構記錄的判語：「解決一個不存在的問題」。&lt;/p>
&lt;p>這個形態跟&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_async_query_overdesign_oscillation/" data-link-title="同一個子系統膨脹兩次：異步查詢系統的過度設計震盪" data-link-desc="過度設計會復發、且兩輪的機制不同：設計期的膨脹來自想像的需求（別層已處理的重試、用不到的優先級佇列），迭代期的膨脹來自不刪的舊版本（三個實作並存、狀態多處追蹤）。偽需求的檢驗法是問「這個能力已經有別層在做嗎」。">異步查詢系統的第一輪膨脹&lt;/a>同構——設計期用「系統該有的完備性」取代「操作需要的能力」。測試工具的完備性想像還更容易失控，因為它不受產品需求審查：沒有 PM 會問「為什麼測試 helper 需要 Mutex」。&lt;/p>
&lt;h2 id="形態三mock-了不需要-mock-的東西放在不該放的地方">形態三：mock 了不需要 mock 的東西、放在不該放的地方&lt;/h2>
&lt;p>兩個架構層級的錯：&lt;/p>
&lt;p>&lt;strong>&lt;code>MockBook&lt;/code> 取代真 domain entity。&lt;/strong> mock 的正當對象是有副作用、慢、或不可控的依賴（網路、資料庫、時間）；&lt;code>Book&lt;/code> 是純資料物件、建構它比 mock 它便宜——真 entity 直接用就好。mock 純物件的代價是雙重維護：entity 加欄位、MockBook 也要加，兩者漂移時測試守的是一個不存在的形狀。&lt;/p>
&lt;p>&lt;strong>mock 放在 &lt;code>lib/&lt;/code> 而不是 &lt;code>test/&lt;/code>。&lt;/strong> 測試碼進了生產依賴圖——而且這不是手滑，Phase 1 設計文件把 &lt;code>test/ → lib/mocks/ → lib/core/&lt;/code> 畫成正式的單向依賴規範。方向本身「乾淨」，但前提就錯了：&lt;code>lib/&lt;/code> 的語意是「會被打包進 App 的程式碼」，mock 不屬於那裡。&lt;/p>
&lt;h2 id="精緻的設計文件不是價值證明">精緻的設計文件不是價值證明&lt;/h2>
&lt;p>這個 case 最值得記的一點：被刪掉的系統有 783 行設計文件、有架構圖、有依賴方向規範、有驗收條件——&lt;strong>整個設計流程認真地規劃了一個不需要存在的東西&lt;/strong>。精緻度讓它更難殺：看起來像紮實的工程產出，審查者要先推翻「這麼認真的東西應該有價值」的直覺才下得了手。&lt;/p>
&lt;p>同專案的另一個記錄（同步 domain 架構評分 A- 但不能編譯）是同一課的另一面：&lt;strong>評價「做得好不好」之前，先評價「該不該做」&lt;/strong>。Phase 4 重構記錄把這個順序寫成了方法論建議——「優先考慮刪除：先問『這個真的需要嗎？』再問『如何改善？』」。&lt;/p>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;ul>
&lt;li>mock 類別 mock 的是純資料物件（entity / value object）——真物件更便宜、直接用&lt;/li>
&lt;li>測試 helper 出現 framework 級機能（並發鎖、洩漏防護、自製 logger）——問是哪個測試需求逼出來的、指不出來就是完備性想像&lt;/li>
&lt;li>&lt;code>lib/&lt;/code> 底下出現 &lt;code>mocks/&lt;/code> 或 &lt;code>test&lt;/code> 字樣的目錄——測試碼在生產依賴圖裡&lt;/li>
&lt;li>helper 的行數超過用它的測試——工具比問題大&lt;/li>
&lt;li>「用這套工具寫測試」需要先讀文件——標準模式的優勢正是新人零學習成本&lt;/li>
&lt;/ul>
&lt;h2 id="相關閱讀">相關閱讀&lt;/h2>
&lt;ul>
&lt;li>同機制的產品側版本：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_async_query_overdesign_oscillation/" data-link-title="同一個子系統膨脹兩次：異步查詢系統的過度設計震盪" data-link-desc="過度設計會復發、且兩輪的機制不同：設計期的膨脹來自想像的需求（別層已處理的重試、用不到的優先級佇列），迭代期的膨脹來自不刪的舊版本（三個實作並存、狀態多處追蹤）。偽需求的檢驗法是問「這個能力已經有別層在做嗎」。">異步查詢系統的過度設計震盪&lt;/a>——設計期完備性想像的兩個現場&lt;/li>
&lt;li>mock 該放哪、多少才夠：&lt;a href="https://tarrragon.github.io/blog/work-log/testing_three_layer_strategy/" data-link-title="192 個測試全過、實機全壞：Mock 遮蔽真實行為的三層測試策略" data-link-desc="unit test 全綠、實機部署後功能整片壞掉。mock-only 策略的結構盲區（text vs binary frame、缺 auth handshake、ANSI 多樣性被 FakeWebSocketChannel 遮蔽），以及分層測試各抓什麼、各遮蔽什麼。">192 個測試全過、實機全壞&lt;/a>——那篇談 mock 過多遮蔽真實、本文談 mock 系統本身過重，同一個「mock 是手段不是資產」的兩面&lt;/li>
&lt;li>原則層：&lt;a href="https://tarrragon.github.io/blog/report/decide-later-as-valid-option/" data-link-title="「現在不決定」是合法選項：context 不足時延後決策" data-link-desc="被問到時不一定要立刻答 — 「先補 context、回頭再決」是合法選項、卻常被當「拖延」忽略。LLM / agent 預設「問了就要立刻答」是錯誤前提：使用者有權延後到 context 補齊、推薦時應主動標出「也可選『先 X 再回來決』」。本卡是 #58 篩選三問、#74 決策呈現的時間軸延伸。">#77「現在不決定」是合法選項&lt;/a>——多語系測試工具的正確處置是等需求出現、不是先蓋起來放&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter 書籍管理 App 的 Widget 測試基礎設施，設計階段產出 783 行的完整規格、實作 1101 行；下一個 Phase 的重構審查判定整套重做，刪到剩 193 行（-82.5%）、其中核心 helper 62 行
<strong>疑問來源</strong>：一套經過完整設計流程、有架構圖有依賴規範的基礎設施，為什麼是該刪的？審查依據是什麼？
<strong>整理目的</strong>：把這次刪除拆成三種可辨識的過度工程形態、以及「寫測試工具之前」的檢查點
<strong>本文邊界</strong>：素材是該專案 v0.7.0 的 Phase 1 設計文件與 Phase 4 重構記錄——同一個東西的誕生與死亡、對照完整</p></blockquote>
<hr>
<h2 id="被刪掉的是什麼">被刪掉的是什麼</h2>
<p>四個檔案、一個目錄：</p>
<table>
  <thead>
      <tr>
          <th>檔案</th>
          <th>行數</th>
          <th>職責</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>lib/mocks/widget_test_mocks.dart</code></td>
          <td>389</td>
          <td>Mock 架構（含 <code>MockBook</code>、Mock Provider 群）</td>
      </tr>
      <tr>
          <td><code>lib/helpers/multi_language_widget_test_helper.dart</code></td>
          <td>388</td>
          <td>多語系測試工具（Mutex 狀態鎖、記憶體洩漏防護、三類溢位檢測）</td>
      </tr>
      <tr>
          <td><code>lib/helpers/widget_test_helper.dart</code></td>
          <td>193</td>
          <td>自建測試環境建立工具</td>
      </tr>
      <tr>
          <td><code>lib/helpers/test_logger.dart</code></td>
          <td>131</td>
          <td>自製測試日誌系統</td>
      </tr>
  </tbody>
</table>
<p>取代它們的是 62 行的 helper 加標準 Riverpod 模式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">WidgetTestHelper</span><span class="p">.</span><span class="n">createFullTestApp</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">const</span> <span class="n">LibraryDisplayPage</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="p">[</span><span class="n">libraryDisplayViewModelProvider</span><span class="p">.</span><span class="n">overrideWith</span><span class="p">(()</span> <span class="o">=&gt;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">      <span class="n">LibraryDisplayViewModelForTest</span><span class="p">(</span><span class="n">LibraryDisplayState</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>功能沒有變少——變少的是「為了用這套工具而要學的東西」。</p>
<h2 id="形態一重新發明框架已有的輪子">形態一：重新發明框架已有的輪子</h2>
<p>整套 Mock Provider 架構要解的問題——「測試時把真實依賴換成受控替身」——Riverpod 內建的 <code>overrideWith</code> 一行就是官方解。389 行的 Mock 架構等於是在框架旁邊蓋了一座平行的依賴注入系統，每個新測試都得學它、每次框架升級它都可能斷。</p>
<p>寫測試工具前的第一個檢查點就是這條：<strong>先問「這個框架 / 生態的標準做法是什麼」、再問「標準做法哪裡不夠」</strong>。找不出第二題的答案，自建工具的每一行都是純負債——它不是產品碼、卻要跟產品碼一樣被維護。</p>
<h2 id="形態二解決不存在的問題">形態二：解決不存在的問題</h2>
<p>388 行的多語系測試 helper 是精緻度的巔峰：Mutex 狀態鎖、記憶體洩漏防護機制、三類版面溢位檢測（RenderFlex、文字截斷、螢幕邊界）。每個機能單獨看都「很完備」——但當時的 Widget 測試需求是「讓測試能編譯、能跑」，多語系切換測試根本還不在任何 use case 裡。重構記錄的判語：「解決一個不存在的問題」。</p>
<p>這個形態跟<a href="/blog/work-log/flutter_async_query_overdesign_oscillation/" data-link-title="同一個子系統膨脹兩次：異步查詢系統的過度設計震盪" data-link-desc="過度設計會復發、且兩輪的機制不同：設計期的膨脹來自想像的需求（別層已處理的重試、用不到的優先級佇列），迭代期的膨脹來自不刪的舊版本（三個實作並存、狀態多處追蹤）。偽需求的檢驗法是問「這個能力已經有別層在做嗎」。">異步查詢系統的第一輪膨脹</a>同構——設計期用「系統該有的完備性」取代「操作需要的能力」。測試工具的完備性想像還更容易失控，因為它不受產品需求審查：沒有 PM 會問「為什麼測試 helper 需要 Mutex」。</p>
<h2 id="形態三mock-了不需要-mock-的東西放在不該放的地方">形態三：mock 了不需要 mock 的東西、放在不該放的地方</h2>
<p>兩個架構層級的錯：</p>
<p><strong><code>MockBook</code> 取代真 domain entity。</strong> mock 的正當對象是有副作用、慢、或不可控的依賴（網路、資料庫、時間）；<code>Book</code> 是純資料物件、建構它比 mock 它便宜——真 entity 直接用就好。mock 純物件的代價是雙重維護：entity 加欄位、MockBook 也要加，兩者漂移時測試守的是一個不存在的形狀。</p>
<p><strong>mock 放在 <code>lib/</code> 而不是 <code>test/</code>。</strong> 測試碼進了生產依賴圖——而且這不是手滑，Phase 1 設計文件把 <code>test/ → lib/mocks/ → lib/core/</code> 畫成正式的單向依賴規範。方向本身「乾淨」，但前提就錯了：<code>lib/</code> 的語意是「會被打包進 App 的程式碼」，mock 不屬於那裡。</p>
<h2 id="精緻的設計文件不是價值證明">精緻的設計文件不是價值證明</h2>
<p>這個 case 最值得記的一點：被刪掉的系統有 783 行設計文件、有架構圖、有依賴方向規範、有驗收條件——<strong>整個設計流程認真地規劃了一個不需要存在的東西</strong>。精緻度讓它更難殺：看起來像紮實的工程產出，審查者要先推翻「這麼認真的東西應該有價值」的直覺才下得了手。</p>
<p>同專案的另一個記錄（同步 domain 架構評分 A- 但不能編譯）是同一課的另一面：<strong>評價「做得好不好」之前，先評價「該不該做」</strong>。Phase 4 重構記錄把這個順序寫成了方法論建議——「優先考慮刪除：先問『這個真的需要嗎？』再問『如何改善？』」。</p>
<h2 id="判讀徵兆">判讀徵兆</h2>
<ul>
<li>mock 類別 mock 的是純資料物件（entity / value object）——真物件更便宜、直接用</li>
<li>測試 helper 出現 framework 級機能（並發鎖、洩漏防護、自製 logger）——問是哪個測試需求逼出來的、指不出來就是完備性想像</li>
<li><code>lib/</code> 底下出現 <code>mocks/</code> 或 <code>test</code> 字樣的目錄——測試碼在生產依賴圖裡</li>
<li>helper 的行數超過用它的測試——工具比問題大</li>
<li>「用這套工具寫測試」需要先讀文件——標準模式的優勢正是新人零學習成本</li>
</ul>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>同機制的產品側版本：<a href="/blog/work-log/flutter_async_query_overdesign_oscillation/" data-link-title="同一個子系統膨脹兩次：異步查詢系統的過度設計震盪" data-link-desc="過度設計會復發、且兩輪的機制不同：設計期的膨脹來自想像的需求（別層已處理的重試、用不到的優先級佇列），迭代期的膨脹來自不刪的舊版本（三個實作並存、狀態多處追蹤）。偽需求的檢驗法是問「這個能力已經有別層在做嗎」。">異步查詢系統的過度設計震盪</a>——設計期完備性想像的兩個現場</li>
<li>mock 該放哪、多少才夠：<a href="/blog/work-log/testing_three_layer_strategy/" data-link-title="192 個測試全過、實機全壞：Mock 遮蔽真實行為的三層測試策略" data-link-desc="unit test 全綠、實機部署後功能整片壞掉。mock-only 策略的結構盲區（text vs binary frame、缺 auth handshake、ANSI 多樣性被 FakeWebSocketChannel 遮蔽），以及分層測試各抓什麼、各遮蔽什麼。">192 個測試全過、實機全壞</a>——那篇談 mock 過多遮蔽真實、本文談 mock 系統本身過重，同一個「mock 是手段不是資產」的兩面</li>
<li>原則層：<a href="/blog/report/decide-later-as-valid-option/" data-link-title="「現在不決定」是合法選項：context 不足時延後決策" data-link-desc="被問到時不一定要立刻答 — 「先補 context、回頭再決」是合法選項、卻常被當「拖延」忽略。LLM / agent 預設「問了就要立刻答」是錯誤前提：使用者有權延後到 context 補齊、推薦時應主動標出「也可選『先 X 再回來決』」。本卡是 #58 篩選三問、#74 決策呈現的時間軸延伸。">#77「現在不決定」是合法選項</a>——多語系測試工具的正確處置是等需求出現、不是先蓋起來放</li>
</ul>
]]></content:encoded></item><item><title>同一個子系統膨脹兩次：異步查詢系統的過度設計震盪</title><link>https://tarrragon.github.io/blog/work-log/flutter_async_query_overdesign_oscillation/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_async_query_overdesign_oscillation/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter 書籍管理 App 的「背景查詢書籍資料」子系統，在兩個月內走了兩輪「膨脹 → 大砍」：第一輪把大系統設計砍成一個純狀態追蹤器、第二輪把三個並存的實作版本砍回一個
&lt;strong>疑問來源&lt;/strong>：第一輪已經用力砍過偽需求了，為什麼同一個子系統還會再膨脹一次？
&lt;strong>整理目的&lt;/strong>：記下兩輪膨脹各自的機制、以及「真需求 vs 偽需求」的可操作檢驗法
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.4.1 與 v0.5.2 的重構記錄；量化數字（874 → 250 行等）出自 log 自報、未經獨立重算，本文引用時只取數量級意義&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="第一輪膨脹設計期想像出來的需求">第一輪膨脹：設計期想像出來的需求&lt;/h2>
&lt;p>初版的異步查詢設計把它當一個大系統做：優先級佇列、重試策略、九種事件類型、Isolate 執行、三層快取。每一項單獨看都「像是背景查詢系統該有的」。&lt;/p>
&lt;p>審查砍掉它們的依據不是「以後再說」，而是逐條檢驗後發現&lt;strong>多數能力已經有別層在做&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>重試策略——API 實作層（&lt;code>GoogleBooksApiImplementation&lt;/code>）已經處理&lt;/li>
&lt;li>格式轉換——三層資料模型（DTO → EnrichmentData → Metadata）已經處理&lt;/li>
&lt;li>UI 非阻塞——統一 API 架構本身就是非同步的、不需要額外機制&lt;/li>
&lt;/ul>
&lt;p>砍完之後真需求只剩兩個：&lt;strong>查詢狀態追蹤&lt;/strong>跟&lt;strong>查詢取消&lt;/strong>。落地成一個 &lt;code>QueryTracker&lt;/code>：一個 &lt;code>Map&amp;lt;String, SimpleQueryState&amp;gt;&lt;/code>、一個 &lt;code>Map&amp;lt;String, Completer&amp;gt;&lt;/code>、四個狀態值。這是第一輪的教訓形態——設計期的偽需求長得像完備性（「系統該有的都列上」），檢驗法是逐條問「&lt;strong>這個能力在這個架構裡、已經有哪一層在負責？&lt;/strong>」答案存在，這條就是偽需求；把它做進來不只是浪費、還會製造兩層之間的職責衝突（兩層都重試 = 重試次數相乘）。&lt;/p>
&lt;h2 id="第二輪膨脹迭代期不刪的舊版本">第二輪膨脹：迭代期不刪的舊版本&lt;/h2>
&lt;p>一個版本之後（v0.5.1），同一個子系統又膨脹了、但形態完全不同：Scanner 的查詢服務出現&lt;strong>三個並存的實作版本&lt;/strong>（enhanced、integrated、v1）、一個 &lt;code>AsyncQueryManager&lt;/code> 帶著回調地獄、查詢狀態在多個層級各自追蹤、資料所有權混亂。&lt;/p>
&lt;p>這輪的成因與想像力無關——它是迭代的沉積：每次改進都新開一個版本、舊版本「先留著以防萬一」、狀態追蹤跟著每個版本各長一份。沒有人設計出這個複雜度，它是&lt;strong>不刪除&lt;/strong>的累積結果。&lt;/p>
&lt;p>第二次收縮（v0.5.2）的修法對準這個機制：&lt;code>BookQueryResult&lt;/code> 成為查詢狀態的單一真相來源、&lt;code>BookQueryService&lt;/code> 取代 AsyncQueryManager、三個實作統一成一個、回調改 Future。log 自報的規模：約 874 行減到約 250 行、核心類別 7 個減到 3 個、移除 12 個舊檔案；重構後的新架構測試 78 個全數通過。&lt;/p>
&lt;h2 id="兩輪機制不同對策也不同">兩輪機制不同、對策也不同&lt;/h2>
&lt;p>把兩輪並排，能看出「過度設計」這個標籤蓋住了兩種需要不同對策的問題：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>輪次&lt;/th>
 &lt;th>膨脹機制&lt;/th>
 &lt;th>形態&lt;/th>
 &lt;th>對策&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>設計期&lt;/td>
 &lt;td>想像未來需求、追求完備性&lt;/td>
 &lt;td>用不到的佇列 / 事件 / 快取層&lt;/td>
 &lt;td>逐條檢驗「別層是否已負責」、需求清單要能對應到實際操作&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>迭代期&lt;/td>
 &lt;td>舊版本不刪、狀態隨版本增生&lt;/td>
 &lt;td>N 個實作並存、多處狀態追蹤&lt;/td>
 &lt;td>版本並存視為暫態、開新版時排定舊版的刪除點；狀態收斂單源&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>第一輪的警訊出現在設計文件裡（能力清單長於操作清單）；第二輪的警訊出現在檔案系統裡（同名服務帶 enhanced / v1 / unified 後綴並存）。第二輪也解釋了「砍過一次為什麼不免疫」——第一輪的對策防的是想像，防不了沉積。&lt;/p>
&lt;h2 id="檔名後綴是第二輪的早期訊號">檔名後綴是第二輪的早期訊號&lt;/h2>
&lt;p>第二輪膨脹在檔名上留下了可 grep 的痕跡：&lt;code>enhanced_scanner_book_info_service&lt;/code>、&lt;code>..._v1&lt;/code>、重構後又出現 &lt;code>..._unified&lt;/code>。把版本狀態編進檔名，意味著「哪個是現役版本」這個資訊只存在人的記憶裡——三個檔案都在、都能被 import、新程式碼引用哪個全憑作者當下的認知。同專案更早的重構也清過一批同型的 &lt;code>_simple&lt;/code> / &lt;code>_equatable&lt;/code> 後綴。訊號的用法：檔名裡出現版本形容詞的當下，就把「刪除舊版」排進同一輪工作，而不是等它長到三個版本。&lt;/p>
&lt;h2 id="相關閱讀">相關閱讀&lt;/h2>
&lt;ul>
&lt;li>概念地基：&lt;a href="https://tarrragon.github.io/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南&lt;/a> 的「從操作推導領域」——真需求清單的來源是使用者操作、不是系統完備性想像&lt;/li>
&lt;li>上游事件：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_duplicate_service_fake_coverage/" data-link-title="兩個 domain 各自實作同一個 API service — 100% 覆蓋率的假象" data-link-desc="同名 service 在多個 domain 各自實作時，覆蓋率數字會失去意義：每份實作各測各的、mock 各有介面，統一的行為從未被測過。重複實作是上游訊號——規劃文件沒抽出跨 domain 的共同技術需求；單檔品質審查看不到跨檔重複。">兩個 domain 各自實作同一個 API service&lt;/a>——本文的統一 API 架構正是那次架構債修正的產物，兩篇合起來是 v0.4 架構稽核的完整弧線&lt;/li>
&lt;li>同型原則：&lt;a href="https://tarrragon.github.io/blog/report/decide-later-as-valid-option/" data-link-title="「現在不決定」是合法選項：context 不足時延後決策" data-link-desc="被問到時不一定要立刻答 — 「先補 context、回頭再決」是合法選項、卻常被當「拖延」忽略。LLM / agent 預設「問了就要立刻答」是錯誤前提：使用者有權延後到 context 補齊、推薦時應主動標出「也可選『先 X 再回來決』」。本卡是 #58 篩選三問、#74 決策呈現的時間軸延伸。">#77「現在不決定」是合法選項&lt;/a>——設計期對想像需求的正確處置是「延後 + 條件」、不是先做起來放&lt;/li>
&lt;li>後續案例：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_query_ownership_and_structurally_immune_test/" data-link-title="一行查詢放哪、一個測試留不留：結構改動後的兩次「還需要存在嗎」" data-link-desc="修完一個應收金額 bug 收尾時，兩個『還需不需要』的問題浮出來。一段『過濾出已結帳品項』的查詢該掛哪一層——inline 在 widget 是洩漏、開一個 service 是儀式，判準是『查詢誰擁有的資料就掛給誰』，答案是既有的 repository。一個為了鎖住修復而寫的測試該不該留——當修復是靠刪掉舊函式、移除參數達成時，那個 bug 被結構免疫了，測試變贅述；判準是『這測試守的失敗模式，結構是否已經替它擋掉』。">一行查詢放哪、一個測試留不留&lt;/a>——同樣是查詢層的取捨，這次的膨脹方向是「為一個 &lt;code>.where&lt;/code> 開一個空殼 service」，判準是「查詢誰擁有的資料就掛給誰」&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter 書籍管理 App 的「背景查詢書籍資料」子系統，在兩個月內走了兩輪「膨脹 → 大砍」：第一輪把大系統設計砍成一個純狀態追蹤器、第二輪把三個並存的實作版本砍回一個
<strong>疑問來源</strong>：第一輪已經用力砍過偽需求了，為什麼同一個子系統還會再膨脹一次？
<strong>整理目的</strong>：記下兩輪膨脹各自的機制、以及「真需求 vs 偽需求」的可操作檢驗法
<strong>本文邊界</strong>：素材是該專案 v0.4.1 與 v0.5.2 的重構記錄；量化數字（874 → 250 行等）出自 log 自報、未經獨立重算，本文引用時只取數量級意義</p></blockquote>
<hr>
<h2 id="第一輪膨脹設計期想像出來的需求">第一輪膨脹：設計期想像出來的需求</h2>
<p>初版的異步查詢設計把它當一個大系統做：優先級佇列、重試策略、九種事件類型、Isolate 執行、三層快取。每一項單獨看都「像是背景查詢系統該有的」。</p>
<p>審查砍掉它們的依據不是「以後再說」，而是逐條檢驗後發現<strong>多數能力已經有別層在做</strong>：</p>
<ul>
<li>重試策略——API 實作層（<code>GoogleBooksApiImplementation</code>）已經處理</li>
<li>格式轉換——三層資料模型（DTO → EnrichmentData → Metadata）已經處理</li>
<li>UI 非阻塞——統一 API 架構本身就是非同步的、不需要額外機制</li>
</ul>
<p>砍完之後真需求只剩兩個：<strong>查詢狀態追蹤</strong>跟<strong>查詢取消</strong>。落地成一個 <code>QueryTracker</code>：一個 <code>Map&lt;String, SimpleQueryState&gt;</code>、一個 <code>Map&lt;String, Completer&gt;</code>、四個狀態值。這是第一輪的教訓形態——設計期的偽需求長得像完備性（「系統該有的都列上」），檢驗法是逐條問「<strong>這個能力在這個架構裡、已經有哪一層在負責？</strong>」答案存在，這條就是偽需求；把它做進來不只是浪費、還會製造兩層之間的職責衝突（兩層都重試 = 重試次數相乘）。</p>
<h2 id="第二輪膨脹迭代期不刪的舊版本">第二輪膨脹：迭代期不刪的舊版本</h2>
<p>一個版本之後（v0.5.1），同一個子系統又膨脹了、但形態完全不同：Scanner 的查詢服務出現<strong>三個並存的實作版本</strong>（enhanced、integrated、v1）、一個 <code>AsyncQueryManager</code> 帶著回調地獄、查詢狀態在多個層級各自追蹤、資料所有權混亂。</p>
<p>這輪的成因與想像力無關——它是迭代的沉積：每次改進都新開一個版本、舊版本「先留著以防萬一」、狀態追蹤跟著每個版本各長一份。沒有人設計出這個複雜度，它是<strong>不刪除</strong>的累積結果。</p>
<p>第二次收縮（v0.5.2）的修法對準這個機制：<code>BookQueryResult</code> 成為查詢狀態的單一真相來源、<code>BookQueryService</code> 取代 AsyncQueryManager、三個實作統一成一個、回調改 Future。log 自報的規模：約 874 行減到約 250 行、核心類別 7 個減到 3 個、移除 12 個舊檔案；重構後的新架構測試 78 個全數通過。</p>
<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>用不到的佇列 / 事件 / 快取層</td>
          <td>逐條檢驗「別層是否已負責」、需求清單要能對應到實際操作</td>
      </tr>
      <tr>
          <td>迭代期</td>
          <td>舊版本不刪、狀態隨版本增生</td>
          <td>N 個實作並存、多處狀態追蹤</td>
          <td>版本並存視為暫態、開新版時排定舊版的刪除點；狀態收斂單源</td>
      </tr>
  </tbody>
</table>
<p>第一輪的警訊出現在設計文件裡（能力清單長於操作清單）；第二輪的警訊出現在檔案系統裡（同名服務帶 enhanced / v1 / unified 後綴並存）。第二輪也解釋了「砍過一次為什麼不免疫」——第一輪的對策防的是想像，防不了沉積。</p>
<h2 id="檔名後綴是第二輪的早期訊號">檔名後綴是第二輪的早期訊號</h2>
<p>第二輪膨脹在檔名上留下了可 grep 的痕跡：<code>enhanced_scanner_book_info_service</code>、<code>..._v1</code>、重構後又出現 <code>..._unified</code>。把版本狀態編進檔名，意味著「哪個是現役版本」這個資訊只存在人的記憶裡——三個檔案都在、都能被 import、新程式碼引用哪個全憑作者當下的認知。同專案更早的重構也清過一批同型的 <code>_simple</code> / <code>_equatable</code> 後綴。訊號的用法：檔名裡出現版本形容詞的當下，就把「刪除舊版」排進同一輪工作，而不是等它長到三個版本。</p>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>概念地基：<a href="/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南</a> 的「從操作推導領域」——真需求清單的來源是使用者操作、不是系統完備性想像</li>
<li>上游事件：<a href="/blog/work-log/flutter_duplicate_service_fake_coverage/" data-link-title="兩個 domain 各自實作同一個 API service — 100% 覆蓋率的假象" data-link-desc="同名 service 在多個 domain 各自實作時，覆蓋率數字會失去意義：每份實作各測各的、mock 各有介面，統一的行為從未被測過。重複實作是上游訊號——規劃文件沒抽出跨 domain 的共同技術需求；單檔品質審查看不到跨檔重複。">兩個 domain 各自實作同一個 API service</a>——本文的統一 API 架構正是那次架構債修正的產物，兩篇合起來是 v0.4 架構稽核的完整弧線</li>
<li>同型原則：<a href="/blog/report/decide-later-as-valid-option/" data-link-title="「現在不決定」是合法選項：context 不足時延後決策" data-link-desc="被問到時不一定要立刻答 — 「先補 context、回頭再決」是合法選項、卻常被當「拖延」忽略。LLM / agent 預設「問了就要立刻答」是錯誤前提：使用者有權延後到 context 補齊、推薦時應主動標出「也可選『先 X 再回來決』」。本卡是 #58 篩選三問、#74 決策呈現的時間軸延伸。">#77「現在不決定」是合法選項</a>——設計期對想像需求的正確處置是「延後 + 條件」、不是先做起來放</li>
<li>後續案例：<a href="/blog/work-log/flutter_query_ownership_and_structurally_immune_test/" data-link-title="一行查詢放哪、一個測試留不留：結構改動後的兩次「還需要存在嗎」" data-link-desc="修完一個應收金額 bug 收尾時，兩個『還需不需要』的問題浮出來。一段『過濾出已結帳品項』的查詢該掛哪一層——inline 在 widget 是洩漏、開一個 service 是儀式，判準是『查詢誰擁有的資料就掛給誰』，答案是既有的 repository。一個為了鎖住修復而寫的測試該不該留——當修復是靠刪掉舊函式、移除參數達成時，那個 bug 被結構免疫了，測試變贅述；判準是『這測試守的失敗模式，結構是否已經替它擋掉』。">一行查詢放哪、一個測試留不留</a>——同樣是查詢層的取捨，這次的膨脹方向是「為一個 <code>.where</code> 開一個空殼 service」，判準是「查詢誰擁有的資料就掛給誰」</li>
</ul>
]]></content:encoded></item><item><title>宣告回歸原生 Exception、三個版本後階層重生 — 需求不死、只是換宿主</title><link>https://tarrragon.github.io/blog/work-log/flutter_exception_hierarchy_regrowth/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_exception_hierarchy_regrowth/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter 書籍管理 App 的錯誤處理系統重構。起點是一套公認過度設計的系統（雙目錄、包裝器、adapters），重構目標寫得斬釘截鐵：「完全放棄複雜設計、回歸原生 Dart Exception + ErrorCode、移除 80% 程式碼、零學習成本」。三個小版本後，一個帶五個欄位、五個工廠方法、JSON 序列化的 &lt;code>AppException&lt;/code> 基類完工——階層回來了
&lt;strong>疑問來源&lt;/strong>：這是重構失敗嗎？還是「回歸原生」這個目標本身有問題？
&lt;strong>整理目的&lt;/strong>：記下「砍掉的實作」跟「實作回應的需求」是兩回事、以及怎麼在砍之前把兩者分開
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.9.0 的重構計畫與 v0.9.6 的實作記錄；效能數字（&amp;lt; 0.1ms、&amp;lt; 200 bytes）為 log 自報&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="被砍的東西跟砍它的理由">被砍的東西、跟砍它的理由&lt;/h2>
&lt;p>舊系統的過度設計是實錘的，重構計畫列的證據都站得住：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>雙重系統並存&lt;/strong>：兩個目錄各有一個 &lt;code>app_error.dart&lt;/code>、互相依賴、責任不明&lt;/li>
&lt;li>&lt;strong>無資訊的欄位&lt;/strong>：&lt;code>StandardError&lt;/code> 是 &lt;code>BusinessLogicError&lt;/code> 的薄包裝，&lt;code>businessRule&lt;/code> 欄位永遠是固定值 &lt;code>'LEGACY_STANDARD_ERROR'&lt;/code>——欄位存在、但永遠同值，等於沒有這個欄位&lt;/li>
&lt;li>&lt;strong>解不存在問題的機能&lt;/strong>：錯誤物件的深度複製與循環參照處理、多餘的 &lt;code>ErrorAdapters&lt;/code> 轉換層&lt;/li>
&lt;/ul>
&lt;p>新架構的設計範例極簡：一個 &lt;code>ErrorCode&lt;/code> enum、幾個 &lt;code>implements Exception&lt;/code> 的輕量類別，&lt;code>code&lt;/code> 加 &lt;code>message&lt;/code> 兩個欄位。目標指標全是刪減向的：程式碼 -80%、建立時間、記憶體、零學習成本。&lt;/p>
&lt;h2 id="三個版本後階層原地重生">三個版本後：階層原地重生&lt;/h2>
&lt;p>v0.9.6 交付的 &lt;code>AppException&lt;/code> 長這樣：&lt;code>errorCode&lt;/code>、&lt;code>userMessage&lt;/code>、&lt;code>context&lt;/code>、&lt;code>timestamp&lt;/code>、&lt;code>stackTrace&lt;/code> 五個欄位；&lt;code>withStackTrace&lt;/code>、&lt;code>wrap&lt;/code> 加五個分類便利工廠（validation / network / business / storage / platform）；完整的 &lt;code>toJson&lt;/code> / &lt;code>fromJson&lt;/code>；從 errorCode 衍生 &lt;code>category&lt;/code> 與 &lt;code>isRecoverable&lt;/code>。之後的版本再從它派生出五個分類子類、配上分類一致性的建構不變式。&lt;/p>
&lt;p>對照 v0.9.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;code>userMessage&lt;/code>&lt;/td>
 &lt;td>UI 的錯誤顯示（繁中文案、跟開發者訊息分離）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>JSON 序列化&lt;/td>
 &lt;td>跨平台錯誤格式（Chrome Extension 端要吃同一種錯誤）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>category&lt;/code> / 子類&lt;/td>
 &lt;td>錯誤路由與統計（哪類錯誤重試、哪類直接報）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>isRecoverable&lt;/code>&lt;/td>
 &lt;td>錯誤處理流程的分支決策&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>wrap&lt;/code> / stackTrace&lt;/td>
 &lt;td>第三方異常的統一化、除錯追蹤&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>需求不死、只是換宿主。&lt;/strong> 舊系統被砍掉時、這些需求沒有跟著消失，它們安靜地等著，在新架構的第一批真實使用裡逐個把自己長回來。&lt;/p>
&lt;h2 id="這不是繞回原點但成本可以更低">這不是繞回原點——但成本可以更低&lt;/h2>
&lt;p>重生的版本跟舊系統不等價：沒有雙目錄、沒有薄包裝、沒有 adapters、沒有深拷貝。全砍再重長的過程實際上完成了一次&lt;strong>需求過濾&lt;/strong>——有消費者的機能重生了、沒有的死透了。從結果看，這比在舊系統上修修補補乾淨。&lt;/p>
&lt;p>但同樣的過濾有更便宜的做法：砍之前對舊系統逐機能問「&lt;strong>這個機能有沒有真實的消費者？&lt;/strong>」——&lt;code>businessRule&lt;/code> 固定值沒有（砍）、深拷貝沒有（砍）、&lt;code>userMessage&lt;/code> 有 UI 在用（留、換宿主）、序列化有 Chrome Extension 在吃（留）。盤點的產物是一張「保留需求清單」，新架構從第一天就對著清單設計，不用等真實使用把需求一個個撞回來——這個專案為這輪錯誤系統遷移付出了整個中版本、幾十個子版本的 migration 工作量，其中一部分就是「長回來」的返工。&lt;/p>
&lt;h2 id="口號式目標不可驗收需求清單可以">口號式目標不可驗收、需求清單可以&lt;/h2>
&lt;p>「回歸原生、零學習成本、砍 80%」作為重構目標的問題在驗收：v0.9.6 的 &lt;code>AppException&lt;/code> 算不算違反「零學習成本」？五個工廠方法算不算「原生」？口號沒有判準，於是重生過程既沒有被擋下（沒人能說它違規）、也沒有被承認（文件仍然掛著回歸原生的旗）——方向的實質轉彎沒有留下決策記錄。&lt;/p>
&lt;p>換成需求清單做目標（「錯誤系統要服務：UI 文案、跨平台序列化、分類路由；不服務：深拷貝、adapter 轉換」），每一次擴充都可以對表：在清單上、做；不在、先補清單再做。同一個結果、但每一步有據可查——這跟 &lt;a href="https://tarrragon.github.io/blog/work-log/flutter_value_object_encapsulation_oscillation/" data-link-title="Value Object 的封裝擺盪：從全移除、完全封裝、到加回 .value getter" data-link-desc="VO 的封裝邊界在兩個極端之間來回——純字串（零封裝）跟完全封裝（禁止取原始值）各有成立的理由、也各自撞牆。穩態是給原始值一個有語意的官方出口，而不是把「取原始值」本身當違規。含 176 個編譯錯誤的工作量低估、以及「相容性介面」作為理想撤退訊號的判讀。">VO 封裝擺盪&lt;/a>的收束是同一個藥方：&lt;strong>把邊界寫成決策記錄，下一任重構者才不會從自己撞到的那一面再推一次極端&lt;/strong>。&lt;/p>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;ul>
&lt;li>重構目標以口號形式出現（回歸原生、極簡、零依賴）而沒有「保留哪些需求」的清單——重生已在路上，差別只是有沒有人記錄它&lt;/li>
&lt;li>被砍系統裡有固定值欄位、無人呼叫的轉換層——真該砍的部分，砍之前留一行「為什麼它沒有消費者」&lt;/li>
&lt;li>新架構上線後前幾個版本連續「補回」被砍的機能——每一筆都是需求盤點的漏項，把它們補進當初缺席的清單&lt;/li>
&lt;li>效能目標（&amp;lt; 0.1ms、&amp;lt; 200 bytes）當重構主訴求——先確認錯誤建立真的在熱路徑上，否則這是拿好量的指標代替難量的設計問題&lt;/li>
&lt;/ul>
&lt;h2 id="相關閱讀">相關閱讀&lt;/h2>
&lt;ul>
&lt;li>同專案的擺盪家族：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_value_object_encapsulation_oscillation/" data-link-title="Value Object 的封裝擺盪：從全移除、完全封裝、到加回 .value getter" data-link-desc="VO 的封裝邊界在兩個極端之間來回——純字串（零封裝）跟完全封裝（禁止取原始值）各有成立的理由、也各自撞牆。穩態是給原始值一個有語意的官方出口，而不是把「取原始值」本身當違規。含 176 個編譯錯誤的工作量低估、以及「相容性介面」作為理想撤退訊號的判讀。">VO 封裝擺盪&lt;/a>——封裝政策的來回、本文是抽象層級的來回，共同根因都是邊界沒有決策記錄&lt;/li>
&lt;li>重生後的階層怎麼運作：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_exception_error_category_invariant/" data-link-title="Exception 型別綁 ErrorCategory 的建構不變式 — 以及合法需求撞上不變式的時刻" data-link-desc="把「錯誤代碼必須屬於對應分類」做成建構期不變式，錯誤分類錯亂會變成測試失敗而不是靜默混亂；同一批修復出現三種形態——換對值、換精確值、以及改繼承逃離約束。第三種是分類學本身的訊號：一個 domain 的錯誤天生橫跨技術分類時，分類軸跟階層軸不正交。">Exception 型別綁 ErrorCategory 的建構不變式&lt;/a>——本文的 AppException 家族在後續版本的分類不變式實戰&lt;/li>
&lt;li>過度設計的姊妹篇：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_async_query_overdesign_oscillation/" data-link-title="同一個子系統膨脹兩次：異步查詢系統的過度設計震盪" data-link-desc="過度設計會復發、且兩輪的機制不同：設計期的膨脹來自想像的需求（別層已處理的重試、用不到的優先級佇列），迭代期的膨脹來自不刪的舊版本（三個實作並存、狀態多處追蹤）。偽需求的檢驗法是問「這個能力已經有別層在做嗎」。">異步查詢系統的過度設計震盪&lt;/a>——那篇砍的多數是偽需求所以砍得掉；本文砍的混著真需求所以長回來，兩篇合起來是「砍之前先分真偽」的完整論證&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter 書籍管理 App 的錯誤處理系統重構。起點是一套公認過度設計的系統（雙目錄、包裝器、adapters），重構目標寫得斬釘截鐵：「完全放棄複雜設計、回歸原生 Dart Exception + ErrorCode、移除 80% 程式碼、零學習成本」。三個小版本後，一個帶五個欄位、五個工廠方法、JSON 序列化的 <code>AppException</code> 基類完工——階層回來了
<strong>疑問來源</strong>：這是重構失敗嗎？還是「回歸原生」這個目標本身有問題？
<strong>整理目的</strong>：記下「砍掉的實作」跟「實作回應的需求」是兩回事、以及怎麼在砍之前把兩者分開
<strong>本文邊界</strong>：素材是該專案 v0.9.0 的重構計畫與 v0.9.6 的實作記錄；效能數字（&lt; 0.1ms、&lt; 200 bytes）為 log 自報</p></blockquote>
<hr>
<h2 id="被砍的東西跟砍它的理由">被砍的東西、跟砍它的理由</h2>
<p>舊系統的過度設計是實錘的，重構計畫列的證據都站得住：</p>
<ul>
<li><strong>雙重系統並存</strong>：兩個目錄各有一個 <code>app_error.dart</code>、互相依賴、責任不明</li>
<li><strong>無資訊的欄位</strong>：<code>StandardError</code> 是 <code>BusinessLogicError</code> 的薄包裝，<code>businessRule</code> 欄位永遠是固定值 <code>'LEGACY_STANDARD_ERROR'</code>——欄位存在、但永遠同值，等於沒有這個欄位</li>
<li><strong>解不存在問題的機能</strong>：錯誤物件的深度複製與循環參照處理、多餘的 <code>ErrorAdapters</code> 轉換層</li>
</ul>
<p>新架構的設計範例極簡：一個 <code>ErrorCode</code> enum、幾個 <code>implements Exception</code> 的輕量類別，<code>code</code> 加 <code>message</code> 兩個欄位。目標指標全是刪減向的：程式碼 -80%、建立時間、記憶體、零學習成本。</p>
<h2 id="三個版本後階層原地重生">三個版本後：階層原地重生</h2>
<p>v0.9.6 交付的 <code>AppException</code> 長這樣：<code>errorCode</code>、<code>userMessage</code>、<code>context</code>、<code>timestamp</code>、<code>stackTrace</code> 五個欄位；<code>withStackTrace</code>、<code>wrap</code> 加五個分類便利工廠（validation / network / business / storage / platform）；完整的 <code>toJson</code> / <code>fromJson</code>；從 errorCode 衍生 <code>category</code> 與 <code>isRecoverable</code>。之後的版本再從它派生出五個分類子類、配上分類一致性的建構不變式。</p>
<p>對照 v0.9.0 的口號，這是違規；對照需求，每一個「長回來的東西」都有名有姓的消費者：</p>
<table>
  <thead>
      <tr>
          <th>重生的機能</th>
          <th>消費者</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>userMessage</code></td>
          <td>UI 的錯誤顯示（繁中文案、跟開發者訊息分離）</td>
      </tr>
      <tr>
          <td>JSON 序列化</td>
          <td>跨平台錯誤格式（Chrome Extension 端要吃同一種錯誤）</td>
      </tr>
      <tr>
          <td><code>category</code> / 子類</td>
          <td>錯誤路由與統計（哪類錯誤重試、哪類直接報）</td>
      </tr>
      <tr>
          <td><code>isRecoverable</code></td>
          <td>錯誤處理流程的分支決策</td>
      </tr>
      <tr>
          <td><code>wrap</code> / stackTrace</td>
          <td>第三方異常的統一化、除錯追蹤</td>
      </tr>
  </tbody>
</table>
<p><strong>需求不死、只是換宿主。</strong> 舊系統被砍掉時、這些需求沒有跟著消失，它們安靜地等著，在新架構的第一批真實使用裡逐個把自己長回來。</p>
<h2 id="這不是繞回原點但成本可以更低">這不是繞回原點——但成本可以更低</h2>
<p>重生的版本跟舊系統不等價：沒有雙目錄、沒有薄包裝、沒有 adapters、沒有深拷貝。全砍再重長的過程實際上完成了一次<strong>需求過濾</strong>——有消費者的機能重生了、沒有的死透了。從結果看，這比在舊系統上修修補補乾淨。</p>
<p>但同樣的過濾有更便宜的做法：砍之前對舊系統逐機能問「<strong>這個機能有沒有真實的消費者？</strong>」——<code>businessRule</code> 固定值沒有（砍）、深拷貝沒有（砍）、<code>userMessage</code> 有 UI 在用（留、換宿主）、序列化有 Chrome Extension 在吃（留）。盤點的產物是一張「保留需求清單」，新架構從第一天就對著清單設計，不用等真實使用把需求一個個撞回來——這個專案為這輪錯誤系統遷移付出了整個中版本、幾十個子版本的 migration 工作量，其中一部分就是「長回來」的返工。</p>
<h2 id="口號式目標不可驗收需求清單可以">口號式目標不可驗收、需求清單可以</h2>
<p>「回歸原生、零學習成本、砍 80%」作為重構目標的問題在驗收：v0.9.6 的 <code>AppException</code> 算不算違反「零學習成本」？五個工廠方法算不算「原生」？口號沒有判準，於是重生過程既沒有被擋下（沒人能說它違規）、也沒有被承認（文件仍然掛著回歸原生的旗）——方向的實質轉彎沒有留下決策記錄。</p>
<p>換成需求清單做目標（「錯誤系統要服務：UI 文案、跨平台序列化、分類路由；不服務：深拷貝、adapter 轉換」），每一次擴充都可以對表：在清單上、做；不在、先補清單再做。同一個結果、但每一步有據可查——這跟 <a href="/blog/work-log/flutter_value_object_encapsulation_oscillation/" data-link-title="Value Object 的封裝擺盪：從全移除、完全封裝、到加回 .value getter" data-link-desc="VO 的封裝邊界在兩個極端之間來回——純字串（零封裝）跟完全封裝（禁止取原始值）各有成立的理由、也各自撞牆。穩態是給原始值一個有語意的官方出口，而不是把「取原始值」本身當違規。含 176 個編譯錯誤的工作量低估、以及「相容性介面」作為理想撤退訊號的判讀。">VO 封裝擺盪</a>的收束是同一個藥方：<strong>把邊界寫成決策記錄，下一任重構者才不會從自己撞到的那一面再推一次極端</strong>。</p>
<h2 id="判讀徵兆">判讀徵兆</h2>
<ul>
<li>重構目標以口號形式出現（回歸原生、極簡、零依賴）而沒有「保留哪些需求」的清單——重生已在路上，差別只是有沒有人記錄它</li>
<li>被砍系統裡有固定值欄位、無人呼叫的轉換層——真該砍的部分，砍之前留一行「為什麼它沒有消費者」</li>
<li>新架構上線後前幾個版本連續「補回」被砍的機能——每一筆都是需求盤點的漏項，把它們補進當初缺席的清單</li>
<li>效能目標（&lt; 0.1ms、&lt; 200 bytes）當重構主訴求——先確認錯誤建立真的在熱路徑上，否則這是拿好量的指標代替難量的設計問題</li>
</ul>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>同專案的擺盪家族：<a href="/blog/work-log/flutter_value_object_encapsulation_oscillation/" data-link-title="Value Object 的封裝擺盪：從全移除、完全封裝、到加回 .value getter" data-link-desc="VO 的封裝邊界在兩個極端之間來回——純字串（零封裝）跟完全封裝（禁止取原始值）各有成立的理由、也各自撞牆。穩態是給原始值一個有語意的官方出口，而不是把「取原始值」本身當違規。含 176 個編譯錯誤的工作量低估、以及「相容性介面」作為理想撤退訊號的判讀。">VO 封裝擺盪</a>——封裝政策的來回、本文是抽象層級的來回，共同根因都是邊界沒有決策記錄</li>
<li>重生後的階層怎麼運作：<a href="/blog/work-log/flutter_exception_error_category_invariant/" data-link-title="Exception 型別綁 ErrorCategory 的建構不變式 — 以及合法需求撞上不變式的時刻" data-link-desc="把「錯誤代碼必須屬於對應分類」做成建構期不變式，錯誤分類錯亂會變成測試失敗而不是靜默混亂；同一批修復出現三種形態——換對值、換精確值、以及改繼承逃離約束。第三種是分類學本身的訊號：一個 domain 的錯誤天生橫跨技術分類時，分類軸跟階層軸不正交。">Exception 型別綁 ErrorCategory 的建構不變式</a>——本文的 AppException 家族在後續版本的分類不變式實戰</li>
<li>過度設計的姊妹篇：<a href="/blog/work-log/flutter_async_query_overdesign_oscillation/" data-link-title="同一個子系統膨脹兩次：異步查詢系統的過度設計震盪" data-link-desc="過度設計會復發、且兩輪的機制不同：設計期的膨脹來自想像的需求（別層已處理的重試、用不到的優先級佇列），迭代期的膨脹來自不刪的舊版本（三個實作並存、狀態多處追蹤）。偽需求的檢驗法是問「這個能力已經有別層在做嗎」。">異步查詢系統的過度設計震盪</a>——那篇砍的多數是偽需求所以砍得掉；本文砍的混著真需求所以長回來，兩篇合起來是「砍之前先分真偽」的完整論證</li>
</ul>
]]></content:encoded></item></channel></rss>