一个 Maven 多模块项目,order-service 依赖 user-service,user-service 为了"复用订单里的用户工具类"又依赖了 order-common,而 order-common 反向引用了 order-service 的领域模型——三条依赖围成一个环。编译能过(因为 Maven 允许某些环),但构建顺序开始不稳定,某个模块单独打包时会缺类。
依赖方向的问题不会立刻报错,它表现为"构建时快时慢""单独跑某模块失败""改一处要连带改三处"。更麻烦的是它"能工作":环在多数构建里能过,于是团队会认为"没问题",直到某次拆分发布或独立测试时才暴露。这类问题往往在"要拆服务/要独立部署"时才被重视。
而这些症状都不指向"依赖方向"这个原因。
1. 三种依赖方向
| 方向 | 含义 |
|---|---|
| 向下(到基础设施) | 正常,如 service → repo |
| 横向(同层模块之间) | 需谨慎,容易成环 |
| 向上(到上层) | 危险,如 repo → service |
第三项是"架构被反转"的信号:底层模块引用了上层模块的类第三项的典型例子是"工具类里出现了业务概念":一个本应通用的工具方法,参数类型是业务领域对象,于是工具模块被迫依赖领域模块。
,于是底层不再能独立使用或测试。
第二种在业务拆分时最常见:order 与 user 互相需要对方的一点东西,于是互相依赖。判据是"这点东西属于谁"——如果它是"用户"的概念,就该在 user 模块里,而 order 依赖 user 是正常方向。
2. 依赖成环的三种来源
| 来源 | 表现 |
|---|---|
| 共用工具类无处安放 | 谁先写就放谁家 |
| 领域模型互相引用 | Order 里有 User,User 里有 List<Order> |
| 常量/枚举共享 | 两边都 import |
第一项还有一个"时间"版本:A 模块先写了某个工具,半年后 B 模块也需要,于是 B 依赖 A——而这条依赖在当时的评审里是"复用",看起来是好事。
第一项最普遍:"这段代码两边都用"这个判断,本身说明它属于第三个模块。而多数团队的做法是"放到先写的那个模块里",于是依赖方向立刻反向。
第二项是建模问题:双向引用意味着这两个概念在同一个聚合里双向引用还会带来"加载"问题:序列化 Order 会带出 User,再带出他的 orders,于是出现无限递归或意外的全量加载。这类问题在接上 JSON 序列化后才暴露。
,或者需要"ID 引用"而非"对象引用"。
// 反例:双向对象引用,两个模块互相依赖
class Order { private User user; }
class User { private List<Order> orders; }
// 正例:单向引用或 ID 引用
class Order { private Long userId; } // order 依赖 user 的 ID,不依赖 user 类
class User { private List<Long> orderIds; }
// 或把关系放进第三个模块(如 order-user-rel)
3. 拆环的四种做法
| 做法 | 代价 |
|---|---|
| 抽出公共模块 | 需判断归属 |
| 依赖倒置(接口在上层) | 需要接口与实现分离 |
| 事件解耦 | 引入异步与最终一致 |
| 合并模块 | 承认拆得太细 |
第一项最直接:把"两边都用"的部分抽到新模块,让两个原模块都依赖它,于是依赖方向变成单向树。
第二项的实现要点是"接口的归属":接口放在上层(调用方)模块,实现放在底层,于是底层编译时不依赖上层的具体类,只依赖接口——而接口属于上层,所以底层仍然依赖上层?
正确的做法是把接口放在更底层的"契约模块",于是上下都依赖契约,而不互相依赖。
第二项在"底层需要通知上层"时用:上层定义接口,底层实现或调用它。这是把"向上依赖"变成"向下依赖"的标准手法。
依赖方向还有一个"细粒度"的层次:包级依赖。模块之间干净、但模块内部包与包互相引用,同样会让"改动扩散"。所以边界检查通常要看两遍——先看模块,再看包。
// 包级依赖的三条约定(示例,六边形/分层风格)
domain 可依赖:无(纯模型)
application 可依赖:domain
infra 可依赖:domain, application(实现它们的接口)
web 可依赖:application, domain(不直接依赖 infra)
// 反向依赖一律违例:domain 不得 import infra
4. 怎么让坏依赖立刻暴露
| 手段 | 说明 |
|---|---|
| 构建工具的依赖检查 | Maven Enforcer / Gradle 插件 |
| 架构测试 | ArchUnit 类断言 |
| 模块图可视化 | 生成依赖图看环 |
| 编译顺序警告 | 出现警告就查 |
第二项最实用:把"允许的依赖方向"写成测试断言,违反即失败。第三项(依赖图)的价值在"人看得懂":把生成的图贴在架构文档里,每季度更新一次,新人能在五分钟内理解模块边界,而不必读三年代码。
它比文档有效,因为文档不会被 CI 执行。
// ArchUnit:把依赖方向写成可执行断言
@ArchTest static final ArchRule 领域层不依赖基础设施 =
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAPackage("..infra..");
@ArchTest static final ArchRule 模块之间无环 =
slices().matching("com.shop.(*)..").should().beFreeOfCycles();
// 一旦有人加了反向依赖,CI 立刻红
监控"依赖健康度"有个便宜的指标:每季度统计一次"改动一个模块会连带重编多少模块"。这个数在变大,说明依赖在变密,而它比"图上有没有环"更早发出信号。
// 粗略统计:某模块的直接+传递下游数量(Maven 示意)
mvn -q dependency:tree -Dincludes=com.shop:order-common -DoutputFile=deps.txt
grep -c 'com.shop' deps.txt # 依赖了它的模块数(近似)
// 趋势比绝对值重要:三个月内从 4 涨到 9 → 该看边界了
5. 依赖方向与构建时间
| 现象 | 原因 |
|---|---|
| 全量构建慢 | 依赖链长,无并行 |
| 改一个模块要重编多个 | 依赖是"向下流入"的 |
| 单独打包失败 | 依赖未声明(靠传递) |
| 构建顺序不稳 | 存在环 |
第五项(测试要起整个项目)还有一个"传导"效应:测试变慢 → 开发不跑测试 → 质量下降。所以依赖过重的代价最终落在质量上,而不是构建时间上。
第四项在 Maven 里表现为"reactor 排序不稳定":有环时排序依赖图的遍历顺序,于是同一次改动在不同机器上可能编出不同的顺序,偶尔失败。
第一项的解法是"减少跨层依赖":依赖链越浅,可并行的模块越多。
还有一类"隐性依赖"不在编译期出现:共享数据库或共享 Redis 键空间。两个模块通过同一张表交换数据,编译上完全独立,运行上强耦合——改表结构时两边都要改,而依赖图上看不出来。
// 隐性依赖的三种形态(编译期看不见)
1 共享表/字段(模块 A 写,模块 B 读)
2 共享缓存键(前缀相同,语义不同)
3 共享消息主题(格式未约定)
// 判据:改一处是否需要"协调另一个团队"
6. 排错:五种"依赖问题的表现"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 单独跑某模块缺类 | 依赖靠传递获得 | 显式声明 |
| 改一处连带三处 | 横向依赖过密 | 抽公共或合并 |
| 循环依赖报错 | 真的成环 | 抽公共/倒置 |
| 编译顺序偶尔失败 | 环导致排序不稳 | 拆环 |
| 测试要起整个项目 | 依赖太重 | 倒置或抽接口 |
第五项是"依赖过重"的直接代价:一个本该单测的类,因为依赖链上有数据库与消息队列,只能起整个应用才能测。
7. 边界:什么情况可以接受横向依赖
| 场景 | 为什么 | 做法 |
|---|---|---|
| 明确的上下游(order → user) | 业务方向清晰 | 可接受 |
| 同一限界上下文内 | 本就是一个模块 | 可接受 |
| 临时脚本/工具 | 不长期 | 随意 |
| 已用事件解耦 | 不是编译期依赖 | 无问题 |
判断口径一句话:这个依赖会不会让"单独构建/测试这个模块"变难。最后一条与"团队"有关的边界:模块边界往往跟团队边界一致。两个模块互相依赖,通常意味着两个团队在互相等对方发版——而"拆环"实际上是组织协调问题的技术表达。
会,就必须处理;不会(依赖很轻、无环),横向依赖可以接受。
8. 自测
- 三种依赖方向里,哪一种意味着"架构被反转"?它会让底层模块失去什么?
- "这段代码两边都用"这个判断为什么说明它属于第三个模块?
Order里有User、User里有List<Order>意味着什么建模问题?两种正解是什么?- 依赖倒置如何把"向上依赖"变成"向下依赖"?
- 为什么"构建顺序偶尔失败"指向环的存在?
- 判断"横向依赖能否接受"的那个问题是什么?