設計驅動重構方法論 - Domain Root 檢查與技術債務清理實戰
在一次常規的 Phase 4 重構評估中,我們打開了 library_domain.dart 這個檔案,發現它足足有 600 多行,裡面混雜著聚合根、Value Object 和服務類別,彼此糾纏在一起。更糟的是,統計書籍狀態的函式正在使用根本不存在的枚舉值——程式編譯不過,但那些錯誤已經在那裡不知多久了。
如果在重構之前沒有系統性地理解設計意圖,我們很可能會把問題修錯方向,或者在「修好」一處的同時破壞了其他地方。
於是我們建立了一套方法論,把設計文件的閱讀和驗證,放在所有重構動作的最前面。
為什麼設計文件要在程式碼之前讀
看到壞味道就處理、看到重複就提取——傳統重構路線沒有問題,但對 Domain 層的核心實體來說,這樣做有個盲點:我們可能根本不清楚這段程式碼的業務意圖。
不清楚「Library 為什麼要追蹤 SourceType 而不是 ReadingStatus」,技術上漂亮的重構可能在業務上悄悄引入錯誤。設計驅動重構的前提是:所有重構決策必須基於現有的設計文件,而不是單純從程式碼的技術形狀來判斷。
驗證流程
第一階段:設計文件檢查
在動任何一行程式碼之前,我們先回頭讀文件。閱讀順序很重要:從需求規格出發,再到用例說明,再到 UI 設計規格,最後到錯誤處理設計。
這個閱讀順序的邏輯是:先確立業務目標(需求規格),再理解使用場景(用例),再看 UI 層如何呈現(UI 規格),最後檢查系統如何應對異常(錯誤處理)。這樣走一遍,Domain Root 在整個系統中扮演的角色就會變得清晰。
以 Library 實體為例,文件告訴我們它需要支援不同來源類型的書籍統計——實體書、電子書、借閱書各有不同的計算邏輯。但程式碼裡用的是 ReadingStatus 枚舉,而且還在使用 'digital'、'borrowed' 這些根本不在枚舉定義裡的值。這不是設計問題,這是實作根本跑錯了方向。
第二階段:測試覆蓋率分析
讀完文件之後,我們看測試。測試是另一種形式的設計文件,它告訴我們這段程式碼「被期待如何運作」。
這個階段最常發現的問題是測試的方向跑偏了。我們在 book_test.dart 裡發現,它引用了 flutter_test 套件,但 Domain 層的單元測試根本不應該依賴 Flutter 框架——這意味著這些測試在純 Dart 環境中根本無法執行,CI 流程也因此產生了盲點。
此外還有大量被 skip 的測試。每個 skip 都是一個未被驗證的業務假設,累積起來就是風險。
第三階段:程式實作驗證
前兩個階段讓我們建立了對「設計意圖」的理解。第三階段才是真正對照程式碼,逐一確認哪裡偏離了設計。
這個階段的關鍵產出是一份問題清單,而且每個問題都有分類:是類型系統錯誤、是架構違規、是技術債務,還是可維護性問題。分類決定了後續的修復優先級。
問題分類與修復優先級
我們在實際的 Book 和 Library 重構中,識別出了四類問題,按照影響範圍決定修復順序。
類型系統錯誤優先級最高。 使用不存在的枚舉值、把 String 傳給期望強類型的參數,這類問題一旦進入生產環境就是執行時崩潰。我們把所有這類問題排在第一批處理。
異常處理不統一緊隨其後。 Library 實體裡有自定義的 DuplicateBookException,但整個專案其他地方都統一使用 AppError 體系。這種不一致性不只是風格問題,它讓錯誤處理的呼叫端無法用一致的方式應對。
技術債務屬於第二優先級。 最典型的例子是 author 欄位使用 String 類型。這在功能上可以運作,但它限制了後續的多作者支援、譯者解析和富文字搜尋能力。這類問題有明確的演進路徑,但不是緊急的。
可維護性問題排在最後。 包括缺乏需求追蹤編號的註解、業務規則說明不足等。這些問題不影響功能,但它們是技術債務的溫床——當下一個人來維護這段程式碼時,他沒有足夠的上下文。
幾個具體的重構模式
類型系統修正
問題很常見:用字串或錯誤的枚舉值來做判斷,而不是使用正確的類型。
修正前:
1books.where((book) => book.readingStatus == 'digital')修正後:
1books.where((book) => book.source.type.isDigital)表面上看這只是換了一個屬性,但背後的意義完全不同:前者依賴字串的巧合匹配,後者依賴類型系統的保證。
異常處理統一
自定義異常類別散落在程式碼各處,是一種常見的技術債務模式。
修正前:
1throw DuplicateBookException('message', bookId: id, title: title);修正後:
1return OperationResult.failure(
2 BusinessLogicError(
3 message: 'message',
4 businessRule: 'BOOK_DUPLICATE_PREVENTION',
5 code: ErrorCodes.duplicateBook,
6 context: {'bookId': id, 'title': title},
7 ),
8 'userMessage'
9);這個轉換不只是換個類別,它同時把拋出異常改成了回傳結果——這是更適合 Domain 層的錯誤傳遞方式,讓呼叫端可以明確地決定如何處理每種失敗情況。
Value Object 重構
把 String 提升為 Value Object 是一個需要仔細計畫的重構,因為它涉及所有使用處的同步更新。
修正前:
1final String author;修正後:
1final BookAuthor author;關鍵在於轉換策略要設計好:BookAuthor.fromString(stringValue) 負責從舊有字串資料遷移,bookAuthor.displayValue 負責在需要字串輸出的場合提供相容性。有了這兩個轉換點,才能在保持向後相容性的前提下完成遷移。
分階段執行,每段必驗
每個修復階段結束後都要驗證:dart analyze 無錯誤、核心功能正常運作、API 介面保持不變。我們曾經跳過這個步驟,結果後續階段的問題疊加在一起,讓除錯變得極其困難。
從這次重構中得到的
這次對 Book 和 Library 兩個 Domain Root 的重構,最終涉及了 35 個以上的檔案,把 24 個編譯錯誤降到了零,並且完整建立了需求追蹤的 REQ-LIB-XXX 編號體系。
收穫更大的是設計文件優先讓我們避開了幾個危險方向。如果直接看到 author 是 String 就去重構,可能會建出不符合業務需求的 Value Object。如果直接看到自定義異常就去統一,可能會在類型系統問題還沒修好的基礎上做錯誤的抽象。
下次面對需要重構的模組時,先花 15 分鐘讀相關設計文件,再決定從哪裡下手。那 15 分鐘很可能讓你避開一個需要幾個小時才能發現的方向錯誤。