☰
DDD官方示例深度解析:用货运系统讲透领域驱动设计
2026/10/14 16:35:55 网站建设 项目流程

简介:一份完整的领域驱动设计(DDD)官方示例代码包,面向正在学习DDD的开发者与架构师。资源基于船运系统真实业务场景,完整呈现领域模型、聚合、实体与值对象、领域事件、领域服务、边界上下文及战略建模等核心概念,并配套仓储持久化、基础设施解耦、测试驱动开发等落地实现;尤其展示了如何用限界上下文映射划分不同业务模块,帮助读者将方法论从理论映射到干净可维护的代码。压缩包共426个文件,约23.57MB,以Java源码(142个)、编译后类文件(110个)、前端HTML(56个)为主,辅以Spring相关JAR、XML配置、数据库脚本及工程配置文件,目录结构清晰便于对照学习。目前已有1899人学习下载。通过学习该示例,可快速掌握DDD在复杂业务中的建模顺序与聚合边界设计思路,也可直接参考其包结构、领域服务写法、仓储实现与测试用例,用于自身项目重构或新系统架构设计。

1. DDD 官方示例代码:一个货运系统凭什么讲清整套领域驱动设计

「DDD 官方示例代码」单看名字,很容易被误解成一份 PPT 里附带的 UML 图集,实际上它是一个可以下载、构建、启动的完整 Java 工程。它模拟了一家航运公司的货物跟踪业务:客户订舱、货物装船、到达卸货、清关、最终签收,整个过程由后台事件驱动着运转。它的价值在于,把限界上下文、聚合、仓储、领域事件这些只可意会的概念,变成了你能断点调试的一行行代码。适合三类人:啃概念书啃不下去的初学者、准备架构评审或 DDD 方向岗位面试的开发者、想给老系统找改造模板但身边没人带的技术负责人。下文我把它拆成「结构—启动—设计—避坑」四段来讲,看完你至少知道这份代码怎么用、哪些可以直接搬进自己的项目。

2. 官方示例的项目结构:限界上下文在代码上的第一道边界

很多人打开 Spring 项目时已经习惯了 controller/service/dao 三层,所有业务逻辑堆在 service 里,两个业务之间靠包名彬彬有礼地隔离。示例工程不是这么切的。它先在根目录上按业务模块切分,每个业务模块内部再分自己的应用层、领域层和基础设施层。限界上下文在代码上的第一道边界,就是这一层目录结构。

2.1 按业务模块切工程:限界上下文第一次落到目录

示例工程的根目录大致长这样,具体模块名在不同维护版本里会有调整,但主线非常一致:

sample-ddd/ ├── booking/ # 订舱限界上下文 │ ├── application/ # 应用服务:用例编排、事务边界 │ ├── domain/ # 聚合、值对象、仓储接口、领域事件 │ └── infrastructure/ # JPA 实现、消息发送、外部接口适配 ├── tracking/ # 货物跟踪限界上下文 │ ├── application/ │ ├── domain/ │ └── infrastructure/ ├── routing/ # 航线规划限界上下文 └── shared/ # 跨限界上下文共享的通用基础设施

先按业务拆,再按技术分层,这个顺序是示例教的第一课。订舱只管订舱,跟踪只管装卸与签收,航线规划只管路径计算。shared里放的是时间工具、通用事件框架、结果类型这类与具体业务无关的东西,业务对象(比如货物)不允许放进去。初读这份代码时最容易犯的错,是盯着某个类看半天,忘了先确认它属于哪个限界上下文——模块归属决定业务语义,包名只负责组织代码。

用模块隔离比用包名隔离硬得多。包名靠团队自觉,模块却能在编译期让跨模块引用直接失败。示例工程靠 Maven 依赖控制做到了这一点:booking模块不声明对tracking模块的依赖,tracking模块里的类在booking里就无法被 import。边界不是注释写出来的,是构建工具强制出来的,这一点是它跟普通教程工程的本质差别。

2.2 领域层接口、基础设施层实现:依赖方向是反着画的

再往单个模块内部看,最值得抄的是它的依赖方向。以一个业务模块为例,包结构示意如下:

com.example.booking ├── domain │ ├── model/Cargo.java # 聚合根 │ ├── model/Itinerary.java # 值对象 │ ├── model/Delivery.java # 值对象,派生自装卸事件 │ ├── repository/CargoRepository.java # 仓储接口,只放接口 │ └── event/CargoBooked.java # 领域事件 ├── application │ └── BookingService.java # 应用服务,编排用例 └── infrastructure ├── persistence/CargoRepositoryJpa.java # 仓储的 JPA 实现 └── messaging/BookingNotifier.java # 消息通知适配

domain层只放接口与模型,不依赖 Spring,不依赖数据库驱动;infrastructure层负责把接口实现掉,并反向依赖领域层。application层编排领域对象完成一个业务用例,并承担事务边界。读代码时有个偷懒技巧:先跳过 infrastructure 包,把 domain 层当成一个独立 jar 读,你会发现它完全不关心数据存哪里、事务谁开。

有个细节会被较真的人反复讨论:领域模型上是否允许出现 JPA 注解。示例的做法很务实——模型上带注解,但仓储仍然保持接口与实现分离,不让领域层直接出现EntityManager。这是单体应用里最现实的折中:既享受注解带来的持久化便利,又把仓储的「契约」和「实现」切干净。你抄的时候不必为了纯洁度把注解全删掉,那是重构到极限的玩法,不是大多数项目该直接上的强度。

2.3 把“禁止依赖”写进构建配置:边界不是注释出来的

模块拆好了,依赖规则还得有人执行。示例工程靠 pom 的依赖控制就能约束大半:每个业务模块只声明自己需要的依赖,booking不引用tracking,自然编不过。但真实项目里团队一旦变大,就会出现「先 import 再说」的情况,所以我一般会在自己的工程里加一层 ArchUnit 规则把边界固化下来:

@ArchTest static final ArchRule booking_must_not_depend_on_tracking = classes().that().resideInAPackage("..booking..") .should().onlyDependOnClassesThat() .resideInAnyPackage("..booking..", "..shared..", "java..", "javax..");

规则的含义很直白:booking包下的任何类,只能依赖booking、shared以及 Java 标准库。谁违反,CI 直接红。示例工程本身未必配了这类检查,但它是把「限界上下文」从概念变成可验证规则的关键一步。

模块间出现循环依赖时,不要急着用中间层去解,先问边界是不是切错了。比如「订舱」和「跟踪」如果互相要调用对方的领域对象,很可能这两个业务实际上属于同一个限界上下文,强行拆成两个模块只会得到两个互相扯皮的模块。边界是人类划分的认知边界,工具只负责让这个认知不被悄悄破坏。

3. 在本地跑通官方示例:环境、命令与三个必改配置

看代码不如跑代码。示例工程启动后有一套完整的事件流在后台跑,货物状态会在界面里自动流转。这一章直接讲怎么把它拉起来,以及起来之后看什么。

3.1 构建与启动的最小路径:JDK、Maven 和两条命令

先说环境。示例工程的老版本基于 JDK 8 构建最稳,后期维护版本可以跑到 11,我建议你先用 JDK 8 或 11 试,不要一上来就上最新版——后面避坑章会解释为什么。Maven 3.6 以上基本够用,依赖下载慢的先配好镜像,这能让后面省掉大量等待时间。

构建命令只有一条:

mvn install -DskipTests

-DskipTests跳过测试编译与执行,第一次跑能省不少时间。示例工程的测试写得很用心,但第一遍跑通为主,等结构读熟了再回头跑测试。构建成功后,启动方式取决于你下载的版本:在 IDE 里直接搜索所有模块里带public static void main的类,找到入口运行;想用命令行,就用 Maven 的 exec 插件,模块名和主类名替换成你工程里实际的:

mvn exec:java -pl <模块名> -Dexec.mainClass=<主类全限定名>

启动后不需要装任何数据库。示例默认使用纯内存数据库,启动时自动建表、自动灌模拟数据,日志里能看到连接串和初始化信息。这一步是新手体验最好的地方:没有任何外部依赖,拉下来就能跑,跑起来就能点。

3.2 跑起来之后看什么:事件流和生命周期的现场演示

启动日志大致长这样,不同版本文案略有差异:

[main] INFO - 内存数据库启动完成:jdbc:hsqldb:mem:cargo [main] INFO - 初始化演示数据:5 条航线、12 票货物、3 个航次 [scheduling-pool-1] INFO - 事件已派发:CargoBooked(DDD-1001) [scheduling-pool-1] INFO - 事件已派发:CargoRouted(DDD-1001) [scheduling-pool-1] INFO - 事件已派发:CargoHandled(DDD-1001, LOAD)

这三行日志其实就是整个示例的教学主线:订舱产生CargoBooked,分配路线产生CargoRouted,装卸动作产生CargoHandled。后台调度线程每隔几秒模拟一件业务发生,把事件挨个发出来,界面上的货物状态随之变化。

界面通常提供两个视角:一个是订舱操作台,用来创建新货物并分配路线;另一个是货物跟踪页,能看到一票货物从Not Routed到Routed,再到In Port、Claimed的完整生命周期。建议你操作一遍:自己创建一票货物,给它分配一条航线,然后盯着跟踪页看它被后台事件推着走。这一遍下来,比读十篇「领域事件是什么」的文章都管用。

3.3 三个必改参数:数据源、模拟调度、日志开关

示例能跑,但不一定适合你观察。工程里有一份核心配置文件,键名按实际版本为准,常见做法长这样:

# 数据源配置 database.mode=mem # mem=内存库, file=文件库 database.url=jdbc:hsqldb:mem:cargo database.user=sa database.password= # 后台模拟调度 event.scheduler.enabled=true event.scheduler.interval.ms=3000 # 日志 logging.level.com.example=INFO

三个参数位的含义如下:

参数作用修改建议
database.mode内存库还是文件库调试用mem,想观察持久化行为才切file
event.scheduler.interval.ms后台模拟事件的触发间隔默认 3000,想慢慢看状态流就调成 8000,想快速演示就调 1000
event.scheduler.enabled是否启动自动调度调试事件发布机制时可先关掉,手动触发单步观察

改event.scheduler.interval.ms是最直观的调参体验。调大后你能看清每个状态之间的转换是怎么被事件驱动的,调小后能看到事件像流水一样刷屏。注意切file模式时,工程退出后会产生本地数据库文件,第二次启动若没等上一个进程完全退出,会报文件锁错误——这是下一章要讲的常见坑之一。

提示:先跑通默认配置,再动手改参数。示例工程的教学价值在于事件流本身,而不是让你上来就研究数据库方言。

4. 示例里最值得抄的三个设计落点:聚合、仓储与领域事件

跑起来只是热身。这份示例真正值钱的,是三个可以直接抄进自己项目里的设计落点:聚合的行为建模、仓储接口与实现的分离、领域事件的发布方式。下面用示例风格的代码骨架来说明。

4.1 Cargo 聚合:用行为方法代替 setter,状态变迁由枚举说了算

随手打开示例里的Cargo类,你会发现它跟普通 CRUD 实体最大区别是:几乎看不到对外暴露的 setter。货物从订舱到签收的每一步,都通过行为方法完成,非法状态直接拦在聚合内部。结构上长这样:

public class Cargo { private CargoId cargoId; // 标识符值对象 private BookingAmount amount; // 货物描述值对象 private Itinerary itinerary; // 行程值对象,为空表示未定路线 private Delivery delivery; // 运输状态,由装卸事件派生 // 私有构造,外部只能通过工厂方法创建 private Cargo(CargoId cargoId, BookingAmount amount) { this.cargoId = cargoId; this.amount = amount; } // 工厂方法:订舱 public static Cargo book(CargoId cargoId, BookingAmount amount, Location origin, Location destination) { Cargo cargo = new Cargo(cargoId, amount); cargo.delivery = Delivery.initial(origin, destination); return cargo; } // 行为方法:分配路线 public void assignToRoute(Itinerary itinerary) { if (this.itinerary != null) { throw new IllegalStateException("该货物已绑定行程,不能重复分配"); } this.itinerary = itinerary; this.delivery = this.delivery.withRoute(itinerary); } public boolean isRouted() { return itinerary != null; } }

关键在assignToRoute里的校验:重复分配路线直接抛异常,这个规则被写在聚合根内部,而不是放在 service 里靠 if 判断。状态字段全部私有,外部拿不到引用,只能通过行为方法去触达。值对象Itinerary、Delivery是不可变的,创建后不能被改字段,要变就生成新的实例返回。这套写法的教学意义很直接:聚合根是业务规则的守护者,不是数据的搬运工。

新手抄的时候最容易翻车的地方,是给Cargo补一个setRoute或者把字段改成 public 方便测试。一旦开了这个口子,状态机约束就名存实亡,聚合退化成带注解的 POJO。示例里那些枚举状态变量(比如路由状态、运输状态)不是装饰,它们是聚合生命周期的骨架。

4.2 仓储接口放领域层、实现放基础设施层:依赖反转的标准切法

示例的仓储层是教科书级的依赖反转。接口放在domain包,只声明聚合生命周期需要的方法:

public interface CargoRepository { CargoId nextId(); Optional<Cargo> find(CargoId cargoId); void save(Cargo cargo); }

实现放在infrastructure包,用 JPA 落地:

@Repository public class CargoRepositoryJpa implements CargoRepository { @PersistenceContext private EntityManager entityManager; @Override public CargoId nextId() { return CargoId.of(UUID.randomUUID().toString()); } @Override public Optional<Cargo> find(CargoId cargoId) { return Optional.ofNullable(entityManager.find(Cargo.class, cargoId)); } @Override public void save(Cargo cargo) { entityManager.persist(cargo); } }

两个细节值得琢磨。第一,仓储只处理聚合根,不接受save(orderItem)这种细粒度操作,因为聚合内部的子对象由聚合根负责持久化。第二,仓储接口的返回类型是Optional<Cargo>,不是Cargo,把「可能不存在」写在类型系统里。findById批量查询、模糊查询这些都不该出现在仓储接口里,那是查询模型和 DAO 的活。事务注解我一般放在应用服务层而不是仓储实现上,理由很简单:一个用例可能跨多个仓储操作,事务必须覆盖整个用例,而不是跟着单次 save 走。

提示:判断仓储有没有抄歪,就看一件事——接口里有没有出现findByName、countByStatus这类查询。出现就是退化成 DAO 了。

4.3 领域事件:把副作用从业务用例里拆出去

事件机制是示例最能体现「解耦」的部分。先定义一个通用接口,再定义具体事件:

public interface DomainEvent { Instant occurredAt(); }
public class CargoBooked implements DomainEvent { private final CargoId cargoId; private final Location origin; private final Location destination; private final Instant occurredAt; public CargoBooked(CargoId cargoId, Location origin, Location destination) { this.cargoId = cargoId; this.origin = origin; this.destination = destination; this.occurredAt = Instant.now(); } @Override public Instant occurredAt() { return occurredAt; } }

应用服务在用例完成后发布事件:

public class CargoBookingService { private final CargoRepository cargoRepository; private final DomainEventPublisher publisher; public CargoBookingService(CargoRepository cargoRepository, DomainEventPublisher publisher) { this.cargoRepository = cargoRepository; this.publisher = publisher; } public void book(CargoId cargoId, BookingAmount amount, Location origin, Location destination) { Cargo cargo = Cargo.book(cargoId, amount, origin, destination); cargoRepository.save(cargo); publisher.publish(new CargoBooked(cargoId, origin, destination)); } }

事件命名一律用过去式:Booked、Routed、Handled,表示「已经发生的事实」。事件体里放的是业务事实的关键信息,不是实体快照,更不是给消费者准备的 DTO。发布时机在仓储保存之后、用例方法结束之前——同一个事务内发布,消费者失败不会让主业务一起回滚,这个边界要靠部署和消息机制去保证,示例里用同步发布演示了整个链路。

订阅方只关心事件本身。后台调度器监听CargoBooked后安排运输,监听CargoHandled后更新跟踪状态,监听器之间互相不知道对方的存在。这就是示例想演示的:业务用例只负责自己的核心逻辑,通知、审计、联动这些副作用全部通过事件挂出去。落到真实项目里,这个位置就是消息推送、审计日志、跨系统通知的挂载点。

5. 常见问题与避坑:从跑不起来到照着抄翻车

这一章是我自己踩出来的清单。示例工程本身质量不错,但环境差异和「抄一半」带来的问题,比代码本身的坑多得多。

5.1 环境层:JDK 版本、依赖镜像与工程导入

现象 1:mvn install在编译阶段挂掉,报错指向注解处理器或 Lombok 相关内部错误。

原因:示例老版本依赖的编译工具链对高版本 JDK 支持不好,属于典型的工具链版本冲突。解决:换 JDK 8 或 11 再跑,不要尝试逐个改源码去适配新 JDK——那会让你陷入跟业务无关的兼容性泥潭。

现象 2:依赖下载卡在某个 artifact 上,进度条半天不动。

原因:Maven 中央仓库在当前网络环境下不稳定。解决:在settings.xml里配置一个你所在区域可用的镜像仓库,把mirrorOf设为central,几分钟内就能拉完大部分依赖。

现象 3:IDE 导入后工程没被识别成 Maven 工程,pom 文件灰色不可用。

原因:导入时选错了目录,只导入了子目录而不是根目录。解决:删掉导入记录,以根目录 pom 重新导入,或者直接在命令行执行一次mvn install让 IDE 感知构建结果。

5.2 启动层:界面空白、事件不流动与数据库锁

现象 1:窗口能打开,但货物列表是空的。

原因:初始化数据没执行,或执行时建表失败被吞掉了。解决:回看启动日志里数据初始化那一行,确认是否出现「初始化演示数据」;若初始化失败,多半是数据库配置或方言问题,先把database.mode调回mem,排除文件库干扰。

现象 2:界面有数据,但货物状态永远停在Not Routed,不往后走。

原因:后台调度器没启动,事件链没被驱动。解决:检查event.scheduler.enabled是否为 true,再搜日志里有没有CargoRouted关键字。没有出现,就顺着调度线程的启动日志排查监听器注册。

现象 3:第二次启动时报数据库文件被锁,进程起不来。

原因:之前把database.mode改成了file,上一个进程没退出干净,文件锁没释放。解决:先关掉所有相关进程,删掉生成的数据库文件,或者干脆切回mem模式调试。

5.3 学习层:包名换了但代码还是老样子

现象 1:把模块和包结构完整抄回项目,Controller/Service 改名为 Application/Domain/Repository,但业务代码和原来一模一样,全是 getter 和 setter。

原因:只抄了目录结构,没抄行为建模。聚合里没有状态机、没有业务规则,领域层只是一个换了名字的普通数据类。解决:把聚合根的状态字段改成私有,对外只暴露行为方法,把 if 校验从 service 搬进聚合内部。做不到这一步,抄什么架构都是白抄。

现象 2:仓储接口里躺着一堆findByName、countByStatus、searchOrders,service 把仓储当 DAO 用。

原因:没理解仓储的职责边界,所有查询需求都往接口里塞。解决:仓储只保留聚合生命周期所需的方法;列表查询、统计查询放到独立的查询通道,用只读模型实现,不经过聚合根。

现象 3:团队里每个人都在发领域事件,事件满天飞,但消费者寥寥无几,或者消费者里又改了别的聚合,事务边界稀碎。

原因:把领域事件当成万能解耦工具,为了事件而事件。解决:先确认每个事件都在事务内发布、由明确的业务事实触发;跨聚合的状态变更放到流程编排层,而不是藏在一个监听器里偷偷改数据。

6. 把官方示例抽成自己的脚手架:我删掉了什么,留下了什么

示例工程最大的价值不在界面,也不在内存数据库,而在三件事:聚合的行为建模、仓储接口与实现的分离、领域事件的发布方式。落到自己项目时,我会把 Swing 界面、模拟调度器、HSQLDB 都删掉,只留下一个有约束力的骨架:

your-project/ ├── order/ # 第一个限界上下文 │ ├── domain/ # 聚合、值对象、仓储接口、事件定义 │ ├── application/ # 用例编排、事务边界 │ └── infrastructure/ # JPA 实现、消息发送、防腐适配 ├── payment/ # 第二个限界上下文 └── shared/ # 通用工具、事件框架、结果类型

UI 换成 REST 接口层,内存库换成项目实际使用的数据库,模拟调度换成 MQ 或定时任务。其余部分,尤其是聚合的状态机写法,原样保留。落地的时候我会先挑一个业务练手,把它的核心聚合按「工厂方法创建、行为方法改状态、枚举约束生命周期」重写一遍,跑通后再铺开到其他模块。

我给自己定过一个检查习惯:每写一个 service 方法,先问它是不是在表达一个业务用例;如果一个方法同时改了三个聚合的状态,就回到应用层重新审视编排方式。曾有一个项目,我把「订单」照抄成Cargo的形态,但图省事保留了 setter,结果状态流在第二个月乱成一锅粥,最后回到示例这种写法才理顺。那一次让我彻底明白,行为建模比目录结构重要得多。希望这段拆解能帮你在自己的项目里少走这段弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询