上一篇文章中,我们将模块化总结为“把一起变化的东西归在一起,建立边界”。
但拆分模块后,真正的问题才开始。拆出的部分会开始相互引用。
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 文章介绍的依赖反转,就这样直接应用到了模块层面。
第三,把组装上移。
不直接连接两个模块,而由同时了解两者的上层模块(应用目标或组装层)负责连接。功能模块需要互相调用来完成页面跳转时尤其有用。
| 情况 | 打破方法 |
|---|---|
| 双方只需要对方的一部分类型 | 将公共类型提取到下层模块 |
| 一方调用另一方的功能 | 定义协议后反转依赖 |
| 功能模块之间进行页面跳转 | 在上层组装层连接 |
什么时候适用,什么时候过度?
管理依赖方向也有成本,因为会增加协议和组装代码。
- 如果模块达到 3~4 个以上且团队已经分开,值得记录方向规则并用工具监控循环。
- 如果是只有两个模块的小型项目,只要避免循环即可。把所有引用都包成协议属于过度设计。
- 如果是已经存在循环的遗留系统,不要试图一次全部切断,先从变化最频繁的模块整理箭头。
面试时会这样问
Q. 模块间循环依赖为什么是问题?你会如何解决?
循环会让两个模块在构建、测试和部署层面变成一个整体,从而失去模块化的好处。可以把公共类型提取到下层模块,通过协议反转依赖方向(DIP),或在上层组装层连接两个模块,使箭头保持单向。
Q. 请解释稳定依赖原则(SDP)。
模块只能依赖比自己更稳定、也就是变化更少的模块。如果经常变化的模块位于依赖关系下游,其变化就会传播到整个上游,因此应将低变化频率的领域模块和接口模块放在图的下方。
下一篇将介绍许多团队在模块化过程中会掉入的陷阱:光是名字就很危险的 Common 模块。
我们将讨论“反正是公共的,就放进 Common 吧”如何让整个模块系统重新变成一个整体,以及如何用分层结构预防这种情况。

