Event Sourcing中命令处理程序如何处理外键关联查询?
在CQRS/Event Sourcing中处理跨聚合的业务逻辑验证
一、直接用读模型解决查询需求
在CQRS架构里,命令端负责写逻辑,查询端负责读逻辑,但命令处理程序完全可以依赖读模型来获取验证所需的查询数据——这是很常见的做法,只要你能接受读模型的最终一致性(如果事件流处理足够快,读模型几乎是实时的)。
你的GetLinesFromHoles这类跨聚合的查询,完全可以交给读模型来实现:
- 定义读模型接口,提供所需查询方法:
public interface ILineReadModel { // 根据关联的Hole ID获取相关Line(返回只读DTO,无需聚合) IEnumerable<LineDto> GetLinesAssociatedWithHoles(IEnumerable<int> holeIds); // 验证Hole是否存在 bool HoleExists(int holeId); }
- 在命令处理程序中注入读模型,替代聚合仓库的跨聚合查询:
public class CreateLineCommandHandler { private readonly ILineRepository _lineRepo; private readonly ILineReadModel _lineReadModel; public CreateLineCommandHandler(ILineRepository lineRepository, ILineReadModel lineReadModel) { _lineRepo = lineRepository; _lineReadModel = lineReadModel; } public void Handle(CreateLineCommand command) { // 验证至少关联一个Hole if (command.HoleLeft is null && command.HoleRight is null) throw new InvalidOperationException("Line必须关联至少一个Hole"); // 验证左右Hole不能相同 if (command.HoleLeft == command.HoleRight) throw new InvalidOperationException("左右Hole不能为同一个"); // 验证Hole存在 if (command.HoleLeft is not null && !_lineReadModel.HoleExists(command.HoleLeft.Value)) throw new InvalidOperationException("指定的左侧Hole不存在"); if (command.HoleRight is not null && !_lineReadModel.HoleExists(command.HoleRight.Value)) throw new InvalidOperationException("指定的右侧Hole不存在"); // 获取相关Line并检查交叉 var targetHoleIds = new List<int>(); if (command.HoleLeft.HasValue) targetHoleIds.Add(command.HoleLeft.Value); if (command.HoleRight.HasValue) targetHoleIds.Add(command.HoleRight.Value); var existingLines = _lineReadModel.GetLinesAssociatedWithHoles(targetHoleIds); var newLine = new Line(command.Id, command.Start, command.End, command.HoleLeft, command.HoleRight); foreach (var lineDto in existingLines) { var existingLine = new Line(lineDto.Id, lineDto.Start, lineDto.End, null, null); if (LinesCross(existingLine, newLine)) { throw new InvalidOperationException("新Line与已有Line交叉"); } } // 创建聚合并保存 _lineRepo.Save(newLine); } // 线段交叉判断逻辑示例 private bool LinesCross(Line a, Line b) { // 实现具体的几何判断逻辑 return false; } }
注意:读模型返回的是只读DTO,仅用于验证,绝对不能用它修改状态,状态修改只能通过聚合根的方法完成。
二、重新设计聚合边界(更符合DDD的方式)
你当前把Line和Hole作为独立聚合,但业务规则中Line的创建依赖整个“蛛网”的状态(不能交叉、关联Hole属于当前蛛网),这说明整个蛛网应该是一个聚合根,比如WebAggregate,内部包含所有Line和Hole。
这种设计的核心优势是:所有业务规则都能在聚合内部完成,无需跨聚合查询——聚合根可以直接访问自身内部的所有实体。
示例代码:
public class WebAggregate { public Guid Id { get; private set; } private readonly List<Hole> _holes = new(); private readonly List<Line> _lines = new(); private readonly List<IDomainEvent> _uncommittedEvents = new(); // 从事件流重建聚合 public WebAggregate(Guid id, IEnumerable<IDomainEvent> events) { Id = id; foreach (var e in events) ApplyEvent(e); } // 创建Line的方法,内部完成所有验证 public void CreateLine(int lineId, Point start, Point end, int? holeLeft, int? holeRight) { // 验证1:至少关联一个Hole if (holeLeft is null && holeRight is null) throw new InvalidOperationException("Line必须关联至少一个Hole"); // 验证2:左右Hole不能相同 if (holeLeft == holeRight) throw new InvalidOperationException("左右Hole不能为同一个"); // 验证3:Hole存在于当前蛛网 if (holeLeft.HasValue && !_holes.Any(h => h.Id == holeLeft.Value)) throw new InvalidOperationException("指定的左侧Hole不存在"); if (holeRight.HasValue && !_holes.Any(h => h.Id == holeRight.Value)) throw new InvalidOperationException("指定的右侧Hole不存在"); // 验证4:新Line不与已有Line交叉 var newLine = new Line(lineId, start, end, holeLeft, holeRight); if (_lines.Any(existing => LinesCross(existing, newLine))) throw new InvalidOperationException("新Line与已有Line交叉"); // 生成并应用事件 var @event = new LineCreatedEvent(Id, lineId, start, end, holeLeft, holeRight); _uncommittedEvents.Add(@event); ApplyEvent(@event); } private void ApplyEvent(IDomainEvent e) { switch (e) { case LineCreatedEvent lineCreated: _lines.Add(new Line(lineCreated.LineId, lineCreated.Start, lineCreated.End, lineCreated.HoleLeft, lineCreated.HoleRight)); break; case HoleAddedEvent holeAdded: _holes.Add(new Hole(holeAdded.HoleId)); break; // 处理其他事件 } } // 获取未提交事件 public IEnumerable<IDomainEvent> GetUncommittedEvents() => _uncommittedEvents; public void ClearUncommittedEvents() => _uncommittedEvents.Clear(); private bool LinesCross(Line a, Line b) { // 实现具体的几何判断逻辑 return false; } }
对应的命令处理程序会大幅简化:
public class CreateLineCommandHandler { private readonly IWebAggregateRepository _webRepo; public CreateLineCommandHandler(IWebAggregateRepository webRepo) { _webRepo = webRepo; } public void Handle(CreateLineCommand command) { // 加载整个蛛网聚合 var web = _webRepo.GetById(command.WebId); // 调用聚合方法创建Line(内部完成所有验证) web.CreateLine(command.LineId, command.Start, command.End, command.HoleLeft, command.HoleRight); // 保存聚合的未提交事件 _webRepo.Save(web); } }
这种设计完全符合DDD聚合原则:聚合根负责维护自身内部的所有业务不变量,所有修改必须通过聚合根的方法完成。
三、处理竞态条件与最终一致性
如果选择用读模型做验证,需要注意竞态条件:比如刚用读模型验证完无交叉,在保存新Line事件前,另一个命令已创建了交叉Line,导致读模型信息过时。
解决方法:
- 乐观锁:在聚合根中加入版本号,保存时指定
ExpectedVersion,事件存储会检查版本号,若不匹配则抛出异常,此时可重试命令。 - 事件驱动的读模型更新:用事件处理器监听事件流,一旦收到
LineCreatedEvent就实时更新读模型,尽可能缩小读模型与写模型的时间差。
内容的提问来源于stack exchange,提问作者codingman123
相关产品推荐
相关产品推荐

