You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Event Sourcing中命令处理程序如何处理外键关联查询?

在CQRS/Event Sourcing中处理跨聚合的业务逻辑验证

一、直接用读模型解决查询需求

在CQRS架构里,命令端负责写逻辑,查询端负责读逻辑,但命令处理程序完全可以依赖读模型来获取验证所需的查询数据——这是很常见的做法,只要你能接受读模型的最终一致性(如果事件流处理足够快,读模型几乎是实时的)。

你的GetLinesFromHoles这类跨聚合的查询,完全可以交给读模型来实现:

  1. 定义读模型接口,提供所需查询方法:
public interface ILineReadModel
{
    // 根据关联的Hole ID获取相关Line(返回只读DTO,无需聚合)
    IEnumerable<LineDto> GetLinesAssociatedWithHoles(IEnumerable<int> holeIds);
    // 验证Hole是否存在
    bool HoleExists(int holeId);
}
  1. 在命令处理程序中注入读模型,替代聚合仓库的跨聚合查询:
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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.25 00:27:23