Spring Data JDBC 聚合根实战:从零理解并落地领域驱动设计

Spring Data JDBC 聚合根实战:从零理解并落地领域驱动设计

【免费下载链接】spring-data-relational Spring Data Relational. Home of Spring Data JDBC and Spring Data R2DBC. 【免费下载链接】spring-data-relational 项目地址: https://gitcode.com/gh_mirrors/sp/spring-data-relational

很多开发者在用 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)天然是一个聚合,订单就是聚合根。订单上的"明细条数"与明细表的实际记录数,在聚合内部必须实时一致;而订单与"客户"这种跨聚合的引用,则允许"最终一致",不用强求每一毫秒都同步。

聚合根必须承担的三大职责

  1. 划定边界:明确哪些对象属于聚合内部(随根一起存取),哪些只是外部引用(只存 ID)。边界画错了,后面的所有代码都会跟着错。
  2. 守住事务一致性:聚合内部任何状态变化,都必须作为一个原子操作提交,要么全成、要么全败。
  3. 持有身份标识:只有聚合根拥有全局唯一 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 字段,框架会在每次更新时校验版本号,冲突时抛出异常。聚合根级别的版本控制,意味着整个聚合的"竞争"在根部就被拦截,内部实体不必各自维护版本。

新手最容易踩的四个坑

  1. 跨聚合直接引用对象:订单里直接塞一个 Customer 对象,而不是 customerId。这会让两个聚合的边界模糊,级联删除时极易误伤。
  2. 聚合做得过大:把用户、订单、商品全塞进一个聚合,每次加载都要捞一大片数据,性能和灵活性双双受损。
  3. 绕过聚合根改内部状态:直接给明细写一个 Repository,绕开订单根去增删明细,等于自废武功,把框架的一致性保护拱手扔掉。
  4. 忽略版本字段:没有 @Version 的聚合在高并发下容易出现"最后写入覆盖前面提交"的经典事故。

六个可以立刻落地的设计建议

  • 保持聚合小巧:宁多勿大,聚合越小,事务边界越清晰,锁竞争越小。
  • 跨聚合只存 ID:需要另一个聚合的数据时,通过 Repository 按 ID 查询,而不是持有对象引用。
  • 业务规则全部收敛到聚合根:验证、计算、状态流转都放进根的方法里,外部只能"调用行为",不能"直接改数据"。
  • 一个聚合根对应一个 Repository:不要为内部实体单独建 Repository,这会让聚合边界形同虚设。
  • 善用版本控制:凡是有并发写可能的聚合,都加上 @Version 乐观锁。
  • 为聚合根编写生命周期测试:参考 AbstractJdbcAggregateTemplateIntegrationTests 的用例模式,把"保存-修改-删除"整条链路覆盖清楚。

写在最后

聚合根听起来是个抽象概念,但落到 Spring Data JDBC 里,它就是"所有数据操作必须经过的那道门"。框架通过 DefaultRootAggregateChange 的类型校验、RelationalEntityWriter 的动作拆分、AggregateChangeExecutor 的事务执行,把聚合边界、事务一致性、身份标识这三大职责落到了每一行持久化代码里。

无论你用的是 Spring Data JDBC 还是响应式的 Spring Data R2DBC,理解并正确应用聚合根,都是构建高质量领域模型的关键一步。从今天起,先把自己的订单、用户、商品按聚合边界重新梳理一遍,你会发现代码的清晰度和可维护性都会上一个台阶。

【免费下载链接】spring-data-relational Spring Data Relational. Home of Spring Data JDBC and Spring Data R2DBC. 【免费下载链接】spring-data-relational 项目地址: https://gitcode.com/gh_mirrors/sp/spring-data-relational

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值