很多團隊遇到老系統,最後都會冒出一句話:
「乾脆重寫好了。」
這句話很有吸引力,因為舊系統裡可能充滿奇怪命名、重複邏輯、沒有測試、框架耦合、資料庫直連、歷史例外與沒人敢碰的區塊。從零開始,看起來像是一次把問題全部清掉。
但大重寫(Big Bang Rewrite)最危險的地方,是你不只丟掉壞設計,也可能一起丟掉多年累積、卻沒有被文件記錄的商業規則。
舊系統至少已經在跑;新系統一開始則同時缺少兩樣東西:完整行為知識,以及真實環境磨出來的例外處理。
技術債最麻煩的地方,是「改一點就怕壞一片」
技術債遠不只程式碼不好看。
比較實際的訊號包括:
- 一個小功能要改很多檔案。
- 沒有人敢碰某個核心模組。
- 一改資料格式,很多地方一起壞。
- 測試很少,或測試早就被關掉。
- 新增需求只能繼續加 if、flag、特殊案例。
- 部署前大家靠人工祈禱,而不是靠回饋機制確認。
所以第一個目標應該是讓修改重新變得可驗證;程式碼漂不漂亮反而排在後面。
第一步:先建立安全網(Safety Net)
對遺留系統來說,最重要的起點是安全網(Safety Net)。
如果現在完全不知道某段程式改了會不會破壞既有行為,直接重構只是把風險換一種寫法。
比較安全的順序是:
- 找出你下一個真的要改的功能。
- 找到影響它的主要模組與依賴。
- 先用測試把目前行為固定下來。
- 確認測試綠燈後,再開始改結構。
如果你正面對「沒測試又不敢動」的舊程式,可以先看Working Effectively with Legacy Code:沒測試又不敢動的舊程式,要怎麼安全修改。這一步的重點是先讓變更有回饋,不是先追求漂亮架構。
特性測試(Characterization Test):先記錄現在怎麼跑,不急著判斷對不對
特性測試(Characterization Test)和一般新功能測試的心態不同。
這一步先記錄「系統現在實際怎麼做」,暫時不要把「理想上應該怎麼做」混進來。
這一點很重要,因為老系統裡有些看起來像 Bug 的行為,可能早就有其他流程依賴。
如果你在建立測試時順手把它修掉,測試就失去「固定現況」的功能。
比較穩健的做法是先把現有行為釘住,另外開一個技術債或 Bug 項目,再決定是否改行為。
先找變更點(Change Point),再找測試點(Test Point)
技術債很多時,最容易犯的錯是「從整個系統最醜的地方開始清」。
這通常會變成無止境整理。
更有效的起點,是找變更點(Change Point):下一個需求實際會碰到哪裡?
接著找測試點(Test Point):在哪個位置可以用最少測試,觀察到最多重要行為?
這樣技術債的還法就會從「全站大掃除」,變成「每次走過的路,都讓它比上次安全」。
接縫(Seam):沒有測試入口時,先創造一個測試入口
很多舊程式難測,問題往往出在依賴全部黏死,未必是測試工具不夠。
例如一個函式裡直接:
- 連資料庫。
- 呼叫付款 API。
- 寄 Email。
- 讀系統時間。
- 寫檔案。
這時候可以先建立接縫(Seam),讓其中一個依賴能被替換。
接縫不一定需要大改架構,有時只是把依賴從函式內建立,改成由外部傳入;或抽出一個介面,讓測試可以替換真實服務。
重點是先讓行為可觀察、可測,再談更大的結構改善。
來不及全面保護時,可以用萌芽(Sprout)或包覆(Wrap)
有些老系統真的太危險,短期內很難替整個核心模組補測試。
這時可以用 Sprout(萌芽)或 Wrap(包覆)的方式降低風險。
例如新需求不要直接繼續塞進原本巨型函式,而是把新邏輯放到一個可測的新模組,再由舊程式呼叫它。
這不是最終架構,但至少讓新增加的部分開始有安全邊界,也避免技術債繼續同樣速度擴大。
第二步:小步重構(Refactoring),一次只改一件事
有安全網後,才適合進入重構(Refactoring)。
重構是在不改變外部行為的前提下改善內部結構,不是把程式改寫成另一種個人偏好的樣子。
比較安全的節奏是:
- 測試綠燈。
- 做一個小型結構變更。
- 再跑測試。
- 仍然綠燈才提交。
如果中途紅燈,先回到上一個可工作的狀態,而不是一邊大改一邊除錯。
不要把「修錯誤」和「重構」混在同一次變更
這個原則很實用。
結構性修改和行為修改如果混在一起,出了問題會很難判斷到底是哪一種改動造成的。
更清楚的做法是:先在測試保護下完成純結構重構,確認行為完全一樣;接著再用另一個變更處理 Bug 或新功能。
這樣回滾、程式碼審查(Review)與查問題都容易很多。
技術債不只存在於程式碼,也存在於邊界
有些系統表面上程式碼不算亂,但商業邏輯直接綁在框架、資料庫與第三方 SDK 上。
只要要換資料庫、支付服務或框架版本,就會牽動核心規則。
這種技術債通常不是把函式縮短就能解決,真正卡住的是依賴方向。
這時可以搭配Clean Architecture:為什麼商業邏輯不該被框架與資料庫綁死,逐步把核心規則和外部技術細節分開。
別把模組切得更碎,當成是在還債
整理老系統時,很容易掉進另一個陷阱:看到一個大類別,就拆成十個小類別。
如果這十個類別仍然共享同一批知識、每次修改還是要一起動,複雜度並沒有消失,只是多了更多介面。
比起「模組越多越現代」,更值得問的是:哪個模組可以把一整段複雜度藏起來,讓其他地方不需要知道?
這個觀念可以延伸到A Philosophy of Software Design:真正好的模組,應該把複雜藏在裡面。
第三步:把「大家都知道」的規則變成明確邊界
老系統常有大量 Tribal Knowledge(團隊默契):某欄位不能為空、這個 API 不能重試兩次、這個狀態改動前要先做另一件事。
只要這些規則只存在人的記憶裡,就會一直產生未知風險。
可以逐步把它們變成:
- 測試。
- 明確介面。
- 驗證規則。
- 領域物件。
- 架構決策紀錄。
這些紀錄的目的,是讓重要規則不再只靠「剛好有人記得」,文件數量本身不是重點。
第四步:可靠性也是技術債的一部分
系統在本機能跑,不代表在正式環境安全。
常見的可靠性技術債包括:
- 外部 API 沒有逾時(timeout)設定。
- 失敗後無限制重試(retry)。
- 查詢沒有分頁或上限。
- 第三方服務一慢,整個系統跟著卡住。
- 部署等於立刻對所有使用者開功能。
如果系統已經有真實使用者,這些問題的重要性不會低於程式碼整潔。
技術債累積到最後,會讓系統更容易在真實環境失敗;問題早已超過工程師看程式碼順不順眼。
建立技術債清單(Debt Ledger),才不會每次都靠印象決定先修哪裡
技術債清單(Debt Ledger)可以把「大家都覺得很亂」變成可排序的工作項目。
每一筆至少記錄:問題在哪裡、屬於哪一種債、風險多高、估計處理成本、優先順序,以及目前狀態。
這樣團隊就能區分「難看但很少碰」和「每週都改、又容易出事故」的差別。
有些債可以刻意延後,前提是你知道它存在、知道延後的理由,也知道什麼事件發生時要重新提高優先順序。最危險的不是暫時不還,而是沒有被記錄,最後變成只有某個人腦中知道的地雷。
技術債要排優先順序,不是看到髒的就清
一個實用的排序方式,可以同時看三件事:
| 判斷 | 問題 |
|---|---|
| 變更頻率 | 這個區塊是不是常常要改? |
| 商業重要度 | 它是不是核心營運或主要收入流程? |
| 失敗風險 | 壞掉會造成多大影響? |
一個十年沒改、也幾乎不會再動的醜模組,可能沒有必要先重構。
相反地,一個每週都在改、又牽涉付款的模組,即使只有幾百行,也更值得先建立安全網。
大重寫為什麼常常比想像中危險?
整套重寫不是永遠錯,但它的風險常被低估。
舊系統裡有很多規則沒有被正式寫下來,例如:
- 某類客戶有特殊處理。
- 某筆舊資料格式不完整。
- 某第三方 API 有歷史例外。
- 某個看似奇怪的流程,其實在避免另一個問題。
當你重寫時,工程團隊很容易重建「理想規格」,卻漏掉「真實系統已經學會的規格」。
直到切換上線,這些未知才一次冒出來。
比較安全的做法,是逐步替換(Strangler Pattern)
如果某一塊真的需要重做,可以逐步把新功能放到新邊界,再慢慢讓流量與功能移過去。
舊系統在過渡期間仍然可以服務使用者,新系統則可以被逐段驗證。
這比「半年後一次切換全部」更容易控制風險,也讓團隊有機會在遷移過程中修正錯誤假設。
還技術債的節奏,應該和日常開發綁在一起
如果技術債永遠等「之後有空再處理」,通常永遠不會有空。
更可持續的方式,是把設計改善放進平常開發:
- 新增功能時,順便替變更點補安全測試。
- 碰到難用介面時,先做小型預備重構。
- 發現重複規則時,把知識集中。
- 看到危險外部呼叫時,補 timeout 與失敗策略。
每次只改善一點,但每次都留下更安全的路。
不要把技術債 KPI 變成「刪了多少行程式碼」
技術債更值得改善的是變更能力。
比較有意義的訊號包括:
- 原本不敢改的模組有了測試。
- 改一個規則需要碰的檔案變少。
- 部署後回滾更容易。
- 新工程師理解核心流程所需時間降低。
- 第三方故障不會拖垮整個系統。
程式碼行數變少可能是結果,但不應該是目標。
一個實用的技術債償還順序
- 先找下一個會修改的地方。
- 用特性測試固定現在行為。
- 建立必要接縫,讓依賴可替換。
- 用小步重構降低局部複雜度。
- 整理模組與架構邊界。
- 補可靠性保護。
- 把留下來的債務明確記錄與排序。
這個順序的核心只有一件事:每一步都讓下一次修改更安全,而不是等一個「全部重寫完就會變好」的未來。
常見問題
技術債很多,是不是代表一定要重寫?
不一定。多數系統更適合先把高風險、高變更頻率的區域保護起來,再逐步替換。只有當現有系統的核心限制已經無法透過局部改善處理時,才需要評估更大規模重建。
沒有測試的舊系統,要先補多少覆蓋率才可以動?
不用先追求全系統高覆蓋率。先保護下一個確定要修改的路徑與核心行為,讓這次變更有可信的回饋,再逐步擴大安全網。
發現舊系統有程式錯誤,可以在寫特性測試時順便修嗎?
最好分開。先用測試記錄現況,再把 Bug 當成獨立行為變更處理。這樣比較不會在建立安全網時意外破壞既有依賴。
微服務可以解決技術債嗎?
不會自動解決。如果邊界還沒釐清,只是把耦合拆到網路上,可能得到更難追蹤的分散式單體。應先把模組與領域邊界整理清楚。
技術債是不是工程團隊自己的問題?
不是。當技術債讓新功能愈做愈慢、事故增加、部署風險提高,它已經是產品與營運問題。優先順序應該同時考慮商業價值與技術風險。
每個迭代週期都要固定安排技術債嗎?
可以保留固定容量,也可以把改善直接綁在功能工作裡。重點是不要讓所有設計改善永遠排在「有空再做」。
還技術債,是把每一次變更重新變安全
老系統最需要的是重新建立回饋能力;英雄式重寫反而容易一次放大風險。
當你知道現在行為是什麼、知道哪裡可以安全切開、知道每次改動影響到哪裡,團隊就會從「不敢碰」慢慢變成「可以小步修改」。
當每一次變更都比以前更可預測、更容易驗證,也更容易回頭,技術債才算開始被有效降低;程式碼看起來是不是全新,反而沒那麼重要。
參考資料
Michael Feathers:Working Effectively with Legacy Code。
Martin Fowler:Refactoring。
Robert C. Martin:Clean Code;Clean Architecture。
John Ousterhout:A Philosophy of Software Design。
Andrew Hunt、David Thomas:The Pragmatic Programmer。
Michael Nygard:Release It!。
Eric Evans:Domain-Driven Design。
