简介:领域驱动设计(DDD)官方示例代码,面向后端开发者与架构设计人员,帮助理解如何将DDD核心理念落地到真实业务系统。该示例以船运系统为业务背景,完整演示了领域模型、聚合、实体与值对象、领域事件、领域服务、边界上下文等关键概念的工程化实现,同时涵盖仓储模式、持久化策略以及配套测试驱动开发实践。压缩包共426个文件,以Java源码为主,辅以JSP页面、HTML文档、XML配置、JAR依赖库及class文件等,包体约23.57MB,目录结构清晰,便于对照学习领域层、应用层与基础设施层的分层组织方式。资源含后台管理控制器、货物跟踪适配器、样本数据生成器等典型实现,适合希望系统掌握DDD战术建模与战略设计、并参考官方级示例搭建项目骨架的开发者深入学习。目前已有1901人学习下载,是理论与实践结合度较高的DDD入门与进阶参考资料。
1. 领域驱动设计官方示例代码:很多人卡在 DDD 门口,是因为只读了理论没拆过代码
领域驱动设计(DDD)的书和文章讲得再透彻,落到工程上依然是一道坎:聚合根到底怎么写、仓储接口该放哪一层、领域事件接在哪个事务边界,这些细节不是靠"悟"能出来的。这套官方示例代码的价值,就是把 DDD 书里的概念全部编译成一个可运行的工程——分层、聚合、值对象、领域事件、仓储模式一条线拉通,打开 IDE 就能逐行对照。适合两类人:一是读过 DDD 理论但项目里还在写事务脚本的后端工程师,二是正准备把遗留系统往 DDD 方向重构的团队。与其自己拍脑袋拆模块,不如先把它跑起来再动手。
2. 先读懂分层边界:官方示例的包结构与依赖方向
2.1 从目录树看四层模型
拿到示例的第一步,别急着跑。先打开包结构,把目录树截下来放手边。这套官方示例的包结构沿用了 DDD 最经典的"接口层 / 应用层 / 领域层 / 基础设施层"四层划分,各层职责极其明确:
com.example.ddd ├── interfaces │ ├── rest │ │ ├── OrderController.java │ │ └── dto │ └── facade ├── application │ ├── service │ │ └── OrderApplicationService.java │ ├── command │ └── assembler ├── domain │ ├── model │ │ ├── order │ │ │ ├── Order.java │ │ │ ├── OrderId.java │ │ │ ├── OrderItem.java │ │ │ └── OrderStatus.java │ │ └── money │ │ └── Money.java │ ├── repository │ │ ├── OrderRepository.java │ │ └── ProductRepository.java │ ├── service │ │ └── DiscountPolicy.java │ └── event │ ├── DomainEvent.java │ └── OrderSubmittedEvent.java └── infrastructure ├── persistence │ ├── OrderRepositoryImpl.java │ └── mapper ├── messaging │ └── EventPublisherImpl.java └── config对照这张树形图,四层各自的职责是这样的:
- interfaces 层只做协议转换:接收 HTTP 请求、把 JSON 转成 application 层需要的 Command 对象,再把结果转成 DTO 返回。这里不写任何业务判断,连参数校验都只是格式校验。
- application 层编排用例:一个方法对应一个用户意图,比如"提交订单"。它负责取聚合、调聚合方法、再调仓储保存,同时管理事务边界。
- domain 层是核心,包含业务实体、值对象、聚合根、仓储接口和领域事件。这一层不依赖任何框架。
- infrastructure 层是实现细节:数据库访问、消息队列、第三方 HTTP 调用全部在这里,并且去实现 domain 层定义的接口。
这个目录结构本身就是一份"分层规范说明书"。我拆过很多项目,发现多数团队落地 DDD 失败,第一步就栽在分层上:有人把 DTO 放到 domain 包里,有人让 Controller 直接调仓储,还有人把 SQL 写在 Controller 里。官方示例的包结构就是用来纠正这些习惯的——它用目录强制约束了依赖方向。
2.2 依赖倒置:领域层为什么不写框架注解
四层模型里最有含金量的约束是依赖方向:interfaces 依赖 application,application 依赖 domain,infrastructure 依赖 domain,而 domain 层谁也不依赖。注意最后一句——domain 层是绝对核心,它必须保持纯净。
打开示例里 domain 包的仓储接口,你会发现它是个纯 Java 接口:
public interface OrderRepository { OrderId nextId(); Order findById(OrderId id); void save(Order order); }这里故意不写 @Repository、不继承 JpaRepository 这类框架接口。原因是仓储接口属于领域层的"契约",它描述的是"订单要能被保存、能被按 ID 找回"这件事;而真正用 MyBatis 还是 JPA 去实现,是基础设施层的选择。接口放 domain,实现放 infrastructure,这就是依赖倒置。
对应地,infrastructure 包里能看到 OrderRepositoryImpl 这样的实现类,它上面才会加 @Repository 注解,并且在内部处理数据映射、事务挂接。这样设计有个直接收益:单测领域模型时不需要启动 Spring 容器,纯 JUnit 就能跑。因为 domain 层没有任何框架注解,new 一个 Order 实例不需要依赖任何上下文。
我一般会提醒团队注意一个妥协点:如果示例用了 JPA,实体上会带 @Entity、@Table 这类注解,这其实是"领域模型与持久化模型合一"的折中方案。官方示例里也存在这种写法,因为它要降低入门门槛。但如果你想追求纯净模型,就把持久化注解全部移到 infrastructure 层的映射类上,domain 保持干净。两种风格都有人用,取舍标准是看你们团队能不能接受多写一层映射代码。
3. 把示例跑起来:环境准备、启动顺序与三处验证
3.1 前置环境与一条命令启动
官方示例的默认技术栈是 Java 8+ 和 Maven,数据库默认用 MySQL,部分分支会用内存数据库 H2 方便快速启动。环境准备清单不长:JDK 8 以上、Maven 3.6 以上、Docker(用于拉起数据库和消息队列),另外准备一个 MySQL 客户端用于连库查验。
# 1. 克隆官方示例到本地 git clone <示例仓库地址> ddd-example cd ddd-example # 2. 编译并跑完整测试(含单元测试和集成测试) mvn clean verify # 3. 启动基础设施:MySQL 和消息队列 docker-compose up -d mysql rabbitmq # 4. 启动应用 java -jar target/ddd-example.jar --spring.profiles.active=dev这套命令里几个参数值得说清楚:clean verify比package多跑了集成测试,第一次跑官方示例建议用 verify,它能顺带验证基础设施连接是否正常;--spring.profiles.active=dev指定读取 application-dev.yml,里面配置了数据库连接串、消息队列地址和端口。如果你本机没有 Docker,也可以手动装 MySQL 再改配置文件里的 datasource 地址,常见做法是把 url、username、password 三个参数换成自己的。
启动后看到日志输出Started DddExampleApplication和监听端口 8080,说明应用起来了。这时去 MySQL 里查一下,示例的初始化脚本会自动建好核心业务表,比如订单表、订单条目表、商品表,每条表的注释都对应着 DDD 里的一个概念。这一步能帮你把"概念"和"物理表"对应起来。
3.2 先跑测试再跑服务:用测试验证领域行为
官方示例里最有价值的学习材料其实不是主代码,而是测试代码。它的测试分两类:领域层单元测试和应用层集成测试。领域层测试直接验证聚合根的业务规则,不依赖任何外部组件。
# 只跑订单聚合的单元测试 mvn test -pl domain -Dtest=OrderAggregateTest-pl domain表示只构建 domain 模块,-Dtest=OrderAggregateTest指定只跑这一个测试类。这个测试类里会覆盖订单的几个核心业务规则:订单不能重复提交、订单金额必须大于零、取消订单只能发生在待付款状态。跑完如果全绿,说明这条业务链路是通的。
集成测试在 application 模块下,它会起真实数据库,模拟从 Controller 到仓储的完整调用链。常见做法是测试里直接用 MockMvc 发一个 POST 请求创建订单,然后断言数据库里多了对应记录。这类测试的价值在于验证事务边界和事件发布是否按预期工作。
验证完测试,再手动发一个请求做端到端确认:
curl -X POST http://localhost:8080/orders \ -H "Content-Type: application/json" \ -d '{"productId":"p-1001","quantity":2}'返回体里能看到新订单的 ID 和状态字段。这一步能确认 Controller 到领域层再到数据库的完整链路没问题。如果返回 500,优先看日志里是数据库连接异常还是业务规则异常,前者查配置,后者查你传的参数是否违反了订单的领域规则。
4. 战术建模核心:聚合根、值对象与领域事件的落地写法
4.1 聚合根:把业务规则收进模型而不是 Service
很多人在示例代码里最先注意到的,是 Order 实体里的方法而不是它的字段。这就是 DDD 战术建模和传统 JavaBean 最大的区别。看这段典型写法:
public class Order { private OrderId id; private List<OrderItem> items; private OrderStatus status; private Money totalAmount; public void addItem(ProductId productId, int quantity, Money price) { if (quantity <= 0) { throw new IllegalArgumentException("商品数量必须大于0"); } if (!status.isPending()) { throw new IllegalStateException("只有待付款订单可以增加条目"); } this.items.add(new OrderItem(productId, quantity, price)); this.recalculateTotal(); } public void submit() { if (this.items.isEmpty()) { throw new IllegalStateException("空订单不能提交"); } this.status = OrderStatus.SUBMITTED; this.registerEvent(new OrderSubmittedEvent(this.id, this.totalAmount)); } private void recalculateTotal() { this.totalAmount = this.items.stream() .map(OrderItem::getSubtotal) .reduce(Money.ZERO, Money::add); } }这段代码体现了三个关键点。第一,业务规则写在聚合根方法里:数量校验、状态校验、空订单校验都在 addItem 和 submit 内部完成,应用层 Service 完全不需要这些 if-else。第二,字段没有公开 setter,状态流转只能通过行为方法触发,外部无法绕过规则直接改 status,保证不变量不被破坏。第三,totalAmount 是计算得来的值,不提供 setAmount 方法,防止金额与条目明细不一致。
参数方面要注意 OrderId 和 Money 都是值对象,不是 String 和 BigDecimal 裸字段。订单 ID 哪怕底层是字符串,在领域层也要包一层 OrderId,这样仓储接口签名里 findById(OrderId id) 的语义是自描述的,也不会出现"把订单 ID 传给商品查询"这种低级错误。Money 同理,金额计算必须经过 add 方法,避免直接拿 BigDecimal 做加减丢失精度。
对比一下反例:如果 Order 只有 getter/setter,所有校验都放在 OrderApplicationService 里,那这个 Service 会越来越胖,最后变成几百行的"上帝类"。这也是判断一个项目是不是伪 DDD 的最快方法——看业务规则到底住在哪。
4.2 领域事件:在正确的事务边界发布和消费
领域事件是聚合之间通信的机制。订单提交后需要通知库存扣减、发送消息,这些副作用如果直接写在 submit 方法里,会让聚合根依赖外部服务,破坏聚合的独立性。官方示例的做法是聚合根内部只记录事件,由应用层在事务提交后统一发布。
public interface DomainEvent { Instant occurredAt(); } public class OrderSubmittedEvent implements DomainEvent { private final OrderId orderId; private final Money totalAmount; private final Instant occurredAt; public OrderSubmittedEvent(OrderId orderId, Money totalAmount) { this.orderId = orderId; this.totalAmount = totalAmount; this.occurredAt = Instant.now(); } // getter 省略 }事件类本身是纯数据载体,只携带聚合 ID 和业务结果,不包含任何逻辑。occurredAt 记录事件发生时间,这个字段在事件回溯和排查时非常有用。
发布机制上,示例通常采用"事务内收集、事务提交后发布"的模式。应用层 Service 在事务方法里调用聚合的 submit 方法拿到事件列表,然后注册到 Spring 的事件机制里。常见做法是用 @TransactionalEventListener 监听这些领域事件,它默认在事务提交后触发,保证"订单一定保存成功了才去通知库存"。
这里有一个容易踩的坑:如果你把事件改成同步直接调用,在 submit 方法里立刻发消息给库存服务,那么一旦库存扣减失败,主事务也会回滚,两个强耦合的系统互相拖累。而如果事件发布和主事务分离,就需要自己处理消息丢失和重复消费的问题。官方示例的代码里你能同时看到这两种场景的实现,对比看一遍,比单纯看理论文章理解得深得多。
5. 避坑:照着官方示例学 DDD 最容易翻车的五个点
5.1 现象:抄了四层包结构,代码还是贫血模型
不少团队照着示例建了 interfaces、application、domain、infrastructure 四个包,结果 domain 里的实体清一色只有字段和 getter/setter,业务全写在 Service 里。代码评审时一问,回答是"分层我照做了,但不知道业务规则怎么往实体里放"。
原因:把 DDD 理解成了"分层架构",以为包结构对了就是 DDD。实际上包结构只是壳,充血模型才是魂。业务规则还是习惯性写在 Service 里,本质是没做职责梳理。
解决:改代码前先做一步"行为迁移"。打开你的 Service,把里面每个 if 判断和状态流转都抄出来,问自己一句话:"这个规则是描述订单自己的行为,还是描述订单和其他系统的协作?"凡是描述聚合自身不变量和状态流转的,全部搬进聚合根方法;只有涉及多个聚合的编排,才留在 Service 里。迁移完你会发现 Service 瘦了一半以上。
5.2 现象:领域事件在事务内同步发送,主接口被拖慢
有人照着示例加了领域事件,订单提交后要在监听器里调会员积分接口。结果订单接口从 50ms 变成 500ms,而且积分接口一超时,订单提交也跟着失败。
原因:事件监听器用了同步调用,远程接口的耗时直接算进了主事务。更隐蔽的问题是,同一个事务里做远程调用,数据库连接会被长时间占用,连接池一旦打满,整个应用雪崩。
解决:把外部副作用拆出去。主事务里只做本库操作和事件发布,监听器收到事件后写入本地事件表,再由独立的任务把事件投递到消息队列,下游系统消费队列做积分赠送。这样主链路的耗时只包含本地操作,远程故障也不会回滚订单。
5.3 现象:聚合边界照抄示例,线上频繁跨聚合查询
示例里的订单聚合包含订单条目、收货地址、商品快照,团队就照这个边界建模。结果报表需求一上来,发现查询要跨五个聚合 join,SQL 慢到超时。
原因:聚合边界不是照抄的,它由业务一致性决定。示例里订单和条目是强一致关系,必须放一个聚合;但收货地址和商品快照只是"下单那一刻的状态",不是订单一致性的组成部分。把不需要强一致的东西塞进聚合,只会让聚合膨胀、查询变难。
解决:返回头做一次一致性梳理。问业务方:"修改地址的时候,正在进行的订单要不要立刻感知?下单后商品改名,订单里的名字要不要跟着变?"答案是否的,就从聚合里拆出去,改成引用 ID 加只读快照。查询场景单独建读模型,不要试图让聚合根承担所有查询需求。
5.4 现象:仓储接口放对了,实现里还是裸 SQL 和事务脚本
有人把 Repository 接口放进了 domain 包,但实现类里直接注入 SqlSessionTemplate,Service 里取到聚合后,把聚合的字段一个个拿出来拼 UPDATE 语句,再手动调 sqlSession 执行。
原因:把仓储当成了 DAO 的改名版。DAO 的粒度是表,仓储的粒度是聚合;DAO 暴露 updateById、deleteById 这种数据操作,仓储应该暴露 save、findById 这种领域语义。你还在按 DAO 的思路写,说明没理解仓储存在的意义是"让领域层不感知持久化细节"。
解决:仓储接口的方法名全部改成领域语义,参数和返回值必须是聚合根或值对象,禁止把领域对象拆成字段传进去。实现里如果用 MyBatis,就在 infrastructure 层维护一个聚合映射器,负责把聚合组装起来;如果用 JPA 就是仓储实现里直接调 EntityManager。判断标准很简单:换一种持久化方案,domain 包里的代码一行都不用改。
5.5 现象:领域事件和聚合 ID 直接用数据库自增主键
示例跑通后有人把订单 ID 换成数据库自增主键,理由是"省事"。结果做事件表和业务表对账的时候发现,自增 ID 在数据库迁移、分库分表之后完全对不上,事件重放时 ID 全变了。
原因:数据库自增 ID 由基础设施生成,不带业务语义。领域事件是给下游系统消费的,事件里的订单 ID 必须稳定可追溯,自增主键在跨库、跨系统场景下不具备这个能力。
解决:聚合根 ID 在创建时就用全局唯一 ID 生成器生成,常用户型为雪花 ID 或 UUID。生成器放在 infrastructure 层,通过仓储接口的 nextId 方法暴露给领域层,领域层不需要关心 ID 具体是怎么来的。事件里携带的是聚合 ID,不是数据库主键,这样无论数据迁到哪,事件和聚合都能对上。
6. 把官方示例改造成个人骨架:删业务、留边界、换中间件
6.1 剥离演示业务的操作顺序
官方示例跑通、关键模式看完之后,下一步不是直接拿来写业务,而是把它改造成你自己的项目骨架。改造顺序我一般按"删、留、换"三步走:
| 操作 | 对象 | 说明 |
|---|---|---|
| 删 | interfaces/rest 下的示例 Controller、DTO、Command | 保留空壳,替换成自己的接口 |
| 删 | domain/model 下的订单、商品等演示聚合 | 业务代码全部移除,只留结构 |
| 留 | domain/event 的 DomainEvent 接口、抽象基类 | 事件机制是所有聚合通用的 |
| 留 | infrastructure 的配置类、异常处理、日志切面 | 这些属于工程骨架,与业务无关 |
| 换 | 数据库、消息队列的具体中间件配置 | 按团队实际环境替换连接参数 |
删的过程比想象中花时间,因为示例业务之间是有依赖的,订单聚合关联着商品和库存,不把关联全部理清就删不干净。建议从聚合根开始删,往上一层层引用它的应用服务和接口同步处理。
6.2 保留一个复用度最高的事件基座
改造后最值得保留的是聚合根的事件注册机制。官方示例里每个聚合根都要维护自己的事件列表,这个逻辑完全可以抽象到基类里:
public abstract class AbstractAggregateRoot { private final List<DomainEvent> domainEvents = new ArrayList<>(); protected void registerEvent(DomainEvent event) { if (event == null) { throw new IllegalArgumentException("事件不能为空"); } this.domainEvents.add(event); } public List<DomainEvent> getDomainEvents() { return List.copyOf(this.domainEvents); } public void clearDomainEvents() { this.domainEvents.clear(); } }这个基类的作用有三层:registerEvent 供聚合根在业务方法里登记事件,入参校验保证空事件不会被压入;getDomainEvents 返回不可变副本,防止外部修改内部列表;clearDomainEvents 用于事务提交后清空列表,避免同一个聚合实例在分布式调用时重复发事件。你的每个聚合根继承它,就天然拥有了事件能力,不需要每个聚合重复写这段样板代码。
从那以后,我每次拿新项目做骨架,都强制自己先走一遍流程:画事件风暴图圈聚合边界,再对照示例的包结构搭骨架,最后才写第一条业务代码。这个习惯帮我省掉了无数次回头改边界的麻烦。这套官方示例的价值不在代码本身,而在于它把 DDD 的抽象概念钉在了具体的工程坐标上——你下载下来拆一遍,收获的是一整套可以复用的建模方法和踩坑地图,希望帮到你。
本文还有配套的精品资源,点击获取