觸發場景:Flutter 書籍管理 App 的「背景查詢書籍資料」子系統,在兩個月內走了兩輪「膨脹 → 大砍」:第一輪把大系統設計砍成一個純狀態追蹤器、第二輪把三個並存的實作版本砍回一個 疑問來源:第一輪已經用力砍過偽需求了,為什麼同一個子系統還會再膨脹一次? 整理目的:記下兩輪膨脹各自的機制、以及「真需求 vs 偽需求」的可操作檢驗法 本文邊界:素材是該專案 v0.4.1 與 v0.5.2 的重構記錄;量化數字(874 → 250 行等)出自 log 自報、未經獨立重算,本文引用時只取數量級意義


第一輪膨脹:設計期想像出來的需求

初版的異步查詢設計把它當一個大系統做:優先級佇列、重試策略、九種事件類型、Isolate 執行、三層快取。每一項單獨看都「像是背景查詢系統該有的」。

審查砍掉它們的依據不是「以後再說」,而是逐條檢驗後發現多數能力已經有別層在做

  • 重試策略——API 實作層(GoogleBooksApiImplementation)已經處理
  • 格式轉換——三層資料模型(DTO → EnrichmentData → Metadata)已經處理
  • UI 非阻塞——統一 API 架構本身就是非同步的、不需要額外機制

砍完之後真需求只剩兩個:查詢狀態追蹤查詢取消。落地成一個 QueryTracker:一個 Map<String, SimpleQueryState>、一個 Map<String, Completer>、四個狀態值。這是第一輪的教訓形態——設計期的偽需求長得像完備性(「系統該有的都列上」),檢驗法是逐條問「這個能力在這個架構裡、已經有哪一層在負責?」答案存在,這條就是偽需求;把它做進來不只是浪費、還會製造兩層之間的職責衝突(兩層都重試 = 重試次數相乘)。

第二輪膨脹:迭代期不刪的舊版本

一個版本之後(v0.5.1),同一個子系統又膨脹了、但形態完全不同:Scanner 的查詢服務出現三個並存的實作版本(enhanced、integrated、v1)、一個 AsyncQueryManager 帶著回調地獄、查詢狀態在多個層級各自追蹤、資料所有權混亂。

這輪的成因與想像力無關——它是迭代的沉積:每次改進都新開一個版本、舊版本「先留著以防萬一」、狀態追蹤跟著每個版本各長一份。沒有人設計出這個複雜度,它是不刪除的累積結果。

第二次收縮(v0.5.2)的修法對準這個機制:BookQueryResult 成為查詢狀態的單一真相來源、BookQueryService 取代 AsyncQueryManager、三個實作統一成一個、回調改 Future。log 自報的規模:約 874 行減到約 250 行、核心類別 7 個減到 3 個、移除 12 個舊檔案;重構後的新架構測試 78 個全數通過。

兩輪機制不同、對策也不同

把兩輪並排,能看出「過度設計」這個標籤蓋住了兩種需要不同對策的問題:

輪次膨脹機制形態對策
設計期想像未來需求、追求完備性用不到的佇列 / 事件 / 快取層逐條檢驗「別層是否已負責」、需求清單要能對應到實際操作
迭代期舊版本不刪、狀態隨版本增生N 個實作並存、多處狀態追蹤版本並存視為暫態、開新版時排定舊版的刪除點;狀態收斂單源

第一輪的警訊出現在設計文件裡(能力清單長於操作清單);第二輪的警訊出現在檔案系統裡(同名服務帶 enhanced / v1 / unified 後綴並存)。第二輪也解釋了「砍過一次為什麼不免疫」——第一輪的對策防的是想像,防不了沉積。

檔名後綴是第二輪的早期訊號

第二輪膨脹在檔名上留下了可 grep 的痕跡:enhanced_scanner_book_info_service..._v1、重構後又出現 ..._unified。把版本狀態編進檔名,意味著「哪個是現役版本」這個資訊只存在人的記憶裡——三個檔案都在、都能被 import、新程式碼引用哪個全憑作者當下的認知。同專案更早的重構也清過一批同型的 _simple / _equatable 後綴。訊號的用法:檔名裡出現版本形容詞的當下,就把「刪除舊版」排進同一輪工作,而不是等它長到三個版本。

相關閱讀