跳转到主要内容

聚合根与领域事件:DDD 战术建模

从一致性边界到事件驱动,拆解聚合根的设计原则、领域事件的发布与消费,以及在银行核心系统里的落地坑点。

银行核心系统里最容易被低估的概念,是「聚合」。很多人把 DDD 战术设计理解成「给实体加几个注解」,结果聚合越写越大,事务越来越长,最后把数据库和团队都拖垮。本文把聚合根和领域事件放回它们本来的位置:一致性边界的设计工具,以及领域向外界发声的窗口。

为什么需要聚合

领域模型不是一张 ER 图。ER 图关心「数据怎么存」,聚合关心「哪些数据必须在同一个事务里保持一致」。这两件事经常错位,而错位的代价在金融场景里格外昂贵——账务不平、状态错乱,往往不是因为算法错,而是因为一致性边界划错了。

一致性边界

聚合的第一性原理是:在边界之内,用强一致性保证业务规则不被破坏;在边界之外,用最终一致性传递变化。一个账户余额不能为负,这条规则必须在一个事务内被满足,所以它属于聚合内部。而「账户余额变动后通知风控」,则属于边界之外,交给领域事件。

经验法则:如果两条业务规则必须同时为真,它们大概率属于同一个聚合;如果可以接受短暂的不一致,它们就分属不同聚合。

聚合不是「大对象」

最常见的反模式,是把「客户」做成包含地址、联系人、合同、账户、画像的超级对象。它在概念上很完整,但在工程上是个灾难:每次改一个手机号,都要加载半个数据库,还要锁住一堆根本无关的数据。

聚合的边界应以事务一致性而非业务概念大小来划。概念上属于「同一个东西」的数据,未必需要在同一个事务里。

聚合根的设计原则

聚合根是聚合对外的唯一入口。外部只能持有聚合根的引用,不能直接操作聚合内部的实体或值对象。

引用靠 ID,不靠对象

跨聚合的关联,永远用标识符,而不是对象引用。聚合 A 不应直接持有聚合 B 的对象,否则两个聚合会被悄悄绑进同一事务。

public class Account {
    private final AccountId id;          // 聚合根标识
    private final CustomerId ownerId;    // 跨聚合引用:只存 ID
    private final Money balance;
    private final List<DomainEvent> pendingEvents = new ArrayList<>();

    public void withdraw(Money amount) {
        if (balance.isLessThan(amount)) {
            throw new InsufficientBalanceException(id);
        }
        balance = balance.subtract(amount);
        pendingEvents.add(new MoneyWithdrawn(id, amount, now()));
    }

    public List<DomainEvent> drainEvents() {
        var copy = new ArrayList<>(pendingEvents);
        pendingEvents.clear();
        return copy;
    }
}

工厂与仓储

复杂聚合的创建交给工厂,避免调用方掌握内部构造细节;聚合的持久化交给仓储,且仓储只按聚合根 ID 加载整个聚合,不存在「只查一半」的仓储方法。

  • 工厂负责「从无到有」并保证初始不变式成立。
  • 仓储负责「整存整取」,不暴露部分更新。
  • 应用服务协调工厂、仓储与领域事件发布,但不写业务规则。

不变式要内聚

不变式(invariant)是聚合存在的理由。余额不为负、转账双方必须同币种、合同生效前不能放款——这些规则写在聚合内部的方法里,而不是散落到 service 层去做 if 校验。把规则内聚到聚合里,测试时只需构造一个对象就能验证业务,不必搭一整套上下文。

领域事件是聚合的「对外窗口」

聚合根在自己身上记录了事件,但绝不能自己发消息。聚合只负责产生事件对象,发布动作由应用服务或仓储在完成事务后统一处理。这样聚合保持纯净,测试也简单。

事件如何产生

事件是对「已经发生的事实」的描述,命名用过去式:MoneyWithdrawnAccountOpenedLimitExceeded。它携带聚合根 ID、关键数值和时间戳。

public record MoneyWithdrawn(
        AccountId accountId,
        Money amount,
        Instant occurredAt
) implements DomainEvent {}

发布与订阅的边界

发布要在本地事务提交之后,否则会出现「消息发了、事务回滚」的幽灵事件。常见做法是在同一个数据库事务里写一张发件箱表(outbox),再由独立投递器搬运到消息队列。

Outbox 模式的价值不在于花哨,而在于它把「数据库一致」和「消息可靠」这两件事重新对齐:要么都成功,要么都失败。

方案一致性保证复杂度
事务内直发 MQ弱,可能幽灵事件
Outbox + 投递器强,与本地事务同生命周期
CDC 捕获 binlog强,对业务无侵入

一个银行开户的例子

假设开户需要:建立客户档案、创建主账户、初始化额度、通知风控建档。

步骤归属聚合一致性要求
创建客户档案Customer 聚合强一致,档案落库即生效
创建主账户Account 聚合强一致,账户初始余额为零
初始化额度Limit 聚合强一致,额度与账户绑定
通知风控跨聚合最终一致,事件驱动

前三项各自在自己的聚合内完成,开户服务用一次 saga 或本地事务把它们串起来;最后一项通过领域事件异步触发,风控系统晚几秒建档完全可接受。

事件流

CustomerOpened -> AccountOpened -> LimitInitialized -> (事件) -> RiskProfileRequested

订阅方消费 AccountOpened 即可并行去建额度、建风控档案,不必等开户服务逐个调用。这里体现的是聚合协作的核心思想:同步走强一致,异步走事件,两者各司其职。

常见误区

  1. 把聚合当 CRUD 载体:聚合方法名是 saveupdate,里面没有任何业务规则。这时 DDD 只是换了个地方写 SQL。
  2. 跨聚合事务:为了「保证一起成功」而在应用层开一个大事务锁住多个聚合。正确做法是 sagas/事件,接受最终一致。
  3. 聚合里注入仓储或发消息:聚合一旦依赖外部基础设施,就再也单元测试不了,领域层也被污染。
  4. 过度拆分聚合:为了「小」而把本应一致的两条规则拆到两个聚合,结果反而要处处补偿。
  • 聚合越小,并发越高,但跨聚合协作成本也越高。
  • 聚合越大,一致性越好写,但锁竞争和加载成本会反噬。
  • 平衡点来自对业务规则的诚实审视,而非拍脑袋。

结论

聚合根和领域事件不是花活,它们是把「业务的硬约束」翻译成「代码的硬边界」的手段。先把一致性边界划对,再让聚合通过事件对外低耦合地协作,银行核心系统才能真正既稳又活。下一讲我们会把这套思路接到 CQRS 与 Saga 上,看复杂长事务怎么在不牺牲一致性的前提下拆开。