☰
DDD官方示例代码:可运行的领域建模骨架
2026/10/25 13:03:45 网站建设 项目流程

简介:本资源是领域驱动设计(DDD)的官方示例代码实现,面向中高级Java开发者、架构师及对复杂业务系统建模感兴趣的工程实践者,旨在帮助理解DDD核心概念在真实船运业务场景中的落地方式。压缩包共426个文件,涵盖142个Java源码(含领域实体、聚合根、领域服务等关键类)、110个编译后class文件、56个HTML文档(含API说明与示例视图)、35个JAR依赖库及33个XML配置文件(如Spring上下文与持久化映射),整体大小23.57MB,结构完整,体现典型分层架构与限界上下文划分。已有1899人学习下载,读者可直接运行分析dddsample船运系统,掌握聚合设计、领域事件发布、仓储模式实现、边界上下文隔离等关键实践,并通过SampleDataGenerator、BookingServiceFacadeImpl等典型类深入理解领域逻辑组织与测试驱动开发思路。

1. 领域驱动设计官方示例代码:不是Demo,是能抠出业务骨架的“可执行说明书”

你翻过《领域驱动设计》原著第14章,也试过用DDD术语写满白板,但一回到CRUD项目里,还是忍不住把User、Order、Payment塞进一个Service里——不是不懂,是缺一个能跑起来、能改、能debug、能映射到自己业务里的锚点。所谓“领域驱动设计官方示例代码”,指的不是某本教材附赠的玩具项目,而是Eric Evans团队早期在DDD社区沉淀、经多年演进验证、被Spring PetClinic、Microsoft eShopOnContainers等工业级项目反复引用的最小可行领域建模骨架:它用真实订单履约场景(下单→库存校验→支付→发货)组织代码,每个模块都带明确的限界上下文边界、聚合根约束、领域事件传播路径和仓储契约定义。它不教你怎么画UML,而是让你在IDE里单步调试Order.place()时,亲眼看到InventoryService.reserve()被领域事件触发、看到OrderStatusChanged如何跨上下文广播、看到OrderRepository如何拒绝直接new Order()而强制走工厂构造。适合正在重构遗留系统、准备启动中台化、或刚带团队落地DDD却卡在“概念到代码”断层的后端/架构师——你不需要从零发明轮子,只需要把它的骨架,换成你自己的商品、库存、履约规则。


2. 拆解官方示例代码的四大核心模块:为什么必须按这个结构组织

官方示例代码不是按技术分层(Controller/Service/DAO),而是严格按限界上下文(Bounded Context)划分物理包结构。我本地克隆的是GitHub上最常被引用的ddd-sample仓库(v2.0+,基于Java 17 + Spring Boot 3.x),其目录树直击DDD本质:

src/main/java/ ├── com.example.ordering/ ← 订单上下文(核心业务流) │ ├── domain/ ← 领域层:实体、值对象、聚合根、领域服务 │ ├── application/ ← 应用层:用例编排、DTO转换、事务边界 │ └── infrastructure/ ← 基础设施层:JPA实现、事件发布器、外部API适配 ├── com.example.inventory/ ← 库存上下文(独立自治) │ ├── domain/ │ └── infrastructure/ ├── com.example.payment/ ← 支付上下文(第三方集成) └── com.example.shared/ ← 共享内核:通用值对象(Money、Quantity)、领域事件基类

这种结构不是为了好看,而是解决三个现实问题:

  • 避免贫血模型:Order聚合根内封装状态变更逻辑(如place()校验库存可用性),而非暴露setter任由Service随意修改;
  • 隔离技术侵入:OrderRepository接口定义在domain/下,其实现类JpaOrderRepository藏在infrastructure/里,换MongoDB只需重写实现,不碰领域逻辑;
  • 显式上下文边界:inventory和ordering包之间零直接依赖,通信只通过OrderPlacedEvent事件——这逼你思考“库存扣减”到底该由谁发起、谁负责最终一致性。

提示:别急着复制包名!先确认你的业务是否真需要拆成多个上下文。如果当前系统只有“下单+扣库存+发通知”一条线,强行拆inventory上下文反而增加复杂度。官方示例的威力在于它展示了当业务变复杂时,代码如何自然生长,而不是要求你第一天就画满所有上下文。

2.1 聚合根设计:为什么Order要包含Item,却不包含Customer?

在ordering.domain包中,Order类是典型的聚合根:

// src/main/java/com/example/ordering/domain/Order.java public class Order { private final OrderId id; private final List<OrderItem> items; // 值对象集合,受聚合根保护 private OrderStatus status; // 工厂方法:强制业务规则入口 public static Order place(OrderId id, List<OrderItem> items) { if (items.isEmpty()) throw new IllegalArgumentException("Order must have items"); return new Order(id, items, OrderStatus.PENDING); } // 领域方法:封装状态变更逻辑 public void confirm() { if (status != OrderStatus.PENDING) throw new IllegalStateException("Only pending orders can be confirmed"); this.status = OrderStatus.CONFIRMED; } }

关键点在于:

  • OrderItem是值对象(无ID、不可变),其生命周期完全依附于Order,删除Order即删除所有Item;
  • Customer是另一个聚合根,存在于customer上下文(示例中未展开,但预留了CustomerId类型),Order只持有CustomerId(ID引用),绝不持有Customer实体——否则会形成跨上下文强耦合;
  • 所有状态变更必须通过confirm()、cancel()等显式领域方法,禁止order.setStatus(...)这种绕过业务规则的直写。

参数说明:OrderId是自定义ID类(非Long/String),确保类型安全;OrderStatus是枚举,而非String,防止非法状态值混入。这是DDD对“让错误在编译期暴露”的实践,不是玄学。

2.2 领域事件:OrderPlacedEvent如何触发库存扣减?

官方示例用Spring Event机制实现上下文解耦。OrderApplicationService在确认订单后发布事件:

// src/main/java/com/example/ordering/application/OrderApplicationService.java @Service public class OrderApplicationService { private final OrderRepository orderRepository; private final ApplicationEventPublisher eventPublisher; public void confirmOrder(OrderId orderId) { Order order = orderRepository.findById(orderId) .orElseThrow(() -> new OrderNotFoundException(orderId)); order.confirm(); // 领域逻辑 orderRepository.save(order); // 持久化 // 发布领域事件:注意——这里不调用inventory服务! eventPublisher.publishEvent(new OrderPlacedEvent(orderId, order.getItems())); } }

而在inventory.infrastructure包中,监听器接收事件并执行库存操作:

// src/main/java/com/example/inventory/infrastructure/InventoryEventListener.java @Component public class InventoryEventListener { private final InventoryService inventoryService; @EventListener public void handleOrderPlaced(OrderPlacedEvent event) { try { inventoryService.reserveStock(event.getOrderId(), event.getItems()); } catch (InsufficientStockException e) { // 处理失败:发补偿事件或告警,不抛异常阻塞主流程 log.warn("Stock reservation failed for order {}", event.getOrderId(), e); } } }

这种设计让ordering上下文完全不知道inventory如何实现,甚至不知道它是否存在——只要OrderPlacedEvent结构不变,库存系统可替换为Redis计数器或外部SaaS API。事件名称、字段、序列化方式(示例用JSON)就是两个上下文间的契约协议。


3. 在本地跑通最小可运行版本:三步完成从clone到debug

官方示例代码(以ddd-samplev2.1为例)已预置H2内存数据库和嵌入式Tomcat,无需额外部署中间件。但直接mvn spring-boot:run会因缺少配置失败——因为示例刻意隐藏了“开箱即用”的甜点,逼你理解配置含义。

3.1 下载与初始化:避开Git Submodule陷阱

# 官方仓库地址(请以GitHub实际为准,此处为示意) git clone https://github.com/ddd-sample/ddd-sample.git cd ddd-sample git checkout v2.1

⚠️ 注意:该仓库使用Git Submodule管理shared模块,若git clone后shared/目录为空,执行:

git submodule init git submodule update

否则编译会报错package com.example.shared does not exist。这是新手第一道坎——不是代码问题,是Git操作遗漏。

3.2 修改application.yml:激活H2控制台与日志

官方示例默认关闭H2控制台(h2.console.enabled=false),且日志级别设为INFO,掩盖关键领域事件日志。需手动修改:

# src/main/resources/application.yml spring: h2: console: enabled: true # 启用H2控制台,访问 http://localhost:8080/h2-console path: /h2-console datasource: url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE driver-class-name: org.h2.Driver jpa: database-platform: org.hibernate.dialect.H2Dialect hibernate: ddl-auto: create-drop # 每次启动重建表,方便调试 logging: level: com.example: DEBUG # 关键!开启领域包DEBUG日志,看到事件发布/消费细节 org.springframework.context.event: DEBUG # 查看事件监听器注册情况

逻辑说明:ddl-auto: create-drop确保每次重启数据库干净,避免旧数据干扰;DEBUG日志级别让你在控制台看到类似Publishing event [OrderPlacedEvent]和Handling event [OrderPlacedEvent]的输出,这是验证事件机制是否生效的黄金指标。

3.3 编写第一个测试用例:用REST API触发完整流程

示例自带OrderController,提供标准REST端点。用curl模拟下单:

# 步骤1:创建订单(POST /api/orders) curl -X POST http://localhost:8080/api/orders \ -H "Content-Type: application/json" \ -d '{ "customerId": "cust-123", "items": [ {"productId": "prod-001", "quantity": 2} ] }' # 返回:{"id":"order-456","status":"PENDING","items":[...]} # 此时H2数据库中orders表已插入记录,但inventory表仍为空 # 步骤2:确认订单(PUT /api/orders/{id}/confirm) curl -X PUT http://localhost:8080/api/orders/order-456/confirm # 观察控制台日志:应出现"Publishing event [OrderPlacedEvent]"和"Handling event [OrderPlacedEvent]" # 检查H2控制台:inventory表中应新增一条reservation记录

成功标志:

  • 控制台输出Handling event [OrderPlacedEvent];
  • H2控制台查询SELECT * FROM inventory_reservations;返回一行数据;
  • Order状态变为CONFIRMED。

参数说明:customerId和productId是字符串ID,示例中不校验其真实性(生产环境需对接Customer/Inventory上下文),重点验证领域流程是否贯通。


4. 避坑指南:五个让团队在DDD落地初期集体翻车的硬伤

DDD示例代码最大的价值不是教你“怎么写”,而是提前暴露“哪里会崩”。以下是我在三个项目中踩过的坑,按发生频率排序:

4.1 现象:OrderRepository.save()后,Order状态没更新到数据库

原因:Order聚合根未正确实现equals()和hashCode(),导致JPA无法识别同一聚合根实例,每次save都当作新记录插入。官方示例中OrderId类已重写这两个方法,但若你自定义ID类(如ProductId),忘记重写就会翻车。
解决:所有ID类必须重写equals()/hashCode(),且仅基于ID值比较(不包含业务字段)。用Lombok的@EqualsAndHashCode注解最安全:

@Value @Builder public class ProductId { String value; // 不要暴露getter/setter,保持不可变 } // Lombok自动生成equals/hashCode,基于value字段

4.2 现象:OrderPlacedEvent发布后,InventoryEventListener没收到

原因:Spring Event默认是同步调用,若监听器抛出未捕获异常(如NullPointerException),整个事务会回滚,订单确认失败。但示例中handleOrderPlaced()方法加了try-catch吞掉异常,导致事件“静默失败”。
解决:监听器内异常必须显式处理,并记录ERROR日志。更健壮的做法是引入消息队列(如RabbitMQ)实现异步解耦,但示例为简化未启用——本地调试时,务必打开logging.level.org.springframework.context.event=DEBUG,确认事件是否注册成功。

4.3 现象:修改OrderItem数量后,Order.totalAmount没自动更新

原因:Order聚合根未将totalAmount设为计算属性(computed property),而是作为字段存储。当items列表被外部修改(如order.getItems().add(...)),totalAmount不会同步刷新。
解决:totalAmount必须是getter方法计算得出,而非字段:

public BigDecimal getTotalAmount() { return items.stream() .map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))) .reduce(BigDecimal.ZERO, BigDecimal::add); }

4.4 现象:测试用例中mock OrderRepository,但领域方法place()抛出IllegalArgumentException,测试失败

原因:测试时未注入真实的OrderFactory,而是直接new Order()绕过工厂校验。官方示例强调“聚合根只能通过工厂创建”,但测试常忽略这点。
解决:单元测试必须使用Order.place()工厂方法创建实例,而非new Order(...)。Mockito mock对象时,用@MockBean注入OrderRepository,但Order实例仍需工厂创建:

@Test void shouldRejectEmptyOrder() { // 错误:Order order = new Order(...); // 正确: assertThrows(IllegalArgumentException.class, () -> Order.place(new OrderId("test"), Collections.emptyList()) ); }

4.5 现象:切换MySQL后,H2的CREATE TABLE语句报语法错误

原因:H2支持AUTO_INCREMENT,但MySQL需用AUTO_INCREMENT且字段类型必须为BIGINT。官方示例的schema-h2.sql用H2方言,未提供MySQL兼容脚本。
解决:删掉src/main/resources/schema-h2.sql,改用JPA自动生成DDL(ddl-auto: update),或手写schema-mysql.sql并配置spring.sql.init.schema-locations。血泪经验:生产环境永远不要用create-drop,用Flyway管理SQL迁移才是正途。


5. 把官方示例变成你项目的“领域骨架”:三步迁移法与参数调优清单

别把官方示例当黑匣子——它真正的价值是给你一个可编辑的领域建模模板。我带团队落地时,从不全盘复制,而是用“三步迁移法”把它的骨架,长成自己业务的血肉:

5.1 第一步:替换核心领域概念,保留结构骨架

以电商系统为例,官方示例的Order对应你的TradeOrder,OrderItem对应TradeItem,但不要改包名和模块划分。直接在ordering.domain下新建TradeOrder类,继承AggregateRoot(示例中的基类),复用OrderId、OrderStatus等基础设施:

// 你的代码:src/main/java/com/yourcompany/trade/domain/TradeOrder.java public class TradeOrder extends AggregateRoot<TradeOrderId> { private final List<TradeItem> items; private TradeStatus status; // 复用示例的工厂模式,但业务规则按你需求写 public static TradeOrder create(TradeOrderId id, List<TradeItem> items) { // 加入你的风控规则:如单笔超5万需人工审核 if (items.stream().mapToDouble(i -> i.getAmount().doubleValue()).sum() > 50000) { throw new RiskControlException("Order exceeds limit"); } return new TradeOrder(id, items, TradeStatus.CREATED); } }

关键参数调优:TradeStatus枚举值必须覆盖你业务全生命周期(如CREATED→PAID→SHIPPED→COMPLETED→REFUNDED),少一个状态,后续流程就断。我见过团队因漏掉REFUNDED,导致退款单无法关联原订单。

5.2 第二步:用领域事件驱动你的第三方集成

官方示例用OrderPlacedEvent触发库存,你可以用TradePaidEvent触发物流下单、用TradeShippedEvent触发积分发放。重点不是事件名,而是事件载荷设计:

事件名必含字段为什么必须有生产环境建议
TradePaidEventtradeOrderId,paymentId,amount,payTime物流系统需知道“哪笔订单、付了多少钱、何时付的”,用于生成运单加traceId便于全链路追踪
TradeShippedEventtradeOrderId,logisticsNo,shipTime,warehouseCode积分系统需知道“哪个仓库发出的货”,用于计算区域激励字段用String而非对象,避免上下文耦合

表格说明:事件载荷必须是扁平化、不可变、自描述的数据结构。禁止传递TradeOrder实体(会拖垮序列化性能并泄露内部结构),只传必要ID和业务字段。

5.3 第三步:基础设施层替换——用MyBatis替代JPA,但不碰领域层

很多团队因JPA学习成本高,想换MyBatis。完全可以!官方示例的OrderRepository接口定义在domain/下,其实现类JpaOrderRepository在infrastructure/里。你只需:

  1. 在ordering.infrastructure下新建MyBatisOrderRepository,实现OrderRepository接口;
  2. 用@Mapper注解定义XML映射,SQL中INSERT INTO trade_orders (...) VALUES (...);
  3. domain/下的Order、OrderId等类一行代码都不用改。
// src/main/java/com/example/ordering/infrastructure/MyBatisOrderRepository.java @Repository public class MyBatisOrderRepository implements OrderRepository { private final TradeOrderMapper tradeOrderMapper; @Override public void save(Order order) { // 将Order转为TradeOrderDO,调用MyBatis insert TradeOrderDO do = TradeOrderDO.fromDomain(order); tradeOrderMapper.insert(do); } @Override public Optional<Order> findById(OrderId id) { TradeOrderDO do = tradeOrderMapper.selectById(id.getValue()); return Optional.ofNullable(do).map(TradeOrderDO::toDomain); } }

这就是DDD的威力:领域层是纯Java,不依赖任何框架。我曾用此法,在一周内将老系统JPA迁移到MyBatis,领域逻辑零改动,测试用例全部通过。

最后说个习惯:我每次拿到新业务需求,第一件事不是写Controller,而是打开官方示例的Order类,对照着写YourDomainEntity的工厂方法和领域方法。不是为了炫技,而是强迫自己先问:“这个操作,业务规则是什么?哪些状态能变?哪些不能?”——示例代码不是终点,是你每天写代码时,放在IDE侧边栏的那面镜子。希望帮到你。

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

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

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

立即咨询