2026/10/28 6:45:26

领域驱动设计官方示例代码拆解:从跑通到二次开发改造

领域驱动设计官方示例代码拆解:从跑通到二次开发改造 简介这份资源是领域驱动设计DDD的官方示例代码面向希望深入理解 DDD 方法论并落地实践的 Java 开发者与架构学习者。示例以船运系统为业务背景涵盖领域模型、聚合、实体与值对象、领域事件、领域服务、限界上下文、仓储持久化及分层架构等核心概念的具体实现可帮助读者对照代码理解如何将 DDD 原则应用于真实业务场景。压缩包共 426 个文件以 142 个 Java 源码和 110 个 class 文件为主体辅以 56 个 html 页面、33 个 xml 配置、35 个 jar 依赖及少量 jsp、properties、sql 等资源整体约 23.57MB目录结构完整便于按模块检索研读。目前已有 1901 人学习下载适合需要系统掌握 DDD 建模思路、提升系统可维护性的中高级开发者参考借鉴。1. 从一份“能跑起来的 DDD 样板”说起很多人第一次接触领域驱动设计都是从一堆概念开始的聚合根、值对象、限界上下文、防腐层、领域事件。概念背得滚瓜烂熟真到项目里却不知道从哪下手最后写出来的代码还是“Service 里塞满 if-else实体只剩 getter/setter”。这份领域驱动设计官方示例代码的价值就在这儿——它不是讲概念的 PPT而是一套能直接拉下来跑、能对着改的工程骨架。它把分层、聚合、仓储、领域事件这些抽象词落成了具体目录和类适合两类人一是想搞明白 DDD 到底怎么落到代码结构上的新手二是准备在真实项目里引入 DDD、需要一份可参照模板的熟手。下面我按“它是什么 → 怎么跑 → 怎么改”的顺序把这份示例拆开讲。2. 项目定位与技术选型它到底解决了什么问题2.1 这份示例的定位不是框架是结构范本先明确一点这份代码不是某个通用框架也不是拿来即用的脚手架它的定位是“结构范本”。传统三层架构里业务逻辑散落在 Service实体是贫血的改一个规则要翻好几个文件。这份示例要解决的核心问题是把业务规则收拢到领域层让聚合根自己守住不变量应用层只负责编排用例基础设施层负责技术细节。换句话说它示范的是“依赖倒置 领域建模”怎么在工程里落地。从目录上就能看出这个意图领域层不依赖任何外部技术应用层依赖领域层基础设施层实现领域层定义的接口接口层Web/API只做参数转换和调用。这种依赖方向是单向的领域层处在最内圈这也是它敢叫“官方示例”的底气——结构本身就是教学内容。2.2 技术栈与分层约定示例通常采用主流的企业级技术栈常见组合是 Java Spring Boot JPA/MyBatis或者 C# .NET具体以你拿到的版本为准。不管语言怎么变分层约定是一致的我按最常见的 Java 版本来讲其他语言对照着看即可。层次职责依赖方向接口层 Interface接收请求、参数校验、DTO 转换依赖应用层应用层 Application编排用例、事务边界、调用领域对象依赖领域层领域层 Domain聚合根、实体、值对象、领域服务、仓储接口不依赖任何外层基础设施层 Infrastructure仓储实现、持久化、消息、外部服务适配实现领域层接口这张表看着简单但真正写代码时最容易翻车的就是依赖方向。我见过不少项目嘴上说 DDD结果领域层的实体上挂着Entity注解、直接引用了 JPA领域层被迫依赖基础设施分层就名存实亡了。这份示例在这一点上是克制的领域对象尽量保持纯净持久化映射放在基础设施层或用独立的映射配置。提示判断一份 DDD 代码是否合格先看领域层有没有 import 外部框架的包。有就说明边界没守住。2.3 为什么值得拿来当参照市面上的 DDD 资料要么太理论要么是某个大厂内部框架的裁剪版普通人拿不到完整上下文。这份示例的好处是“小而全”聚合、值对象、仓储、领域事件、应用服务、DTO 转换都有对应代码规模不大一两天能读完。它不追求覆盖所有 DDD 模式但把最核心的那条链路走通了。对熟手来说它的价值在于提供一个“可以对着挑毛病”的基线——你可以拿它和团队现有结构对比找出哪里该收、哪里该放。3. 目录结构与核心模块拆解一个用例从入口到落库3.1 目录结构长什么样拿到代码先别急着跑花十分钟把目录过一遍后面读代码会顺很多。典型结构大致如下以 Java 多模块或单模块分包为例src/main/java/com/example/ddd ├── interfaces/ # 接口层 │ ├── rest/ # Controller、请求响应对象 │ └── assembler/ # DTO 与领域对象互转 ├── application/ # 应用层 │ ├── service/ # 用例编排 │ └── command/ # 命令对象 ├── domain/ # 领域层 │ ├── model/ # 聚合根、实体、值对象 │ ├── repository/ # 仓储接口 │ ├── service/ # 领域服务 │ └── event/ # 领域事件 └── infrastructure/ # 基础设施层 ├── persistence/ # 仓储实现、ORM 映射 └── messaging/ # 事件发布实现这个结构的关键不在名字而在“谁引用谁”。domain包里不应该出现infrastructure的任何类application包只通过接口和领域层打交道。你二次开发时新增功能先想清楚这段逻辑属于哪一层再决定放哪个包别一股脑塞进 Controller。3.2 聚合根与值对象业务规则的落脚点聚合根是 DDD 里最容易被写歪的东西。贫血模型下实体只有属性规则全在 Service这份示例里聚合根自己维护状态和不变量。看一段典型的聚合根代码public class Order { private OrderId id; private ListOrderItem items; private OrderStatus status; // 工厂方法创建保证创建时状态合法 public static Order create(OrderId id, ListOrderItem items) { if (items null || items.isEmpty()) { throw new IllegalArgumentException(订单至少需要一个商品项); } Order order new Order(); order.id id; order.items new ArrayList(items); order.status OrderStatus.CREATED; return order; } // 业务方法内聚规则而不是暴露 setter public void pay() { if (status ! OrderStatus.CREATED) { throw new IllegalStateException(当前状态不可支付: status); } this.status OrderStatus.PAID; } public Money totalAmount() { return items.stream() .map(OrderItem::subtotal) .reduce(Money.ZERO, Money::add); } }这段代码有几个点值得说。第一构造走静态工厂create而不是公开构造函数目的是在创建时就校验不变量避免出现“半成品”对象。第二状态流转用pay()这样的业务方法而不是setStatus()这样规则跟着数据走外部改不了状态。第三totalAmount()是计算属性不落库避免冗余字段和主数据不一致。OrderId、Money这类是值对象通常做成不可变相等性按值比较而不是按引用。参数上要注意OrderId一般包装一层避免和普通字符串混用Money要带币种别用double存金额这是血泪经验浮点误差在金额场景里迟早出事。3.3 仓储接口与实现依赖倒置的落点仓储是领域层和持久化之间的桥。示例里的做法是接口定义在领域层实现放在基础设施层。接口只描述领域需要的能力不暴露 ORM 细节。// domain/repository/OrderRepository.java public interface OrderRepository { OptionalOrder findById(OrderId id); void save(Order order); ListOrder findByStatus(OrderStatus status); }// infrastructure/persistence/JpaOrderRepository.java Repository public class JpaOrderRepository implements OrderRepository { private final OrderJpaDao dao; private final OrderConverter converter; Override public OptionalOrder findById(OrderId id) { return dao.findById(id.value()).map(converter::toDomain); } Override public void save(Order order) { dao.save(converter.toEntity(order)); } }逻辑说明领域层只认OrderRepository这个接口应用层注入的也是接口运行时由 Spring 注入JpaOrderRepository。这样换持久化技术比如从 JPA 换到 MyBatis只改基础设施层领域层一行不动。converter负责领域对象和数据库实体之间的转换别图省事让领域对象直接带Entity那样领域层就被框架绑死了。参数说明findById返回Optional强制调用方处理“不存在”的情况比返回 null 安全save用“保存或更新”语义具体由底层实现决定二次开发时如果要做乐观锁可以在聚合根上加版本号字段。3.4 应用服务用例编排与事务边界应用服务是“薄”的一层它不写业务规则只负责编排取聚合、调领域方法、存回去、发事件。事务边界通常也放在这一层。Service public class OrderApplicationService { private final OrderRepository orderRepository; private final DomainEventPublisher eventPublisher; Transactional public void payOrder(String orderId) { Order order orderRepository.findById(new OrderId(orderId)) .orElseThrow(() - new OrderNotFoundException(orderId)); order.pay(); // 规则在领域层 orderRepository.save(order); eventPublisher.publish(new OrderPaidEvent(order.id())); } }这段代码短但信息量不小。Transactional标在应用服务方法上保证“改状态 落库 发事件”在一个事务里order.pay()把规则委托给聚合根应用层不判断状态事件发布放在保存之后常见做法是先落库再发事件避免事件发了但数据没存。要注意的是如果事件消费者是异步的得考虑“本地事务提交后事件才真正发出”否则会出现消费者查不到数据的情况这是分布式场景里的经典坑。3.5 接口层与 DTO 转换别让外部模型污染领域接口层只做三件事接参数、调应用服务、转响应。请求对象和领域对象要分开别直接把领域对象序列化出去。RestController RequestMapping(/orders) public class OrderController { private final OrderApplicationService service; PostMapping(/{id}/pay) public ResponseEntityVoid pay(PathVariable String id) { service.payOrder(id); return ResponseEntity.ok().build(); } }参数说明PathVariable拿到的id是字符串进应用服务后转成OrderId值对象返回用ResponseEntity便于控制状态码。二次开发时如果响应字段变多建议单独定义OrderResponse并由 assembler 转换别把聚合根直接返回否则领域模型一改对外接口就跟着崩。4. 把示例跑起来环境、启动与验证4.1 环境准备与依赖安装跑之前先确认本机环境。以 Java 版本为例常见要求是 JDK 17 及以上、Maven 3.8数据库默认可能用 H2 内存库方便演示也可能配了 MySQL。先看项目根目录的pom.xml或build.gradle确认版本再看application.yml里的数据源配置。# 确认 JDK 版本 java -version # 编译并跳过测试先确保能构建 mvn clean package -DskipTests # 启动应用 mvn spring-boot:run如果用的是 Gradle对应命令是./gradlew build -x test和./gradlew bootRun。第一次构建会拉依赖网络慢的话耐心等别中途 CtrlC容易留下损坏的本地仓库后面报一堆找不到包的错。注意如果application.yml里配的是 MySQL 而不是 H2启动前先把库建好、账号密码对上否则启动直接报连接失败。4.2 启动后怎么验证核心链路应用起来后别只看“Started”日志就完事要验证一条完整用例。假设示例提供了订单相关接口可以用 curl 走一遍“创建 → 支付 → 查询”# 创建订单请求体按实际 DTO 调整 curl -X POST http://localhost:8080/orders \ -H Content-Type: application/json \ -d {items:[{productId:P001,quantity:2,price:19.9}]} # 支付订单 curl -X POST http://localhost:8080/orders/{orderId}/pay # 查询订单状态 curl http://localhost:8080/orders/{orderId}验证点有三个创建后状态是否为初始态、支付后状态是否流转、重复支付是否被拒绝。第三个点最能说明领域规则生效了——如果重复支付还能成功说明规则没落在聚合根里或者应用层绕过了领域方法。这一步走通基本就确认示例的核心链路是活的。4.3 测试用例怎么读、怎么补示例一般带单元测试重点看领域层的测试。领域测试不依赖 Spring 容器直接 new 聚合根、调方法、断言状态跑得快、反馈直接。Test void 重复支付应抛出异常() { Order order Order.create(new OrderId(O1), List.of(sampleItem())); order.pay(); assertThrows(IllegalStateException.class, order::pay); }读测试是理解领域规则最快的路径因为测试里写的就是“什么情况下允许、什么情况下拒绝”。二次开发时新增一条业务规则先补一个领域测试再改聚合根这个顺序能逼着你把规则想清楚而不是先改 Service 再补测试凑数。5. 避坑与常见问题分层落地时最容易翻车的地方5.1 领域层被框架注解污染现象领域实体上挂着Entity、Table甚至直接继承框架基类。原因图省事想让 ORM 直接映射领域对象。解决领域对象保持纯净持久化实体单独定义用 converter 转换。代价是多写一个类收益是领域层可独立测试、可换持久化技术。我一般会在代码评审时专门搜领域层的 import发现框架包就打回。5.2 应用服务越写越厚现象应用服务里出现大量 if-else 和状态判断慢慢变成新的“胖 Service”。原因开发者习惯把逻辑写在顺手的地方领域方法没跟上。解决凡是涉及状态流转、金额计算、不变量校验的一律下沉到聚合根或领域服务应用服务只保留“取、调、存、发事件”。判断标准很简单应用服务里出现业务条件分支就该警惕了。5.3 仓储接口暴露了查询细节现象仓储接口里出现findByXxxAndYyyOrderByZzz这类方法越加越多。原因把仓储当 DAO 用。解决仓储面向聚合只提供领域真正需要的查询复杂查询走独立的查询服务CQRS 的读模型别往仓储接口里堆。二次开发时如果发现仓储方法超过七八个基本可以判断设计跑偏了。5.4 领域事件发了但消费不到现象事件发布代码执行了消费者没反应。原因事件发布在事务提交前或者发布器没配异步、没注册监听。解决确认发布时机在事务提交后可用事务同步回调确认监听器被容器扫描到确认事件类型匹配。排查时先在发布处打日志再在监听处打日志两头一对就知道断在哪。5.5 值对象用错导致比较失效现象两个内容相同的值对象用或默认equals比较返回 false去重、缓存全乱。原因值对象没重写equals和hashCode。解决值对象一律不可变按值重写相等性金额、ID 这类尤其要注意。这个坑不显眼但一旦踩上排查起来很费时间。6. 二次开发改造思路从示例到能上生产的骨架把示例跑通只是第一步真正要拿它做项目得做几处改造。第一处是持久化映射示例为了简洁可能让领域对象和数据库实体靠得比较近生产环境建议彻底分离领域对象不落任何 ORM 注解转换逻辑集中在 converter虽然多写代码但换库、加缓存、做审计都方便。第二处是领域事件的可靠性示例里的事件发布往往是同步直发生产环境要考虑“本地事务 可靠消息”常见做法是落一张事件表事务提交后由后台任务投递避免事件丢失。第三处是查询侧。DDD 的写模型适合按聚合操作但列表页、报表这类读多写少的场景硬套聚合会很别扭。我的做法是写侧走聚合 仓储读侧单独建查询服务直接查视图或宽表不强求走领域模型。这不是违背 DDD而是承认读写不对称CQRS 本来就是 DDD 社区常见的配套手段。第四处是模块边界。示例通常是单模块真实项目里限界上下文一多建议按上下文拆模块甚至拆服务模块间通过应用层接口或事件通信别让两个上下文的领域对象互相引用。拆之前先把上下文边界画清楚边界不清就拆只会把耦合从代码里搬到服务调用里。验证改造是否成功我一般看三个信号领域层能不能脱离 Spring 单独跑测试新增一条业务规则是不是只改聚合根和对应测试换一个持久化实现是不是只动基础设施层。三个都满足说明分层是实的不是摆设。说个我自己的教训。早些年我拿到一份类似示例觉得结构清晰直接在上面堆业务结果图快把状态判断写进了应用服务半年后规则一多Service 膨胀到两千行回头重构比重写还累。从那以后我每次引入这类骨架都先强制走一遍“规则下沉”的检查应用服务里出现业务分支就停下来问自己这条规则该不该由聚合根守。希望这份拆解能帮你少走一遍我走过的弯路也希望你能把这份示例真正跑起来、改起来而不只是读一遍。本文还有配套的精品资源点击获取