战术DDD事件溯源系统中发票创建的事务性处理问题
解决战术DDD+事件溯源架构下的发票创建强一致性问题
嘿,这个场景在战术DDD结合事件溯源的架构里确实挺典型的——既要保证“要么全成、要么全败”的原子性,又要守住“项目不能归属多张发票”的核心不变量,还要处理创建后项目转价格的后续动作。我来分享几个实践中验证过的思路,你可以结合系统实际情况调整:
1. 先理清楚聚合边界(战术DDD的核心)
首先得明确聚合根的划分,这直接决定了不变量的保障能力:
- 不要把发票和所有项目塞进同一个聚合:如果项目数量多或者本身有复杂业务逻辑,这个聚合会变得臃肿,违反“聚合要小、只包含必要元素”的原则,还会影响性能和并发能力。
- 拆分两个独立聚合:
- 项目聚合根:包含
ProjectId、IsAssignedToInvoice、BasePrice、FinalPrice等属性,对外暴露MarkAsAssignedToInvoice(invoiceId)和ConvertToFinalPrice()方法——只有当IsAssignedToInvoice为false时,前者才能执行,否则直接抛出领域异常(比如ProjectAlreadyAssignedException)。 - 发票聚合根:包含
InvoiceId、AssignedProjectIds等属性,对外暴露InitializeInvoice(projectIds)方法,负责记录关联的项目ID。
- 项目聚合根:包含
2. 用领域服务做流程协调
因为两个聚合的状态变更需要原子性,单独靠聚合根自己搞不定,这时候就需要领域服务来扮演协调者的角色。举个代码示例(用C#风格,你可以换成自己的语言):
public class InvoiceCreationService { private readonly IProjectRepository _projectRepo; private readonly IEventStore _eventStore; public InvoiceCreationService(IProjectRepository projectRepo, IEventStore eventStore) { _projectRepo = projectRepo; _eventStore = eventStore; } public async Task CreateInvoice(Guid invoiceId, List<Guid> targetProjectIds) { // 第一步:批量校验项目状态,确保都未被分配 var projects = await _projectRepo.GetByIds(targetProjectIds); if (projects.Count != targetProjectIds.Count) { throw new DomainException("部分指定项目不存在"); } if (projects.Any(p => p.IsAssignedToInvoice)) { throw new DomainException("部分项目已归属其他发票,无法重复分配"); } // 第二步:生成所有需要提交的领域事件 var pendingEvents = new List<DomainEvent>(); // 生成发票初始化事件 pendingEvents.Add(new InvoiceInitializedEvent(invoiceId, targetProjectIds)); // 为每个项目生成「分配到发票」和「转换为价格」事件 foreach (var project in projects) { pendingEvents.Add(new ProjectAssignedToInvoiceEvent(project.Id, invoiceId)); pendingEvents.Add(new ProjectConvertedToPriceEvent(project.Id, project.BasePrice)); } // 第三步:原子性提交所有事件到事件存储 // 这里的关键是:事件存储必须保证要么所有事件都写入成功,要么全部失败回滚 await _eventStore.CommitEvents(pendingEvents); } }
3. 靠事件溯源的原子性保障一致性
事件存储的CommitEvents方法是核心——它必须支持原子批量写入。一旦所有事件都成功写入:
- 发票聚合根订阅
InvoiceInitializedEvent,完成自身状态初始化; - 项目聚合根订阅
ProjectAssignedToInvoiceEvent,把IsAssignedToInvoice设为true; - 项目聚合根订阅
ProjectConvertedToPriceEvent,把BasePrice同步到FinalPrice字段。
如果任何一步失败(比如事件存储写入报错),整个流程就会回滚,不会有任何状态变更,完美符合“要么全成要么全败”的要求。
4. 处理并发冲突的细节
难免会有多个请求同时抢同一个项目的情况,这时候要加乐观并发控制:
- 每个聚合根维护一个
Version字段,每次处理事件时版本号递增; - 提交事件时,事件存储要检查每个聚合根的当前版本是否和事件中携带的版本一致;
- 如果版本不匹配,直接抛出并发异常,此时前端可以提示用户重试,或者后端自动重试几次。
5. 跨限界上下文的备选方案:Saga模式
如果发票和项目属于不同的限界上下文(比如项目属于采购上下文,发票属于财务上下文),那领域服务就不太适用了,这时候可以用Saga(事务流程管理器):
- 启动Saga后,依次给每个项目发送
AssignToInvoice命令; - 如果所有命令都执行成功,再发送
CreateInvoice命令; - 如果某个命令失败,立刻给已经成功分配的项目发送
UnassignFromInvoice补偿命令; - Saga的状态也用事件来持久化,这样整个流程的每一步都可追溯,出问题了也能快速排查。
内容的提问来源于stack exchange,提问作者aef
相关产品推荐
相关产品推荐

