系列文章目录
一、简单的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),因为我们可以(而且很可能会)在聚合中嵌套调用方法。回想一下关于领域模型封装的文章中的图表:
程序流看起来像下面这样:
以及代码结构(简化后的):
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在他的文章中提出了这种方法。整个解决方案非常简单:
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)
最后但同样重要的解决方案被称为“始终有效”,它只是在聚合方法中抛出异常。这意味着我们在第一次违反聚合不变量时就完成了业务操作的处理。这样,我们确信我们的聚合始终有效:
各解决方案的比较
我不得不承认我不喜欢验证对象和延迟验证方法,我推荐始终有效的策略。我的理由如下。
返回验证对象的方法污染了我们的方法声明,给我们的实体增加了意外的复杂性,并且违背了快速失效原则。此外,验证对象成为我们的领域模型的一部分,它肯定不是通用语言的一部分。
另一方面,推迟验证意味着不封装聚合,因为验证器对象必须访问聚合内部以正确检查不变量。
然而,这两种方法都有一个优点——它们不需要抛出异常,而只有在发生意外时才应该抛出异常。打破商业规则并不意外。
然而,我认为这是一个罕见的异常,我们可以打破这一规则。对我来说,抛出异常和始终有效的聚合是最好的解决方案。我想说的是:“为达目的不择手段”。我认为这个解决方案类似于发布-订阅模式的实现。领域模型是破坏的不变量消息的发布者,应用程序是这个消息的订阅者。主要的设定是,在发布消息之后,发布者停止处理,因为这是异常机制的工作方式。
始终有效的实现
异常抛出是内置在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));
});
结果返回给客户端:
对于更简单的验证,如检查空值,空列表等,你可以创建守卫库(见守卫模式)或你可以使用外部库。参见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存储库