早期我开始在公司技术部落地DDD时,我在订单主表写了一个Repository,然后又给订单明细表写了一个Repository。我当时是认为明细表有自己的增删改查需求,单独一个Repository用起来更方便。
但是这个做法却让聚合根逐渐失去对整个聚合的控制,一致性边界也会随之被打破。
日常开发中碰到的单据,几乎都是一张主表加一张明细表的结构。订货单、配货单、收货单、出入库单、盘点单,全是这个模式。主表存单据的整体信息,明细表存这条单据里每一行具体的条目。下面这篇内容就从这个最常见的表结构出发,聊聊为什么一个聚合只能有一个仓储,以及在代码里怎么用编译器来强制保证它。
仓储面向的是聚合,不是表
DDD里的Repository,和传统三层架构里的DAO不是一回事。DAO面向表,一张表一个DAO。Repository面向聚合,一个聚合一个Repository。
一个聚合可能对应一张表,也可能对应多张表。对于最常见的单据模型,它通常对应一张主表和一张明细表。Repository的save方法,调用方传进去的是一个聚合根对象,Repository内部自己处理主表和明细表的写入顺序、事务管理、外键回填。调用方不知道底层是两张表还是十张表,它只认聚合根。
反过来,getById也是一样的。Repository从主表查出数据,再从明细表批量查出关联数据,按主表id分组后组装成一个完整的聚合根对象返回。调用方拿到的就是一个明细列表已经有数据的聚合根,不需要自己再去做任何额外的查询或拼装。
Repository负责聚合的持久化。调用方只看领域对象,数据库怎么存、怎么查,都封装在Repository里面。
如果给明细单独建一个Repository,调用方就可以绕过聚合根直接操作明细数据,聚合根也就无法再统一维护整个聚合的一致性。
一致性边界是根源
为什么主表和明细表要放在一个聚合里?不是因为它们在数据库里有外键关系,而是因为在业务上它们经常也同时修改的。
拿一个最常见的场景来说:对单据执行某个状态变更操作时(从待发货扭转到已返回),主表的status字段要更新,明细表里的数量字段也要同步填入。这两件事必须在同一个事务里完成,否则主表和明细的数据就会出现不一致。
主表和明细表是一荣俱荣一损俱损的关系,这个一致性边界就是聚合存在的根本理由。
一个Repository保存的不是几张表,而是一个一致性边界。
既然是一个整体,对它的持久化操作就应该由一个Repository来统一管理。save的时候主表和明细一起存,update的时候主表和明细的变更一起同步到数据库,包括新增的明细、修改的明细、以及被聚合根移除的明细。这个职责只能由同一个Repository负责。
给明细单独建Repository,就意味着主表和明细的写入可以由不同的调用方在不同的时间点触发,一致性边界就被打破了。
子实体的写操作必须经过聚合根
在DDD里,子实体不是聚合根,没有独立的生命周期。子实体可以被单独查询,但不能绕过聚合根独立修改。实际业务中更多的操作是围绕整张单据展开的:「给我查一下这张单据的所有明细」「把这张单据里某个条目的数量改成50」。大多数业务操作都是围绕整张单据完成的,而不是某一条明细。
体现在代码上,对明细的增删改都由聚合根提供方法。添加明细通过聚合根的addItem,修改某条明细通过聚合根提供的方法,删除明细通过聚合根的removeItem。外部代码不能绕过聚合根直接操作子实体。
聚合根在操作子实体的时候还可以加上业务校验。比如removeItem在删除前先判断单据是不是待处理状态,已提交的不允许删除明细。这个校验涉及两个信息:单据自身的status和明细列表,都在聚合根的控制范围内。如果允许外部直接操作明细,这类校验逻辑就会散落到每个调用点,维护起来很痛苦。
子实体的持久化跟着聚合根一起走。调用repository.save(order)时,Repository实现里把主表和明细一起存。调用repository.update(order)时,Repository把主表和明细的变更一起同步到数据库。明细不需要自己的DomainRepository。
读操作是例外
上面讨论的都是写模型。写操作必须严格走聚合根,这一点没有商量余地。对于读模型,DDD没有要求一定经过聚合根,读操作可以放宽。
聚合根的Repository实现里可以提供按条件查询明细的方法,比如按单据id查明细列表、按某个字段查某条明细。这属于读取侧的便利接口,不违反聚合的写操作约束。读操作不改变数据状态,没有一致性风险。
管理后台的多条件分页查询场景更极端,可能需要跨聚合JOIN。CQRS的实践是:写操作严格走领域模型,读操作优先走Repository直接查,不得已时允许跨聚合SQL。读操作的放宽不会破坏聚合的完整性,因为数据只被读取不被修改。
小结
一个聚合一个仓储,这条原则不是DDD的教条,而是从一致性边界自然推导出来的设计决策。数据必须同时改的东西放在一个聚合里,一个聚合的持久化由一个Repository统一管理,子实体的写操作必须经过聚合根。