从Cargo示例理解领域驱动设计:聚合、值对象与仓储

发布时间:2026/9/7 10:30:54
从Cargo示例理解领域驱动设计:聚合、值对象与仓储 简介领域驱动设计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 和 hashCodepublic 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 上下文每发生一次装卸记录就会产生一个HandlingEventpublic 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大概率会有一种“原来我之前一直在裸奔”的感觉。本文还有配套的精品资源点击获取