从域模型通信回到应用层

Communicate from Domain Model back down to Application Layer

我有一个包含价格列表的领域模型产品。

  public class Product
  {    
    private List<int> _prices; //Note that this is a value object in my actual code

       public void AddPrice(int price)
       {

         var currentPrice = _prices.LastOrDefault();
      
          if(price < currentPrice)
            _prices.add(price)
       }

    }

当价格发生变化时,我希望发生很多事情。使用贫血域模型这很容易,因为我可以在我的服务中保留这一点:

 if(price < currentPrice)
        _prices.Add(price)

然后处理一堆我想做的事情:

     if(price < currentPrice)
        {
            product.Prices.Add(price);
            _emailService.Email();
            _discordBot.Broadcast();
            _productUpdater.UpdateRatings();
           //etc etc
        }

如何在不让我的域依赖服务的情况下实现这一点?或者我应该将它们传递到我的域吗?

不确定最佳方法(或者老实说,任何方法),我已经阅读了领域事件,但我认为这些有点高于我目前的经验水平,而且我不太了解 material

不幸的是,如果 Product class 是一个 ORM 实体,您最终会得到贫血的域模型和所谓的服务实体架构,其中模型是数据结构,服务是无状态的一堆过程。但是,您仍然可以分层组织代码,因此 Product class 不应依赖于应用层。这可以通过Observer pattern来解决。在客户端,它看起来像

product.OnPriceAdded(
    new EmailObserver(emailService)
)

product.OnPriceAdded(
    new ProductObserver(productUpdater)
)

或者,如果您有服务实体架构,您可以在处理加价的服务中使用事件调度程序

if (product.AddPrice(price)) { // you'll have to modify AddPrice to return bool
    this.dispatcher.dispatch(new PriceAddedEvent(product, price))
}

当您初始化您的应用程序时,您会在 EventDispathcher 中注册具体事件的侦听器,然后将调度程序实例注入所需的服务。这篇关于集中式事件调度器的文章 Part 1, Part 2

你也可以使用事件调度器作为合作者对象:

public void AddPrice(int price, IEventDispatcher $dispatcher)
{
    var currentPrice = _prices.LastOrDefault();
      
    if(price < currentPrice) {
        _prices.add(price)
        dispatcher.dispatch(new PriceAddedEvent(this, price))
    }
}

在这种情况下,EventDispatcher 接口成为域模型的一部分。

如果你有 Product 接口,还有另一种方法。您将能够通过 Dispatchable 实现包装原始产品:

class DispatchableProduct : IProduct
{
    public DispatchableProduct(IProduct origin, IEventDispathcer disp) {
        // init properties
    }

    public void AddPrice(int price) bool {
        if (origin.AddPrice(price)) {
            disp.dispatch(new PriceAddedEvent(origin, price))
            return true;
        }
 
        return false;
    }
}

在客户端它看起来像

new DispatchableProduct(originProduct, dispatcher).AddPrice(price)

P.S。 总是if 语句中使用花括号

How can I implement this without making my domain reliant on the services?

两个常见的答案。

首先是您的域模型具有管理信息的单一职责,而您的应用程序代码具有检索和分发信息的职责。

if(price < currentPrice)
    _prices.Add(price)

这是对本地信息的操纵,因此它通常在您的域模型中

{
    _emailService.Email();
    _discordBot.Broadcast();
    _productUpdater.UpdateRatings();
    //etc etc
}

这是信息的分布,因此它通常存在于您的应用程序代码中。

谓词通常在应用程序代码中,使用领域模型

中的信息
product.addPrice(price)
if (product.IsPriceChanged()) {
    _emailService.Email();
    _discordBot.Broadcast();
    _productUpdater.UpdateRatings();
}

另一种方法是将与外界通信的能力传递给域模型,并在模型本身中承载副作用逻辑。您有时会听到人们将此称为“域服务”模式。

public class Product
{    
    private List<int> _prices; //Note that this is a value object in my actual code

    public void AddPrice(int price, IEmailService emailService, IDiscordBot discordBot, IProductUpdater productUpdater) {
        // you know what goes here
    }

您需要注意此处的依赖箭头 - 此模型依赖于那些接口,因此它的稳定性取决于接口本身 - 如果您决定更改 IEmailService,则更改会产生涟漪通过一切。因此,在模型中定义这些接口是很常见的,而您的应用程序代码提供了实现。

how would you implement product.IsPriceChanged(). Are we just updating a bool during the product.AddPrice() call and then checking its state with the IsPriceChanged()?

您可以按照自己喜欢的方式进行操作,真的 - 选择正确的数据结构,以及从该数据结构中提取信息的方法,是工作的一部分。

正如 Pavel Stepanets 所指出的,这是一个时间查询,因此您可能需要建模时间。所以它不会是“价格是否改变”而是“价格是否改变 X”其中X是某种时钟的测量(系统时间,对象的版本,与以前缓存的某些值相比发生了变化,依此类推)。

结果也可能是“添加价格”协议 的簿记与产品聚合 是不同的对象。如果是这样,您可能希望以这种方式对其进行显式建模 - 您正在寻找的布尔值或其他内容可能应该在协议数据结构中而不是在对象数据结构中。

我可以想到不同的选项,这些选项或多或少适合您的具体要求,并且可以为不同的用例选择不同的方法并将它们混合在您的解决方案中。

为了说明这一点,我想研究基于产品应用程序操作的不同选项,我将其简单地称为 AddPriceToProduct(AddProductPriceCommand pricingCommand)。它表示添加产品新价格的用例。 AddProductPriceCommand 是一个简单的 DTO,它包含执行用例所需的所有数据。


选项 (A)注入相应的服务(例如,电子邮件服务) 在将域逻辑执行到域对象的方法中时需要调用(此处 AddPrice)。

如果您选择此方法,请始终传递接口(在您的域层中定义)而不是实际实现(这应该在基础设施层中定义)。此外,如果在您的域操作中发生之后发生某些事情,我不会选择这种方法。

public void AddPriceToProduct(AddProductPriceCommand pricingCommand)
{
    var product = _productRepository.findById(pricingCommand.productId);
    product.AddPrice(pricingCommand.price, _emailService);
    _productRepository.Update(product);
}

相应的 AddPrice 方法可能如下所示:

public void AddPrice(int price, IEmailService emailService)
{
    var currentPrice = _prices.LastOrDefault();
  
    if(price < currentPrice)
    {
        _prices.add(price);
        // call email service with whatever parameters required
        emailService.Email(this, price);  
    }
}

选项(B):让应用服务(编排用例)调用相应的服务(s) 在您调用了应用用例需要执行的相应聚合(或域服务)方法后。

如果在执行特定域模型操作后始终发生这种情况,这可能是一种简单有效的方法。我的意思是,在您的聚合(或域服务)上调用方法后,在您的 AddPrice 方法中,没有 没有条件逻辑 是否应该调用其他服务(例如电子邮件)。

public void AddPriceToProduct(AddProductPriceCommand pricingCommand)
{
    var product = _productRepository.findById(pricingCommand.productId);
    product.AddPrice(pricingCommand.price);
    _productRepository.Update(product);
    // always send an email as part of the usual workflow
    _emailService.Email(product, pricingCommand.price);
}

在这种情况下,我们假设正常工作流程将始终包含此附加步骤。我在这里务实一点没看出来有什么问题,直接在应用服务方法中调用相应的服务即可。


选项(C):与选项(B)类似,但有条件逻辑 在调用 AddPrice 后执行。在这种情况下,这个逻辑可以包装到一个单独的 域服务 中,它将根据 Product 的当前状态处理条件部分或结果 - 如果有 - 域操作 (AddPrice).

让我们首先通过包含一些领域知识来简单地更改应用程序服务方法:

public void AddPriceToProduct(AddProductPriceCommand pricingCommand)
{
    var product = _productRepository.findById(pricingCommand.productId);
    product.AddPrice(pricingCommand.price);
    _productRepository.Update(product);

    if (product.HasNewPrice())
    {
        _emailService.Email(product, pricingCommand.price;
    }
    if (product.PriceTargetAchieved())
    {
        _productUpdater.UpdateRatings(product, pricingCommand.price);
    }
}

现在这种方法有一些 space 需要改进的地方。由于要执行的逻辑绑定到产品的 AddPrice() 方法,因此可能很容易错过需要调用附加逻辑(在某些情况下调用电子邮件服务或更新程序服务)。当然,您可以将所有服务注入 Product 实体的 AddPrice() 方法,但在这种情况下,我们希望研究将逻辑提取到 [ 中的选项=105=]域服务.

首先让我们看一下新版本的应用服务方式:

public void AddPriceToProduct(AddProductPriceCommand pricingCommand)
{
    var product = _productRepository.findById(pricingCommand.productId);
    _productPricingService.AddPrice(product, pricingCommand.price);
    _productRepository.Update(product);
}

现在让我们看一下域服务对应的域服务方法调用,例如ProductPricingService:

public void AddPrice(Product product, int price)
{
    if (product.HasNewPrice())
    {
        _emailService.Email(product, pricingCommand.price;
    }
    if (product.PriceTargetAchieved())
    {
        _productUpdater.UpdateRatings(product, pricingCommand.price);
    }
}

现在,处理产品价格更新的逻辑在域层处理。此外,域逻辑更容易进行单元测试,因为更少的依赖关系(例如,这里不关心存储库)并且需要使用更少的测试替身(模拟)。

当然仍然不是领域模型内部最高程度的业务逻辑封装和最低程度的依赖 , 但它至少更近了一点。

要实现上述组合领域事件,我们将投入使用,但当然这些也可能需要更多的实施工作。这个我们下一个选项再看。


选项 (D):从您的域实体引发域事件并实施相应的处理程序,这些处理程序可以是域服务甚至基础设施服务。

域事件发布者(您的域实体或域服务)和订阅者(​​例如电子邮件服务、产品更新程序等)之间的连接。

在这种情况下,我建议不要立即分派引发的事件,而是收集它们,只有在一切正常后(即没有抛出异常,状态已持久化等)分派它们进行处理。

让我们使用相应的域事件再次查看 Product 实体的 AddPrice() 方法。

public void AddPrice(int price, IEmailService emailService)
{
    var currentPrice = _prices.LastOrDefault();
  
    if(price < currentPrice)
    {
        _prices.add(price);
        RaiseEvent(
            new ProductPriceUpdatedEvent(
                this.Id,
                price
            ));
    }
}

ProductPriceUpdateEvent 是一个简单的 class,表示过去发生的业务事件以及该事件的订阅者所需的信息。在您的情况下,订阅者将是电子邮件服务、产品更新服务等。

RaiseEvent() 方法视为一种简单的方法,它将创建的事件对象添加到产品实体的集合中,以便收集一个或多个业务期间发生的所有事件从应用程序或域服务调用的操作。此事件收集功能也可以是实体库的一部分 class 但那是一个实现细节。

重要的是,在执行完AddPrice()方法后,应用层会确保所有收集到的事件都会分发给相应的订阅者。

因此,域模型完全独立于基础设施服务依赖项以及事件调度代码。

“调度前提交” blog post by Vladimir Khorikov 中描述的方法说明了这个想法,也是基于您的技术堆叠.

注意:与其他解决方案相比,您的 Product 域实体的逻辑单元测试现在非常简单,因为您没有任何依赖关系,并且不需要模拟根本。并且测试是否在正确的操作中调用了相应的域事件也很容易,因为您只需在调用业务方法后从 Product 实体查询收集的事件。


回到您的问题

How can I implement this without making my domain reliant on the services?

要实现这一点,您可以查看选项 (B)、(C) 和 (D)

Or should I be passing those to my domain?

这可能是一种有效的方法 - 请参阅 选项 (A) - 但请注意,如果在可维护性方面需要注入多个依赖项,这会使事情变得更加复杂领域模型的可测试性 classes.

当我在这些不同的选项之间做出选择时,我总是试图找出所执行的操作的哪些部分确实属于相应的业务操作,哪些部分或多或少无关并且不需要进行业务交易一个有效的。

例如,如果需要由服务执行的某些操作需要发生,否则整个操作根本不应该发生(就一致性而言),那么选项 (A) - 将服务注入到域模型方法 - 可能是一个很好的选择。否则我会尝试将任何后续步骤与域模型逻辑分离,在这种情况下应考虑其他选项。

让我们尝试将您的逻辑分成两部分:

  1. 你有产品域逻辑,正在检查价格是否低于当前价格,你可以更新它。
  2. 您有该产品域逻辑的副作用,它也是域逻辑的一部分

副作用的好解决方案是事件。可以查看这篇文章Domain events: design and implementation

我要强调的一点是Open/Close原则(对扩展开放,对修改关闭)。您的产品逻辑不应该知道电子邮件或其他通知服务。如果它知道,您将面临 open/close 原则的问题。让我们尝试制作示例:如果 product.AddPrice(...) 发送通知,让我们现在使用电子邮件和不和谐,然后当您的应用程序增长时,您想要添加短信通知或更多副作用,您将需要更改 product.AddPrice(...)代码,从 open/clos 原则的角度来看并不好。

一个很好的解决方案是事件模式。您可以像上面 Pavel Stepanets 所说的那样注入 IEventDispathcer 或像上面微软的文章那样填充事件。就个人而言,我更喜欢填充事件,然后我的应用程序层进行编排部分(调度等)

这里是示例代码:

public abstract class EntityBase {
  public IReadOnlyList<IEvent> Events { get;}
  protected AddEvent(...){...}
  public ClearEvent(..){...}
}

public class ProductPriceChangedEvent : IEvent {
  public Product Product { get; private set; }
  public int OldValue {get; private set;}
  public int NewValue {get; private set;}
  
  public ProductPriceChangedEvent(...) {...}
}

public class Product : EntityBase
{    
    private List<int> _prices;

    public bool TryAddPrice(int price)
    {
      var currentPrice = _prices.LastOrDefault();
  
      if(price < currentPrice) {
        _prices.add(price)

        AddEvent(new ProductPriceChangedEvent(this, currentPrice, price));

        return true;
      }
      
      return false;
    }
}

public class SendEmailNotificationOnProductPriceChanged : IEventHandler<ProductPriceChangedEvent> {
  public void Handle(ProductPriceChangedEvent eventItem) { ... }
}

public class SendDiscordNotificationOnProductPriceChanged : IEventHandler<ProductPriceChangedEvent> {
  public void Handle(ProductPriceChangedEvent eventItem) { ... }
}

public class UpdatedRatingOnProductPriceChanged : IEventHandler<ProductPriceChangedEvent> {
  public void Handle(ProductPriceChangedEvent eventItem) { ... }
}

// Your application logic
// You can also wrap dispatching event inside UnitOfWork when you want to save them to database
public class UpdatePriceCommandHandler or Controller {
  private IProductRepository _productRepository;
  private IEventDispatcher _eventDispatcher;

  public Handle(UpdatePriceCommand command)
  {
    var product = _productRepository.FindById(command.ProductId);
    var isPriceChanged = product.TryAddPrice(command.Price);

    if(isPriceChanged)
    {
      _eventDispatcher.Dispatch(product.Events)
    } 
    else {
      throw new Exception("Your message here")
    }
  }
}

或者对于更具防御性的编程风格,您可以抛出异常而不是 return 布尔值,这样您就不需要检查价格是否已更改(可能会造成混淆),但您需要处理该失败or exception(明明是失败)

public void AddPrice(int price)
{
  var currentPrice = _prices.LastOrDefault();

  if(price >= currentPrice) {
  {
    throws new ArgumentException(price, "Product price should less than blabla")
  }

  _prices.add(price)

  AddEvent(new ProductPriceChangedEvent(this, currentPrice, price));
}

对于 C# 社区,有 MediatR 库可用于事件模式。我不知道其他语言。也许其他人可以添加它们。