资讯详情

DDD官方示例深度解析:用货运系统讲透领域驱动设计

📅 2026/10/14 16:36:01 | 华诺云谱 👁 阅读
DDD官方示例深度解析:用货运系统讲透领域驱动设计
简介一份完整的领域驱动设计DDD官方示例代码包面向正在学习DDD的开发者与架构师。资源基于船运系统真实业务场景完整呈现领域模型、聚合、实体与值对象、领域事件、领域服务、边界上下文及战略建模等核心概念并配套仓储持久化、基础设施解耦、测试驱动开发等落地实现尤其展示了如何用限界上下文映射划分不同业务模块帮助读者将方法论从理论映射到干净可维护的代码。压缩包共426个文件约23.57MB以Java源码142个、编译后类文件110个、前端HTML56个为主辅以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.modemem # mem内存库, file文件库 database.urljdbc:hsqldb:mem:cargo database.usersa database.password # 后台模拟调度 event.scheduler.enabledtrue event.scheduler.interval.ms3000 # 日志 logging.level.com.exampleINFO三个参数位的含义如下参数作用修改建议database.mode内存库还是文件库调试用mem想观察持久化行为才切fileevent.scheduler.interval.ms后台模拟事件的触发间隔默认 3000想慢慢看状态流就调成 8000想快速演示就调 1000event.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(); OptionalCargo 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 OptionalCargo find(CargoId cargoId) { return Optional.ofNullable(entityManager.find(Cargo.class, cargoId)); } Override public void save(Cargo cargo) { entityManager.persist(cargo); } }两个细节值得琢磨。第一仓储只处理聚合根不接受save(orderItem)这种细粒度操作因为聚合内部的子对象由聚合根负责持久化。第二仓储接口的返回类型是OptionalCargo不是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 版本、依赖镜像与工程导入现象 1mvn install在编译阶段挂掉报错指向注解处理器或 Lombok 相关内部错误。原因示例老版本依赖的编译工具链对高版本 JDK 支持不好属于典型的工具链版本冲突。解决换 JDK 8 或 11 再跑不要尝试逐个改源码去适配新 JDK——那会让你陷入跟业务无关的兼容性泥潭。现象 2依赖下载卡在某个 artifact 上进度条半天不动。原因Maven 中央仓库在当前网络环境下不稳定。解决在settings.xml里配置一个你所在区域可用的镜像仓库把mirrorOf设为central几分钟内就能拉完大部分依赖。现象 3IDE 导入后工程没被识别成 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、searchOrdersservice 把仓储当 DAO 用。原因没理解仓储的职责边界所有查询需求都往接口里塞。解决仓储只保留聚合生命周期所需的方法列表查询、统计查询放到独立的查询通道用只读模型实现不经过聚合根。现象 3团队里每个人都在发领域事件事件满天飞但消费者寥寥无几或者消费者里又改了别的聚合事务边界稀碎。原因把领域事件当成万能解耦工具为了事件而事件。解决先确认每个事件都在事务内发布、由明确的业务事实触发跨聚合的状态变更放到流程编排层而不是藏在一个监听器里偷偷改数据。6. 把官方示例抽成自己的脚手架我删掉了什么留下了什么示例工程最大的价值不在界面也不在内存数据库而在三件事聚合的行为建模、仓储接口与实现的分离、领域事件的发布方式。落到自己项目时我会把 Swing 界面、模拟调度器、HSQLDB 都删掉只留下一个有约束力的骨架your-project/ ├── order/ # 第一个限界上下文 │ ├── domain/ # 聚合、值对象、仓储接口、事件定义 │ ├── application/ # 用例编排、事务边界 │ └── infrastructure/ # JPA 实现、消息发送、防腐适配 ├── payment/ # 第二个限界上下文 └── shared/ # 通用工具、事件框架、结果类型UI 换成 REST 接口层内存库换成项目实际使用的数据库模拟调度换成 MQ 或定时任务。其余部分尤其是聚合的状态机写法原样保留。落地的时候我会先挑一个业务练手把它的核心聚合按「工厂方法创建、行为方法改状态、枚举约束生命周期」重写一遍跑通后再铺开到其他模块。我给自己定过一个检查习惯每写一个 service 方法先问它是不是在表达一个业务用例如果一个方法同时改了三个聚合的状态就回到应用层重新审视编排方式。曾有一个项目我把「订单」照抄成Cargo的形态但图省事保留了 setter结果状态流在第二个月乱成一锅粥最后回到示例这种写法才理顺。那一次让我彻底明白行为建模比目录结构重要得多。希望这段拆解能帮你在自己的项目里少走这段弯路。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑