設計瑕疵還是避免過度設計?YAGNI 的真實適用條件
核心命題:YAGNI 不是「永遠選最受限選項」的原則,是「不為未來投入額外成本」的原則。 判斷工具:成本對稱性、可逆性、領域先驗——三軸框架。
起點:一個常見的工程爭論
「最早的設計者沒考慮到多個監聽需求,這算設計瑕疵,還是避免過度設計?」
這類問題在 code review、事故檢討、技術選型討論裡反覆出現。指控太重會打擊個別工程師的判斷力信心,放任又會讓同類事故反覆發生。
要釐清這個爭論,得先回到 YAGNI 原則的真實定義——很多被當成 YAGNI 的例子根本不在它的射程內。
YAGNI 的真實範圍
YAGNI(You Aren’t Gonna Need It)的原意是:不要投入額外成本去蓋你尚未需要的東西。它防的是這類情境:
- 「我先寫個 plugin 系統,未來可以擴充」(成本:協議設計、抽象層、擴充點測試)
- 「我先做多語系,未來會國際化」(成本:i18n 框架、所有字串外移)
- 「我先支援多資料庫」(成本:repository 抽象、SQL 方言處理)
- 「我先建多租戶切割」(成本:資料 schema 加 tenant 欄位、所有 query 加過濾)
這些選擇的共通特徵是:為了未來付出當下的具體成本——抽象層、額外測試、複雜配置、學習負擔。YAGNI 說:別付,等真正需要再付,因為很可能你永遠不需要。
但很多被指控為「過度設計」的選擇其實沒有 upfront cost 差異。例如:
- Stream 工具用單訂閱版本還是廣播版本:建構子多打 11 個字元
var還是final:3 個字元- ID 用
int還是String(UUID):抽象層成本一樣 - API 設計成同步還是 async:簽章只差
Future<>包裝 - Class 預設可繼承還是 sealed:一個 modifier
- Database column 預設 nullable 還是 NOT NULL:一個 keyword
這些不在 YAGNI 的射程內。把它們當成 YAGNI 來防禦會選錯方向。
真正的判斷軸:成本不對稱性
判斷「該不該選更通用的選項」,跑三個軸。
軸 1:成本對稱性
「選擇 A 比選擇 B 多付出多少當下成本?」
- 對稱(成本相當、差幾個字元、無新概念):選未來更可能需要的那個——這不是過度設計,是合理 default
- 不對稱(一邊明顯較貴、要多寫框架、多加抽象、多學概念):YAGNI 適用,選便宜的,需要時再升級
軸 2:改變決定的成本
「如果選錯了,未來修正要付出什麼?」
- 可逆(一行改完、無 API 契約變動、無資料遷移):YAGNI 適用,先選簡單的
- 不可逆 / 修正昂貴(牽動 API 契約、資料庫 schema、客戶端版本相容性、第三方 integration):偏向預先選擇通用的
軸 3:領域先驗(domain prior)
「這個領域裡、這個模式發生的機率有多高?」——「先驗」(prior)借自 Bayesian 統計、用來指「在沒看到具體證據前、我們對某事發生機率的合理預期」。在工程領域、這個機率來自累積的領域知識(多視角同步、retry、併發、認證⋯⋯這些 pattern 的歷史發生率)。
- 強先驗(教科書級別):多視角狀態同步是廣播、有用戶系統一定有 logged-in / anonymous 兩種、長時間運行服務一定會有 retry 需求、有交易就會有併發
- 弱先驗(純臆測):「未來可能會有 plugin 機制吧」「未來可能要換資料庫吧」「未來可能要支援其他平台吧」
三軸的綜合判斷
任一軸顯著偏向「該選通用」,YAGNI 就不適用。
選通用不是過度設計,是對工具屬性與領域常識的尊重。
案例對照:兩個極端
案例 A:Stream 預設選錯
某個事件廣播 service 用了 StreamController() 預設建構子(單訂閱)。當下只有一個訂閱者,運作正常數個月。後來加第二個訂閱者,瞬間 throw Bad state: Stream has already been listened to。
跑三軸:
- 成本對稱性:對稱(差 11 個字元、零認知負擔)
- 可逆性:中等偏高(事故必須在 production 暴露才會發現,要審所有訂閱方、改實作 + mock)
- 領域先驗:強(pub-sub / 事件廣播場景天生多訂閱)
三軸都指向廣播版本。這是設計瑕疵——不是因為「沒考慮多訂閱」,而是在三軸都不利於單訂閱的情況下選了單訂閱。
完整事故重現、單訂閱 vs broadcast 的程式碼對比、修復決策過程:Dart StreamController:single-subscription vs broadcast 的事故實錄。
案例 B:建立 plugin 系統
「我先建個 plugin 系統,未來功能模組可以動態擴充」——典型的 over-engineering 焦慮表現。
跑三軸:
- 成本對稱性:嚴重不對稱(plugin 系統需要設計協議、加載機制、版本管理、隔離測試)
- 可逆性:可逆(之後要做的話成本跟現在做差不多)
- 領域先驗:弱(多數應用程式不會有第三方擴充需求)
三軸都指向「先別做」。這是 YAGNI 的標準適用情境。
兩個案例的對比
| 案例 | 成本對稱性 | 可逆性 | 領域先驗 | 該怎麼選 |
|---|---|---|---|---|
| Stream 預設 | 對稱 | 中等偏高 | 強 | 提前選通用 |
| Plugin 系統 | 嚴重不對稱 | 可逆 | 弱 | YAGNI(先別做) |
兩者表面看都是「未來可能需要」,但三軸框架告訴你它們是完全不同類別的決定。一概而論「該/不該為未來準備」會兩邊都做錯。
為什麼這類瑕疵「可被原諒」
要老實講:指出某個選擇是設計瑕疵,不等於把責任全部推給個別工程師。
同類型瑕疵在實務上極常見,原因往往是系統性陷阱。
1. 語言 / 工具的預設值誤導
很多語言把「需要明確選擇」的東西做成「最少打字的預設」:
- Dart 的
StreamController()是 single-subscription - 多數 SQL 的 column 預設 nullable
- JavaScript 的
==預設寬鬆比對 - 多數語言的 class 預設可繼承
- HTTP 預設不加密
- 多數語言的 mutable 是 default
這些預設都把多數人推向「比較容易出錯但不立即爆」的選項。API 設計把成本均衡的選擇做成「便宜便輸出受限」vs「貴一點輸出通用」是 framework 設計的責任轉嫁——把跨用例的判斷成本丟給用戶。
2. 領域知識需要被觸發過才會內化
很多事是遇過一次才會記得。「stream 預設是單訂閱」「nullable column 之後加 NOT NULL 要 backfill」「同步 API 之後改 async 是 breaking change」——這些不是經驗少的問題,是這些事實需要遇到才會內化進直覺判斷。
新人讀文件不會看到、code review 不會自動 catch、靜態分析不會主動警告——只能等某次遇到。
3. 失敗模式的低調性掩蓋風險
很多設計瑕疵的失敗模式只在特定觸發條件下顯現:
- Stream 多訂閱限制只在第二次
listen()時暴露 - Mutable shared state 的 race condition 只在高併發下爆
- Cache 失效邏輯只在 cache miss 模式變化時出問題
- API 沒做 idempotent 只在重試時出現重複
平常測試跑都過,給人「沒問題」的錯覺。沒有立即反饋的設計瑕疵 = 隱形的技術債。
4. 工具替代品掩蓋知識需求
有些底層概念被高層框架封裝後,使用者根本不會碰到,所以「應該知道」的知識沒有被反覆強化。例如:
- Flutter 開發者多用 GetX / Riverpod / Bloc,極少碰 raw
StreamController - ORM 用戶多不寫 SQL,極少思考 query plan
- 雲端 SDK 用戶多不思考 retry / backoff,極少接觸底層 HTTP
當有一天必須繞過框架直接用底層工具時,那個事故就會發生。
結論
設計者只承擔最後一棒。要把同類瑕疵變少,修補方向在制度層面。
制度層面的補強
要把「該選通用 default 但選了受限預設」的錯誤變少,個人記憶不可靠,要靠三層機制。
機制 1:介面層的 review checklist
把容易出錯的 default 列入 PR review 檢查清單。例如:
- Service 對外暴露
Stream<T>時、預設用 broadcast;用 single 要在註解寫明理由 - 資料庫 column 預設用 NOT NULL;nullable 要在註解寫明業務理由
- 公開 API 預設用 async;sync 要寫明理由
- 公開類別預設用 sealed / final;可繼承要寫明理由
- HTTP 預設用 HTTPS;plain HTTP 要寫明理由
把「需要記得」變成「review 強制檢查」。Checklist 不需要多,每個項目對應一個遇過的事故。
機制 2:架構規範把選擇從 default 取消
更徹底的做法是用工具或規範禁掉問題 default:
- App 層 service 禁用 raw
StreamController,強制用框架的廣播原語 - 用 lint rule 警告
StreamController()的無參數呼叫 - DB schema migration 工具預設產出 NOT NULL,nullable 要明確指定
- API gateway 預設 deny,要顯式 allow 才放行
這把選擇從「需要記得」變成「不需要選,做錯會被擋」。是最高效的補強。
機制 3:領域先驗清單
每個團隊應該維護一份「我們的領域裡這些事一定會發生」的清單。範例:
POS 系統:
- 一台主機要服務多視角(多顯示螢幕、多通知模組)
- 會員身份會即時切換
- 有離線運作需求
- 多分店不同設定
電商:
- 商品價格會變動,歷史訂單要保留下單當時的價格
- 庫存會超賣,需要 reserve / commit 機制
- 退款是必然發生的,不是 edge case
- 客戶會有多個收件地址
新功能設計時對照清單——強領域先驗就直接設計進去,不必每次重新評估。新進團隊成員也能快速吸收領域常識。
一個能套到無數情境的 heuristic
把整個討論濃縮成一句話:
當你的選擇「沒有 upfront cost 差異」時、就該選未來自由度高的那個。
這個 heuristic 能套到無數技術決定:
| 場景 | 「便宜但受限」 | 「同樣便宜但通用」 |
|---|---|---|
| Stream 廣播 | StreamController() | StreamController.broadcast() |
| 集合不可變性 | var list = [1, 2] | final list = const [1, 2] |
| API 回傳值 | 同步 method | Future<> 包裝 |
| 函式參數 | positional args | named args |
| Class 設計 | 預設可繼承 | sealed / final class |
| Resource handle | manual cleanup | RAII / using block |
| Time | local time | UTC + timezone metadata |
| ID 型別 | int auto-increment | String (UUID) |
| Money | double | 專用 Decimal 型別 |
| 字串編碼 | 平台預設 | 顯式 UTF-8 |
這些都不是「過度設計」,是在零成本差異下選擇未來自由度更高的選項。YAGNI 不適用——YAGNI 的成本門檻在這裡根本不存在。
反向校正:什麼時候該堅持 YAGNI?
為了避免本文被讀成「永遠選通用」,補一個反向案例。
YAGNI 在這些情境是對的:
| 情境 | 為什麼 YAGNI 適用 |
|---|---|
| 「先做個 admin 後台,未來方便」 | 成本巨大,需求未確認,可逆 |
| 「先支援自訂主題系統」 | 成本中等,弱領域先驗,可逆 |
| 「先做 API rate limiting」 | 成本中等,現階段流量沒問題,可逆 |
| 「先設計 multi-region 部署」 | 成本巨大,多數產品永遠單 region |
| 「先抽 service 層」 | 成本中等,function 直接呼叫已經夠用 |
這些都是為了未來付出當下具體成本——抽象層、新概念、額外測試、配置複雜度。YAGNI 在這些情境會帶你做出對的選擇。
判斷的差異是:這個決定是「選哪個免費選項」,還是「要不要付一筆額外開發成本」? 前者三軸框架;後者 YAGNI。
總結
YAGNI vs 過度設計的爭論,常常因為兩邊在用不同定義而無法收斂。釐清如下:
YAGNI 適用於「為了未來而付出當下的具體成本」 不適用於「在成本相當的選項中選擇更通用的那個」
判斷時跑三軸:
- 成本對稱性:兩個選項的 upfront cost 是否相當?
- 可逆性:選錯的話修正昂貴嗎?
- 領域先驗:這個模式在領域裡發生機率多高?
任一軸顯著偏向「該選通用」,YAGNI 就不適用,這不是過度設計。
回到開頭問題——「最早的設計者沒考慮到多個監聽需求、這算設計瑕疵還是避免過度設計?」答案取決於這三軸的具體狀況、不能一概而論。
但如果像 Stream 這個案例、三軸全部不利於受限預設、那就是設計瑕疵。只是這類瑕疵反映的是工具預設與領域知識內化的系統性問題、不是個別工程師的判斷力不足——修補方向是制度而非個人責備。
一句話帶走
日常情境中、把三軸壓縮成一個問題就夠用:
「我在多付什麼成本?」
- 多付抽象層、新概念、額外測試 → YAGNI 適用、先別付
- 多付幾個字元、一個關鍵字 → 不是 YAGNI、選通用的
需要更精細的時候、再回頭跑完整三軸框架。