DDD:建模聚合

DDD : modeling aggregates

我面临一个设计问题,我想在两个不同的有界上下文中对完全相同的物理模型 object 进行建模。

为了尽可能准确地描述我的问题,即使我知道这只是一个实现细节,我将从我的事件溯源机制开始。

我的事件存储机制

以下内容受到 Greg Young 的 CQRS 文档 https://cqrs.wordpress.com/documents/ 的广泛启发(注意 PDF "building an Event Storage" 部分)。

我有 2 tables,一个叫 Aggregates,另一个叫 Events(注意复数形式,因为它们是 tables,而不是 objects!),看起来像这样:

聚合 Table

我所有的聚合都存储在这个table中;它有 3 列(所以不支持 md table 格式,所以,抱歉,我要一个列表):

事件 Table

任何聚合发出的每个域事件都存储在那里;它有 5 列:

具有 2 个限界上下文的域示例

现在让我们考虑一个商家:

采购部会考虑如下产品:

另一方面,销售部门会以不同的方式考虑产品:

that sounds like 2 distinct bounded contexts, right?

实际上,从具有图片、类别和投票属性的网站角度来看,Product 对我来说听起来像是第三个有界上下文,但为了示例的缘故,我们不讨论它。 ..

现在让我们用领域专家规范来完成这个领域示例:

每个 BC 1 个产品汇总 => 2 个不同的产品 AR

好吧,在传统设计中,我最终可能会得到一个很大的 Product Entity,其中包含与销售观点和供应观点相关的属性。

但我想采用 DDD 方法,而 DDD 说我应该在有界上下文中保护我的不变量。 因此,产品的域模型根据我是在销售范围内还是在供应限界上下文中而不同。

据我了解,因此我应该有 2 个实体:

还是为了示例,我们承认这 2 个产品实体已决定提升到其各自 BC 中的聚合根范围。

总而言之,我们有:

2 bounded context

1 product Aggregate per bounded context

但这是完全相同的产品吧?

供应链 BC

中设计 Product AR

以下受到广泛启发:

首先,让我们看一下我的抽象 AggregateRoot class :

  namespace DomainModel/WriteSide;

  abstract class AggregateRoot
  {
    protected $lastRecordedEvents = [];

    protected function recordThat(DomainEvent $event)
    {
      $this->latestRecordedEvents[]=$event;
      $this->apply($event);
    }

    protected function apply(DomainEvent $event)
    {
      $method = 'apply'.get_class($event);
      $this->$method($event);
    }

    public function getUncommittedEvents()
    {
      return $this->lastestRecordedEvents;
    }

    public function markEventsAsCommitted()
    {
      $this->lastestRecordedEvents = [];
    }

    public static function reconstituteFrom(AggregateHistory $history)
    {
      foreach($history as $event) {
        $this->apply($event);
      }
      return $this;

    abstract public function getAggregateId();

  }

基本上,这个class持有ES机制。

现在让我们看看它在供应链 BC 中的产品实现:

namespace DomainModel/WriteSide/SupplyChain;
use DomainModel/WriteSide/AggregateRoot as BaseAggregate;

Class Product extends BaseAggregate
{
  private $productId;
  private $productName;
  //some other attributes related to the supply chain BC...


  public function getAggregateId()
  {
    return $this->productId;
  }

  private function __construct(ProductId $productId, $productName)
  {
    //private constructor allowing factory methods
  }

  public static function AddToCatalog(AddProductToCatalogCommand $command)
  {
    //some invariants protection stuff
    $this->recordThat(new ProductWasAddedToCatalog($command->productId));
  }

  private function applyProductWasAddedToCatalog(DomainEvent $event)
  {
    $newProduct = new Product($event->productId);
    return $newProduct;
  }

  //more methods there...
}

流量

以下内容广泛受到@codescribler 博客 post 的启发:http://goo.gl/yuIjzf

  1. UI(来自供应链 dpt. 的用户)已经通过服务层发送了 AddProductToCatalogCommand(/*...*/)(又名。命令总线将命令转发给它的处理程序),
  2. 处理程序已准备好产品聚合(换句话说,通过向其应用所有先前的事件使其达到当前状态)并将命令传递给他。

  3. 如果没有出现异常(换句话说,聚合正确处理了命令),我们现在正处于处理程序请求更改聚合的位置应用于自身。

    处理程序现在将更改保存在数据库中:

    • 它在 Aggregates table 中插入一个新行:
    • AggregateId = ProductId
    • 聚合类型 = /some/namespace/Product
    • 聚合版本 = 0
    • 它在 Events table 中插入一个新行:
    • AggregateId = ProductId
    • Event = ProductWasAddedToCatalog($productId)(当然是序列化形式)
    • 版本 = 0
  4. 持久化进展顺利,因此处理程序将事件转发到服务层(又名。事件总线将事件转发到其处理程序 s) 供订阅者完成他们的工作。

here comes my problem!

其中一个订阅者是事件处理程序,它为 Sales BC 产品聚合发出命令。

namespace DomainModel/WriteSide/Sales;
use DomainModel/WriteSide/AggregateRoot as BaseAggregate;

Class Product extends BaseAggregate
{
  private $productId;
  //some other attributes related to the Sales BC, like sales price, guarantees...


  public static function AddAutomaticallyProductToCatalogSinceSupplyChainAddedIt(UpdateSalesCatalogCommand $command)
  {
    // some invariants' protection code here

    $this->recordThat(new ProductWasAutomaticallyAddedToSalesCatalog($command->productId));

  }
}

So now, what is my $command->productId?

正如 Jimmy Bogard 在 http://goo.gl/QHBkSr 中总结的那样:"Each Aggregate has a Root Entity [...] The root Entity has global identity and is ultimately responsible for checking invariants"

全球身份是关键词。

所以在我的用例中,我们有 2 个不同的聚合,因此我们应该有 2 个不同的 AggregateRoot 的 ID

在上面描述的事件存储机制下更加明显,因为如果两个 AR 具有相同的 Id,那么一个在处理其 public static function reconstituteFrom(AggregateHistory $history)

时会收到另一个的一些事件

So 2 distinct Ids. But still it's the very same product right? How do i make that explicit?

可能的解决方案

经过调查,我提出了 3 种可能的解决方案。我希望有人能够引导我进入正确的...

解决方案 1:持有引用

销售 BC 产品聚合持有对供应链产品聚合的引用。

这看起来像这样:

namespace DomainModel/WriteSide/Sales;
use DomainModel/WriteSide/AggregateRoot as BaseAggregate;

Class Product extends BaseAggregate
{
  private $productId;
  private $supplyChainProductId;   //the reference to the supply chain BC Product AR...


  public function getAggregateId()
  {
    return $this->productId;
  }

  //more methods there...
}

解决方案 2:在事件存储中使用复合主键

虽然我目前使用 AggregateId 列作为主键,但我可以同时使用 AggregateIdAggregateType

因为这将允许我拥有具有相同 ProductId 的两个 Product AR,这对我来说看起来像一种气味......单独因为 AR 全局身份的概念会被破坏......

解决方案 3:在两个 AR

中使用产品 sub-entity

仍然来自 Jimmy Bogard 的 http://goo.gl/QHBkSr,“边界内的实体具有本地标识,仅在聚合内是唯一的。”

所以我可以像下面这样对销售 BC 产品聚合进行建模:

namespace DomainModel/WriteSide/Sales;
use DomainModel/WriteSide/AggregateRoot as BaseAggregate;

// **here i'd introduce my sub-entity**
use DomainModel/Sales/Product/Entities/Product as ProductEntity;

Class Product extends BaseAggregate
{
  private $_Id;
  private $product;   //holds a ProductEntity instance


  public function getAggregateId()
  {
    return $this->_Id;
  }

  public function getProductId()
  {
    return $this->product->getProductId();
  }

  //more methods there...
}

虽然这可以让两个 AR 保持相同的 productId,但这对我来说没有意义,因为获取聚合的唯一方法是通过其 AR 的 Id(和不是通过其任何 sub-entities).

的 ID

我们可以想象在查询端有一种映射器:

namespace DomainModel/QuerySide;

Class ProductMapping
{
  private $productId;
  private $salesAggregateId;
  private $supplyChainAggregateId;
  private $product;   //holds a ProductEntity instance


  public function getSalesAggregateId()
  {
    return $this->salesAggregateId;
  }

  public function getSupplyChainAggregateId()
  {
    return $this->supplyChainAggregateId();
  }

}

Class ProductMappingRepository
{

  public function findByproductId($productId)
  {
    //return the ProductMapping object
  }

  public function addFromEvent(DomainEvent $event)
  {
    //this repository is an event subscriber....
  }

}

除了这个ProductMapper,查询端只知道ProductId。似乎都完成了... 但这对我来说又不合适。真的不能说为什么,就是没有!...

结论

这是一个虚假的用例,因此,可能还不确定table是否应该对上述 2 个有界上下文进行建模。

但我希望我表达清楚了,即如何在 2 个不同的 BC 中识别完全相同的物理 object(在该用例中为产品)。

Thx in advance for ur help!!!

注意。虽然我的第一个 Post 包含许多语言误用,因此遗漏了许多解释的大门,导致对我试图解决的问题的误解,但我选择完全 re-edit 它。为了将来 reader 理解以前的回复和评论,我将第一个 post 版本留在下面

=========================================== =====================

4 月 18 日在 11:51

提出的问题

让我们直接从上下文开始(取自这张 Codemotion 会议幻灯片 http://goo.gl/lMWSFZ)。

域扩展rt 是商人,他购买、销售和转移产品。他有 :

因此,我们可以考虑为每个限界上下文设置一个 Product Aggregate :

领域专家还说:

Now my question is simple :

how do I reference each aggregate to each other, given it's in the end "the very same Product"?

Should Sales and Logisitics Aggregates contains an PurchasedProductId ? I've been told to be very carefull with external references but ... how else?

编辑:

必须根据事件存储模式来看待这个问题,其中:

因此,如果应使用与@Plalx 在回复中建议的相同的 ProductId,则问题变为:

how can you have 2 Aggregates using the same Id whereas, by definition, an Aggregate is a self-containing Entity and, still by definition, an Entity must have a unique Id?

没有 PurchasedProductId 恕我直言,只有 ProductId。当在 Purchase 上下文中创建新产品时,可以将 ProductCreated { ProductId, ...} 事件发送到消息传递基础结构。其他有界上下文将为该事件设置订阅者,并在接收到事件后,使用存储在事件中的产品标识创建并保留他们自己的已创建视图 Product

"how can you have 2 Aggregates using the same Id whereas, by definition, an Aggregate is a self-containing Entity and, still by definition, an Entity must have a unique Id?"

我将尝试阐明这一点,至少从我的理解来看是这样。就像限界上下文 (BC) 是对概念问题(例如子域)的技术解决方案一样,聚合根 (AR) 本质上是一种战术(技术)模式,允许您制定事务一致的边界,以保护不变量一组特定操作的实体。 AR 是实体的专门表示。

因此,各自 BC 中的每个 Product AR 实现只是同一实体的不同表示,但专门针对它的问题 space。根据您的领域,我猜您可能有一个抽象的 Product class 存在于共享内核中并在每个 BC 中使用,但依赖性可能也不值得。

DDD 与 SOA 共享许多共同原则。由于它们依赖于有界上下文,因此域彼此不了解。他们通过通知进行交流。这些通知可以由合同支持的 DTO 体现。在本示例中,ProductId 在 DTO 中将作为 属性。一旦通知由 BC 产生,它就会被另一个使用(作为 table 轮询过程的输出,或者在服务总线上传输之后......)。给定其 ProductId,产品在数据库中的消费者 BC 中再水化。该产品存在于每个有意义的 BC 中。我认为你错过了 BC。你是吗?

哇,这里发生了很多事情。 AR 建模既是一门艺术,也是一门科学!

第一个建议:在设计 AR 时不要涉及数据库。为什么? CQRS、AR 和事件溯源都是来自 DDD 的战略和战术模式。重点是消除建模过程中的干扰。数据库是一种干扰(在这种情况下)。这可能是你在这里遇到困难的根本原因。

限界上下文是一种简化建模的机制。他们应该反映各个部门如何看待 Products/Items 之类的事情。事实上,这个模型就是一个很好的例子。模型名称反映了企业在每个上下文中使用的词。在某些方面,当他们谈论同一件事时,他们是不同的。它们在各自的上下文中有不同的含义。因此需要分别对它们建模。

外部引用呢...

一个 AR 可以引用另一个 AR,但只能以 ID 的形式(不一定是数据库密钥)。实际上,AR 本身不得包含对另一个 AR 的引用,即。包含另一个 AR(有一个)的私有变量。这是因为 AR 只能保证在其边界内保持一致。

这就引出了问题中的问题。我们如何协调来自不同限界上下文的这 3 个 AR?

第一种方法是询问它们是否实际上处于不同的有界上下文中。有时,这些建模问题是触发重新思考模型的有用方式。

让我们假设您的域是正确的。我们如何协调它们?

在这种情况下,流程管理器和反腐败层似乎是一个不错的选择。流程经理将监听产品和/或项目创建的事件。然后它将生成用于创建其他实体的适当命令。很有可能,每个上下文都以不同的方式处理这个问题。因此需要 ACL。 ACL 将负责将请求转换成在其域内有意义的内容。这可能就像将原始 AR 的外部 ID 添加到命令以创建它的 AR 一样简单。或者它可能只是将信息保存在临时区域中,直到满足各种其他条件。

在高层次上,监听事件并使用它们触发其他有界上下文中的相关进程。使用流程管理器,如果需要,还可以使用 ACL。

最后是存储问题...

我会在这里选择一个简单的事件存储策略。将每个事件保存在一个流中。使用 AR ID 为任何单个 AR 拉回事件。

对于读取模型,我将使用一组监听事件流的非规范化器。然后他们会生成一个为 UI 定制的读取模型(在本例中)。这可能涉及组合来自不同 BC 的信息。任何对您的用户有意义的东西。

我在我的博客 post 中介绍了其中一些想法:4 Secrets to Inter Aggregate Communication

无论如何,我希望这对您有所帮助。

IMO 这个问题太长了。如果我正确地理解了这个问题,那么您有采购和销售域(和 BC),并且两个域中的产品之间有一些共同点,即产品本身。您正在努力将这两者放在一起而不将它们合并到一个 BC 或域中。

将采购和销售拆分为两个 BC 是合乎逻辑的。 BC 代表一种无处不在的语言,这两个域对一个词(产品)的含义不同,你提到了这一点。

但是,产品本身是一个物理实体,具有描述、重量、尺寸和图片等属性。这些属性在所有域中共享,并且毫无疑问这些东西的含义。

这是一个非常典型的商业领域,对此有一个典型的解决方案。您需要一个包含具有 ProductId 的产品的 ProductCatalog 域。 Sales 和 Purchase 都将通过 ProductId 从 ProductCatalog 引用聚合,但有自己的、独立的聚合和自己的 ID,他们基本上不需要了解彼此,除非你的购买对销售有一些影响(通常是通过使销售能够销售来实现的)下订单或要求购买 运行 缺货的商品时更多)。您可能还需要一个 Stock 或 Warehouse 域,它也将引用 ProductCatalog 域中的产品。来自 Sales 和 Purchase 域的事件将直接影响持有库存水平的域。