Spring Data JDBC 聚合根实战:从零理解并落地领域驱动设计
很多开发者在用 Spring Data JDBC 时会有同样的困惑:明明数据库里有主子表、有外键,为什么框架非要让我把对象组织成"一棵树",还规定只能从树的顶部去操作?这其实不是什么怪癖,而是 Spring Data Relational(Spring Data JDBC 与 Spring Data R2DBC 的共同母项目)对领域驱动设计(DDD)的一次彻底践行。本文将从一段真实的"坏味道"代码讲起,带你理解聚合根(Aggregate Root)的本质、框架在源码层面如何强制约束它,并给出可直接照抄的设计套路,帮你少走弯路。
一段让人头疼的"自由操作"代码
假设你正在维护一个订单系统,最初为了"灵活",代码是这样写的:
// 业务代码里,到处散落着对订单明细的直接操作
orderItemRepository.insert(item);
orderItemRepository.deleteById(itemId);
orderRepository.updateTotalAmount(order.getId());
表面上看每行代码都没错,但时间一长问题就来了:谁能保证"删明细"和"改订单总金额"这两步一定同时成功?谁能保证明细的总数跟订单上记录的条目数始终一致?一旦中间某一步抛异常,订单数据和明细数据就"各说各话"了。
这种痛苦在关系型数据库场景下特别常见,因为大家习惯了面向表编程:一张表一个仓库,想操作哪张表就调哪个仓库。可这种习惯恰恰与"领域模型一致性"的诉求背道而驰。
聚合根:给领域对象装上一道"门禁"
要解决上面的问题,领域驱动设计给出的答案是:把一组必须保持一致的对象打包成一个聚合(Aggregate),并指定其中一个对象作为聚合根(Aggregate Root)。聚合根就像小区的门禁,所有进出小区的行为都要经过它把关,业主不能自己撬开侧门进出。
拿订单举例:订单(Order)和订单明细(OrderItem)天然是一个聚合,订单就是聚合根。订单上的"明细条数"与明细表的实际记录数,在聚合内部必须实时一致;而订单与"客户"这种跨聚合的引用,则允许"最终一致",不用强求每一毫秒都同步。
聚合根必须承担的三大职责
- 划定边界:明确哪些对象属于聚合内部(随根一起存取),哪些只是外部引用(只存 ID)。边界画错了,后面的所有代码都会跟着错。
- 守住事务一致性:聚合内部任何状态变化,都必须作为一个原子操作提交,要么全成、要么全败。
- 持有身份标识:只有聚合根拥有全局唯一 ID,内部实体通常只有"本地标识"(例如明细在订单内的序号),外部代码只能通过聚合根来间接引用它们。
聚合与聚合根:一字之差,别搞混
聚合是"集合"的概念,是那组对象的整体;聚合根是这个集合的"代言人"和唯一入口。一个聚合有且只有一个聚合根,外部对聚合内任何对象的操作,都必须经过聚合根的方法来完成——这正是前面那段坏味道代码被否定的根本原因。
框架源码如何"焊死"聚合根规则
Spring Data JDBC 不是只在文档里喊口号,它在持久化链路里用代码强制约束了聚合根行为。我们从 spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/ 目录下的核心类说起。
setRoot 的类型校验:不是聚合根,门都进不去
DefaultRootAggregateChange 是描述"整个聚合即将发生的变化"的载体。它有一个 setRoot 方法,专门用来设定本次操作的目标聚合根,内部代码如下:
Assert.isInstanceOf(this.entityType, aggregateRoot,
String.format("AggregateRoot must be of type %s", entityType.getName()));
这句话的意思是:你传进来的对象必须与当前聚合根类型一致,否则直接抛异常。换句话说,框架在数据写入的第一道关卡就确认了"只有聚合根类型才能作为操作目标",从机制上杜绝了绕过聚合根去操作内部实体的可能。
一次保存背后的"动作清单"
聚合根的保存并非一条 SQL 就完事。RelationalEntityWriter 会把聚合根对象转换成一份 RootAggregateChange,这份"变更单"里记录着一连串 DbAction(数据库动作):对根执行 INSERT 或 UPDATE,对聚合内的其他实体执行 INSERT、UPDATE、DELETE。在 JDBC 模块中,JdbcAggregateTemplate 通过 AggregateChangeExecutor 按顺序执行这些动作,并在同一个事务里提交——这保证了聚合的原子性。
你甚至可以在 AggregateChange 接口的 Kind 枚举里看到框架对操作的抽象:SAVE(根上做插入/更新,聚合内其他元素做增删改)和 DELETE(删除聚合包含的所有实体)。一个聚合被删除,意味着它内部的实体一并被清理,这正是聚合边界在持久化层的体现。
内部实体怎么存?先删后建,别惊讶
需要提醒的是:当前实现中,从聚合根可达的内部实体,默认采用的是"删除后重建"策略,而不是精细比对后只更新差异行。这是框架为简化实现做的取舍。如果你有特殊的数据处理需求,完全可以重写 Repository 方法,用符合自己数据库设计风格的方式落地——文档中对此也有明确说明。
实战:用订单模型跑通聚合根的完整生命周期
理论说完,我们来动手。定义订单聚合,明细作为聚合内部实体:
class Order {
@Id
private Long id;
private String customerName;
private int numberOfItems; // 与明细条数保持一致的冗余字段
@MappedCollection(idColumn = "ORDER_ID")
private List<OrderItem> items;
public void addItem(OrderItem item) {
this.items.add(item);
this.numberOfItems = this.items.size();
}
}
class OrderItem {
@Id
private Long id;
private String productName;
private BigDecimal price;
}
注意两点:明细通过 @MappedCollection 挂到订单下,且业务规则(明细条数与总金额的联动)收敛在 addItem 方法里——这正是"聚合根负责验证"的体现。
保存与删除的语义:跟着聚合根走
在 JdbcAggregateTemplate 的集成测试中,spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/AbstractJdbcAggregateTemplateIntegrationTests.java 覆盖了大量聚合根生命周期场景,例如带引用实体的批量保存与删除、带版本字段的聚合操作等。核心语义是:你保存一个订单,框架会自动级联处理明细;你删除一批订单,明细也会跟着清掉,不会留下"孤儿数据"。
乐观锁:给聚合根加一道并发防线
多线程并发修改同一聚合时,需要乐观锁兜底。给聚合根加上 @Version 字段,框架会在每次更新时校验版本号,冲突时抛出异常。聚合根级别的版本控制,意味着整个聚合的"竞争"在根部就被拦截,内部实体不必各自维护版本。
新手最容易踩的四个坑
- 跨聚合直接引用对象:订单里直接塞一个
Customer对象,而不是customerId。这会让两个聚合的边界模糊,级联删除时极易误伤。 - 聚合做得过大:把用户、订单、商品全塞进一个聚合,每次加载都要捞一大片数据,性能和灵活性双双受损。
- 绕过聚合根改内部状态:直接给明细写一个 Repository,绕开订单根去增删明细,等于自废武功,把框架的一致性保护拱手扔掉。
- 忽略版本字段:没有
@Version的聚合在高并发下容易出现"最后写入覆盖前面提交"的经典事故。
六个可以立刻落地的设计建议
- 保持聚合小巧:宁多勿大,聚合越小,事务边界越清晰,锁竞争越小。
- 跨聚合只存 ID:需要另一个聚合的数据时,通过 Repository 按 ID 查询,而不是持有对象引用。
- 业务规则全部收敛到聚合根:验证、计算、状态流转都放进根的方法里,外部只能"调用行为",不能"直接改数据"。
- 一个聚合根对应一个 Repository:不要为内部实体单独建 Repository,这会让聚合边界形同虚设。
- 善用版本控制:凡是有并发写可能的聚合,都加上
@Version乐观锁。 - 为聚合根编写生命周期测试:参考
AbstractJdbcAggregateTemplateIntegrationTests的用例模式,把"保存-修改-删除"整条链路覆盖清楚。
写在最后
聚合根听起来是个抽象概念,但落到 Spring Data JDBC 里,它就是"所有数据操作必须经过的那道门"。框架通过 DefaultRootAggregateChange 的类型校验、RelationalEntityWriter 的动作拆分、AggregateChangeExecutor 的事务执行,把聚合边界、事务一致性、身份标识这三大职责落到了每一行持久化代码里。
无论你用的是 Spring Data JDBC 还是响应式的 Spring Data R2DBC,理解并正确应用聚合根,都是构建高质量领域模型的关键一步。从今天起,先把自己的订单、用户、商品按聚合边界重新梳理一遍,你会发现代码的清晰度和可维护性都会上一个台阶。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



