1. 认识依赖倒置原则:到底是插座定义了插头,还是插头定义了插座
依赖倒置原则(DIP)这个概念,你只要翻过任何一本讲架构或设计模式的书,就一定会撞见那两句绕口令一样的定义:高层模块不应该依赖低层模块,两者都应该依赖抽象;抽象不应该依赖细节,细节应该依赖抽象。说老实话,我第一次读完这两句话的时候只有一个感觉:每个字都认识,合在一起不知道它在让我写什么。后来我在一个真实项目里反复做了两轮重构,又把这个问题掰开揉碎了想了很久,才意识到它讲的其实是你每天都能见到的场景——墙上那个插座和你手里电器插头的关系。
先别急着笑,这个例子真的能帮你把 DIP 的骨架立起来。我们家每天用的电冰箱、洗衣机、手机充电器,从来不需要知道电是来自火力发电、水力发电还是屋顶光伏,它们只认一件事情:墙壁上的插座形态。换句话讲,插头的形状是被一个更稳定的“公共约定”定义好的,而不是由某个发电厂的设备型号决定的。发电端怎么变、输电线路怎么走,对电器生产商来说统统被挡在了那道墙后面。电器需要电,但不绑死任何一座电站;电站想给电器供上电,就得先把那套标准接口对接明白。
在代码里,我刚才说的这句话几乎原封不动:“高层模块需要某种能力,但不绑死某个具体实现;低层模块为了被使用,反过来去实现高层真正需要的那份契约。”也就是说,依赖的方向和大多数人初学编程时候的直觉是相反的。平常我们写三层架构,自然沿着“Controller 调 Service、Service 调 DAO、DAO 去连 MySQL”的方向一路画箭头;这是调用关系,是数据流的方向,但依赖倒置原则关心的是另一件事:当我们想替换掉某个工具、某个服务商、某个数据库访问方式的时候,哪些源码需要被动修改。如果没有中间那道“抽象层”,改动就会像多米诺骨牌一样,从最底层的实现顺着调用链一路传染到业务代码,那种代码才是 DIP 真正要治的病。
1.1 把类比起码对应完整:谁是高层,谁是低层
用插座和插头打比方的时候,很多人会卡在一个问题上:“到底谁是高层模块,谁是低层模块?”如果把电线那头供电网当成低层细节,那么电冰箱这类消费电器就是高层业务模块。电冰箱不会去 import 某个电站提供的 API,它只声明一件事:我要符合国家标准的 220V 交流电,你只要给我这个标准的电,我就能工作。真正把发电机、变压器这些细节包装起来的,是那个看起来不起眼的抽象层——插座形态和对应供电参数标准。
在这个结构里,“谁定义谁”就不难回答了。插座规格先被固定下来,插头按照它成型,所以是规范定义了插头。应用到软件里就是:抽象契约先被固定,实现来适配它,所以不是实现者牵着业务逻辑走。另有一种更精确的说法是,真正的高层模块作为需求方,往往有权力去定义“我需要什么”,而低层模块只能去满足这个标准。比如订单服务说“我需要一个能通知用户订单已创建的东西”,并不会提前规定必须用短信,还是必须用邮件;通知这个抽象能力是为了承载订单业务目标而存在的,短信服务商后补实现这个接口即可。
想通这层之后,你会发现不少代码库的实际依赖方向完全画反了。很多业务 Service 直接依赖了短信 SDK、支付 SDK、对象存储 SDK,相当于电冰箱直接按照某家发电厂的插孔设计了一个专用插头。今天能用,是因为项目凑巧只接了这一家;明天如果你想换服务商,或者想在上线测试时把通知替换成 mock,你会发现那些业务代码和某个 SDK 的 import 紧紧绑在一起,不拆掉接线根本没法搬动。
1.2 DIP 真正要根治的是哪一类代码坏味道
依赖倒置原则要解决的核心问题可以浓缩成一个词:传导。实现细节一旦变化,就顺着依赖关系传导到所有上游代码中。失去 DIP 保护的模块,往往不是因为它没接口,而是因为它的接口定义位置不对,又或者接口参数里漏了一堆实现细节。最常见的现象是:一个老旧的 Service 里到处都是第三方 SDK 的类型签名,比如订单方法返回的是某个网关特有的结果对象,比如配置对象直接依赖云服务商的客户端。这种代码并不是不能跑,只要你十年不换供应商、不再改业务流程,它甚至可以一直稳定运行;但它把“你业务的本质”和“你碰巧用了什么工具”焊死在了同一块钢板上。
希望实现扩展的时候,就能鉴别出来了。你新增一个通知渠道、替换一套缓存组件、把单体中的某个远程调用改成消息队列,本来都应该是很局部的操作;但在违反 DIP 的代码库里,这些工作一定会蔓延到多个上层类,甚至会让核心业务类跟着一起做兼容补丁。依赖倒置原则真正想替你做的是降噪:把易变实现和稳定业务隔离开,让业务方向保持稳定,让实现方向各自进化。整体上就是你家里砌墙时预留的那排标准插座——电路改造不需要把全屋的电器都换一遍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 不遵守依赖倒置原则的代码到底有多危险
上一节全程在讲“思想”,这节把问题摆到真实的工程代码里。下面这个例子来自一个很典型的订单场景:用户下单成功后,系统要给用户发一条短信通知。第一次写的时候,工程师很自然地会把短信客户端的代码直接 new 进来,用一个静态配置读一下应用 ID 和密钥。这段代码在功能上没有任何毛病,但它在架构上埋了一根很粗的雷管。
2.1 反例:订单服务直接封装短信客户端
java复制public class OrderService {
private final SmsVendorClient smsClient;
public OrderService() {
this.smsClient = new SmsVendorClient(
SmsConfig.getAppId(),
SmsConfig.getSecret()
);
}
public void placeOrder(Order order) {
// 1. 校验订单
this.validate(order);
// 2. 扣减库存
this.deductStock(order);
// 3. 发通知
smsClient.send(order.getUserId(), "订单已创建,订单号:" + order.getOrderNo());
}
}
先说明一下,这里我故意把问题简化了。真实项目里,OrderService 可能还依赖订单仓库、库存服务、优惠券服务,这些都有自己的接口。但短信这块如果写成上面这种“直接 new”的方式,就已经足够让人头疼了。因为它让订单业务与短信服务商的 SDK 发生了强烈的源码级耦合:OrderService 得知道 SmsVendorClient 这个类存在,还要知道它的构造参数怎么来,甚至还得手动读取一个全局配置去初始化它。从职责角度看,订单服务本来只关心“下单这件事”,现在却兼职做了短信客户端的装配工。
这种代码最直接的坏处是测试没法写。你想为 placeOrder 写单元测试,总不能真的发给用户一条短信吧?于是要么 mock 静态方法,要么把 SMS 服务商的调用单独包一层,包着包着你就会发现,自己其实是在为没有一开始写好接口而打补丁。更让人难受的是,如果短信服务商升级了 SDK,把构造器参数从两个变成三个,所有依赖这个客户端的业务 Service 都要跟着改。这不是危言耸听,我确实见过一次这样的升级,把支付、订单、通知三个模块里的几十处调用点几乎全改了一遍。
2.2 替换需求一来,依赖风暴就出现了
假设半年后产品经理说:“很多用户没绑定手机号,我们能不能支持邮件通知?”你打开上面这段代码,会发现短信客户端和新加的邮件客户端都要塞进 OrderService。你开始考虑用多个类型判断,于是代码变成 if (user has email) emailClient.send() else smsClient.send()。如果过阵子又希望支持站内信、App Push,业务类会越堆越厚,最后膨胀成一个所有通知渠道都认识的上帝类。
这是典型的凡是没有 DIP 的模块最终都会走向的结局:业务规则被基础设施牵着鼻子走。每替换一个外部依赖,业务代码都要被打上一块新补丁。反观遵循 DIP 的写法,订单服务不需要关心用户到底走短信还是邮件,它只依赖一个抽象契约“通知接口”。谁有了新渠道,谁就去实现那个接口,再通过配置或容器把它注入进来。业务代码可以保持长期稳定,后来者只需要在“插头”那侧做适配。说句实在话,这个道理一旦写出来并不神秘,难的是在项目里形成习惯,因为从第一次写 new SmsVendorClient() 开始,你其实已经在用“方便”换“稳定”了。
2.3 判断一个依赖是不是该被抽象
看完上面的反例,你可能想问:“是不是所有依赖都要拆一层接口?”当然不是。如果某个类在你的系统里非常稳定,它的类型不可能变化,那直接依赖它反而是最诚实的做法。比如业务里不变量很强的金额、日期、订单状态,抽象出接口反而徒增复杂度。真正的判断标准应该看两点:第一,这个概念是否可能有多套实现;第二,这个实现的变化是否会伤及上层业务。如果两个答案都是肯定的,那它就该被抽象。
对存储层来说,这一点尤其明显。很多团队在一开始就为订单仓库定义一个 OrderRepository 接口,并不是因为他们真的打算今天换 MySQL、明天换 PostgreSQL,而是因为测试时需要注入内存版、隔离基础设置时需要替换实现。数据库驱动本身并不是“邪恶的低层模块”,真正危险的是让业务代码直接依赖某个数据库客户端的所有方法,导致后续想加一层缓存、想切换数据源都无从下手。依赖倒置原则在这里给出的处方从来都不是“消灭具体类”,而是要求你把那些充满变化意图的细节统统放在抽象后面。
经验之谈:当你发现某个具体类从项目立项到现在从没变化过,而且你也完全无法想象它会变化,就没必要为它强行造一个接口。DIP 的价值在“变化点”上体现,不在“看起来工整”的代码结构上体现。
3. 用依赖倒置原则重构:从短信绑定到系统解耦
这部分我把完整的重构过程走一遍,只要照着这个思路做,你大可以把同样的套路平移到支付、存储、消息队列、文件上传等等各种场景。我喜欢用一个公式来概括落地过程:定义高层需要的抽象契约 → 让实现方反过来依赖这个契约 → 在系统入口完成组装和注入。三步各做各的,千万不要混在一起。
3.1 第一步:抽象契约必须从业务需求生长出来,而不是从实现类里反推
很多人觉得抽象就是“把现有类改成接口,再把方法粘过去”,这是对这个原则最大的误读。正确姿势是先回到业务本身问一句:订单服务到底需要通知模块提供什么能力?在这个例子里,答案是“把订单创建成功这件事告诉用户”,它甚至不一定需要知道渠道是短信还是邮件。所以契约可以设计成这样:
java复制public interface OrderNotifier {
/**
* 通知用户某个订单相关事件
*
* @param userId 接收通知的用户ID
* @param title 通知标题
* @param content 通知正文
*/
void notify(Long userId, String title, String content);
}
这个接口有一个值得注意的细节:它用的参数是“用户ID”“标题”“正文”,而不是“短信模板编号”“签名”这类渠道特有概念。一个稳定抽象最怕的一个问题,就是把某种实现的特殊性暴露到接口签名里。如果接口方法写成 void sendSms(Long userId, String templateId),那它本质还是个短信接口,未来邮件怎么实现邮件正文?所以抽象的定义要尽量偏向业务语义,让任何一种实现都能按自己的方式完成同一目标,这其实就是设计原则里常说的“接口隔离”和“抽象稳定性”之间的关系。
3.2 第二步:具体实现反过来依赖抽象,并负责完成渠道细节
接下来就让具体实现承担它应该承担的细节。短信客户端、密钥、模板 ID 这些东西,全部从订单业务类里搬走,收拾到实现类内部:
java复制public class SmsOrderNotifier implements OrderNotifier {
private final SmsVendorClient smsClient;
public SmsOrderNotifier(SmsVendorClient smsClient) {
this.smsClient = smsClient;
}
@Override
public void notify(Long userId, String title, String content) {
String templateNo = "order_created";
smsClient.send(userId, templateNo, title + "," + content);
}
}
此时能明显看到方向的反转:原来 OrderService 依赖短信商 SDK,现在短信商 SDK 被埋在 SmsOrderNotifier 里;SmsOrderNotifier 依赖的是订单模块定义出的 OrderNotifier 接口。你再抬头看调用关系,订单模块并不认识短信商,整个依赖箭头都指向了接口那一端。也就是说高层模块不再依赖实现细节,而是实现细节为了成事,反过来去实现一个更上层的业务承诺。
假如后来需要支持邮件通知,只要真写一个 EmailOrderNotifier,同样实现 OrderNotifier,把它注入到需要的位置。订单业务本身的逻辑不用动,通知模块相当于一个实现了同一份“合约”的新插头。补一句题外话:在当前编程生态里,还有很多团队会直接用事件机制,比如在 OrderCreatedEvent 发布后由监听器异步处理通知;这本质上是 DIP 的一种强化方案,核心思路并没有变,只是把同步调用变成了事件驱动。
3.3 第三步:在系统入口完成组装,依赖注入只是手段
现在你回头重构 OrderService。它的构造器不再负责 new 短信客户端,而只是接收一个 OrderNotifier 参数:
java复制public class OrderService {
private final OrderRepository orderRepository;
private final OrderNotifier orderNotifier;
public OrderService(OrderRepository orderRepository, OrderNotifier orderNotifier) {
this.orderRepository = orderRepository;
this.orderNotifier = orderNotifier;
}
public void placeOrder(Order order) {
this.validate(order);
this.deductStock(order);
orderRepository.save(order);
orderNotifier.notify(order.getUserId(), "订单已创建", "订单号:" + order.getOrderNo());
}
}
到这里,依赖注入容器其实还没有登场。因为依赖注入只是手段,不是原则本身。在最小规模的工程里,你甚至可以在应用启动入口处手工组装一次:
java复制OrderNotifier notifier = new SmsOrderNotifier(
new SmsVendorClient(smsConfig.getAppId(), smsConfig.getSecret())
);
OrderService orderService = new OrderService(orderRepository, notifier);
这样的手动装配一旦多了以后,你会明显感觉到它不好维护,这时候才轮到 Spring、Guice 这类依赖注入框架上场。需要注意:框架解决的只是“装配”的动作,它并不替你完成抽象设计。如果业务类从一开始就画错了依赖方向,再差的容器也倒置不回来。依赖注入容器就像装配流水线,它把各个零件自动怼到一起,可零件本身的设计必须已经符合 DIP 的要求,否则最后集成的还是一坨乱线。
3.4 测试视角立即获得的回报
重构完成后,最有感知的变化出现在测试用例里。以前测订单服务要 mock 静态方法或者搭一个真短信通道,现在的做法非常干净:既然 OrderService 只认 OrderNotifier 接口,我就在测试里丢一个记录型实现,它什么也不真正发送,只把通知参数记下来。
java复制@Test
void shouldNotifyUserWhenOrderPlaced() {
RecordingNotifier notifier = new RecordingNotifier();
OrderService service = new OrderService(new InMemoryOrderRepository(), notifier);
Order order = OrderBuilder.validOrder();
service.placeOrder(order);
assertEquals("订单已创建", notifier.getTitle());
assertEquals(order.getUserId(), notifier.getUserId());
}
RecordingNotifier 就是测试模块内一个用了十来行的假实现,没有网络调用、没有短信服务商依赖、没有测试环境的账号,跑起来飞快。这带来的不只是方便,更是一种安全感:你在 CI 里真正验证了“用户下单成功后会触发一次通知”这条业务规则。以前很多团队一测这种业务就要把真实 SDK 打桩打半天,最后干脆放弃测试;有了 DIP 的接口保护,业务核心和外部能力边界清清楚楚,这种测试基本是水到渠成的事。
4. 用三个问题判断你的类是否真的遵守了依赖倒置原则
很多开发者在“接口 + 注入”的包装下会自我感觉良好,但代码评审时我把关键点落在了深层检查上。与其咬文嚼字地说“你有没有遵循 DIP”,不如直接问几个很通俗的问题:如果我不依赖这个具体类,项目还能不能照常表达业务?新加一种实现的时候,我是不是只需要做增量?底层变化会不会波及到上层源码?把这些问题浓缩成下面三个可操作的判断,会比较有抓手。
4.1 问题一:高层模块能不能完全绕开实现类?
看代码时,我最先找的就是 new 关键字。一个遵循 DIP 的高层模块,通常不会在自身内部直接 new 一个重要依赖;它的依赖会在构造器、工厂、容器或启动配置里被组装好之后传递过来。如果看到 OrderService 里写满 new SmsClient()、new PaymentService()、new FileStorageClient(),那基本不用再看别的,这条就违规了。
你可能会问:那程序总得有个地方 new 这些实现吧?当然要有,但这个职责应该放在“组装根”,而不是业务类里面。Robert Martin 有一句很通俗的话:具体的东西应该留在启动配置这些足够高层的入口处,业务代码内部只谈抽象。这句话也是我们做代码评审时的“红线”之一,如果业务类里出现了基础设施类名,就得停下来讨论一下它是不是真的越界了。
4.2 问题二:依赖箭头的方向和稳定的层级相反了没?
如果给模块之间的依赖画箭头,你会希望箭头统一从“易变”指向“稳定”,或者从“细节”指向“抽象”,而不是反过来。在没遵守 DIP 的包结构里,依赖箭头常常指向第三方 SDK 或底层存储客户端;在重构后的代码里,箭头指向的是业务域接口所在的那个包。
这里有一个比较实操的技巧:把你的核心业务接口放进高层模块所在的包,比如 domain、model、core,不要让业务模块的源码反过来 import 一个“notification-implementation”包。如果项目里基础设施包依赖了业务接口包,而业务接口包完全不依赖基础设施包,那这个依赖方向基本就是安全的。用一个不太严格但很好用的说法:高层包的 pom.xml 或 gradle 依赖里,不应该出现短信商、存储商、支付商的 SDK 名。
4.3 问题三:修改某个实现时,会不会导致上层跟着改?
这是最直接的“探测针”。你把你手头某个依赖的实现类整个换掉,看看影响范围落在哪里。假设把 MySQL 换成 PostgreSQL,本来大多数 Repository 的实现都被接口屏蔽,那你很可能只需要改少量配置或某个实现类;如果发现 Controller 或 Service 里有人在操作 Connection、PreparedStatement,那恭喜你,依赖已经从细节里漏到上层了。
再举个例子,如果你依赖内部某个老系统的 SDK,而 SDK 接口要升级版本,原本调用方应该只改适配器层就够了。但如果改动把业务代码里的参数类型从 String 改成 SDK 的某个 DTO 对象,那说明抽象设计得还不够干净,接口层把细节的形态洩漏出来了。依赖倒置不是写一个 interface 就算完事,还要保证 interface 的“语言”属于业务层,而不是属于某个具体实现生态。
4.4 一份自查清单:快速给当前模块打分
下面这张表是我自己评审代码时常用的快速检查表,每次拿不准某个类到底算不算“倒置成功”,就逐行核对一遍:
| 检查项 | 合格表现 | 典型违规表现 |
|---|---|---|
| 构造/初始化外部能力 | 通过参数、工厂或容器注入 | 业务类内部直接 new 外部客户端 |
| import 方向 | 核心包不 import 基础设施实现 | Service 里出现短信/支付/存储 SDK |
| 接口参数类型 | 使用自己的业务模型或基本类型 | 接口参数里出现 SDK 专用 DTO |
| 更换实现的影响 | 只改适配器或装配配置 | 上游业务源码大面积改动 |
| 单元测试难度 | 轻松注入 mock 或记录型实现 | 必须 mock 静态方法/还原真实调用 |
| 新增一种渠道的代价 | 新增实现类 + 注入,不碰上层 | 在上层增加 if/switch 分支 |
只要你发现有两行以上不达标,基本就说明这个类或这个模块在 DIP 上处于失控状态。好的架构不会一夜之间崩塌,它就是从这样一个一个被忽略的细节开始蚀坏的;早一点用这张表去审视,就能少欠一点技术债。
5. 依赖倒置原则在真实项目里的落地边界:适配器、防腐层与领域服务
原则讲完、例子也跑通了,很多人下一步会遇到的问题是:“那我的项目也不是一个小订单服务,那么多模块、那么多第三方依赖,到底先从哪里开始?”我的建议是从“端口与适配器”的角度思考整个业务架构,也就是把系统看作两层:内部是稳定业务核心,外部是各种会变的实现。下面列几个最容易落地 DIP 的典型场景,每一个都是在真实项目里验证过高回报率的。
5.1 基础设施适配器:数据库、MQ、文件存储都是典型目标
第一类适用范围是“外部基础设施”,包括关系型数据库、缓存、消息队列、对象存储、邮件服务。它们共同的特征是:即便以后想换掉的可能性不高,测试和演进过程中也需要有一个可替代的边界。比如消息队列,很多团队一开始只接 Kafka,后来因为运维复杂想换成云上的 RocketMQ 或 Topic 服务,如果所有业务类都直接操作 KafkaProducer,届时要做的工作量会相当惊人。相反,如果只定义一个 MessageBus 接口,实现类选择哪套 MQ 都是适配器的事,上层业务发送事件时根本不知道底层用的是什么。
对象存储的场景同样如此。你的业务代码应该只依赖 FileStorage 这样的接口,它提供 putObject、getUrl 这类业务语义;实现上当地换成 OSS、S3、自建 MinIO 时,不过是在适配器里换一个 SDK。数据库层的 Repository 更是 DIP 最经典的主场,因为你是真的希望在单测中切到内存实现,而不想每次跑测试都起一个 MySQL 容器。
5.2 第三方系统边界:用防腐层拦截外部模型入侵
第二类适用范围是“外部系统的 API”。对接外部支付机构、风控服务、报关系统时,外部系统不仅接口会变,连数据模型都和我们内部的领域模型不一样。如果你把外部返回的 DTO 直接拿来赛进业务代码,比如订单状态直接依赖支付平台返回的字符串状态码,那你等于把外部世界的质量缺陷导入了内部。更理想的方案是在系统入口处做一个防腐层,外部系统调用到达时先被翻译成我们自己的模型,再用内部接口继续驱动业务逻辑。
防腐层是 DIP 在架构层面的典型应用:外部系统是“细节”,我们自己的业务模型是“抽象”,外部 DTO 不能穿透业务层。这里有个小技巧是,防腐层适配器的输入输出应该尽量使用内部定义的对象,即使它们看起来只是“字段结构差不多”,也不能为了省事直接复用外部 SDK 的类。多做一次拷贝、多写一个转换函数,换来的是内部世界不因外部升级而震荡,非常划算。
5.3 领域服务与策略:把“怎么做”和“做什么”分开
第三类范围更贴近业务本身:有些规则在当前开发时只有一种实现,但未来必定会长出分支。典型的例子是优惠计算、运费试算、订单状态流转,不同用户类型、不同商品类目会有不同算法。这种场景也可以用 DIP 做策略化:核心流程只知道“有一个价格计算策略”,具体是会员价策略还是散户默认策略,由运行时的注入器决定。本质上是把“做什么”和“怎么做”分离,业务主流程不因策略膨胀而不断改动。
还拿我之前重构的支付模块举例:刚开始接入单一支付渠道,代码很正常,但第二季度要求新增分期支付和钱包支付时,如果没有在早期定义 PaymentGateway 接口,改动成本会立刻失控。而有了策略式抽象后,引入新渠道只需要实现接口并注册,不需要给原有核心流程增加任何 if 分支。这也印证了我一直以来的观点:DIP 不是过度设计的掩护,它是专门为“你已经能看到变化正在来的方向”这样的场景保驾护航。
6. 把 DIP 落地到团队习惯里:最常踩的坑与避坑建议
理论讲得再漂亮,最后不落到代码习惯里都是空话。依赖倒置原则的优点很容易理解,但它在我接触过的项目里也带来了不少副作用,有的团队把原则执行成了纯粹的表演,接口写了一堆,代码复杂度反而暴涨;有的团队则把 DIP 和依赖注入框架彻底画了等号。以下是我根据自己的实际经历整理出来的几个高频坑,以及对应的应对办法。
6.1 把 DIP 等同于依赖注入框架是最大的误区
很多聊 DIP 的文章会把镜头切到 Spring 的 @Autowired 上,导致初学者以为“用了依赖注入框架就算遵循了依赖倒置”,这个误解是很有危害的。依赖注入框架确实能帮我们把实例装配从业务代码里搬走,让构造器里的依赖更干净,但它只是一个“装配工”;真正决定箭头方向的,是你在设计阶段有没有把接口放到业务层、实现层是否遵守这个抽象,而不是你用了几个注解。
反过来,很多人不用任何容器也能做到倒置。早年间写 Java 的时候,我就见过一个老工程师全凭手动在 main 方法里做装配,代码结构依然清爽得不得了。DI 容器是锦上添花,不是救命稻草;指望一个框架替你解决所有架构问题,最终结果往往是框架的魔法掩盖了本应被直视的坏味道。依赖注入框架真正适合处理的是大型代码库的组装复杂度,而 DIP 本身更多是一种思维判断,它管的是源头设计,不是装配动作。
6.2 每个类都拆接口是另一种自我感动
反向踩坑是“为了 DIP 而 DIP”。有些代码库恨不得一个实体类配一个接口,一张表配一个 Repository 接口,连一个只有三种状态枚举的转换工具类也要抽一层接口,结果就是类数量翻倍,阅读跳转变得极难受。原则没错,错在把它当成一种机械规则来执行。如果一个事物目前只有单一实现,并且看不出任何变化征兆,直接使用那个具体类可能是最诚实的表达,这并不丢人。
我自己的判断原则很简单:如果这个接口在未来一年内替换掉的概率没有高到让我愿意付出二三十行适配代码的成本,那就不必急着引入额外抽象。变化点没来,你造好的接口就只是预支复杂度;等变化点真的出现时,再按第一章的重构步骤把边界抽出来也不迟。架构要跟随真实业务演进,而不是做一个提前把整片森林画在纸上的规划狂。
6.3 抽象接口里泄漏实现细节,接口等于白写
还有一类代码,接口画得挺整齐,设计的却是细节的形状。比如支付接口长这样:boolean pay(PaymentVendorRequest request),这个方法把某支付服务商的请求对象直接作为参数暴露给上层。这样一来就算调用方依赖的是接口,接口的方言仍然是外部供应商的方言,你并没有真正隔离变化。
为了保证抽象稳定,接口的输入输出尽量使用自己业务领域的模型,实在没有就用基础类型和简单的值对象。早期多花点时间做模型转换,比后续十几次业务改动都被外部 SDK 模型绑架要值太多。每一次你在接口签名里塞进第三方类型时,相当于给未来的替换搬来一块更重的石头;当你发现接口层出现大量“为什么订单状态要跟在支付渠道状态后面”的问题,那多半就是抽象被细节反噬了。
6.4 我养成的唯一“强制规范”:每处改动前先画依赖方向
最后分享一个让我少趟很多坑的个人习惯:每当我准备在一个模块里引入一个新的外部依赖时,不管这个依赖是内部的还是第三方的,我都会先停一下,在脑海里或草稿纸上画出改动前后的依赖箭头。目标就一句话:确保箭头的终点只有抽象,不要让业务核心指向具体实现。如果箭头上出现了业务模块到基础设施 SDK 的边,我会条件反射地问自己一句:这个 SDK 是会变的,还是业务本身不可变的一部分?
因为人的记忆是靠不住的,团队里的人一波波更换,口头约定很快就会被遗忘;但代码里可读的依赖方向不会说谎。我之前在一个老项目里做的第一件重构,不是去给所有类加接口,而是用一个脚本扫出了所有“业务包 import 基础设施包”的源码,把它们按危险程度排了个序,再循序渐进地一个个包到适配器后面。那次重构前后花了几周,看似动作不大,却让后续接入新服务商时改动量从几十个文件骤降到了两三个。依赖倒置原则的价值可能不像新框架那样闪闪发光,但当你体验到“核心逻辑一两年不用被动修改”的稳定感之后,就很难再认同那种所有东西硬绑在一起、看着很热闹其实寸步难行的代码风格了。
与其纠结题目里的插座和插头哪一个“先来”,不如把关注点放在一个更确定的结论上:抽象不是哪一方定义的,而是为了避免两边互相绑架而存在的那道稳定鸿沟。写代码时,也别急着给所有东西加接口;先搞清楚变化可能从哪边来,再让抽象长在该长的地方,依赖倒置才算真正落了地。
