聚合根与领域事件:DDD 战术建模
银行核心系统里最容易被低估的概念,是「聚合」。很多人把 DDD 战术设计理解成「给实体加几个注解」,结果聚合越写越大,事务越来越长,最后把数据库和团队都拖垮。本文把聚合根和领域事件放回它们本来的位置:一致性边界的设计工具,以及领域向外界发声的窗口。
为什么需要聚合
领域模型不是一张 ER 图。ER 图关心「数据怎么存」,聚合关心「哪些数据必须在同一个事务里保持一致」。这两件事经常错位,而错位的代价在金融场景里格外昂贵——账务不平、状态错乱,往往不是因为算法错,而是因为一致性边界划错了。
一致性边界
聚合的第一性原理是:在边界之内,用强一致性保证业务规则不被破坏;在边界之外,用最终一致性传递变化。一个账户余额不能为负,这条规则必须在一个事务内被满足,所以它属于聚合内部。而「账户余额变动后通知风控」,则属于边界之外,交给领域事件。
经验法则:如果两条业务规则必须同时为真,它们大概率属于同一个聚合;如果可以接受短暂的不一致,它们就分属不同聚合。
聚合不是「大对象」
最常见的反模式,是把「客户」做成包含地址、联系人、合同、账户、画像的超级对象。它在概念上很完整,但在工程上是个灾难:每次改一个手机号,都要加载半个数据库,还要锁住一堆根本无关的数据。
聚合的边界应以事务一致性而非业务概念大小来划。概念上属于「同一个东西」的数据,未必需要在同一个事务里。
聚合根的设计原则
聚合根是聚合对外的唯一入口。外部只能持有聚合根的引用,不能直接操作聚合内部的实体或值对象。
引用靠 ID,不靠对象
跨聚合的关联,永远用标识符,而不是对象引用。聚合 A 不应直接持有聚合 B 的对象,否则两个聚合会被悄悄绑进同一事务。
工厂与仓储
复杂聚合的创建交给工厂,避免调用方掌握内部构造细节;聚合的持久化交给仓储,且仓储只按聚合根 ID 加载整个聚合,不存在「只查一半」的仓储方法。
- 工厂负责「从无到有」并保证初始不变式成立。
- 仓储负责「整存整取」,不暴露部分更新。
- 应用服务协调工厂、仓储与领域事件发布,但不写业务规则。
不变式要内聚
不变式(invariant)是聚合存在的理由。余额不为负、转账双方必须同币种、合同生效前不能放款——这些规则写在聚合内部的方法里,而不是散落到 service 层去做 if 校验。把规则内聚到聚合里,测试时只需构造一个对象就能验证业务,不必搭一整套上下文。
领域事件是聚合的「对外窗口」
聚合根在自己身上记录了事件,但绝不能自己发消息。聚合只负责产生事件对象,发布动作由应用服务或仓储在完成事务后统一处理。这样聚合保持纯净,测试也简单。
事件如何产生
事件是对「已经发生的事实」的描述,命名用过去式:MoneyWithdrawn、AccountOpened、LimitExceeded。它携带聚合根 ID、关键数值和时间戳。
发布与订阅的边界
发布要在本地事务提交之后,否则会出现「消息发了、事务回滚」的幽灵事件。常见做法是在同一个数据库事务里写一张发件箱表(outbox),再由独立投递器搬运到消息队列。
Outbox 模式的价值不在于花哨,而在于它把「数据库一致」和「消息可靠」这两件事重新对齐:要么都成功,要么都失败。
| 方案 | 一致性保证 | 复杂度 |
|---|---|---|
| 事务内直发 MQ | 弱,可能幽灵事件 | 低 |
| Outbox + 投递器 | 强,与本地事务同生命周期 | 中 |
| CDC 捕获 binlog | 强,对业务无侵入 | 高 |
一个银行开户的例子
假设开户需要:建立客户档案、创建主账户、初始化额度、通知风控建档。
| 步骤 | 归属聚合 | 一致性要求 |
|---|---|---|
| 创建客户档案 | Customer 聚合 | 强一致,档案落库即生效 |
| 创建主账户 | Account 聚合 | 强一致,账户初始余额为零 |
| 初始化额度 | Limit 聚合 | 强一致,额度与账户绑定 |
| 通知风控 | 跨聚合 | 最终一致,事件驱动 |
前三项各自在自己的聚合内完成,开户服务用一次 saga 或本地事务把它们串起来;最后一项通过领域事件异步触发,风控系统晚几秒建档完全可接受。
事件流
订阅方消费 AccountOpened 即可并行去建额度、建风控档案,不必等开户服务逐个调用。这里体现的是聚合协作的核心思想:同步走强一致,异步走事件,两者各司其职。
常见误区
- 把聚合当 CRUD 载体:聚合方法名是
save、update,里面没有任何业务规则。这时 DDD 只是换了个地方写 SQL。 - 跨聚合事务:为了「保证一起成功」而在应用层开一个大事务锁住多个聚合。正确做法是 sagas/事件,接受最终一致。
- 聚合里注入仓储或发消息:聚合一旦依赖外部基础设施,就再也单元测试不了,领域层也被污染。
- 过度拆分聚合:为了「小」而把本应一致的两条规则拆到两个聚合,结果反而要处处补偿。
- 聚合越小,并发越高,但跨聚合协作成本也越高。
- 聚合越大,一致性越好写,但锁竞争和加载成本会反噬。
- 平衡点来自对业务规则的诚实审视,而非拍脑袋。
结论
聚合根和领域事件不是花活,它们是把「业务的硬约束」翻译成「代码的硬边界」的手段。先把一致性边界划对,再让聚合通过事件对外低耦合地协作,银行核心系统才能真正既稳又活。下一讲我们会把这套思路接到 CQRS 与 Saga 上,看复杂长事务怎么在不牺牲一致性的前提下拆开。