核心命題: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 回傳值同步 methodFuture<> 包裝
函式參數positional argsnamed args
Class 設計預設可繼承sealed / final class
Resource handlemanual cleanupRAII / using block
Timelocal timeUTC + timezone metadata
ID 型別int auto-incrementString (UUID)
Moneydouble專用 Decimal 型別
字串編碼平台預設顯式 UTF-8

這些都不是「過度設計」,是在零成本差異下選擇未來自由度更高的選項。YAGNI 不適用——YAGNI 的成本門檻在這裡根本不存在。


反向校正:什麼時候該堅持 YAGNI?

為了避免本文被讀成「永遠選通用」,補一個反向案例。

YAGNI 在這些情境是對的:

情境為什麼 YAGNI 適用
「先做個 admin 後台,未來方便」成本巨大,需求未確認,可逆
「先支援自訂主題系統」成本中等,弱領域先驗,可逆
「先做 API rate limiting」成本中等,現階段流量沒問題,可逆
「先設計 multi-region 部署」成本巨大,多數產品永遠單 region
「先抽 service 層」成本中等,function 直接呼叫已經夠用

這些都是為了未來付出當下具體成本——抽象層、新概念、額外測試、配置複雜度。YAGNI 在這些情境會帶你做出對的選擇。

判斷的差異是:這個決定是「選哪個免費選項」,還是「要不要付一筆額外開發成本」? 前者三軸框架;後者 YAGNI。


總結

YAGNI vs 過度設計的爭論,常常因為兩邊在用不同定義而無法收斂。釐清如下:

YAGNI 適用於「為了未來而付出當下的具體成本」 不適用於「在成本相當的選項中選擇更通用的那個」

判斷時跑三軸:

  1. 成本對稱性:兩個選項的 upfront cost 是否相當?
  2. 可逆性:選錯的話修正昂貴嗎?
  3. 領域先驗:這個模式在領域裡發生機率多高?

任一軸顯著偏向「該選通用」,YAGNI 就不適用,這不是過度設計。

回到開頭問題——「最早的設計者沒考慮到多個監聽需求、這算設計瑕疵還是避免過度設計?」答案取決於這三軸的具體狀況、不能一概而論。

但如果像 Stream 這個案例、三軸全部不利於受限預設、那就是設計瑕疵。只是這類瑕疵反映的是工具預設與領域知識內化的系統性問題、不是個別工程師的判斷力不足——修補方向是制度而非個人責備。

一句話帶走

日常情境中、把三軸壓縮成一個問題就夠用:

我在多付什麼成本?

  • 多付抽象層、新概念、額外測試 → YAGNI 適用、先別付
  • 多付幾個字元、一個關鍵字 → 不是 YAGNI、選通用的

需要更精細的時候、再回頭跑完整三軸框架。