调不动的代码,问题多半出在依赖方向
接手一个三年没动过的支付模块,只想改一行手续费计算,编译却要经过 11 个文件,跑测试得把整个应用启动起来。这不是模块大,而是依赖的箭头指反了。
所谓依赖方向,就是「谁能引用谁」这条单向规矩。A 引用 B,意味着 B 的接口一变,A 就必须跟着改;反过来 B 完全不知道 A 存在,改动就被挡在 B 内部。
太多代码「调不动」,问题不在逻辑复杂,而是业务规则反过来引用了框架、数据库驱动和第三方 SDK——等于把地基砌在了二楼。
一次真实的修改路径
某电商后台把优惠计算写在订单服务里,而订单服务引用了微信支付 SDK 的回调模型类。当支付渠道换成聚合支付时,优惠代码被迫一起改:明明促销规则和支付通道毫无关系。
如果当年把「支付渠道」定义成领域层的一个接口,具体 SDK 放到外层做适配,这次替换只需要新增一个适配器文件,订单与优惠代码一行不动。
同一份需求,两种依赖结构的差别
一个四人小组做 CRM 数据导出。A 组在服务层直接调 ORM 拼 SQL;B 组先定义 ExportPort 接口,再由 MySQL 和 Excel 两个适配器实现。
三个月后产品要新增 CSV 导出。A 组改了 6 个文件、跑了全量回归、上线前一天还在修边界问题;B 组新增一个适配器,三十分钟联调通过。两边代码量差不多,差别只在箭头的朝向。
为什么循环依赖最危险
两个模块互相引用,表面看只是「耦合紧一点」,实际会连带产生一连串后果:
- 任何一方都无法单独编译和测试,验证一个改动必须拉起全局
- 无法按模块拆分服务或包,重构的第一步就被堵死
- 一方的改动会被另一方牵动,缺陷定位范围被放大数倍
- 新人理解成本指数上升,因为「从哪读起」没有入口
最常见的三个误区
第一个误区是用目录名伪装分层。src/domain、src/infra 摆得整整齐齐,可 domain 里的实体照样 import 框架注解和 ORM 基类。分层只停留在文件夹上,箭头方向一点没变。
第二个误区是把接口定义在实现旁边。接口写在基础设施层,领域层再反过来依赖它,箭头立刻掉头。接口应该由使用方定义——调用者拥有接口,实现方去适配。
第三个误区是「以后再说」。依赖方向一旦定型,修正成本随引用数量线性增长,功能还在持续叠加,越拖越只能维持现状。
落到具体动作上
- 以业务概念为圆心,框架、数据库、消息队列、第三方 SDK 全部排在外圈
- 接口由调用方定义,实现方适配,而不是让调用方依赖别人的接口
- 模块之间只允许单向引用,用静态检查或架构测试工具把规则固化到流水线
- 两个模块互相需要数据时,抽出共享的领域对象,或改用事件通知,别让 A 直接调 B 的方法
- 验收标准写具体:能不能只跑一个模块的测试,就验证这次改动完成
一个朴素的判断标准
判断依赖方向是否健康,只需要问一个问题:如果明天把数据库换成文件、把 HTTP 换成命令行,业务代码需要改几行?答案越接近零,方向越对。
另一个信号是编译时长与波及范围。改一行业务规则却要重新编译半个工程,基本可以断定存在反向依赖。
从最小的一步开始
不必立刻推翻架构。先挑一处改动最频繁的功能,把它的外部依赖抽成接口,签名由调用方来定,实现挪到外层。
然后加一条检查规则,禁止内层引用外层的包。规则跑在流水线上之后,方向就不会再悄悄倒转——这是比任何架构图都更管用的约束。