
圖片來源:ChatGPT 生成
通常,一個混亂的系統,不是軟體工程師刻意為之。
系統開發初期,不管是用 DDD、通靈或是其他方式,大多還是會將系統分成幾個模組,讓系統不會變成一團亂。但隨著未來的業務複雜度漸增,原有設計就會開始顯得有點混亂。就像一開始請室內設計師幫新房子依照「目前」的需求做好規劃,但隨著成員增加,物品累積,原先看起來很漂亮的房子也變成一團亂。這時候,有兩個選擇,與混亂共處,或是整理打掃,這一點在軟體開發也是一樣的。
今天就來分享一下最近剛完成的整理過程。
歷史演進
系統一開始設計時,針對顯而易見的子領域就進行了模組化,這些顯而易見的子領域通常是通用子領域,例如 IAM 和 NCC,用 Maven 的模組獨立於核心子領域之外,除了這些子領域,也把與第三方整合的抽象層都各別獨立成 Maven 模組,並透過模組相依管理,確保相依符合 Clean Architecture 由外向內的方向。
在核心子領域中,根據業務邏輯再拆分成更小的子領域,而這些子領域則是透過 Java 的 package 進行管理,雖然沒有用 Java 9 Module System 進一步限制存取規則,但透過自律,整體都還算是有條不紊。
在後續的開發過程中,不只歷經開發方向調整,也配合市場需求對優先序調整,因此在核心子領域中,有個業務子領域快速膨脹,在該 package 中,檔案數來到 233 個,開始出現在該 package 中尋找檔案越來越不容易。
盤根錯節
於是開始思考:這個業務子領域真的有這麼大嗎?還是有多個業務子領域被放到同一個 package 中了?仔細檢視後發現,問題不在於程式碼變多,而在於邊界消失了。這個 package 裡有三個明顯的業務子領域:組別、班表和調查,只是在當初摸索商業邏輯的過程中,邊界比較模糊而沒有被分而治之,該是時候打掃打掃了。
但在拆子領域時馬上就遇到困難了,在架構上,有限制跨 package 不能直接存取非公開的類別或介面,例如 com.abc.xyz 不能直接存取 com.abc.def 的非公開類別或介面,但在同個 package 中,就沒有這限制,例如在指定班次時,會檢查該成員是否有在該組別中,此時會直接用組別的 repository 先檢查組別是否存在,再用成員的 repository 取出資料後檢查是否在該組別中。
這在同個 package 中很容易就直接使用了,但在拆子領域時,組別的程式搬到另一個 package 中,而 repository 不是公開要給外面使用的介面,如果仍是直接存取,不只是違反限制,也失去拆子領域的意義,因為程式碼仍是盤根錯節,到處耦合。
相依轉移
那怎麼辦?此時,就想起那句經典名言:
All problems in computer science can be solved by another level of indirection.
所以,無法解決問題就多加一層抽象。
回到原始問題:為什麼需要存取 repository?目的其實是為了檢查「成員是否屬於該組別」,重點不是在查詢資料庫,而是在驗證一個業務規則。既然如此,我們可以提供一個抽象 ScopeVerifier,給定組別與成員的 ID,如果成員不在該組別中,就拋出對應的例外:
而原本的程式碼反而變得更加簡潔:
有時候有人會覺得這只是把問題往外拋,並沒有真的解決問題啊,答對了,這不是逃避問題,而是把責任移到更合適的層次。在 Clean Architecture 中,最外層的橋接器 (adapters) 最適合處理這些「髒活」,於是把「透過資料庫」檢查成員是否屬於該組別的責任拋到外層。
而外層在實作時,有兩種方式,一個是寫專屬的 SQL,另一個則是用既有的 repository 組合。看類別圖可能比較有感覺,在未重構前,所有的檔案都在同一個 module (粗線區隔) 的同一個 package (粗虛線框) 中,彼此之間互有關連。

在重構後,明顯可以看出模組類有兩個 package,而 package 和 package 之間彼此沒有任何關聯。不論是 ScopeVerifierImpl 用哪種方式實作,重點是確保了 Clean Architecture 的內部 (core module) 是整潔的。

反腐層
在 DDD 的實踐裡,ScopeVerifier 通常會被稱作反腐層,確保上游子領域的變動不會影響到下游子領域,但從 ScopeVerifier 這個介面去體會防腐層的作用比較無感,畢竟 Repository 的介面通常比較穩定,不太會變動。
可能受到要避免重複的程式碼這個深植在很多工程師心中的原則,所以常常會複用 reuse 既有的類別或資料結構,例如,一開始設計時,Survey 會有一些限制,因此有個 Limitation 的資料結構保存這些限制,後來為了讓減少使用者設定的頻率,讓組別有預設的限制,建立問卷時帶入預設的設定,如果不需要變動,可以快速地完成設定。

當時決定複用既有的資料結構,但後來在 Limitation 中新增屬性,在處理向下相容性時,Squad 和 Survey 希望的預設值不同,變成同個資料結構,卻有兩套反序列 (deserialization) 邏輯。反而在重構後,這問題解決了,還提供了一個更具語意的新資料結構 PresetLimitation。

在重構的過程中,不只是介面或是 Entity 會受到影響,Read Model 也會受到影響。例如,為了讓 app 端一次就取得需要的資料,設計了專屬的 Read Model 將資料先聚合起來,當初也是共用了 SimpleSquad。後來有個功能是問卷才需要的設定,把該設定加到 SimpleSquad 是小事,但卻影響到班表相關的程式碼,可見這個贈與 (額外的欄位) 對班表這個子領域是不受歡迎的。

在重構後,彼此都有自己專屬的資料結構,看起來好像有重複的程式碼,但回想起當初看《Clean Architecture》,關於重複的程式碼,有一段有趣的描述:
想像一下,兩個使用案例有相似的畫面結構,架構師會試圖共用資料結構,但應該嗎?是真的重複還是偶然?它們只是巧合相似,隨著時間,這兩個畫面會逐漸分歧,最後完全不同。
而這次的例子就正好印證了這件事,不同的 Read Model (View Model) 通常最後都會長的不一樣。

保護傘
經過重構後,從一個 233 個檔案盤根錯節的 package,變成三個分別有 103 個檔案、74 個檔案和 70 個檔案的 packages,總數 (247) 看起來增加,但卻是三個語意清晰彼此獨立的子領域。
但重構這件事要很小心,整個過程中,有的 PR 其異動到的檔案數超過 600,如果沒有單元測試及整合測試的保護,我大概會選擇繼續走鋼絲,在新增功能時繼續與混亂共處,畢竟找檔案在 IDE 的協助下也不是真的很困難。
沒有測試的重構,其實只是豪賭。這次重構真的是全靠過去累積下來的單元測試保護著。
小結
一個混亂的系統,往往不是刻意為之,而是從需求不明到需求明確的探索過程所留下的結果,但不代表混亂的系統就無法改善,這次的重構,讓原先錯綜複雜的子領域,切割成三個語意完整且彼此獨立的子領域,讓系統再次恢復秩序。
與混亂共處,或是整理打掃,往往只在一念之間。問題從來不是系統有多混亂,而是有沒有想讓它變好的行動。
題外話:雖然說我常常提到 Clean Architecture,但我並沒有 100% 遵循書中描述的規則,例如,我會把 Entity 傳遞出去,違反書中的建議,但我個人仍傾向保持一個好的平衡性即可,對我來說過著潔癖的生活其實是很辛苦的。



















