简介:这份资源是领域驱动设计(DDD)的官方示例代码,面向希望深入理解 DDD 方法论并落地实践的 Java 开发者与架构学习者。它以船运业务为背景,将领域模型、聚合、实体与值对象、领域事件、领域服务、边界上下文、仓储持久化等核心概念融入可运行的工程代码中,帮助读者跳出理论、对照真实项目理解 DDD 的分层结构与建模思路。压缩包为 rar 格式,共 426 个文件,约 23.57MB,其中 142 个 java 源文件承载领域逻辑与分层实现,110 个 class 为编译产物,另有 35 个 jar 依赖、33 个 xml 配置、56 个 html 与 9 个 jsp 页面,以及 properties、sql、wsdl 等配套文件,覆盖从领域层到基础设施层的完整结构。目前已有 1899 人学习下载。通过研读源码,读者可掌握聚合边界划分、领域事件发布订阅、仓储解耦等实战技巧,并借助单元测试理解业务规则验证方式,适合作为 DDD 入门到进阶的参考范例。
1. 从一份官方示例代码说起:DDD 落地到底卡在哪
很多人第一次接触领域驱动设计,都是从啃《领域驱动设计》那本蓝皮书开始的。书翻完了,聚合根、值对象、领域事件这些词都能说上两句,可真到项目里一动手就懵了——实体和数据库表怎么对应?仓储接口到底该放哪一层?应用服务和领域服务怎么分工?这些问题书里讲的是原则,落到代码上全是空白。这份领域驱动设计官方示例代码,解决的就是这个断层。它不是又一篇理论文章,而是一套能跑起来、能拆开看的工程骨架,把 DDD 里那些抽象概念翻译成了具体的目录结构、类关系和调用链路。适合已经了解 DDD 基本概念、但不知道工程上怎么落地的后端开发者,也适合团队里想推 DDD 但缺一个参照标准的技术负责人。下面我按自己拆这套代码的顺序,把关键设计、复现步骤和踩过的坑一条条讲清楚。
2. 工程分层与模块依赖:先看懂骨架再动手
2.1 四层架构在代码里长什么样
DDD 的经典分层是用户接口层、应用层、领域层和基础设施层。这套官方示例代码基本遵循了这个结构,但不同语言版本的目录命名会有差异。以常见的 Java 版本为例,顶层包通常按interfaces、application、domain、infrastructure划分。领域层是核心,里面放聚合、实体、值对象、领域服务和仓储接口;应用层负责编排用例,调用领域对象完成业务动作,本身不包含业务规则;基础设施层实现仓储接口、发消息、调外部服务;用户接口层就是 Controller 或事件监听入口。
关键要看的是依赖方向。领域层不应该依赖任何其他层,仓储接口定义在领域层,实现放在基础设施层,靠依赖倒置把方向掰回来。你打开domain包,如果发现里面 import 了 Spring 的 JPA 注解或者 MyBatis 的 Mapper,那说明分层已经被破坏了。官方示例代码在这一点上通常做得比较干净,可以作为对照标准去检查自己项目里的分层是否串味。
2.2 模块依赖检查的实操步骤
拿到代码后别急着跑,先做一轮依赖体检。以 Maven 多模块项目为例,打开根目录的pom.xml,看模块划分和依赖声明。
# 查看模块列表 grep -A 20 "<modules>" pom.xml # 检查 domain 模块是否被其他模块反向依赖 grep -r "domain" --include="pom.xml" .如果发现infrastructure模块的 pom 里依赖了domain,这是正常的,因为要实现仓储接口。但如果domain模块的 pom 里依赖了infrastructure或者application,那就是循环依赖,说明分层设计有问题。另一种检查方式是看包之间的 import 关系,用 IDE 的依赖分析工具或者 ArchUnit 这类测试框架都能做。
注意:不同语言版本的示例代码目录命名差异较大。比如 C# 版本可能用
Domain、Application、Infrastructure、Web作为顶层项目名,Go 版本可能用internal/domain、internal/app这种结构。不要死记目录名,抓住依赖方向这个本质。
2.3 聚合边界的识别方法
分层看完了,接下来要找到业务核心——聚合。官方示例代码通常会选一个典型业务场景,比如订单、账户或者会议管理。找到聚合根的方法是:看哪个实体持有其他实体的引用,并且负责维护一致性边界。在代码里,聚合根通常有Repository接口与之对应,而且仓储的查询方法一般以聚合根为入口。
// 典型的聚合根定义 public class Order { // Order 是聚合根 private OrderId id; private List<OrderItem> items; // OrderItem 是聚合内实体 private Money totalAmount; // Money 是值对象 public void addItem(Product product, int quantity) { // 业务规则在这里,不在应用层 if (this.items.size() >= MAX_ITEMS) { throw new OrderLimitExceededException(); } this.items.add(new OrderItem(product, quantity)); recalculateTotal(); } }这段代码里,Order是聚合根,OrderItem只能通过Order的方法来修改,不能单独从仓储里查出来改。Money是值对象,没有标识,靠属性相等来判断。识别聚合边界的一个实用标准是:一次事务只修改一个聚合。如果你发现一个业务操作要同时改两个聚合,那要么是聚合边界划错了,要么应该用领域事件做最终一致性。
3. 从领域模型到可运行代码:复现路径与参数配置
3.1 环境准备与项目启动
官方示例代码一般会提供 README 说明运行方式,但有些版本年久失修,依赖的数据库或中间件版本可能已经对不上。我一般会先看构建文件里的依赖版本,再决定用哪个运行时。以 Java + Spring Boot 版本为例,常见做法是:
# 克隆代码后先看构建配置 cat pom.xml | grep -E "spring-boot|java.version" # 如果用的是 Maven Wrapper,直接用 ./mvnw clean install -DskipTests # 启动前确认数据库配置 cat src/main/resources/application.properties配置文件里重点看三处:数据库连接、JPA 的ddl-auto设置、以及是否启用了事件发布机制。ddl-auto在示例代码里常见create-drop或update,生产环境当然不能用,但本地跑通没问题。如果示例用的是 H2 内存数据库,那基本开箱即用;如果配的是 MySQL 或 PostgreSQL,需要先建库再启动。
提示:有些示例代码的 README 里写的数据库版本和实际依赖不一致,以
pom.xml或build.gradle里的版本为准。遇到启动报错先看驱动版本和数据库版本是否匹配。
3.2 领域事件的发布与监听配置
DDD 里聚合之间通信用领域事件,示例代码通常会演示一个完整的发布-监听链路。发布方在聚合根里收集事件,应用层在事务提交后发布出去。不同框架的实现方式不一样,Spring 里常见的是ApplicationEventPublisher,Axon 框架有自己的事件总线。
// 聚合根内部收集事件 public class Order { private List<DomainEvent> domainEvents = new ArrayList<>(); public void confirm() { this.status = OrderStatus.CONFIRMED; domainEvents.add(new OrderConfirmedEvent(this.id, Instant.now())); } public List<DomainEvent> getDomainEvents() { return Collections.unmodifiableList(domainEvents); } } // 应用服务里发布 @Service public class OrderApplicationService { @Transactional public void confirmOrder(OrderId orderId) { Order order = orderRepository.findById(orderId); order.confirm(); orderRepository.save(order); order.getDomainEvents().forEach(eventPublisher::publishEvent); order.clearDomainEvents(); } }这里的关键参数是事务边界。事件发布必须在事务提交之后,否则监听方可能读到还没落库的数据。Spring 里可以用@TransactionalEventListener(phase = AFTER_COMMIT)来保证这一点。如果示例代码里直接在领域层调用了事件发布器,那说明它把基础设施的关注点混进了领域层,属于反面教材,你可以借这个机会看看不这么写会出什么问题。
3.3 仓储实现与持久化映射
仓储接口在领域层,实现在基础设施层。示例代码里常见的实现方式是 Spring Data JPA 或者 MyBatis。JPA 的好处是聚合的保存和查询比较自然,但要注意懒加载和级联配置。MyBatis 更灵活,但需要自己写映射逻辑。
// 领域层仓储接口 public interface OrderRepository { Order findById(OrderId id); void save(Order order); List<Order> findByCustomerId(CustomerId customerId); } // 基础设施层 JPA 实现 @Repository public class JpaOrderRepository implements OrderRepository { private final OrderJpaDao dao; @Override public Order findById(OrderId id) { return dao.findById(id.getValue()) .map(this::toDomain) .orElseThrow(() -> new OrderNotFoundException(id)); } @Override public void save(Order order) { OrderPO po = toPersistence(order); dao.save(po); } }这里有个常见坑:领域对象和持久化对象要不要分开?示例代码里有的版本直接用领域对象加 JPA 注解,有的版本做了 PO 转换。两种做法各有取舍。直接用注解写起来快,但领域层就被 JPA 污染了;做转换保持了领域层干净,但代码量翻倍。我一般建议核心聚合做转换,边缘的简单实体可以直接注解,别一刀切。
4. 避坑与排查:拆这套代码时最容易翻车的几个点
4.1 贫血模型陷阱:实体只有 getter/setter
现象:打开示例代码的实体类,发现除了属性定义和 getter/setter 之外没有任何业务方法,所有业务逻辑都堆在 Service 里。
原因:这是最常见的 DDD 翻车方式。写代码的人把 DDD 理解成了“分层 + 实体”,但实体本身没有行为,退化成了数据容器。官方示例代码如果版本较老或者维护不积极,也可能出现这个问题。
解决:把业务规则搬回实体方法里。判断标准很简单——如果一个操作需要读取实体内部状态并做出决策,那它就应该在实体里。Service 只负责编排和事务控制,不负责业务判断。你可以拿示例代码里的订单确认流程做对照,看confirm()方法是写在 Order 里还是写在 OrderService 里。
4.2 聚合过大导致并发冲突
现象:保存一个聚合时频繁出现乐观锁冲突,或者一次加载把半个数据库都拉进来了。
原因:聚合边界划得太大,把本来不该放在一起的实体塞进了一个聚合。比如把“订单”和“客户”放在一个聚合里,每次改订单都要锁客户,并发一上来就互相等。
解决:聚合只包含真正需要强一致性的对象。客户和订单之间用 ID 引用,不用对象引用。如果示例代码里聚合根直接持有了另一个聚合根的引用,那就要警惕了。正确的做法是持有 ID,需要时通过仓储单独加载。
4.3 领域事件重复消费
现象:监听方收到同一条事件多次,导致重复扣款、重复发短信之类的业务异常。
原因:事件发布没有做幂等,或者消息中间件本身是 at-least-once 语义。示例代码为了简化,往往不做去重处理,直接照搬到生产就会出问题。
解决:监听方维护一个已处理事件 ID 的表,处理前先查是否已经处理过。或者在事件里带一个唯一标识,消费时做去重。示例代码里如果用的是 Spring 的ApplicationEventPublisher,默认是同步调用,不会重复;但如果换成了 Kafka 或 RabbitMQ,就必须自己处理幂等。
4.4 仓储接口泄漏持久化细节
现象:领域层的仓储接口里出现了Pageable、Example、Specification这类框架特有的类型。
原因:写仓储接口的时候直接照着 Spring Data 的JpaRepository抄,把分页、动态查询这些基础设施关注点带进了领域层。
解决:仓储接口只定义业务需要的查询方法,比如findByCustomerId、findOverdueOrders。分页参数可以用一个自定义的PageRequest值对象来传,不要直接用框架的类。示例代码里如果出现了org.springframework.data.domain.Pageable在领域层,那就是一个可以改进的点。
4.5 应用服务事务边界不清
现象:一个用例里调了多个应用服务方法,每个方法各自开事务,中间失败导致数据不一致。
原因:事务边界划在了应用服务方法上,但用例的原子性范围比单个方法大。
解决:事务边界应该和用例边界对齐。一个用例一个事务,在应用服务入口方法上加@Transactional,内部调用的领域方法不再单独开事务。示例代码里如果用了@Transactional在多个层级,需要检查传播行为是否合理。
5. 进阶用法:用示例代码做架构守护与团队规范
拆完这套代码,最有价值的用法不是照抄,而是把它变成团队里的架构守护工具。我一般会做三件事。
第一,提取依赖规则写成自动化测试。用 ArchUnit 或者类似的架构测试框架,把“领域层不能依赖基础设施层”“应用层不能直接调仓储实现”这些规则固化成测试用例。每次 CI 跑一遍,谁破坏了分层立刻报警。
// ArchUnit 规则示例 @ArchTest static final ArchRule domain_should_not_depend_on_infrastructure = noClasses().that().resideInAPackage("..domain..") .should().dependOnClassesThat() .resideInAPackage("..infrastructure..");第二,把示例代码里的聚合设计模式抽成团队模板。比如聚合根的标准结构、领域事件的收集和发布方式、仓储接口的命名规范,做成代码模板或者脚手架。新项目直接生成,省去每次重新讨论的时间。
第三,定期用示例代码做代码评审的参照。评审时遇到“这个逻辑该放哪层”的争论,直接打开示例代码对照。虽然示例不一定百分百完美,但至少提供了一个具体的讨论基础,比空对空争论效率高得多。
| 检查项 | 合格标准 | 常见问题 |
|---|---|---|
| 领域层依赖 | 不依赖基础设施和应用层 | import 了 JPA 或 Mapper |
| 聚合根行为 | 业务方法在实体内 | 只有 getter/setter |
| 仓储接口位置 | 定义在领域层 | 定义在基础设施层 |
| 事件发布时机 | 事务提交后 | 事务内直接发布 |
| 事务边界 | 与应用服务方法对齐 | 多层嵌套事务 |
从那以后我每次拿到一个新的 DDD 示例项目,都强制先跑一遍依赖检查,再看聚合根有没有行为,最后确认事件发布的事务边界。这三步走完,基本就能判断这套代码值不值得深入拆解。希望帮到你。
本文还有配套的精品资源,点击获取