上一篇文章將模組化整理為「把一起變動的東西歸在一起,建立邊界」。
但拆分模組後,真正的問題才開始。拆出的片段會開始互相參照。
A 使用 B,B 使用 C,某天 C 又使用 A。三者實際上變成同一個整體,模組化的優點也就全部消失了。
先說結論:模組相依設計有兩項原則。讓相依方向維持單向,並讓經常變動的一方相依於穩定的一方。
本文整理決定相依方向的標準、循環相依的典型形成路徑,以及三種切斷方法。
相依方向應該往哪裡流動?
相依性具有方向。模組 A import 模組 B 時,A 就相依於 B。
箭頭應該指向哪裡,有一個經典答案。
不穩定的東西應相依於穩定的東西。反過來時,小幅變更就會擴散到整個系統。
穩定模組是不常變動的模組,例如領域模型與共用協定。
不穩定模組是經常變動的模組,例如畫面(UI)及隨需求持續修改的事件處理邏輯。
因此,健康的相依圖通常會呈現以下方向。
- 畫面・功能模組 → 領域模組 → 共用介面模組
- 箭頭只往下方的穩定側流動,不會逆向往上。
Robert C. Martin 將此稱為穩定相依原則(SDP,Stable Dependencies Principle)。
循環相依為何產生?
循環相依多半不是出於惡意,而是很自然地形成。典型路徑如下。
Order 模組參照 User 模組,因為訂單需要訂購者資訊。
一段時間後,需求要求在會員畫面顯示「最近的訂單清單」。User 模組一參照 Order 模組,循環就完成了。
出現循環後,三件事會崩潰。
- 建置單位分離失敗:編譯器無法分別處理兩者,實際上會變成一個模組(Swift 會直接以編譯錯誤禁止模組間循環 import)。
- 無法獨立測試:測試 A 需要 B,測試 B 又需要 A,形成死結。
- 無法預測影響範圍:無論修改哪一方,都必須重新檢查另一方。
如何切斷循環相依?
實務上主要有三種方法。
第一,把共用片段往下移。
如果雙方真正需要的是「對方的一部分」,就把那部分抽取到更穩定的下層模組。例如只把 Order 與 User 共同需要的型別移到領域模型模組。
第二,透過介面反轉方向(DIP)。
將一側的相依替換為協定。User 模組只定義「提供訂單清單的某個東西」這個協定,實作則由 Order 模組負責。
// User 模組:只以協定宣告所需能力
public protocol OrderHistoryProviding {
func recentOrders(of userID: String) -> [OrderSummary]
}
// Order 模組: User 採用模組的協定並實作
public struct OrderHistoryProvider: OrderHistoryProviding {
public func recentOrders(of userID: String) -> [OrderSummary] {
// 查詢訂單儲存庫
}
}
如此一來只剩下 Order → User 的單向箭頭。上一篇 DIP 文章介紹的相依反轉,就原樣套用在模組層級。
第三,把組裝往上移。
不要直接連接兩個模組,而由同時了解兩者的上層模組(App target、組裝層)負責連接。功能模組需要互相呼叫來進行畫面切換時特別有用。
| 情境 | 切斷方法 |
|---|---|
| 雙方只需要對方部分型別 | 將共用型別抽取到下層模組 |
| 一方呼叫另一方的功能 | 定義協定後反轉相依性 |
| 功能模組之間進行畫面切換 | 在上層組裝層連接 |
何時適用,何時會過度?
管理相依方向也有成本,因為會增加協定與組裝程式碼。
- 如果有 3~4 個以上模組且團隊已分組,值得將方向規則文件化並用工具監控循環。
- 如果是只有 2 個模組的小型專案,只要避免循環即可。把所有參照都包成協定會過度設計。
- 如果是已有循環的舊系統,不要一次全部切斷,先從最常變動的模組整理箭頭。
面試時會這樣問
Q. 為什麼模組間循環相依是問題?你會如何解決?
循環會讓兩個模組在建置、測試、部署上成為一體,模組化的優點因而消失。可抽取共用型別至下層模組、定義協定反轉相依方向(DIP),或在上層組裝層連接兩個模組,將箭頭整理為單向。
Q. 請說明穩定相依原則(SDP)。
模組只能相依於比自己更穩定(變更較少)的模組。若經常變動的模組位於相依關係的下游,變更就會傳遍整個上游,因此應將低變更頻率的領域與介面模組放在圖的下方。
下一篇將介紹許多團隊在模組化過程中會掉入的陷阱:光是名稱就很危險的 Common 模組。
我們會探討「反正是共用的,就放進 Common 吧」如何讓整個模組再次變成一個整體,以及如何用分層結構預防。

