简介:这份资源是领域驱动设计(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 List<OrderItem> items; private OrderStatus status; // 工厂方法创建,保证创建时状态合法 public static Order create(OrderId id, List<OrderItem> 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 { Optional<Order> findById(OrderId id); void save(Order order); List<Order> findByStatus(OrderStatus status); }// infrastructure/persistence/JpaOrderRepository.java @Repository public class JpaOrderRepository implements OrderRepository { private final OrderJpaDao dao; private final OrderConverter converter; @Override public Optional<Order> 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 ResponseEntity<Void> 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。第一次构建会拉依赖,网络慢的话耐心等,别中途 Ctrl+C,容易留下损坏的本地仓库,后面报一堆找不到包的错。
注意:如果
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 膨胀到两千行,回头重构比重写还累。从那以后我每次引入这类骨架,都先强制走一遍“规则下沉”的检查:应用服务里出现业务分支就停下来,问自己这条规则该不该由聚合根守。希望这份拆解能帮你少走一遍我走过的弯路,也希望你能把这份示例真正跑起来、改起来,而不只是读一遍。
本文还有配套的精品资源,点击获取