DDD官方示例代码拆解:从分层架构到聚合根建模
简介:这份资源是领域驱动设计官方示例代码包,围绕船运系统将 DDD 的核心概念落到了可运行的 Java 工程中。面向正在学习领域建模、聚合设计或限界上下文映射的后端开发者,也适合架构师在团队内开展技术分享时作为对照案例。压缩包共包含426个文件,以142个Java源文件、110个编译后的class文件、56个HTML说明文档、35个依赖jar包及33个XML配置为主,另有少量JSP、SQL、properties等文件,完整覆盖从实体与值对象、仓储实现到Web展示层的典型分层结构,便于按模块研读。资源包大小约23.57MB,已有1899人学习浏览。通过该示例,读者能直观看到航运调度中如何划分聚合边界、发布领域事件、用领域服务处理路线计算,并借助仓储模式保持领域层与持久化解耦;结合测试驱动开发思路,可系统提升将复杂业务规则转化为高内聚代码的能力,对物流、运输类系统设计具有直接借鉴意义。
1. DDD 官方示例代码:理论看过很多遍,但你需要一份能跑的
DDD 这个概念在国内技术社区已经被聊了很多年,但有一个很尴尬的现象:理论文章到处都是,能直接拿来参考的示例代码却很难找。市面上的内容基本都在讲聚合、限界上下文、防腐层这些概念,画图一张比一张漂亮,可当你真正动手,打算搞清楚代码边界怎么划、仓储接口该放在哪一层、事务注解到底加在哪个方法上时,能落到实处的资料就少得可怜了。这份领域驱动设计官方示例代码,就是用来补上这一环的。它以经典的货代运输业务为背景,把领域层、应用层、基础设施层、接口层的完整代码搭了出来,不是只画了张架构图的 PPT。适合正在做 DDD 落地的团队做结构参照,也适合个人开发者拿来对照理解聚合根、值对象、领域事件在真实代码里长什么样。
2. 分层架构:四层依赖方向与包结构设计
2.1 四层架构与依赖规则
我拆这套示例代码时,第一件事不是看业务实现,而是先把包结构拉出来看了一遍。原因很简单:一个 DDD 工程能不能住人,先看依赖方向守不守规矩。示例代码采用的是经典的 DDD 四层结构,分别是interfaces(接口层)、application(应用层)、domain(领域层)、infrastructure(基础设施层)。这个分层和传统三层架构最大的差别在于,领域层放在最核心的位置,不依赖任何其他层。接口层依赖应用层,应用层依赖领域层,基础设施层实现仓储接口后反过来被应用层和领域层通过接口调用。
这套示例代码的包结构大致是这样:
com.cargotracking ├── interfaces │ ├── web │ │ ├── CargoBookingController.java │ │ └── CargoTrackingController.java │ └── rest ├── application │ ├── dto │ ├── assembler │ └── service │ ├── BookingService.java │ └── CargoInspectionService.java ├── domain │ ├── model │ │ ├── cargo │ │ │ ├── Cargo.java │ │ │ ├── RouteSpecification.java │ │ │ ├── Itinerary.java │ │ │ ├── CargoId.java │ │ │ └── Delivery.java │ │ ├── handling │ │ ├── location │ │ └── voyage │ ├── service │ └── shared │ └── DomainEventPublisher.java └── infrastructure ├── persistence │ ├── CargoRepositoryJpa.java │ └── VoyageRepositoryJpa.java ├── messaging └── config代码块的逻辑很好理解:domain包下面只放领域模型和领域服务,不出现任何@Repository、@RestController注解;infrastructure包专门放 JPA 实现、消息队列客户端等适配代码;interfaces包只负责 HTTP 协议解析和参数绑定,不写业务规则。你几乎可以用肉眼检查依赖方向:凡是domain层的类 import 了 spring-data、javax.persistence 或者某个具体消息中间件的包,结构就已经开始腐化了。
2.2 应用层为什么只编排不写规则
看完包结构,下一步要看应用层写在哪个方法里的。示例代码里的BookingService是典型的应用服务,它做的事情是把多个领域对象串起来,为用例流程服务,但它本身不承载任何业务规则。
@Service public class BookingService { private final CargoRepository cargoRepository; private final LocationRepository locationRepository; private final RoutingService routingService; public BookingService(CargoRepository cargoRepository, LocationRepository locationRepository, RoutingService routingService) { this.cargoRepository = cargoRepository; this.locationRepository = locationRepository; this.routingService = routingService; } @Transactional public CargoId bookCargo(UnLocode originUnLocode, UnLocode destinationUnLocode, LocalDate arrivalDeadline) { Location origin = locationRepository.find(originUnLocode); Location destination = locationRepository.find(destinationUnLocode); RouteSpecification routeSpecification = new RouteSpecification(origin, destination, arrivalDeadline); Cargo cargo = new Cargo(new CargoId(), routeSpecification); cargoRepository.store(cargo); return cargo.identity(); } }这个bookCargo方法里只有两件事:查基础数据、创建货代聚合根并保存。判断起运地和目的地是否相同、截止时间是否合法,这些逻辑不在应用服务里,而是放在RouteSpecification或Cargo自己的方法中。这是 DDD 落地时最容易踩的坑:应用层写着写着就变成了传统的 transaction script,把if判断和set操作全部堆在 Service 方法里,最后领域层的实体全变成只有 getter/setter 的贫血模型。判断代码归不归应用层有个简单标准:如果规则不经过数据库也能用纯单元测试验证,它就应该放在领域层;如果必须依赖外部服务的返回结果才能验证,才放应用层编排。
2.3 基础设施层反向依赖的关键设计
基础设施层和领域层的关系,是这套代码里最值得抄的部分。领域层定义了仓储接口,不写实现,这里控制反转的作用就显示出来了。比如领域层只声明:
public interface CargoRepository { Cargo find(CargoId cargoId); void store(Cargo cargo); CargoId nextIdentity(); }然后在infrastructure包里用 JPA 实现它:
@Repository public class CargoRepositoryJpa implements CargoRepository { @PersistenceContext private EntityManager em; @Override public Cargo find(CargoId cargoId) { return em.find(Cargo.class, cargoId); } @Override public void store(Cargo cargo) { em.persist(cargo); } @Override public CargoId nextIdentity() { return new CargoId(UUID.randomUUID().toString().toUpperCase()); } }注意CargoRepository接口里没有findAll()、deleteById()这类通用 CRUD 方法。我见过不少团队做 DDD 时直接把 Spring Data JPA 的JpaRepository直接当聚合根仓储用,接口里十几个默认方法摆着,看着方便,实际上连聚合的封装边界都拆没了。这套示例代码给我的启发是:仓储接口的方法名应该从业务行为出发定义,从聚合的持久化需求出发定义,而不是从表操作出发定义。
3. 领域模型核心:聚合根、值对象与仓储的建模方法
3.1 聚合根与值对象的边界划分
官方示例代码里,Cargo是聚合根,RouteSpecification、Itinerary、Delivery都是值对象。这个划分很典型,因为它准确抓住了聚合根和值对象的本质区别:Cargo有唯一标识CargoId,而Itinerary和Delivery是被Cargo整体持有的描述性数据,不存在独立的身份。我见过有团队把Itinerary也建一张表加一个主键 ID,这其实是对值对象的误读。
值对象在示例里长这样:
public class RouteSpecification { private final Location origin; private final Location destination; private final LocalDate arrivalDeadline; public RouteSpecification(Location origin, Location destination, LocalDate arrivalDeadline) { if (origin.equals(destination)) { throw new IllegalArgumentException("Origin and destination cannot be the same"); } this.origin = origin; this.destination = destination; this.arrivalDeadline = arrivalDeadline; } public boolean isSatisfiedBy(Itinerary itinerary) { return itinerary.initialDepartureLocation().equals(origin) && itinerary.finalArrivalLocation().equals(destination) && arrivalDeadline.isAfter(itinerary.finalArrivalDate()); } // getters omitted for brevity }RouteSpecification没有 setter,所有字段都是 final,构造函数里直接做合法性校验,行为方法isSatisfiedBy把业务规则内聚在自己的类里。我在很多项目里见过把这种类写出大量 getter/setter 的写法,然后业务判断散落在 Service 层,比如if (itinerary.getOrigin().equals(spec.getOrigin())...)这种代码,一旦规则变复杂,整个 Service 层就变成一大坨不可维护的泥巴。把规则放进值对象,至少能保证规则和它的数据待在一起。
3.2 聚合根的封装与不变式
聚合根的建模,是这套示例代码里最值得反复细看的部分。Cargo聚合根暴露给外部的不是一组 getter/setter,而是领域行为方法。
public class Cargo { private CargoId id; private RouteSpecification routeSpecification; private Itinerary itinerary; private Delivery delivery; public Cargo(CargoId id, RouteSpecification routeSpecification) { this.id = id; this.routeSpecification = routeSpecification; this.itinerary = Itinerary.EMPTY_ITINERARY; this.delivery = Delivery.derivedFrom(routeSpecification, this.itinerary); } public void assignToRoute(Itinerary itinerary) { if (!routeSpecification.isSatisfiedBy(itinerary)) { throw new IllegalArgumentException("Itinerary does not satisfy route specification"); } this.itinerary = itinerary; this.delivery = Delivery.derivedFrom(routeSpecification, this.itinerary); } public CargoId identity() { return id; } public boolean isMisdirected() { return delivery.isMisdirected(); } }这里有几个设计点值得注意。第一,assignToRoute是唯一的修改路径,外部无法跳过它直接改itinerary字段,这保证了聚合内不变式始终成立。第二,Delivery对象是派生出来的,它不独立修改,而是随着routeSpecification和itinerary的变化重新计算出来。第三,identity()方法返回的是CargoId而不是String,这就把业务标识的类型安全做出来了。
我在实际项目里见过不少聚合根建模跑偏的案例:有人在聚合根上写满 CRUD 方法,每条属性一个 setter,外部想怎么改就怎么改,聚合根完全变成数据容器。这样到后期做并发控制、做不可变设计的时候,根本无从下手。这套示例代码里聚合根暴露的方法是“行为”而不是“属性”,这一个原则,建议直接抄进自己项目的规范文档里。
3.3 仓储接口为什么只服务聚合根
示例代码里的仓储接口都是针对聚合根设计的,比如CargoRepository管理整个Cargo聚合,不会单独搞出RouteSpecificationRepository或者ItineraryRepository。原因在 DDD 的定义里很明确:仓储是聚合的存储入口,聚合根是唯一能访问仓储的领域对象。如果你发现你建了很多细粒度的仓储,每个实体的每个表都配一个 Repository,那基本可以断定你的聚合边界没有划分清楚。
判断聚合边界是否合理有一个很实用的观察法:当一个业务操作需要同时修改多个聚合时,先看能不能通过领域事件解耦;如果必须同步修改,再回头检查聚合是否划小了。示例代码里HandlingEvent就是通过领域事件发布和Cargo通信的,这一点我们后面详细讲。
4. 应用服务、事务边界与领域事件发布
4.1 应用服务的用例编排风格
CargoInspectionService是这套示例里体现应用服务定位的另一个好例子。它做的事情是当船舶到达某个港口后,根据实际处理事件更新货物的交付状态。这一过程在领域层中有Cargo.deriveDeliveryProgress()这类方法完成核心状态计算,但应用服务负责协调时间点——比如在事务提交前把领域事件发布出去。
@Service public class CargoInspectionService { private final CargoRepository cargoRepository; private final HandlingEventRepository handlingEventRepository; private final DomainEventPublisher eventPublisher; @Transactional public void inspectCargo(CargoId cargoId) { Cargo cargo = cargoRepository.find(cargoId); List<HandlingEvent> handlingEvents = handlingEventRepository.findByCargo(cargoId); cargo.deriveDeliveryProgress(handlingEvents); if (cargo.isMisdirected()) { eventPublisher.publish(new CargoWasMisdirected(cargoId)); } cargoRepository.store(cargo); } }应用服务里出现的if (cargo.isMisdirected())不是业务规则判断,而是事件发布条件的判断。两者的区别在于:isMisdirected这个结论是由领域模型计算出来的,应用服务只是拿着结论决定是否发消息。把这个责任分清楚,领域层就保持纯业务逻辑,应用层也不会变成看不见规则的代码泥潭。
4.2 事务边界:从请求进入到事务提交
事务边界的选择,是很多团队在 DDD 落地时绕不过去的坎。这套示例代码的做法是事务放在应用服务层,而不是放在仓储实现里。原因是事务的粒度应当与用例粒度保持一致,一个应用服务方法对应一个完整的业务用例,事务从方法进入开始到最后一次仓储调用结束,中间包裹着完整的聚合状态变更。
@Transactional public void bookCargo(...) { ... }仓储实现内部的em.persist(cargo)并不自己开启事务,它依赖 Spring 把事务统一管理起来。这个设计有个特别实际的考量:如果事务放在仓储层,一次应用服务调用里涉及两次仓储操作时,两次操作会各开一个事务,中间任何一步失败,前一步已经提交的数据就回不去了。把@Transactional放在应用服务方法上,强调的是整个用例的原子可见性。
4.3 领域事件:集成事件与本地事务的解耦策略
这套示例代码中,DomainEventPublisher的实现充分体现了 DDD 中领域事件和消息中间件之间的微妙关系。领域事件首先是内聚在领域模型内部的概念,它是一个业务事实的陈述,比如CargoWasMisdirected记录发生了什么事、发生在什么时候。至于这个事件要不要发 Kafka、发 MQ,那是基础设施层的决策。
public class CargoWasMisdirected { private final CargoId cargoId; private final LocalDateTime occurredOn; public CargoWasMisdirected(CargoId cargoId) { this.cargoId = cargoId; this.occurredOn = LocalDateTime.now(); } }领域层的代码在事件发生时先eventPublisher.publish(event),这个发布动作是进程内同步调用,由 Spring 的ApplicationEventPublisher实现,保证事件在事务提交前后能被监听器捕获。如果要发到消息队列,那是在监听器里再做一次转发。我见过一些团队直接把领域事件和 MQ 消息绑定,领域代码里大量 import KafkaTemplate,结果领域层完全被消息中间件的技术细节渗透,领域模型失去了独立性。
5. 避坑指南:拆这套代码时最容易踩的五个坑
5.1 在用 JPA 之前,先想清楚聚合根和表的映射关系
现象:按照这套示例代码结构开始重写项目时,第一版代码发现自己写出来的仓储和以前 Spring Data JPA 的CrudRepository几乎没有区别,大量方法直接暴露给 Service 层调用,聚合根成了一个摆设。
原因:把 JPA 的实体直接当成了领域对象。JPA 的@Entity本质是持久化模型,它要求无参构造函数、可变的字段,和值对象不可变的设计天然冲突。如果把RouteSpecification直接加@Entity注解,它就会被迫引入无参构造和主键字段,此时值对象的“无身份”特征就被破坏了。
解决:借鉴这套示例代码的做法,将领域对象和持久化对象分开。领域对象保持纯 Java 类的样子,JPA 实体放在infrastructure.persistence包里,通过CargoRepositoryJpa完成两者之间的转换。虽然多写一层转换代码,但换来的是领域层完全不感知数据库。
5.2 聚合根内部一致性被你拆成了多个仓储
现象:货物聚合里有Cargo、Itinerary、Delivery,团队为了方便,把每个类都建一个 JPA Repository,然后在应用服务里分别读取并手动把数据拼接起来。
原因:没有意识到聚合的一致性边界在哪里。Delivery是派生的状态,Itinerary是Cargo内部值对象,它们必须作为一个整体被加载和持久化,否则可能出现Cargo已更新但Itinerary还是旧数据的中间状态。
解决:严格按聚合根来定义仓储。这套代码里只有一个CargoRepository,它负责把整个聚合的状态读出来或写进去。当Cargo内部有值对象需要持久化时,通过 JPA 的@Embedded或者专门的 ORM 映射配置解决,不在仓储层分别操作。
5.3 应用服务方法越写越长,领域规则上移
现象:代码评审时发现bookCargo方法已经从最初的十几行变成了一百多行,里面塞满了各种校验逻辑、状态分支和组装代码,领域层的Cargo变成了只有 getter/setter 的贫血模型。
原因:一开始建模的时候没有把业务规则识别完整,很多规则是在后续需求迭代中逐渐暴露的。每来一个新规则,开发人员本能地往改起来最快的地方塞,改应用服务显然比重新设计聚合根容易。
解决:强制坚持一个原则——应用服务里出现两个以上if判断时,就要停下来看这些判断是否属于领域规则。如果是,则提取到领域对象的方法里。这套示例代码里大量方法,比如routeSpecification.isSatisfiedBy(itinerary)、cargo.isMisdirected(),就是在不断重构中把判断逻辑沉淀进领域层的结果。
5.4 领域事件的订阅方在事务提交前就执行了
现象:写了个领域事件监听器,期待事件发布后马上触发后续操作,但调试时发现监听器的代码执行时,主事务还没提交,导致监听器去数据库查数据查不到。
原因:DomainEventPublisher如果在事务提交前同步发布事件,监听器会在同一线程里立即执行,此时数据尚处于事务未提交状态。这也是官方示例代码中事件发布策略容易被人忽略的部分。
解决:将领域事件的发布时机控制在实际操作完成之后、事务提交之前,或者采用@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)让监听器在事务提交后才执行。这一条建议直接写进项目检查清单,每次写事件监听器都过一遍。
5.5 把多个聚合放在一个事务里更新
现象:业务上要求同一时间点修改Cargo和Voyage两个聚合,团队在应用服务里直接同时cargoRepository.store(cargo)和voyageRepository.store(voyage),用一个大事务保证两边的原子性。
原因:觉得两个聚合都有变更,用一个事务包起来是最省力的方案。短期看没问题,但长期来看,多个聚合在一个事务里变更意味着聚合之间的耦合被放大了,后续做性能优化时很难拆分。
解决:官方示例代码的边界非常清晰——一次用例只修改一个聚合,不同聚合之间的协作通过领域事件异步解耦。如果非要同步更新多个聚合,先检查事件机制能不能覆盖掉这个需求。通常把“事务性发件箱”模式搭起来之后,大部分多聚合更新的需求都能用异步事件解决。
6. 验证与进阶:用 ArchUnit 把架构约束固化成自动化检查
每次我拆完一套工程,都会顺手把它的核心架构约束抄到自己的项目里,其中的验证手段通常比业务代码本身更早落地。对这套 DDD 示例代码,最有直接验证价值的就是依赖方向校验。写一个 ArchUnit 测试类,让包结构的依赖规则成为持续集成流水线的一道关卡:
@RunWith(ArchUnitRunner.class) public class ArchitectureTest { @Test public void domain_layer_should_not_depend_on_any_other_layer() { JavaClasses classes = new ClassFileImporter().importPackages("com.cargotracking"); ArchRule rule = layeredArchitecture() .layer("Interfaces").definedBy("com.cargotracking.interfaces..") .layer("Application").definedBy("com.cargotracking.application..") .layer("Domain").definedBy("com.cargotracking.domain..") .layer("Infrastructure").definedBy("com.cargotracking.infrastructure..") .whereLayer("Interfaces").mayNotBeAccessedByAnyLayer() .whereLayer("Application").mayOnlyBeAccessedByLayers("Interfaces") .whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Infrastructure") .whereLayer("Infrastructure").mayNotAccessAnyLayer(); rule.check(classes); } }这段测试跑通后,任何一次代码提交里只要有一条 domain 层 import 了 springframework 的依赖,CI 直接亮红灯。从那以后我每次在一个新团队推行 DDD,都会强制先把这个约束测试搭好,再让团队动手写业务,架构腐化的速度立刻降下来。实践下来,依赖方向约束是最廉价、最早见效的 DDD 落地抓手,等业务代码稳定后,再逐步加入领域模型行为的单元测试覆盖,比如构造不满足最晚交货时间RouteSpecification时是否抛异常,以及Cargo分配非法Itinerary时是否被拒绝。这套验证路径走顺之后,聚合根的内部正确性就有了自动保障。这段拆解里还有不少细节,比如事件发布器的封装方式、值对象的 equals/hashCode 规范,都值得在自己项目里逐条对一遍代码,耐心把每一步跑通了,DDD 在你这里就不再是玄学。希望帮到你。
本文还有配套的精品资源,点击获取