抽象層級的紀律,每層清楚的做一件事情

分層這件事,講起來人人會點頭,做起來幾乎每個專案都歪。歪的方向通常兩種:要嘛抽過頭,為了某個還沒發生的需求先疊三層;要嘛根本沒抽,I/O、商業邏輯、呈現全擠在同一個函式裡。兩種都讓你付一樣的代價,出事的時候得跨好幾層去猜問題在哪。我自己的判準很單純,一層只負責一件講得清楚的事,講不清楚就是還沒切好。

責任模糊,debug 就變成考古

分層的價值不在「看起來有架構」,在於出事時你知道該翻哪一層。如果每一層的責任你都能用一句話講完,那 bug 一出現,你大概就知道它住在哪。反過來,責任含糊的系統 debug 起來像考古,得一層一層挖,因為你根本不確定資料在哪裡被改壞、邏輯在哪裡轉錯。

抽象不足是最常見的起點。一個 handler 函式,裡面同時做了參數解析、開 DB 連線查資料、套商業規則算結果、最後拼 HTML 回去。剛寫的時候很爽,一個檔案全看得到,改一個小地方超快。問題是這種函式長到某個程度就動不了,因為你想改算錢的規則,卻被迫同時面對 SQL 跟 HTML,任何一處手滑都可能噴在意想不到的地方。我接過這種程式,光是要搞清楚某個欄位到底在哪一段被覆寫,就花掉一個下午。責任沒切開,每次改動的影響範圍就是整個函式,不是你動到的那幾行。

但反過來矯枉過正更難救。

過度抽象,是為了「以後可能」付現在的稅

過度抽象大概是這個樣子:因為「以後說不定要支援別的金流/別的資料源/別的通知管道」,所以現在先抽一個 PaymentProvider 介面、一個 Repository 抽象、一個 Notifier 工廠,結果整個專案只有一個實作。每次要改一個很簡單的東西,得穿過介面、穿過工廠、穿過某個 registry,才摸到真正在跑的那二十行。我做 SvelteKit SSR 那次就是這樣,環境裡 Vite 轉換慢,我為了「正確地」處理它,疊了一堆中介層去包,最後每次 debug 都在隧道裡爬。後來整個拆掉改純 SPA,自己寫八十行 hash router,反而五分鐘定位得到問題。

這裡的關鍵是 anti-additive 的直覺:沒有第二個 caller,就別先抽介面。介面是用來整合「已經存在的重複」,不是用來預約「未來可能的重複」。你先寫直白的版本,把邏輯老老實實列在那,等真的冒出第二個、第三個用法,重複在哪裡自己會浮出來,那時候再抽,你會抽得準。提前抽的介面幾乎都猜錯邊界,因為你是在沒有第二個案例的情況下想像共通點,想像出來的共通點通常是錯的,最後變成每個實作都得繞過它。

過度抽象還有個隱性成本:它讓人失去對底層的直覺。包太多層之後,寫的人漸漸不知道一個請求實際上碰到了哪些東西、HTTP 在哪裡結束、資料在哪裡落地。出事的時候,他 debug 的是自己疊出來的抽象,不是真正在跑的系統。

好的分層,是只換一層就好

判斷分層切得好不好,有個很實際的測試:當你要換掉某個東西,需要動幾層。換 DB 從 PostgreSQL 到別的,如果你的資料存取藏在一層後面,理想上只改那一層,上面的商業邏輯一行都不用碰。換 tracing 後端、換 cache、換掉某個外部 API,道理一樣。能做到只換一層,代表那層的責任確實劃清楚了,這跟介面驅動其實是同一件事的兩種講法,介面就是「我只承諾這層對外的行為,內部怎麼換是我的事」。

但要注意,這個好處是結果,不是起點。你不是因為「想要以後好換 DB」才去抽那層,是因為資料存取原本就該跟商業邏輯分開,責任不同。好換只是責任切乾淨之後自然得到的好處。如果你倒過來,為了想像中的可替換性去硬抽,通常就會掉回上一段那個坑。

收個尾。層數不是愈多愈好,也不是愈少愈好,是每一層的責任愈清楚愈好。一個三層但每層責任分明的系統,比一個五層卻層層職責重疊的系統好維護得多,也比一層全部擠在一起的好太多。你要追求的不是某個漂亮的層數,是每一層都能用一句話交代它在幹嘛,以及它不幹嘛。

重點整理

  • 每層只負責一件講得清楚的事,責任講不清楚就是還沒切好。
  • 抽象不足讓單一函式的改動波及全域;過度抽象讓簡單修改要穿過好幾層。
  • 沒有第二個 caller 就別先抽介面,等重複在哪裡浮出來再動手才會準。
  • 好分層的指標是「只換一層就好」,這是責任劃清楚之後的好處,不是抽象的目的。
  • 追求的不是漂亮的層數,是每層都能一句話講清楚它幹嘛、不幹嘛。

觀點 Sheng,內文 Claude 協助 · 列入 20260613 blog 翻新計劃,新漆未乾。