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); ListHandlingEvent 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 在你这里就不再是玄学。希望帮到你。本文还有配套的精品资源点击获取