领域模型验证

原文链接

系列文章目录

一、简单的CQRS实现与原始SQL和DDD
二、使用EF的领域模型的封装和持久化透明(PI)
三、REST API数据验证
四、领域模型验证
五、如何发布和处理领域事件
六、处理领域事件:缺失的部分
七、发件箱模式(The Outbox Pattern)
八、.NET Core中的旁路缓存模式(Cache-Aside Pattern)

引言

在之前的文章中,我描述了如何在应用服务层验证请求输入数据。我展示了在管道模式(Pipeline Pattern)和问题细节标准(Problem Details standard)中使用FluentValidation库。在这篇文章中,我想集中讨论第二种类型的验证,它位于领域层——领域模型验证。

什么是领域模型验证

我们可以根据范围将领域模型的验证分为两种类型——聚合范围和限界上下文(Bounded Context)范围.

聚合范围

让我们通过引用Vaughn Vernon Domain-Driven Design Distilled书中的一段文字来回顾什么是聚合:

每个聚合形成一个事务一致性边界。这意味着在单个聚合中,++根据业务规则,当控制事务提交到数据库时,所有组成部分必须是一致的。++

下划线是引用中最重要的部分。这意味着在任何情况下,我们都不能将聚合持久化到具有无效状态或违反业务规则的数据库。这些规则通常被称为“不变量”,由Vaughn Vernon定义如下:

……业务不变量——软件必须始终遵守的规则——保证在每个业务操作之后保持一致。

因此,在聚合范围的上下文中,我们需要在用例(业务操作)处理期间执行验证来保护这些不变量.

限界上下文范围

不幸的是,仅验证聚合不变量是不够的。有时业务规则可能应用于多个聚合(它们甚至可以是不同类型的聚合)。

例如,假设我们将Customer实体作为聚合根(Aggregate Root),业务规则可能是“Customer电子邮件地址必须唯一”。为了检查这个规则,我们需要检查客户的所有邮件,这些邮件是分开的聚合根。它超出了一个Customer聚合的范围。当然,我们可以创建名为CustomerCatalog的新实体作为聚合根,并将所有的客户聚合到其中,但出于多种原因,这不是一个好主意。本文后面将介绍更好的解决方案。

让我们看看有哪些方案可以解决这两个验证问题。

三个解决方案

返回验证对象

该解决方案基于通知模式(Notification Pattern)。我们定义了一个特殊的类,叫做Notification/ValidationResult/Result/etc,它“在领域层中收集关于错误的信息和其他信息并进行通信”。

这对我们意味着什么?这意味着对于每个改变聚合状态的实体方法,我们都应该返回这个验证对象。这里的关键字是实体(entity),因为我们可以(而且很可能会)在聚合中嵌套调用方法。回想一下关于领域模型封装的文章中的图表:

image

程序流看起来像下面这样:

image

以及代码结构(简化后的):

public class AggregateRoot
{
    private NestedEntity _nestedEntity;

    public ValidationResult DoSomething()
    {
        // logic..
        
        ValidationResult nestedEntityValidationResult = _nestedEntity.DoSomethingElse();
        return Validate(nestedEntityValidationResult);
    }

    private ValidationResult Validate(ValidationResult nestedEntityValidationResult)
    {
        // Validate AggregateRoot and check nestedEntityValidationResult.
    }
}

public class NestedEntity
{
    public ValidationResult DoSomethingElse()
    {
        // logic..
        return Validate();
    }

    private ValidationResult Validate()
    {
        //NestedEntity validation...
    }
}

但是,如果我们不喜欢从每个改变状态的方法中返回ValidationResult,我们可以应用我在发布领域事件的文章中描述的不同方法。简而言之,在这个解决方案中,我们需要为每个实体添加ValidationResult属性(作为Domain Events集合),在聚合处理之后,我们必须检查这些属性并确定整个聚合是否有效。

推迟验证(Deferred validation)

第二种实现验证的方法是在整个Aggregate方法处理完毕后再进行检查。例如,Jeffrey Palermo在他的文章中提出了这种方法。整个解决方案非常简单:

image

public async Task<Unit> Handle(AddCustomerOrderCommand request, CancellationToken cancellationToken)
{
    var customer = await this._customerRepository.GetByIdAsync(request.CustomerId);

    // ....
    
    var order = new Order(orderProducts);
    
    customer.AddOrder(order);

    ValidationResult validationResult = ValidateCustomer(customer);

    await this._customerRepository.UnitOfWork.CommitAsync(cancellationToken);

    return Unit.Value;
}

private ValidationResult ValidateCustomer(Customer customer)
{
    Validatior validator = new Validator();
    return validator.Validate(customer);
}

始终有效(Always Valid)

最后但同样重要的解决方案被称为“始终有效”,它只是在聚合方法中抛出异常。这意味着我们在第一次违反聚合不变量时就完成了业务操作的处理。这样,我们确信我们的聚合始终有效:

image

各解决方案的比较

我不得不承认我不喜欢验证对象和延迟验证方法,我推荐始终有效的策略。我的理由如下。

返回验证对象的方法污染了我们的方法声明,给我们的实体增加了意外的复杂性,并且违背了快速失效原则。此外,验证对象成为我们的领域模型的一部分,它肯定不是通用语言的一部分。

另一方面,推迟验证意味着不封装聚合,因为验证器对象必须访问聚合内部以正确检查不变量。

然而,这两种方法都有一个优点——它们不需要抛出异常,而只有在发生意外时才应该抛出异常。打破商业规则并不意外。

然而,我认为这是一个罕见的异常,我们可以打破这一规则。对我来说,抛出异常和始终有效的聚合是最好的解决方案。我想说的是:“为达目的不择手段”。我认为这个解决方案类似于发布-订阅模式的实现。领域模型是破坏的不变量消息的发布者,应用程序是这个消息的订阅者。主要的设定是,在发布消息之后,发布者停止处理,因为这是异常机制的工作方式。

始终有效的实现

异常抛出是内置在c#语言中的,所以实际上我们拥有了一切。唯一要做的就是创建一个特殊的Exception类,我称之为BusinessRuleValidationException:

public class BusinessRuleValidationException : Exception
{
    public string Details { get; }

    public BusinessRuleValidationException(string message) : base(message)
    {
        
    }

    public BusinessRuleValidationException(string message, string details) : base(message)
    {
        this.Details = details;
    }
}

假设我们定义了一个业务规则,即您不能在同一天订购超过2个订单。实现如下:

// Customer aggregate root.
public void AddOrder(Order order)
{
    if (this._orders.Count(x => x.IsOrderedToday()) >= 2)
    {
        throw new BusinessRuleValidationException("You cannot order more than 2 orders on the same day");
    }

    this._orders.Add(order);

    this.AddDomainEvent(new OrderAddedEvent(order));
}
// Order entity.
internal bool IsOrderedToday()
{
   return this._orderDate.Date == DateTime.UtcNow.Date;
}

我们应该如何处理抛出的异常?我们可以使用REST API数据验证的方法,并将适当的消息作为问题详细信息对象标准返回给客户端。我们所要做的就是添加另一个ProblemDetails类,并在Startup中设置映射:

public class BusinessRuleValidationExceptionProblemDetails : Microsoft.AspNetCore.Mvc.ProblemDetails
{
    public BusinessRuleValidationExceptionProblemDetails(BusinessRuleValidationException exception)
    {
        this.Title = exception.Message;
        this.Status = StatusCodes.Status409Conflict;
        this.Detail = exception.Details;
        this.Type = "https://somedomain/business-rule-validation-error";
    }
}
services.AddProblemDetails(x =>
{
    x.Map<InvalidCommandException>(ex => new InvalidCommandProblemDetails(ex));
    x.Map<BusinessRuleValidationException>(ex => new BusinessRuleValidationExceptionProblemDetails(ex));
});

结果返回给客户端:

image

对于更简单的验证,如检查空值,空列表等,你可以创建守卫库(见守卫模式)或你可以使用外部库。参见Steve Smith创建的GuardClauses。

限界上下文范围验证的实现

那么跨越多个聚合(限界上下文范围)的验证呢?让我们假设我们有一个规则,不能有2个客户具有相同的电子邮件地址。有两种方法可以解决这个问题。

第一种方法是在CommandHandler中获取所需的聚合,然后将它们作为参数传递给聚合的方法/构造函数:

public async Task<CustomerDto> Handle(RegisterCustomerCommand request, CancellationToken cancellationToken)
{
    var allCustomers = await _customerRepository.GetAll();
    var customer = new Customer(request.Email, request.Name, allCustomers);
 
    await this._customerRepository.AddAsync(customer);
 
    await this._customerRepository.UnitOfWork.CommitAsync(cancellationToken);
 
    return new CustomerDto { Id = customer.Id };
}
public Customer(string email, string name, List<Customer> allCustomers)
{
    if (allCustomers.Contains(email))
    {
        throw new BusinessRuleValidationException("Customer with this email already exists.");
    }
    this.Email = email;
    this.Name = name;

    this.AddDomainEvent(new CustomerRegisteredEvent(this));
}

然而,这并不总是一个好的解决方案,因为正如您所看到的,我们需要将所有Customer aggregate加载到内存中。++这可能是严重的性能问题。++

如果我们负担不起(性能问题),那么我们需要引入第二种方法——创建领域服务,领域服务定义如下:

当领域中的重要过程或转换不是实体或值对象的自然职责时,将操作作为声明为服务的独立接口添加到模型中

因此,在这种情况下,我们需要创建ICustomerUniquenessChecker服务接口:

public interface ICustomerUniquenessChecker
{
    bool IsUnique(Customer customer);
}

下面是这个接口的实现:

public class CustomerUniquenessChecker : ICustomerUniquenessChecker
{
    private readonly ISqlConnectionFactory _sqlConnectionFactory;

    public CustomerUniquenessChecker(ISqlConnectionFactory sqlConnectionFactory)
    {
        _sqlConnectionFactory = sqlConnectionFactory;
    }

    public bool IsUnique(Customer customer)
    {
        using (var connection = this._sqlConnectionFactory.GetOpenConnection())
        {
            const string sql = "SELECT TOP 1 1" +
                               "FROM [orders].[Customers] AS [Customer] " +
                               "WHERE [Customer].[Email] = @Email";
            var customersNumber = connection.QuerySingle<int?>(sql,
                            new
                            {
                                customer.Email
                            });

            return !customersNumber.HasValue;
        }
    }
}

最后,我们可以在Customer Aggregate中使用它:

public Customer(string email, string name, ICustomerUniquenessChecker customerUniquenessChecker)
{
    this.Email = email;
    this.Name = name;

    var isUnique = customerUniquenessChecker.IsUnique(this);
    if (!isUnique)
    {
        throw new BusinessRuleValidationException("Customer with this email already exists.");
    }

    this.AddDomainEvent(new CustomerRegisteredEvent(this));
}

这里的问题是,是将领域服务作为参数传递给聚合的构造函数/方法,还是在命令处理程序本身中执行验证呢?如上所述,我是前一种方法的粉丝,因为我喜欢保持我的命令处理程序非常简单。这个选择的另一个原因是,如果我需要从不同的用例中注册Customer,我将无法绕过和忘记这个唯一性规则,因为我必须使用这个服务。

总结

在这篇文章中,领域模型验证涉及了很多内容。让我们总结一下:

  • 我们有两种类型的领域模型验证——聚合范围和限界上下文范围
  • 领域模型验证通常有3种方法
  • 使用验证对象、延迟验证或始终有效(抛出异常)
  • 总是有效的方法是首选
  • 对于限界上下文范围验证,有两种验证方法——将所有需要的数据传递到聚合的方法/构造函数或者创建领域服务(通常是出于性能原因)。

源代码

如果你想看到完整的工作示例——查看我的GitHub存储库

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值