简介:领域驱动设计(DDD)官方示例代码,以船运系统为载体,完整演示了领域模型、聚合、实体与值对象、领域事件、领域服务以及限界上下文等核心概念的实际落地,适合正在学习DDD或准备在项目中引入该方法的开发者参考。整个压缩包共426个文件,约23.57MB,以Java源文件为主,配合编译产物、网页文档、依赖库及配置文件等,同时包含动态页面、属性配置和少量数据库脚本,能够支撑从代码阅读到环境搭建的完整学习过程。资源已有1901人浏览学习。通过分析这套示例,读者可以理解如何划分边界上下文、设计聚合根、通过仓储模式解耦持久化,并借助测试驱动开发验证领域逻辑,从而将DDD原则更准确地应用到自身业务项目中,有效提升领域建模与工程落地能力。
1. 领域驱动设计官方示例代码:一个货物追踪系统打开的正确方式
某团队准备用领域驱动设计(DDD)重构老系统,结果第一次设计评审就卡住了——聚合根怎么画?业务规则写在 Service 里好几年了,突然要拆出领域层,代码反而不知道该往哪放。这种问题靠翻书解决不了,最直接的办法是找一套能跑通的 DDD 完整示例代码,一行行看它怎么组织实体、值对象、仓储、领域事件。这套货物追踪系统(Cargo Tracking)就是这么一份工程:把真实物流场景完整建模,货物从生成订单到装卸、转运、提货,全程都有对应的领域模型支撑。它适合学过 DDD 概念但没见过落地代码的开发者,也适合准备在团队里推行 DDD、需要一套可靠参考架子的人。
2. 系统结构与边界:从业务逻辑到模块划分的 DDD 分层
2.1 业务场景:货物从哪里来,到哪里去
这个示例的核心业务是运输货物。站在业务视角去描述,系统里最关键的对象是货物(Cargo),它有唯一的跟踪号(TrackingId)、有起点和终点、有期望到达时间。货物不是一个简单的一维对象,它由两部分组成:路线规格(RouteSpecification)描述业务意图——从哪里发货、送到哪里、什么时候到;行程(Itinerary)描述实际怎么走,拆成一段一段的运段(Leg)。港口负责装卸行为,货物到达一个港口就会产生一条装卸事件(HandlingEvent),系统根据这些事件推导出货物当前的位置和状态。
这些概念不是拍脑袋想出来的,是 DDD 战略设计的典型产物。整个系统被凝练成一个核心域:货物跟踪。周边围绕的是港口管理和船舶调度这类支撑域。核心域与支撑域之间有明确的边界,也就是限界上下文(Bounded Context)。示例代码里每个上下文对应一套独立的领域模型,不直接引用对方的实体,跨上下文传送数据时通过防腐层做转换。见过不少团队做 DDD 翻车,原因就是上下文之间没切干净——订单上下文里直接引用了用户上下文里的 User 实体,两个上下文最后变成一张大网。
2.2 模块划分:四层架构与依赖方向
工程按经典四层架构组织模块,依赖方向从接口层指向应用层再指向领域层,基础设施层倒挂在最下面:领域层定义接口,基础设施层实现接口。代码用 Maven 多模块拆分,这样做最大的好处是编译期就能强制约束团队遵循架构边界——你不可能在领域层里 import 一个 Spring 的注解还让编译通过,除非手动加依赖。
cargo-tracker/ ├── cargo-tracker-interface/ # 接口层: REST 控制器、DTO、装配器 ├── cargo-tracker-application/ # 应用层: 应用服务、事务边界、事件发布 ├── cargo-tracker-domain/ # 领域层: 实体、值对象、领域服务、仓储接口 ├── cargo-tracker-infrastructure/ # 基础设施层: JPA 实现、消息推送、对象转换 └── cargo-tracker-common/ # 通用工具、统一异常结构参数说明:应用层依赖领域层的接口,不依赖具体实现;基础设施层也依赖领域层接口,通过 Spring 依赖注入把实现注册进去。接口层只做参数校验和 DTO 转换,不写任何业务规则。刚拿到这套代码的人第一反应往往是“类怎么这么多,一个表就能搞定的事拆出三四个类”,但这些多出来的类,恰好就是业务规则安放的位置。Controller 保持瘦、Service 不写业务规则、领域对象承载规则、仓储接口屏蔽存储细节——每一层职责单一,后续加需求的时候才知道什么叫轻松。
2.3 核心领域模型设计:聚合根与值对象
这个项目最关键的设计决策在于聚合的划分。系统只存在两个聚合:货物聚合(Cargo Aggregate)和装卸事件聚合(Handling Event Aggregate)。
Cargo 作为聚合根,内部包含路线规格(RouteSpecification)和行程(Itinerary),对外唯一标识是 TrackingId。**为什么行程要放在 Cargo 聚合内部,而不是单独做一张表?**因为行程的生命周期完全依赖货物——货物被创建时行程为空,随着调度确认,行程被装进来,没有货物就没有独立存在的行程。把它放在外面就是游离实体,业务规则会散落到各个 Service 里。HandlingEvent 作为另一个聚合根,记录货物在某时某地被装载、卸载、海关查验等动作,它只引用 Cargo 的 TrackingId,不持有 Cargo 实体的引用。聚合之间只能通过唯一标识引用对方,不能在领域层直接把对方的实体 load 出来修改。
这就是 DDD 落地最容易翻车的地方。一不留神就把聚合做成上帝对象,一个聚合包含十张表,改任何字段都要锁一整片数据。这个示例最值得抄的就是聚合边界的取舍:装卸事件变化频繁、生命周期独立,所以拆成独立聚合;行程跟货物同生共死,所以放一个聚合里。
2.4 领域事件与消息推送机制
代码里还实现了一套轻量的领域事件机制。货物在装卸过程中会产生业务事件,比如“货物已装船”“货物已抵达某港”,领域层只负责发布意图,应用层订阅后触发后续流程——更新位置、通知货主、判断是否到达目的地。这套机制放在第 3 章的具体链路里讲,因为它是整个项目可读性最高的一段代码,最能体现 DDD 分层协作的实际形态。
3. 核心模块代码拆解:一次货物装卸事件从进入到入库的完整链路
3.1 入口:接口层的 Controller
用户在港口上报一条装卸事件:船名、港口、时间、动作类型(装船/卸船/提取)。请求到达接口层时,先做基础的格式校验,然后转给应用服务。
@RestController @RequestMapping("/handling") public class HandlingEventController { @PostMapping("/register") public void register(@RequestBody HandlingEventRegisterCommand command) { // 参数校验: 时间不能早于当前时间、动作类型必须合法 handlingEventService.register( command.getTrackingId(), command.getPort(), command.getVoyageNumber(), command.getCompletionTime(), command.getType() ); } }逻辑说明:接口层没有写任何业务规则,只做了三件事——接收 JSON、做基础校验、调用应用服务。参数含义:TrackingId 是货物跟踪号,Port 是港口代码,VoyageNumber 是航次编号,CompletionTime 是实际装卸时间,Type 是动作类型枚举。接口层不出现业务对象、不出现仓储调用,这是四层架构的第一条纪律。
实际开发里很多团队在这一步就违规:直接在 Controller 里 Autowire 一个 Repository,把查询逻辑写在 Controller 里。短期看着方便,等业务迭代到第二个月,Controller 就会膨胀成几百行没人敢动的代码。这个示例里 Controller 从头到尾只有几行,所有逻辑都下沉了。
3.2 应用层:事务边界与领域事件触发
应用服务是这条链路的第二站,在这里处理事务边界。
@Service public class HandlingEventService { @Transactional public void register(String trackingId, String port, String voyageNumber, Date completionTime, HandlingEventType type) { // 1. 加载货物聚合根, 检查货物是否存在 Cargo cargo = cargoRepository.find(trackingId); // 2. 通过领域工厂创建装卸事件 HandlingEvent event = handlingEventFactory.createHandlingEvent( trackingId, port, voyageNumber, completionTime, type); // 3. 事件入库 handlingEventRepository.store(event); // 4. 发布领域事件: 通知更新货物位置、计算是否到达 EventPublisher.publish(new CargoHandledEvent(event)); } }逻辑说明:这里先加载聚合根 Cargo,再创建事件。有人会问:HandlingEvent 是独立聚合,为什么还要先 load Cargo?因为需要校验 TrackingId 是否真实存在,以及货物当前状态允不允许这个动作——比如已经 Delivered 的货物不能再装船。校验是业务规则,编排在应用服务里,具体规则落在领域层。
参数说明:方法被 @Transactional 标注,整个方法要么全部成功要么全部回滚。这里有个容易忽略的设计点:领域事件发布发生在事务内,事务回滚,事件也会被撤销,不会出现“事件发布了但业务没执行”的不一致。这个点在后面扩展 MQ 时会成为关键。
3.3 领域层:聚合根与业务规则
Cargo 聚合根的核心代码值得逐行看,它展示了实体和值对象的边界划分。
@Entity @AggregateRoot public class Cargo { @EmbeddedId private TrackingId trackingId; // 值对象作为标识 @Embedded private RouteSpecification routeSpecification; // 路线规格: 起点/终点/截止时间 @Embedded private Itinerary itinerary; // 行程: 由多段运段组成 public void assignToRoute(Itinerary itinerary) { // 业务规则: 行程必须满足路线规格的要求 if (!itinerary.satisfies(routeSpecification)) { throw new IllegalStateException("运段不符合路线规格要求"); } this.itinerary = itinerary; } public Delivery deriveDeliveryProgress(HandlingHistory handlingHistory) { return new Delivery(this, handlingHistory); } // 构造函数私有化, 通过工厂方法创建 public static Cargo create(TrackingId trackingId, RouteSpecification routeSpecification) { if (trackingId == null || routeSpecification == null) { throw new IllegalArgumentException("货物标识和路线规格不能为空"); } return new Cargo(trackingId, routeSpecification); } }这段代码里有几个重要的设计选择。第一,TrackingId 是值对象而不是 String,把“货物编号不是随便填的”这个规则封装在值对象里。第二,assignToRoute 里面校验行程是否满足路线规格——行程的起点必须等于规格的起点,预期到达时间晚于要求时间就不满足。业务规则写在实体方法里,而不是放在 Service 里写 if。第三,deriveDeliveryProgress 是一个只读推算,它根据历史装卸事件算出一个 Delivery 值对象,里面包含当前状态和预计到达时间——这个结果不落库,每次根据事件重新计算,顶尖的物流系统查快递轨迹不查状态表,靠事件流实时推导,这也是 DDD 事件溯源思想的雏形。
参数说明:RouteSpecification 是不可变值对象,字段有 origin(起点)、destination(终点)、arrivalDeadline(期望到达截止时间)。值对象无标识、不可变、按值判断相等——两个路线规格只要起点终点截止时间一样,就是同一个规格。
3.4 基础设施层:仓储实现与表结构
仓储接口在领域层定义,实现放在基础设施层。这个示例的仓储做一个很多项目都忽略的关键选择:手动做 DO 和领域对象的双向转换。
@Repository public class CargoRepositoryImpl implements CargoRepository { private final CargoJpaMapper cargoJpaMapper; @Override public Cargo find(TrackingId trackingId) { CargoDO dataObject = cargoJpaMapper.findByTrackingId(trackingId.getStringValue()); if (dataObject == null) { return null; } // 把数据库对象转换为领域对象, 不把 JPA 实体暴露给上层 return CargoDOConverter.toCargo(dataObject); } @Override public void store(Cargo cargo) { CargoDO dataObject = CargoDOConverter.toDataObject(cargo); cargoJpaMapper.save(dataObject); } }这个实现选择很值得抄:仓储用 JPA,但手动做转换。为什么不直接把 @Entity 注解在 Cargo 上?因为领域对象不需要知道数据库字段名,也不需要管懒加载代理。数据库表结构可以独立演化,领域模型不会因为加了一个索引字段就被迫修改。如果让 JPA 注解污染领域对象,聚合根里就会到处是代理对象、懒加载异常,领域逻辑的纯正性就没了。
表结构方面,建表 SQL 大致有五张表:cargo 表存货物主信息、route_specification 表存路线规格、leg 表存运段、handling_event 表存装卸事件、audit 相关表存操作记录。handling_event 表结构相对简单:tracking_id、port_code、voyage_number、completion_time、event_type,每次装卸插入一行,不做更新,保证事件历史的不可变性。
4. 项目搭建与快速运行:从 IDEA 导入到接口验证
4.1 环境准备
这是标准的 Spring Boot 多模块 Maven 工程,前置环境要求不高,一张表说清楚:
| 依赖 | 建议版本 | 用途 |
|---|---|---|
| JDK | 1.8 或 11 | 编译运行 Java 代码 |
| Maven | 3.6 以上 | 多模块构建 |
| Spring Boot | 2.x | 基础框架 |
| 数据库 | H2 内存库或 MySQL 5.7/8.0 | 持久化 |
| Lombok | IDEA 安装插件 | 减少样板代码 |
工程内置了 H2 内存数据库配置,clone 下来不需要装数据库,直接运行 Application 类就能看到效果。如果要把数据落到 MySQL,改一下 application.yml 里的数据源配置即可。
4.2 导入与配置修改
第一步,在 IDEA 里选择 File -> New -> Project from Existing Sources,选中根目录的 pom.xml,以 Maven 模式导入。第二步,等待依赖拉取完成,注意观察 Maven 仓库里是否有 Spring Boot 相关依赖,如果一直下载失败,检查本地 maven settings.xml 里的镜像配置,换成国内镜像源就能解决。
第三步,修改配置文件。项目在 src/main/resources 下默认有一个 application.yml,里面配置了数据源、JPA、日志级别。用 H2 内存库时基本零配置,但需要确认 spring.jpa.hibernate.ddl-auto 设为 update,这样首次启动会自动建表。如果切 MySQL,把 url、username、password 改成自己的,driver-class-name 换成 com.mysql.cj.jdbc.Driver 即可。
第四步,运行主类。看到控制台输出 Started Application 说明启动成功。启动过程会加载第 2 章说的几张家表,并自动写入测试数据。
4.3 数据初始化与直接开跑
项目内置了一个数据初始化器(SampleDataLoader),在 Application 启动时自动写入测试数据:三个货物、两艘船、若干港口。启动之后直接调接口就能拿到真实的领域数据。
# 查询货物当前状态和路线信息 curl http://localhost:8080/cargo/ABC123 # 注册一条装卸事件 curl -X POST http://localhost:8080/handling/register \ -H "Content-Type: application/json" \ -d '{"trackingId":"ABC123","port":"PT01","voyageNumber":"V001","completionTime":"2024-01-15T10:00:00","type":"LOAD"}'命令说明:第一个接口返回货物的基本信息、行程列表和经推导的交付状态。第二个命令模拟货物在某个港口完成装船,应用日志里会打印领域事件发布记录,货物状态随之更新。参数里的 type 是枚举字符串,取值有 LOAD(装船)、UNLOAD(卸船)、CLAIM(提货)等。8080 是 Spring Boot 默认端口,要改的话去 yml 里的 server.port 配置。
业务规则验证建议:故意把 type 传成 CLAIM,对一个还没完成运输的货物做提货操作,系统会抛出业务异常并返回 400,说明业务规则确实在领域层生效,而不是数据库层随便让你写进去。这是验证 DDD 分层是否起作用最直观的一条测试。
4.4 常见启动问题速查
启动报错 No qualifying bean of type CargoRepository:说明基础设施层的仓储实现没有被 Spring 扫描到。检查 Application 主类的位置,必须放在项目根包下,或者显式加 @ComponentScan 扫描 infrastructure 包。
启动后访问接口返回 404:检查 Controller 的 RequestMapping 路径和实际访问路径是否一致,重点看有没有漏掉 context-path 前缀配置。
H2 控制台打不开:默认路径是 /h2-console,需要在 application.yml 里启用 spring.h2.console.enabled=true,然后用控制台打印的 JDBC URL 连接,注意参数不要写错。
5. DDD 落地避坑:聚合设计、事务边界与事件丢失
这一章写给准备在自己项目里照搬 DDD 的开发者。以下这几条都是实际开发里反反复复出现的坑,每条都按“现象、原因、解决”三段说清楚。
5.1 现象:修改一个聚合却牵连了整片数据
有同学把 Cargo 聚合设计得过大,把 HandlingEvent、Port 全塞进 Cargo 里面,业务上“新增一条装卸事件”变成“更新 Cargo 一整行”。并发卸货时互相锁等,性能骤降。
原因:聚合边界没画清楚,把生命周期不同的对象强行放在一起。一次用户操作应该对应一个事务,事务操作的聚合根只有一个。HandlingEvent 变化频率远高于 Cargo,它有独立生命周期,就应该拆成独立聚合,通过 TrackingId 引用。
解决:按事务边界画聚合。一个业务用例里只修改一个聚合根,事务边界内只锁需要修改的数据,不要贪多。
5.2 现象:仓储接口返回实体还是 DO,团队里吵了一个星期
代码里的仓储接口返回领域实体,数据库映射在基础设施层内部消化。如果让仓储直接返回 JPA 实体,接口层就能拿到懒加载代理,点一下属性触发 N+1 查询,还会因为 Session 关闭报 LazyInitializationException。
原因:JPA 持久化语义泄露到了上层,领域模型被 ORM 绑架。
解决:领域层定义仓储接口,实现类内部做 DO 与领域对象的转换,接口层永远感知不到 JPA。这个示例已经写好了标准示范,连转换器的命名都是现成的,直接照抄就行。
5.3 现象:领域事件在事务提交后丢失
示例里的事件发布是事务内同步调用内存发布。如果把它换成 MQ 异步,常见的问题就是事务还没提交就发消息,消费者立刻回查数据库发现数据不存在,造成脏读;反过来,事务回滚了消息已经发出,造成凭空多出来的业务动作。
原因:消息发送时机没有跟事务边界绑定。
解决:用事务同步管理器把消息发送挂到 afterCommit 阶段,或者先在事件表里落一条待发送记录,由后台任务扫表重发。事务内发消息本身不是错,错的是没考虑消息消费方看到数据的时间差。
5.4 现象:把值对象做成了可变对象
有人把 Itinerary 里的运段列表设计成可动态 add/remove,业务规则被写成“只有某种状态下才能修改行程”,结果代码里出现大量 if 判断散落在各个 Service。
原因:值对象的不可变原则被破坏。值对象应该是声明式的、替换式的,修改行程应该是用新 Itinerary 整体替换旧对象,而不是原地改字段。
解决:值对象只允许替换,不允许原地修改。需要修改行程时,构造一个新的 Itinerary 赋值给聚合根,变更前后的校验都写在聚合根方法里。不可变对象不需要 mock 状态,可测试性也高人一头。
5.5 现象:跨上下文直接调用别的模块的 Mapper
多模块工程里,有人图省事在 Application 层直接 import 其他上下文模块的 Mapper,依赖方向反转,上下文边界瞬间形同虚设。
原因:架构边界靠自觉守不住。
解决:用 ArchUnit 写架构约束测试,或者利用 Maven 模块间的依赖声明做硬限制,CI 里跑依赖检查,一旦出现非法的上层依赖直接拉红。血泪经验:架构边界必须靠编译期约束和自动化检查,靠人自觉维护的边界等于没有边界。
6. 二次开发:如何在这个项目上新增一个“人工改签”功能
最后直接给一个能上手的改造思路。假设产品提了个需求:客服能人工修改货物的目的地,系统要记录是谁改的、什么时候改的。顺着 DDD 的分层把需求放进去,一晚上就能完成。
第一步,在领域层给 Cargo 增加一个方法 changeDestination。这一步不需要改动表结构,因为目的地存在 RouteSpecification 里,修改它相当于把整个 RouteSpecification 值对象替换掉。业务规则写在实体方法里——比如已到港的货物不允许改签。
public void changeDestination(String destination, String operatorId) { if (this.arrivalDate != null) { throw new IllegalStateException("货物已到港, 不允许修改目的地"); } this.routeSpecification = this.routeSpecification.withDestination(destination); }第二步,应用层新增应用服务 ChangeDestinationService:加载 Cargo,调用 changeDestination,再 store。方法加 @Transactional,整次替换是原子操作。第三步,发布一个领域事件 DestinationChangedEvent,基础设施层写一个监听器,把变更记录写入 audit_log 表。审计需求天然被事件记录下来:谁在什么时间把什么货物改到哪里,全在事件流里。
第四步,接口层加一个 REST 端点:
@PostMapping("/cargo/{trackingId}/destination") public void changeDestination(@PathVariable String trackingId, @RequestBody ChangeDestinationCommand command) { changeDestinationService.change(trackingId, command.getNewDestination(), command.getOperatorId()); }最后写一条测试用例:先创建货物,再改目的地,断言 Cargo 的路线规格已经更新,且变更事件里包含操作人字段。整个改动过程不碰别人的聚合、不碰 Controller 里已有的代码,新增功能就像搭积木。从那以后,我在团队里推广任何 DDD 改造,都会先让人用这套示例跑通一个最小特征的改动,再做老系统手术——架构这东西,光靠看永远学不会,上手改一次才是真懂。希望这套代码能帮你找到自己的落地节奏。
本文还有配套的精品资源,点击获取