领域驱动设计:避免贫血领域模型和建模真实世界角色

6
我正在寻求一些关于避免贫血领域模型的建议。我们刚开始接触DDD,并在简单的设计决策方面陷入了分析瘫痪。我们最近卡住的问题是某些业务逻辑的归属,例如我们有一个“Order”对象,它具有诸如“Status”等属性。现在假设我必须执行像“UndoLastStatus”这样的命令,因为有人在订单中犯了一个错误,这不仅仅是更改“Status”那么简单,因为其他信息也必须被记录并更改属性。在现实世界中,这是一个纯粹的管理任务。所以我看到有两种选择:

  • 选项1:将方法添加到订单中,例如“Order.UndoLastStatus()”,虽然这看起来很合理,但它并没有真正反映出领域模型。此外,“Order”是系统中的主要对象,如果涉及订单的所有内容都放在订单类中,事情可能会失控。

  • 选项2:创建一个“Shop”对象,并具有代表不同角色的不同服务。因此,我可能有“Shop.AdminService”、“Shop.DispatchService”和“Shop.InventoryService”。在这种情况下,我将有“Shop.AdminService.UndoLastStatus(Order)”。

现在,第二个选项更符合领域,可以让开发人员与业务专家讨论实际存在的类似角色。但它也朝着贫血模型的方向发展。一般来说,哪种方式更好?

4个回答

6

带来过程化代码的是第二个选项。


可能更容易开发,但维护难度更大。

现实世界中,这是一个纯粹的管理任务。

“管理”任务应该是私有的,并通过公共、完全“领域化”的操作调用。最好还是编写易于理解的代码,从领域驱动。

我认为问题在于,对领域专家来说,“撤消上一个状态”没有多少意义。
更有可能是在谈论订单的制作、取消和填充。

以下内容可能更适合:

class Order{
  void CancelOrder(){
    Status=Status.Canceled;
  }
  void FillOrder(){
    if(Status==Status.Canceled)
      throw Exception();
    Status=Status.Filled;
  }
  static void Make(){
    return new Order();
  }
  void Order(){
    Status=Status.Pending;
  }
}

我个人不喜欢使用"statuses"这个词,因为它们会自动共享到使用它们的所有内容上——我认为这是不必要的耦合

所以我会采用类似这样的方式:

class Order{
  void CancelOrder(){
    IsCanceled=true;
  }
  void FillOrder(){
    if(IsCanceled) throw Exception();
    IsFilled=true;
  }
  static Order Make(){
    return new Order();
  }
  void Order(){
    IsPending=true;
  }
}

如果要在订单状态更改时更改相关事项,最好使用所谓的领域事件
我的代码应该是这样的:

class Order{
  void CancelOrder(){
    IsCanceled=true;
    Raise(new Canceled(this));
  }
  //usage of nested classes for events is my homemade convention
  class Canceled:Event<Order>{
    void Canceled(Order order):base(order){}
  }     
}

class Customer{
  private void BeHappy(){
    Console.WriteLine("hooraay!");
  }
  //nb: nested class can see privates of Customer
  class OnOrderCanceled:IEventHandler<Order.Canceled>{
   void Handle(Order.Canceled e){
    //caveat: this approach needs order->customer association
    var order=e.Source;
    order.Customer.BeHappy();
   }
  }
}

如果订单变得太庞大,您可能需要了解有界上下文是什么(正如Eric Evans所说 - 如果他有机会再次写他的书,他会把有界上下文移到开头)。
简而言之 - 这是一种由领域驱动的分解形式。
这个想法相对简单 - 拥有来自不同视角/上下文的多个订单是可以的。
例如 - 来自购物上下文的订单,来自会计上下文的订单。
namespace Shopping{
 class Order{
  //association with shopping cart
  //might be vital for shopping but completely irrelevant for accounting
  ShoppingCart Cart;
 }
}
namespace Accounting{
 class Order{
  //something specific only to accounting
 }
}

但通常域本身就避免了复杂性,如果您仔细聆听,它很容易被分解。例如,您可能会从专家那里听到OrderLifeCycle、OrderHistory、OrderDescription等术语,您可以利用它们作为分解的锚点。
注:请记住 - 我对您的领域一无所知。很可能我使用的这些动词对它来说完全陌生。

抱歉回复较慢,感谢您提供如此详细和深思熟虑的回答。可能是我收到的最好的答案之一。 - g.foley
当客户取消订单时,以下业务规则位于何处:1)从购物车中删除订单,2)在商店中取消预订,3)赔偿客户已支付的款项?很难想象它们位于“Order.Cancel”方法中。 - Lightman

0

听起来你没有从测试中驱动这个域名。看看Rob Vens的工作,特别是他在探索性建模、时间反演和主动-被动方面的工作。


0

我会遵循GRASP原则。应用信息专家设计原则,即应将责任分配给自然具有最多所需信息以完成更改的类。

在这种情况下,由于更改订单状态涉及其他实体,因此我会使每个低级域对象支持一种方法来针对自身应用更改。然后还要使用一个领域服务层,如选项2中所述,它抽象了整个操作,根据需要跨越多个域对象。

还请参阅Facade模式。


更改订单状态只涉及订单聚合内的实体。因此,我基本上会应用两个选项,而Shop.AdminService.UndoLastStatus(Order)只需调用Order.UndoLastStatus()即可? - g.foley
我从你的问题中理解到,撤销订单时还有其他相关任务需要完成,这就是为什么我认为该任务应该属于更高级别,以便可以访问其他对象(如记录器等)的原因。 - Bill Karwin

0

我认为在Order类中拥有像UndoLastStatus这样的方法有点不妥,因为其存在的原因在某种程度上超出了订单的范围。另一方面,负责更改订单状态的方法Order.ChangeStatus很好地适合于领域模型。订单状态是一个合适的领域概念,应该通过Order类来进行更改,因为它拥有与订单状态相关联的数据 - Order类有责任保持自身一致并处于适当的状态。

另一种思考方式是,Order对象是持久化到数据库中的,并且它是应用于Order的所有更改的“最后一站”。从Order的角度而言,更容易理解订单的有效状态可能是什么,而不是从外部组件的角度来看。这就是DDD和OOP的全部意义,使人们更容易推理代码。此外,执行状态更改可能需要访问私有或受保护的成员,在这种情况下,将方法放在订单类上是更好的选择。这也是贫血领域模型不受欢迎的原因之一 - 它们将保持状态一致的责任转移到了拥有类之外,从而破坏了封装性等其他问题。

实现一种更具体的操作,例如UndoLastStatus的一种方法是创建一个OrderService,该服务公开域,并且是外部组件对域进行操作的方式。然后,您可以创建一个简单的命令对象,如下所示:

class UndoLastStatusCommand {
  public Guid OrderId { get; set; }
}

OrderService会有一个方法来处理该命令:

public void Process(UndoLastStatusCommand command) {
  using (var unitOfWork = UowManager.Start()) {
    var order = this.orderRepository.Get(command.OrderId);
    if (order == null)
      throw some exception

    // operate on domain to undo last status

    unitOfWork.Commit();
  }
}

现在,订单的领域模型公开了与订单相对应的所有数据和行为,但是OrderService和服务层通常声明在订单上执行的不同类型的操作,并公开领域以供外部组件(如表示层)使用。

此外,还应考虑研究领域事件的概念,该概念考虑了贫血的领域模型及其改进方法。


为什么你创建了UndoLastStatusCommand,而不是直接传递OrderId呢? - g.foley
命令对象在面向消息的服务层中充当特殊类型的消息。这只是一种构建服务层的方式,它基于异步集成模式,如CQRS。因此,直接将订单ID传递到名为UndoLastStatus的服务方法中也是有效的,但最终决策取决于对服务层设置的约束和要求。 - eulerfx

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接