简介:领域驱动设计(DDD)官方示例代码包,面向希望掌握 DDD 落地方法的后端开发者与架构师。资源以完整船运系统为业务场景,演示领域模型、聚合、实体与值对象、领域事件、领域服务、限界上下文等核心概念的具体实现,并配合仓储模式与测试驱动开发展示基础设施层的集成方式,是理解 DDD 从理论到实践的高质量参考。压缩包共 426 个文件,以 Java 源码为主(142 个 java 文件),同时包含 110 个 class 文件、35 个 jar 依赖、33 个 xml 配置及部分 html、jsp、properties 等,整体 23.57MB,目录结构清晰,便于对照学习。已有 1899 人学习该资源,适合正在实践 DDD 或准备重构复杂业务模型的开发者深入研究,可据此掌握实体与值对象划分、聚合边界设计及领域事件建模等关键技巧,并能结合示例理解船运业务中的货物跟踪、船期调度等实现细节。 真正做过几年服务端之后,回头再看“领域驱动设计示例代码”这个词,心情比较复杂:资料太多,能打的太少。随手一搜,满屏都是把管理员后台或者博客系统包装成 DDD 的 demo,打开一看,聚合根、值对象倒是都占上了格子,可业务规则却写在 Service 里,一改需求就露馅。我自己的结论是:如果只允许参考一套代码,那就去读 Eric Evans 团队维护的 Cargo 物流示例;如果想搞清楚 DDD 怎么和真实业务结合,这套示例是少有的、能反复来回读三遍的样本。
这篇文章会把我的筛选过程、读代码的方法、实际运行时的踩坑,以及从示例抄到生产项目时的取舍一次性讲清楚。后面所有代码都是我在读示例时自己提取和精简出来的示意写法,不是让你复制到项目里的“官方类”,重点在于它背后那一套设计意图。
1. 我为什么劝你先啃示例代码,而不是直接照着理论搭架子
1.1 概念书里最容易被误解的三个词
先说说我亲眼见过的三种“跑偏”。
第一个是聚合。见过一个支付系统,把用户、账户、账单全部塞进一个聚合根里,结果每次操作都锁一大片数据,后面做分库分表时只能绕过聚合去操作底层表。第二个是值对象。有人写了一个 Money 类,却提供了 setAmount,属性可以被随意改,那这个类根本没有机会成为值对象,因为它不具备“不可变”这个最核心的特征。第三个是仓储。很多项目接口定义得像模像样,实现的本质还是 DAO,仓储成了一个华丽的外壳。
这些偏离不怪开发者,而是因为 Evans 的原书本身偏思想性,没有给出一套能直接跑的一等公民代码。书中解释了很多为什么,但对“代码到底应该长什么样”着墨有限。理论读完之后,很多人被那句“让模型表达业务”打动,却不知道第一个类从哪写起,最终只能靠脑补补全。
1.2 示例代码补上的,正是“理论到落地”之间的大片空白
Cargo 示例的厉害之处,是选了一个只有少数概念、却可以支撑完整业务闭环的领域。货物要被追踪,从某地起运、被装载、被卸载、最终到达目的地,中间还有“走错路”“延误”“卸错港口”这些异常情况。这个领域既没有复杂购物车那样到处是人,也没有太多隐藏的坑,能把注意力集中在模型形态上;同时它又足够完整,涉及预订、路径规划、装卸事件、状态变化,完全可以当一个小型真实系统来观察。
读第一遍时,最大的收益在包结构上。booking、handling、routing、shared 这几个部分不是单纯按技术分层去摆的,而是按业务能力切开的。一眼就能看出哪些类属于一个有界上下文,哪些东西是需要在系统间传递的领域事件。这种能直接看出来的边界感,就是代码把抽象理论“压扁”之后带给你的结果。
有一点需要提前说清楚:如果你只是把示例代码打开扫一遍,看看类名就觉得“我会了”,那基本等于白看。真正有效的读法是,每看到一个模式,都问自己一句:如果我来写这个类,我会不会像它一样卡住业务规则?会不会顺手就把 save 方法写到聚合根里面去?带着这种比较去读,代码才是有意义的。
2. 从“随便搜搜”到“选对样本”:挑示例代码的三条硬标准
2.1 先把我见过的主流示例摆出来对比
我以前也给团队整理过一版“知名 DDD 示例对比”,现在把核心内容放出来做个参考:
| 示例 | 语言/技术栈 | 适合学什么 | 最大代价 |
|---|---|---|---|
| Cargo 物流示例 | Java,偏传统服务端 | 战术模式与战略边界,业务模型表达 | 技术栈偏老,启动要补环境 |
| 电商类 demo 项目 | 各语言都有 | 快速看到分层和聚合写法 | 业务太简单,学不到边界设计 |
| 微服务示例全家桶 | Java / .NET / Go | 有界上下文如何拆成服务 | 会被容器、消息队列等基础设施带走注意力 |
| 博主个人开源项目 | 不确定 | 贴近真实生产代码,有些取舍值得看 | 质量参差不齐,容易学坏习惯 |
我并不是说其他示例没有价值。如果你已经在工作中使用某个技术栈,找一个同语言的 sample 来跑通是很友好的学习路径。但就“读懂 DDD 本身”而言,Cargo 示例是投入产出比最高的,它没有用太多框架糖衣去包裹,业务模型基本能平铺直叙地看见。
2.2 三条硬标准筛选值得反复读的库
第一,领域是否足够“无聊且完整”。电商、博客这类领域看似贴近生活,但业务规则太浅,商品、订单、库存几个类一放就结束了,根本没有机会展现业务规则的约束。物流、保险、航班等领域,规则多而且稳定,沉淀出来的模型才更接近真实系统的复杂度。
第二,代码里是否同时体现了战略设计和战术设计。很多仓库只展示聚合、值对象、仓储,看不到限界上下文的痕迹。真正认真对待战略设计的代码,会有清晰的模块边界、上下文之间的接口、领域事件的发布与订阅,而不是把所有类放在一个平铺的 package 里。
第三,是否能形成完整的基础设施闭环。只画了领域层的接口,没有基础设施层实现,就不行。因为在真实项目里,你一定会纠结“仓储接口后面到底该接什么”。Cargo 示例至少给你一条从聚合到 JPA 实现的完整链路,读完之后你就不容易把仓储当成空中楼阁。
提示:判断一套示例是否值得信任,有个很土的办法——直接打开它的仓储实现。如果所有仓储实现都长一个样,只是机械调用底层的 repository 方法,说明这代码多半是把 DDD 当目录在摆;如果仓储实现里能看到事务边界、聚合裁剪、性能取舍,才说明作者认真思考过它。
3. Cargo 示例里的战术设计:聚合、值对象、仓储到底怎么长
3.1 聚合根 Cargo 与业务不变式
在 Cargo 物流示例里,Cargo是一个典型的聚合根。它对外开放的操作很少,主要就是分配航线、指定目的地、记录到货。外部代码不需要关心Itinerary内部有哪些航线段,也不能直接修改线路里的某个字段。
最值得学习的是它在聚合根里完成的业务校验。当系统想把一条航线分配给货物时,并不是直接调 setter,而是调用assignToRoute,这个方法会先检查航线是否满足起运地、目的地和截止时间,不满足直接抛异常:
public void assignToRoute(Itinerary itinerary) { if (itinerary == null) { throw new NullPointerException("Itinerary is required"); } if (!routeSpecification.isSatisfiedBy(itinerary)) { throw new IllegalArgumentException("Route does not satisfy specification"); } this.itinerary = itinerary; }你有没有注意到,这个聚合根里没有任何保存数据库的代码,也没有事务注解。它只负责维护业务一致性和不变式。至于“更新完之后把聚合落库”,那是调用方在应用服务层做的事,聚合根自己不应该知道数据库的存在。
我见过很多项目把这种判断放在 Service 层,Service 里校验完再调聚合根 setItinerary。短期看没什么问题,但一旦这种判断散落在多个应用服务中,就会出现同一个业务规则在不同接口里表现不一致的现象。聚合根存在的价值,就是让规则只写一遍,并且和它保护的数据放在同一段代码里。
3.2 值对象是抱着规则出生的,不是一个可以随便改的数据类
在示例代码里,TrackingId是一个值对象。它是一个货物跟踪编号,看起来只是包装了一个字符串,但它自己完成了空值校验,还重写了 equals 和 hashCode:
public final class TrackingId implements Serializable { private final String id; public TrackingId(String id) { if (id == null || id.trim().isEmpty()) { throw new IllegalArgumentException("TrackingId cannot be null or empty"); } this.id = id; } public String idString() { return id; } // equals/hashCode 只基于 id }这类不可变小对象,放在真实项目中很容易被开发者嫌弃:不就是个 String 吗,包一层干什么?但如果直接使用 String,系统里会出现一个trackingId到底代表“货物编号”还是“车次编号”的语义混乱;如果它是一个类,编译器就会帮你区分开两者。尽量让基础类型不要直接裸奔在领域模型里,是值对象模式最初的目的。
另一个更重要的值对象是RouteSpecification。它里面没有数据库 id,只有业务要素:起始地、目的地、截止时间,并且直接提供一个isSatisfiedBy方法来判断一条航线是否满足需求:
public class RouteSpecification { private final Location origin; private final Location destination; private final LocalDate arrivalDeadline; public boolean isSatisfiedBy(Itinerary itinerary) { return itinerary.initialDepartureLocation().sameAs(origin) && itinerary.finalArrivalLocation().sameAs(destination) && itinerary.finalArrivalDate().isBefore(arrivalDeadline); } }这种写法把“路线规则”和“路线实体”拆开了。规则不是散落在 Service 里的 if else,而是作为值对象出现在模型里。读代码的人看到RouteSpecification就知道这里有一个业务约束,而不是靠注释去猜测。
3.3 仓储接口为什么只抽象不实现
再看仓储层。官方示例里,CargoRepository 的定义极度克制:
public interface CargoRepository { Cargo find(TrackingId trackingId); void store(Cargo cargo); }初次看会怀疑:就两个方法,够用吗?真实系统里还要按客户查、按日期查、按状态统计,怎么办?
答案是,这些查询未必都要放在聚合仓储里。面向读场景的查询,完全可以用独立的查询服务、读取模型或者直接走数据库视图。把搜索和报表交给仓储,只会让仓储膨胀成一个万能 DAO,最终破坏聚合的封装。
在这个例子里,store(Cargo cargo)只表达“把这个聚合保存起来”,而不是“更新某一张表”。它给了基础设施层极大的自由去决定用 insert 还是 update,也让领域层彻底摆脱了对具体持久化技术的依赖。很多时候我们写仓储接口时总觉得先抽象出来再说,但如果抽象不出来具体场景,不如先照这个最轻量的写法来。
4. 战略设计在示例代码里不是画出来的
4.1 有界上下文变成了真正可点击的包边界
很多人对战略设计的印象停留在画上下文映射图上,但在 Cargo 示例里,有界上下文是直接落进代码结构的。如果你打开工程根目录,看到的不是 controller、service、dao 这种技术包,而是 booking、handling、routing 这种业务包;每个业务包内部才是 application、domain、infrastructure 的分层。这说明作者把“限界上下文”落实成了模块边界。
给读代码的人一个可复制的经验:如果工程的顶层包直接是业务能力,而不是技术层次,至少说明作者考虑过业务边界;如果还发现 application、domain、infrastructure 只是在每个业务包内部重复出现,那就说明战略和战术同时落地了。
很多团队容易搞混一个点,他们把整个工程拆成 entity、repository、service 三个大包,就以为做了分层。其实那只做了技术分层,业务模块之间的依赖和边界仍然是隐形的。一旦业务模块之间可以随便互相 new 对象、互相调 service,聚合边界很快就形同虚设。代码结构上的“物理隔离”,比任何文档约定都更能约束人。
4.2 领域事件是连接上下文之间的消息,而不是内部通知
在 Cargo 示例里,handling 上下文每发生一次装卸记录,就会产生一个HandlingEvent:
public class HandlingEvent { private final HandlingEventType type; private final Location location; private final LocalDateTime completionTime; private final CarrierMovement carrierMovement; }这个事件看起来只是记录“什么时候、在哪个港口、做了什么事”,但它在上下文之间的位置很关键。预订上下文不需要知道装卸记录的所有细节,它只关心“货物有没有被装载上正确的航次”,所以把事件作为连接两个上下文的消息来发布。
这给我们一个很重要的提醒:领域事件不要随便 new 一个对象就发出去。事件应该来自已经落定的业务事实,事件名也要来自统一语言,里面放的是有业务含义的数据,而不是一层未经验证的原始请求。很多系统一听到“事件驱动”就开始上消息队列,但真正做 DDD 时,第一步应该是在代码里定义好事件结构,再考虑用进程内事件还是跨服务消息去传递它。
从战略层面看,这种设计让上下文之间保持了一个很窄的接口。你可以在不改动 handling 内部逻辑的情况下,新增一个新的订阅方去消费装卸事件,这正是限界上下文带来的好处。
5. 亲手跑一跑:示例代码的编译、启动和常见坑
5.1 看代码和跑代码是两件事
我常建议别人把示例 clone 下来真正跑一遍,哪怕只是跑通测试。因为读代码时你会脑补很多东西,跑起来之后才会发现之前对依赖关系的理解有问题。
Cargo 示例是个比较传统的 Java 工程,用 Maven 构建。你可以先把单元测试跑起来:
mvn test正常情况下,你会看到一批测试通过,这些测试覆盖了聚合根的不变式、仓储与数据库的映射、领域事件的发布等等。我建议跑完测试后不要直接关掉,而是手动改一个业务规则,比如把RouteSpecification.isSatisfiedBy里的目的地校验去掉,再跑测试。你会发现很多测试立刻挂掉。这个动作非常有价值,它让你直观体会到:当业务规则发生变化时,是哪些测试在守护这个模型。
如果你连测试都跑不起来,别急着骂示例烂。先检查三件事:JDK 版本、Maven 版本、本地仓库是否缺少依赖。这类历史悠久的示例工程,往往会用比较旧的依赖版本,和现代工具链之间存在兼容性问题。换个 LTS JDK,关掉一些新版本才有的构建特性,通常就能过去。
5.2 和数据库、消息队列耦合的测试,不要灰心
示例工程里有些测试会用内存数据库跑 JPA 映射,有些会模拟事件发送。读代码时建议分两步走:先只读纯领域层和用例测试,理解核心模型;再去看基础设施层,理解 JPA 注解是怎么把聚合映射成表和字段的。
这个过程会有个很明显的感受:领域层本身极其干净,几乎看不见框架;但到了基础设施层,JPA 的注解、实体懒加载、乐观锁版本号这些细节全都冒出来了。这种反差正是 DDD 想要的效果——把复杂的技术细节按在依赖边界后面,让领域模型保持可读性。如果读完示例代码之后你只学会了一件事,我建议是记住这种“分层隔离”带来的舒适感。
6. 从示例代码走进真实项目:照抄与改造的边界
6.1 可以直接抄的骨架与分层方式
Cargo 示例里最值得抄的,不是某个具体类,而是一种组织代码的底层习惯。我把它们列成一张清单:
| 可以直接借鉴 | 最好按项目改造 |
|---|---|
| 顶层包按业务能力切分 | 事件消息格式与重试机制 |
| 聚合根提供完整业务行为并检查不变式 | 仓储实现需要根据查询性能做专门设计 |
| 值对象设计为不可变并自带业务规则 | 规范模式仅在规则足够复杂时使用 |
| 应用服务层只做编排,不写业务规则 | 事务边界要根据具体基础设施调整 |
第一行和第二行基本可以无脑照抄,因为它们解决的是代码组织问题,和具体业务关系不大。第三行和第四行就需要你根据自己的团队水平做判断。
值对象是不可变并且自带规则,这个思路本身没有错,但如果团队对函数式或不可变风格不熟悉,会出现“把所有类都设计成值对象”的极端情况,最后连聚合根也变成不可变的,那就很尴尬。规范模式同理,它适合动态、多变、组合复杂的规则;如果业务规则只有两三个固定条件,写一个 Specification 类反而是过度设计。
6.2 示例代码不会教你的三件事
第一,性能优化。示例代码只关心模型正确性,不关心查询性能。真实系统里,列表页几乎不可能在聚合内部循环查数据库,你需要增加查询模型、缓存、数据库索引甚至 CQRS 来解决。仓储接口可以借鉴,但实现要走自己的路。
第二,事务与消息的一致性。示例里的领域事件发布很干净,但真实系统往往面临“数据库已经提交,消息却没发出去”的分布式一致性问题。事务发件箱、本地消息表和可靠消息中间件,都是示例不会替你解决的。
第三,团队演进成本。DDD 最容易在代码库里建成“圣殿”,让后续维护者不敢动任何类。实际上模型会腐烂,重构不可避免。读示例代码时建立的敬畏感是好的,但不要把它变成不敢改代码的负担。示例是参照系,不是原教旨主义。
我对这套代码最大的体会是,它最大的价值不是告诉你“正确做法长什么样”,而是让你意识到,很多你之前觉得不错的项目,其实只是在结构上长得像 DDD,业务规则的核心根本没有被模型保护起来。读完之后回去重构自己系统里的那堆 if else,大概率会有一种“原来我之前一直在裸奔”的感觉。
本文还有配套的精品资源,点击获取