27.3 模块边界与依赖规则
一句话
模块边界不是画在架构图上的线,而是写进构建与 CI 的 import 规则:谁能依赖谁、跨模块只能走 API、数据不许共享表——规则一旦可执行,边界才存在。
允许与禁止
| 依赖 | 允许? | 理由 |
|---|---|---|
| 上层(API/编排)→ 下层(领域)→ 基础设施 | ✅ | 依赖倒置的方向(见 26.2 依赖倒置) |
| 模块 A 的 API 包 → 模块 B 的 API 包 | ✅ | 显式契约 |
| 模块 A 内部包 → 模块 B 内部包 | ❌ | 边界被击穿 |
| 任何模块 → 共享 “common/util” | ⚠️ | 允许,但要限制为 “无业务语义” 的纯工具 |
| 两个模块循环依赖 | ❌ | 无法独立理解/测试/演进的信号 |
代码:把规则变成 CI 门禁
// ArchUnit:禁止 order 直接依赖 user 的内部实现
@ArchTest
static final ArchRule order_must_not_touch_user_internals =
noClasses().that().resideInAPackage("..order..")
.should().dependOnClassesThat().resideInAPackage("..user.internal..");
// 禁止模块间循环依赖
@ArchTest
static final ArchRule no_cycles =
slices().matching("com.example.(*)..").should().beFreeOfCycles();
# Go:用 arch-go / go-arch-lint 做同样的检查;失败即阻断合并
go run github.com/fe3dback/go-arch-lint@latest check
# Python:import-linter
lint-imports --contract order-cannot-import-user
实测:20 行脚本把违规与环都找出来
对示例工程跑一次静态分析(脚本见下),输出:
模块 → 依赖: {'order': ['common','payment','user/internal'], 'payment': ['order'], 'user': ['user/internal'], 'common': []}
① 跨模块 internal 违规: 1 处 [('order', 'user/internal')]
② 模块级循环: [('order', 'payment')]
③ 被依赖次数(包级): {'payment': 1, 'user/internal': 2, 'common': 1, 'order': 1}
④ 修复后: internal 违规 0 处、循环 []
① 在 Go 里编译期就会失败,② 却能编译、能运行——这正是需要 CI 规则的原因。
# 可复现:解析 import → 建图 → 查违规/环/扇入
mods = {}
for f in Path(".").rglob("*.go"):
mods.setdefault(f.parts[0], set()).update(
re.findall(r'"example\.com/app/([^"]+)"', f.read_text()))
viol = [(m, d) for m, deps in mods.items() for d in deps
if "internal" in d.split("/") and d.split("/")[0] != m]
g = {m: {d.split("/")[0] for d in deps if d.split("/")[0] != m} for m, deps in mods.items()}
cycles = {tuple(sorted((a, b))) for a in g for b in g[a] if a in g.get(b, set())}
反模式
| 反模式 | 后果 |
|---|---|
| 规则只在文档里 | 新人照抄现有违规写法,边界加速腐烂 |
| 一次性全量整改 | 大量 PR 冲突,团队抵触,最后放弃 |
| 允许 “临时例外” 且无到期日 | 例外变永久,规则失去意义 |
| 用反射/动态加载绕开检查 | 静态规则失效,运行时更难查 |
| 只查 import 不查调用 | 通过公共类型/事件间接耦合,边界名存实亡 |
| 规则与团队边界不一致 | 康威定律反向作用(见 74.1 康威定律及其逆定律) |
| 规则用 “当前违规数” 当基线但不冻结 | 存量违规继续增长,规则名存实亡 | | 只检查单向规则,不检查环 | 环是最贵的耦合,却不在门禁里 |
三条铁律
🔑 规则必须可执行(CI 失败),否则等于没有。
🔑 先冻结违规、再逐步清零,不要一次性重构。
🔑 例外要有 owner 和到期日,否则例外会变成规范。
自检
Q1 为什么 “先全量整改再加规则” 通常失败?
▸ 答案:因为全量整改会产生一个巨大的 PR,与所有在途分支冲突,评审无法逐条判断;团队会为了合并而妥协,最终规则被稀释。可行做法是 冻结当前违规(白名单),新代码不许新增,然后按模块逐步清零。
Q2 循环依赖为什么是 “边界失效” 的强信号?
▸ 答案:因为循环意味着两个模块无法独立理解、独立测试、独立演进:改 A 要知道 B,改 B 又要知道 A,任何一方的内部变化都可能影响另一方。它也是 “拆服务” 时的第一道障碍——循环依赖无法映射成单向的服务调用。
Q3 为什么 “共享 common 包” 常常变成新的耦合中心?
▸ 答案:因为 common 包没有 owner、没有边界,任何团队都能往里加东西;一旦它包含业务语义(如 “订单状态枚举”),所有模块都被它绑住——改一个枚举值要全量回归。common 只应放 “无业务语义” 的基础设施(错误码、工具、时钟抽象)。
Q4 依赖规则和团队边界冲突时会怎样?
▸ 答案:康威定律会赢:代码会往 “沟通结构” 的方向漂移。如果规则要求 A 不许依赖 B,但 B 的团队与 A 是同一个组、天天一起改代码,违规会不断出现。要么调整规则(承认他们是一个模块),要么调整团队边界,两者必须一致。
Q5 模块间用 “共享 DTO” 算不算耦合?
▸ 答案:算契约级耦合:DTO 一改,双方都要改。它比 “直接调内部实现” 好(至少是显式契约),但仍需版本化与兼容策略。更好的做法是各自定义自己的模型,在边界处转换(防腐层思路,见 28.5 遗留系统集成 (绞杀者 / 防腐层))。
Q6 什么时候该允许 “跨模块 JOIN”?
▸ 答案:只在报表/分析类只读场景,且明确它不属于任何模块的写路径。业务写路径上跨模块 JOIN 会让表结构成为公共契约,任何 schema 变更都变成跨模块发布。只读分析可以用物化视图/数仓隔离。
Q7 为什么 “扇入” 要进规则看板?
▸ 答案:因为扇入 = 变更影响面:被 8 个模块依赖的包,改一次要回归 8 个模块。把 “扇入超过阈值” 作为告警(如 > 5 必须评审),能把 “公共模块黑洞” 在形成过程中挡住,而不是等它变成 common 里 300 个类。
故障复盘(典型模式,脱敏)
| 阶段 | 现象 | 判断 | 动作 |
|---|---|---|---|
| T+0 | 依赖规则上线,CI 报 200+ 违规 | 存量违规未冻结 | 先加白名单 |
| T+1 周 | 违规数没降,新增代码继续违规 | 白名单成了“合法通道” | 白名单冻结 + 只减不增 |
| T+3 周 | 出现 //nolint 绕过 | 规则可被静默跳过 | 禁用该指令 + 评审拦截 |
| T+2 月 | 违规清零,环从 7 个降到 0 | 规则有效 | 加扇入告警 |
根因:把 “规则” 当 “一次性整改”,没有冻结存量与禁止绕过。代价:一个季度的人力。根治:白名单只减不增 + 禁止绕过 + 每周看板(环数/违规数/扇入)。
关联
- 前置:27.2 模块化单体
- 并列:26.6 模块化与包设计
- 下游:27.4 大泥球的形成路径、58.3 架构评审