关于CQRS与事件溯源中聚合根命令处理器设计的疑问:为何账户创建逻辑不放在命令处理器中?
为什么CQRS+事件溯源中Command Handler只发布事件,实际逻辑在EventHandler?
作为CQRS和事件溯源领域的新手,你的这个疑问非常典型——毕竟直觉上我们会觉得“执行命令就该直接完成操作”,但这种分离设计其实是这两个架构模式的核心要求,我来帮你拆解清楚:
1. 事件溯源的核心:聚合根状态由事件完全驱动
在事件溯源(Event Sourcing)中,聚合根的所有状态变更必须通过事件来记录和重建,而不是直接在Command Handler中修改状态。你的BankAccountAggregate代码里其实少了关键的一部分:聚合根自身处理事件来更新状态的逻辑,完整的聚合根代码应该是这样的:
@AllArgsConstructor @NoArgsConstructor @Getter @Aggregate public class BankAccountAggregate { @AggregateIdentifier private UUID id; private BigDecimal balance; private String owner; @CommandHandler public BankAccountAggregate(CreateAccountCommand command){ // 第一步:验证命令合法性(比如初始余额不能为负) if(command.getInitialBalance().compareTo(BigDecimal.ZERO) < 0) { throw new IllegalArgumentException("初始余额不能为负数"); } // 验证通过后,发布“账户已创建”的事实事件 AggregateLifecycle.apply( new AccountCreatedEvent( command.getAccountId(), command.getInitialBalance(), command.getOwner() ) ); } // 聚合根内部的事件处理器:用事件更新自身状态 @EventHandler public void on(AccountCreatedEvent event) { this.id = event.getId(); this.balance = event.getInitialBalance(); this.owner = event.getOwner(); } }
Command Handler的职责是验证命令是否符合业务规则,然后发布代表“已发生事实”的事件——因为事件是不可篡改的,一旦发布就会被持久化到事件存储中,成为聚合根状态的唯一来源。而聚合根的状态更新,必须由它自己通过处理事件来完成,这样才能保证所有状态变更都有迹可循,后续可以通过回放事件完全重建聚合根的历史状态。
2. CQRS的读写分离:写模型与读模型各司其职
你看到的BankAccountProjection属于CQRS中的读模型,而BankAccountAggregate是写模型:
- 写模型(聚合根):只关注业务规则的正确性,处理命令、发布事件,维护业务状态的强一致性,不关心数据如何被查询。
- 读模型(投影):专门为查询需求优化,将事件转化为适合查询的结构(比如直接存在关系型数据库里),它不参与业务逻辑,只负责同步事件数据供前端或查询接口使用。
这种分离的好处是:
- 写侧可以专注于业务规则,不用考虑查询性能;读侧可以根据不同的查询需求构建多个读模型(比如一个用于查询账户列表,一个用于查询账户交易明细),互不干扰。
- 读写操作完全隔离,避免了传统单体应用中读写互相影响的性能瓶颈。
3. 可靠性与可追溯性的保障
如果直接在Command Handler中创建账户并写入数据库,一旦中间出现故障(比如数据库连接中断),就会导致状态不一致,而且无法追溯到底发生了什么。而通过事件驱动的方式:
- 事件会先被持久化到事件存储中,即使后续的投影处理失败,事件依然存在,服务恢复后可以重新处理事件,保证最终一致性。
- 所有的业务操作都以事件的形式记录,你可以随时回放事件,重现聚合根的整个生命周期,这对排查问题、审计、甚至业务复盘都非常有价值。
总结
这种“Command Handler发布事件,事件驱动状态变更与读模型构建”的设计,是CQRS+事件溯源的标准实践,它完美契合了两个模式的核心思想:
- 事件溯源:用不可篡改的事件记录所有状态变更,保证可追溯性和状态一致性。
- CQRS:彻底分离读写职责,让写侧专注业务,读侧专注查询。
内容的提问来源于stack exchange,提问作者Faris Dewantoro
相关产品推荐
相关产品推荐

