从DDD的仓储层反向依赖,理解DIP、IOC和DI

我从23年开始在公司的技术团队里落地DDD,领域驱动设计开发。落地过程中有一个问题反复被团队成员问到:基础设施层为什么反过来要去依赖领域层?

这个问题问得好。因为从这个问题入手,能一次性把DIP、IoC、DI这三个经常被混淆的概念理清楚。

打开我们项目基础设施模块的pom.xml,能看到这样的依赖声明:

XML 复制代码
<dependency>
    <groupId>com.example</groupId>
    <artifactId>erp-domain</artifactId>
</dependency>

基础设施模块依赖领域模块。再看领域模块的pom.xml,里面没有任何对基础设施模块的依赖。这跟很多人的直觉是反着的:基础设施层提供数据库访问、外部接口调用这些底层能力,按理说应该是业务层依赖它,怎么反过来了?

要理解这个设计,得从传统分层架构的问题说起。

传统分层架构的依赖方向

传统三层架构里,调用方向和依赖方向是一致的。Controller调用Service,Service调用DAO,每一层的代码都要import下一层的类。反映到Maven模块上,就是service模块依赖dao模块。

这个结构用了很多年,大部分项目也没出什么大问题。问题出在哪呢?

Service层写着业务逻辑,同时它的代码里充满了DAO层的类引用。UserDaoLambdaQueryWrapperselectPage这些跟数据库操作相关的API,直接出现在业务代码中。业务逻辑和持久化技术搅在一起。

这在小项目里不是事儿。一旦项目规模上去了,比如要把某个模块的持久化从MyBatis换成JPA,或者某些数据要从MySQL迁到Elasticsearch,你会发现需要改的不只是DAO层,Service层的代码也得动,因为Service里到处import着DAO层的类。

业务逻辑是一个系统里最核心、最应该保持稳定的部分,但在传统分层架构里,它依赖了技术实现细节。技术细节一变,业务代码跟着变。 依赖方向是反的。

DDD项目里的依赖方向

我们的DDD项目按照领域驱动设计拆成了多个Maven模块:

Markdown 复制代码
erp-server          (启动模块,依赖所有其他模块)
erp-facade          (接口层,对外暴露HTTP/RPC接口)
erp-application     (应用层,编排业务流程)
erp-domain          (领域层,核心业务逻辑)
erp-infrastructure  (基础设施层,数据库访问、外部接口调用)
erp-common          (公共模块,通用工具和命令对象)

关键在领域层和基础设施层的依赖关系。领域层定义仓储接口,基础设施层实现这些接口。

领域层里的仓储接口长这样:

Java 复制代码
package com.example.erp.domain.aggregate.purchase.repository;

public interface PurchaseOrderRepository {

    Long save(PurchaseOrderEntity entity);

    PurchaseOrderEntity getById(Long id);

    Page<PurchaseOrderEntity> getList(PurchaseOrderQuery query, PageInfo pageInfo);

    void updateStatus(Long id, String statusCode);

    List<PurchaseOrderEntity> getByDateAndTemplateId(String date, Long templateId);
}

这个接口定义在领域模块里。注意它的方法签名:参数和返回值全是领域对象(Entity、Query、PageInfo),没有任何跟数据库相关的类型。它只描述「能做什么」,不涉及「怎么做」。

基础设施层的实现类长这样:

Java 复制代码
package com.example.erp.infrastructure.repository;

@RequiredArgsConstructor
@Repository
public class PurchaseOrderRepositoryImpl implements PurchaseOrderRepository {
    private final PurchaseOrderMapper purchaseOrderMapper;
    private final PurchaseOrderItemMapper itemMapper;
    private final TransactionTemplate transactionTemplate;

    @Override
    public Long save(PurchaseOrderEntity entity) {
        return transactionTemplate.execute(status -> {
            // Entity转PO
            PurchaseOrderPO po = PurchaseOrderConverter.INSTANCE.entityToPo(entity);
            purchaseOrderMapper.insert(po);

            // 保存明细
            List<PurchaseOrderItemEntity> items = entity.getItemList();
            if (CollectionUtil.isNotEmpty(items)) {
                for (PurchaseOrderItemEntity item : items) {
                    PurchaseOrderItemPO itemPO = PurchaseOrderItemConverter.INSTANCE.entityToPo(item);
                    itemPO.setOrderId(po.getId());
                    itemMapper.insert(itemPO);
                }
            }
            return po.getId();
        });
    }

    @Override
    public PurchaseOrderEntity getById(Long id) {
        PurchaseOrderPO po = purchaseOrderMapper.selectById(id);
        return PurchaseOrderConverter.INSTANCE.poToEntity(po);
    }
}

实现类在基础设施模块里。这里面全是MyBatis Plus的API(selectByIdinsert)、PO对象、Converter转换器,这些都是跟持久化技术强相关的代码。

领域层的DomainService使用仓储接口的方式:

Java 复制代码
package com.example.erp.domain.aggregate.purchase.service.impl;

@RequiredArgsConstructor
@Service
public class PurchaseOrderDomainServiceImpl implements PurchaseOrderDomainService {
    // 注入的是接口,不是实现类
    private final PurchaseOrderRepository purchaseOrderRepository;
    private final OrderNoGeneratorService orderNoGenerator;

    @Override
    public Long submitOrder(CreatePurchaseOrderCommand command) {
        PurchaseOrderEntity entity = PurchaseOrderEntity.createWith(command);
        String orderNo = orderNoGenerator.generate(ModuleEnum.PURCHASE);
        entity.setOrderNo(orderNo);
        return purchaseOrderRepository.save(entity);
    }
}

PurchaseOrderDomainServiceImpl在领域模块里,它通过构造器注入了PurchaseOrderRepository接口。它的import列表里不会出现任何infrastructure包下的类,因为领域模块的pom.xml压根没依赖基础设施模块。编译器从物理层面杜绝了领域层对基础设施层的依赖。

这就是我们项目里「基础设施层反向依赖领域层」的全貌。

这是DIP,不是IoC也不是DI

这种模块间的依赖方向设计,对应的是SOLID原则中的依赖反转原则,英文叫Dependency Inversion Principle,简称DIP。Robert C. Martin在1996年提出了这个原则,表述是:

高层模块不应该依赖低层模块,两者都应该依赖抽象。抽象不应该依赖细节,细节应该依赖抽象。

在DDD的语境下,领域层是高层模块(制定业务规则),基础设施层是低层模块(提供技术能力)。传统架构里高层直接依赖低层,DIP要求把这个方向反过来。

怎么反?在领域层定义一个抽象(PurchaseOrderRepository接口),让基础设施层去依赖这个抽象(PurchaseOrderRepositoryImpl实现这个接口)。两个模块都朝着抽象的方向看,而不是高层看着低层。

这是一个架构设计层面的决策。它发生在你画模块图、写pom.xml的时候,跟Spring容器没有任何关系。就算你的项目不用Spring,这个依赖方向的设计照样成立。

很多人会把这个现象归到IoC或DI头上,但准确地说,它是DIP。IoC和DI在这件事里扮演的是另一个角色。

DIP落地需要IoC和DI

DIP解决了编译期的依赖方向问题。领域模块不依赖基础设施模块,编译通过,模块间的隔离是干净的。

运行时还有一个问题:PurchaseOrderDomainServiceImpl需要一个PurchaseOrderRepository的实例,这个实例从哪来?

接口不能实例化,必须有人创建PurchaseOrderRepositoryImpl的对象,然后把它传给PurchaseOrderDomainServiceImpl。如果让PurchaseOrderDomainServiceImpl自己去new:

Java 复制代码
// 如果这样写,领域层就必须import基础设施层的类,DIP白做了
private final PurchaseOrderRepository repo = new PurchaseOrderRepositoryImpl(...);

领域层的代码里出现了PurchaseOrderRepositoryImpl,编译时就需要依赖基础设施模块,DIP的设计前功尽弃。

这就是IoC和DI出场的地方。

IoC(控制反转)PurchaseOrderDomainServiceImpl不负责创建自己的依赖对象,这件事交给Spring容器。容器在启动时扫描所有带@Repository@Service注解的类,创建实例,管理它们的生命周期。

DI(依赖注入) :Spring容器发现PurchaseOrderDomainServiceImpl的构造器需要一个PurchaseOrderRepository类型的参数,同时容器里有一个PurchaseOrderRepositoryImpl实例实现了这个接口,于是把它注入进去。

整个过程发生在启动模块(erp-server)里。启动模块同时依赖领域模块和基础设施模块,Spring容器在这个模块的上下文中完成装配。领域模块的代码始终不知道PurchaseOrderRepositoryImpl的存在。

DIP在编译期切断了高层对低层的依赖,IoC和DI在运行时把它们重新连接起来。 三者配合,既保证了模块间的隔离,又让程序能正常运行。

DIP、IoC、DI的关系

这三个概念经常被放在一起讨论,但它们不在同一个层面上。

DIP是一条架构设计原则,告诉你模块间的依赖方向应该怎么安排。IoC是一种控制模式,把对象的创建和管理从业务代码移交给框架。DI是IoC最主流的实现方式,框架通过构造器或setter把依赖对象传给使用者。

三者的层次关系:DIP提出了问题(依赖方向不对),IoC给出了策略(让框架来管理),DI提供了具体手段(通过构造器注入)。

维度 DIP(依赖反转原则) IoC(控制反转) DI(依赖注入)
是什么 设计原则 控制模式 实现机制
解决什么问题 模块间的依赖方向不合理 业务代码不应自己创建依赖 怎么把依赖传给使用者
作用时机 架构设计阶段(编译期) 运行时 运行时
没有Spring能不能做 能,这是纯架构设计 能,但需要手写工厂或服务定位器 能,但需要手动通过构造器传参
在DDD项目中的体现 领域层定义接口,基础设施层实现接口,pom依赖方向反转 Spring容器接管所有Bean的创建和生命周期 @RequiredArgsConstructor构造器注入仓储接口

关于IoC和DI的基础概念,我在之前的一篇文章里有更详细的讲解,包括IoC思想的起源(1983年好莱坞原则)、Martin Fowler为什么提出用DI来替代IoC这个模糊的词,感兴趣可以去翻。

小结

大多数人接触这三个概念的顺序是反的:先学DI(被@Autowired注解引进门),然后了解IoC(知道Spring容器在帮你管理对象),最后才有可能接触到DIP。但从理解深度来看,DIP才是这三个概念中真正影响架构质量的那个。IoC和DI是让DIP能够在Spring生态里落地的技术手段。

如果只把DI理解成「让框架帮你new对象」,确实看不出什么价值,不如自己写new来得直接。DI的价值要放在DIP的语境下才能看到:DI不是为了省一个new,而是为了让模块间的依赖方向可以不跟调用方向走。 调用方向是领域层调用基础设施层的能力,但依赖方向可以是基础设施层依赖领域层。这种能力对大型项目的架构稳定性影响很大。

我在团队里落地DDD这几年,发现对这三个概念理解最到位的开发者,往往不是背概念背出来的,而是在拆模块、定义接口、处理模块间依赖的实际过程中,自己体会到的。如果你也在做DDD或者类似的模块化设计,建议打开项目的pom.xml,看看模块间的依赖方向是不是符合DIP。这比读十篇概念解析的文章有效。

希望这篇内容可以帮到你。

最近在知乎出了秒杀专栏,感兴趣的可以订阅一下。至于知识星球的,可以搜:

老码头的技术浮生录

它是一个能实际帮你解决难题的星球。

我的知乎账号:

  • SamDeepThinking

参考的内容

相关推荐
wanhengidc2 小时前
云主机的核心原理与架构
运维·服务器·科技·游戏·智能手机·架构
小Y._2 小时前
JVM垃圾回收算法与调优实战
java·jvm·性能调优·gc
喜欢流萤吖~2 小时前
Nacos 配置中心:微服务的配置管家
java·运维·微服务
逻辑驱动的ken2 小时前
Java高频面试考点场景题10
java·开发语言·深度学习·求职招聘·春招
程序员晨曦2 小时前
理解函数调用Function Call
java·运维·服务器
用户69371750013842 小时前
你每天用的 AI,可能真的被“投毒”了
前端·后端·ai编程
Rust研习社2 小时前
Rust 静态生命周期:从概念到实战避坑
后端·rust·编程语言
indexsunny2 小时前
互联网大厂Java求职面试实战:Spring Boot微服务在电商场景中的应用与挑战
java·spring boot·redis·面试·kafka·oauth2·microservices