从单个 CQRS 命令中调用两个不同的聚合

Calling two different Aggregates from inside a Single CQRS Command

我正在开发在线支持票务系统。在这个系统中,不同的客户可以注册和 post 票(每张票将链接到一个客户)。为了我的问题的简单性,我打算在系统中只保留 2 个聚合,CustomerAggregate 和 TicketAggregate。这 2 个聚合的我的域模型如下所示

/Domain/Entities/CustomerAggregate/Customer.cs

namespace MyApp.Domain.Entities.CustomerAggregate
{
    public class Customer : Entity, IAggregateRoot
    {
        public Customer(string name, int typeId)
        {
            Name = name;
            TypeId = typeId;
        }

        public string Name { get; private set; }

        public int TypeId { get; private set; }

        public CustomerType Type { get; private set; }
    }
}

/Domain/Entities/CustomerAggregate/CustomerType.cs

namespace MyApp.Domain.Entities.CustomerAggregate
{
    public class CustomerType : Enumeration
    {
        public static CustomerType Standard = new(1, nameof(Standard));
        public static CustomerType Premium = new(2, nameof(Premium));

        public CustomerType(int id, string name) : base(id, name)
        {
        }


        public static IEnumerable<CustomerType> List() =>
            new[] { Standard, Premium };

        public static CustomerType FromName(string name)
        {
            var state = List()
                .SingleOrDefault(s => string.Equals(s.Name, name, StringComparison.CurrentCultureIgnoreCase));

            if (state == null)
            {
                throw new MyAppDomainException($"Possible values for CustomerType: {string.Join(",", List().Select(s => s.Name))}");
            }

            return state;
        }

        public static CustomerType From(int id)
        {
            var state = List().SingleOrDefault(s => s.Id == id);

            if (state == null)
            {
                throw new MyAppDomainException($"Possible values for CustomerType: {string.Join(",", List().Select(s => s.Name))}");
            }

            return state;
        }

    }
}

/Domain/Entities/TicketAggregate/Ticket.cs

namespace MyApp.Domain.Entities.Ticket
{
    public class Ticket : Entity, IAggregateRoot
    {
        public Ticket(int customerId, string description)
        {
            CustomerId = customerId;
            Description = description;
        }

        public int CustomerId { get; private set; }

        public string Description { get; private set; }
    }
}

在我的应用层中,我有不同的用例。例如,我有 CreateTicketCommand 基本上可以创建支持票证。我的代码如下所示

/Application/UseCases/Tickets/CreateTicketCommand.cs

namespace ConsoleApp1.Application.UseCases.Tickets.CreateTicket
{
    public class CreateTicketCommand  : IRequest<int>
    {
        public int CustomerId { get; set; }
        public string Description { get; set; }
    }
}

/Application/UseCases/Tickets/CreateTicketCommandHandler.cs

namespace MyApp.Application.UseCases.Tickets.CreateTicket
{
    public class CreateTicketCommandHandler : IRequestHandler<CreateTicketCommand, int>
    {
        private readonly IApplicationDbContext _context;

        public CreateTicketCommandHandler(IApplicationDbContext context)
        {
            _context = context;
        }

        public async Task<int> Handle(CreateTicketCommand command, CancellationToken cancellationToken)
        {
            // Is it OK to fetch Customer Entity (that belongs to different aggregate) inside a Command Handler thats basically is dealing 
            // with another agreegate (Ticket)
            var customer = await _context.Customers.SingleOrDefaultAsync(c => c.Id = command.CustomerId);

            if (customer == null)
            {
                throw new NotFoundException(nameof(Customer), command.CustomerId);
            }

            if (customer.CustomerType == CustomerType.Premium)
            {
                var ticket = new Ticket(command.CustomerId, command.Description);

                await _context.Tickets.AddAsync(ticket, cancellationToken);

                await _context.SaveChangesAsync(cancellationToken);

                return ticket.Id;
            }
            else
            {
                throw new InvalidOperationException();
            }
        }
    }
}

现在我们的一项业务要求是只有高级客户才能创建票证。如果您注意到在 CreateTicketCommandHandler 内部,我首先获取 Customer,并且仅在请求的 CustomerType 为 Premium 时才创建 Ticket。

我的问题是,从单个 Command/Service(在此示例中为 Customer 和 Ticket)与多个 Aggreegates 进行交互是好的做法,还是我应该在其他地方执行此逻辑来检查 CustomerType?

更新:

我考虑的替代解决方案之一是为 CustomerType 创建一个 DomainService。

/Application/UseCases/Customers/DomainServices/CustomerTypeService.cs

    public class CustomerTypeService : ICustomerTypeService
    {
    
          private IApplicationDbContext _context;
    public CustomerTypeService(IApplicationDbContext context)
    {
          _context = context;
    }
    
    public CustomerType GetType(int customerId)
    {
          var customer = _context.Customer.SingleOrDefaultAsync(c => c.Id = customerId);
    
          return customer.Type;
    }
}

接口 ICustomerTypeService 将存在于工单域模型中。

/Domain/Entities/TicketAggregate/ICustomerTypeService.cs

然后在 Ticket 实体中注入 ICustomerTypeService。

public Ticket(int customerId, string description, ICustomerTypeService service)
{

    var customerType = service.GetType(customerId);
    //Check if customerType is valid to perform this operation, else throw exception
    CustomerId = customerId;
    Description = description;
}

那么在这个用例中,将客户类型逻辑放在命令处理程序中是正确的方法吗?或域服务是正确的方法?或者还有其他处理这个用例的方法吗?

“限制自己每次事务更改一个聚合”的经验法则适用于更改,不适用于访问聚合。所以你在处理一个命令的时候加载多个聚合是完全没问题的。

关于你的第二个问题,最好将所有业务逻辑都包含在你的聚合中。理想情况下,命令处理程序应该只负责接收命令、加载适当的聚合、调用指定的聚合方法以及最终持久化聚合。

考虑一个直接处理领域模型的系统。例如,您正在为 back-populate 一些数据编写脚本。您可以编写一个新命令并对其进行处理,但您也可以简单地加载聚合(或初始化新聚合)并持久化。即使在这种情况下,您也希望域规则生效并确保您的不变量得到满足。

具体来说,Ticket 可以接收客户类型值(不是客户本身)作为其构造函数(或工厂方法)的输入。