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 规则有效 加扇入告警

根因:把 “规则” 当 “一次性整改”,没有冻结存量与禁止绕过。代价:一个季度的人力。根治:白名单只减不增 + 禁止绕过 + 每周看板(环数/违规数/扇入)。

关联