聚合根可以引用另一个根吗?

Can aggregate root reference another root?

我有点困惑。我刚刚看了
Julie Lerman 关于 DDD 的 Pluralsight 视频,这是我的困惑: 有一个简单的在线商店示例: 供应商物品的采购订单,这里的聚合根是什么?

技术上采购订单,对吧?这是特定的 Supplier 并且上面有 items。有道理。

但是.. Item 也是聚合根吗?它还有其他 "sub-objects",如 "Brand"、"Designer"、"Color"、"Type" 等...您的 SOA 系统中可能有一个单独的应用程序来编辑和管理项目(无 PO)。所以..在那种情况下,您将必须访问聚合根的组件 - 这是不允许的。

本例中的是否是聚合根?

Having a simple online store example with: Purchase Orders with Items for Suppliers, what's the aggregate root here?

这取决于您如何建模,而这又应取决于您认为信息随时间变化的方式。

例如,一种可能的模型是将所有这些实体放入一个集合中。

更常见的做法是将每个采购订单与其他采购订单分开处理;在这种情况下,您可能会将每个订单设为聚合根。由于多个订单可能与同一供应商有关系,因此供应商也可能是一个集合。

项目不太清楚 - 订单中的条目可能是该订单的本地条目,因此您不太可能创建单独的一致性边界来管理它们。另一方面,products/skus 很可能被多个订单重复使用,这再次表明它们是一个单独的集合。

在这种情况下通常会发生的情况是,聚合不包含彼此的 引用 ,而是包含可用于查找引用的键。

所以我的采购订单 (#12345) 可能包括“2 件产品 (#67890)”,但如果我想知道那 意味着什么 ,那么我必须获取产品 (#67890) 并使用它来查找产品的其余数据。

If I want to have some PO's logic like "Do something with items that we have in stock" I would have to get all items on that PO and call IsInStock() method on them. IsInStock is a public method of the Item so I guess I'm not violating DDD principles. Am I?

简答:没有。

更长的答案:当您有两条数据 必须 始终一致时,您需要非常小心。试图协调 不同 聚合中的数据语义变得非常混乱。

这取决于你所处的上下文。我会尝试用几个不同的上下文示例来解释并在最后回答问题。

假设第一个上下文是关于向系统添加新项目的。在这种情况下,项目是聚合根。您很可能会构建新项目并将其添加到数据存储或删除项目。假设 class 可能如下所示:

namespace ItemManagement
{
    public class Item : IAggregateRoot // For clarity
    {
        public int ItemId {get; private set;}

        public string Description {get; private set;}

        public decimal Price {get; private set;}

        public Color Color {get; private set;}

        public Brand Brand {get; private set;} // In this context, Brand is an entity and not a root

        public void ChangeColor(Color newColor){//...}

        // More logic relevant to the management of Items.
    }
}

现在假设系统的不同部分允许通过在订单中添加和删除项目来组成采购订单。在这种情况下,Item 不仅不是聚合根,而且理想情况下它甚至不会相同 class。为什么?因为品牌、颜色和所有逻辑在这种情况下很可能完全不相关。这是一些示例代码:

namespace Sales
{
    public class PurchaseOrder : IAggregateRoot
    {
        public int PurchaseOrderId {get; private set;}

        public IList<int> Items {get; private set;} //Item ids

        public void RemoveItem(int itemIdToRemove)
        {
            // Remove by id
        }

        public void AddItem(int itemId) // Received from UI for example
        {
            // Add id to set
        }
    }
}

在此上下文中,Item 仅由一个 Id 表示。这是本文中唯一相关的部分。我们需要知道采购订单上有哪些项目。我们不关心品牌或其他任何东西。现在您可能想知道如何知道采购订单上项目的价格和描述?这是另一个上下文 - 查看和删除项目,类似于网络上的许多 'checkout' 系统。在这种情况下,我们可能有以下 classes:

namespace Checkout
{
    public class Item : IEntity
    {
        public int ItemId {get; private set;}

        public string Description {get; private set;}

        public decimal Price {get; private set;}
    }

    public class PurchaseOrder : IAggregateRoot
    {
        public int PurchaseOrderId {get; private set;}

        public IList<Item> Items {get; private set;}

        public decimal TotalCost => this.Items.Sum(i => i.Price);

        public void RemoveItem(int itemId)
        {
            // Remove item by id
        }
    }
}

在此上下文中,我们有一个非常瘦的项目版本,因为此上下文不允许更改项目。它只允许查看采购订单和删除项目的选项。用户可能 select 要查看的项目,在这种情况下,上下文会再次切换,您可以加载完整项目作为聚合根以显示所有相关信息。

在确定您是否有库存的情况下,我认为这是另一个具有不同根源的上下文。例如:

namespace warehousing
{
    public class Warehouse : IAggregateRoot
    {
        // Id, name, etc

        public IDictionary<int, int> ItemStock {get; private set;} // First int is item Id, second int is stock

        public bool IsInStock(int itemId)
        {
            // Check dictionary to see if stock is greater than zero
        }
    }
}

每个上下文,通过其自己的根和实体版本,公开执行其职责所需的信息和逻辑。不多也不少。

我知道您的实际应用程序会复杂得多,需要在将项目添加到采购订单之前进行库存检查等。关键是理想情况下,您的根目录应该已经加载了完成该功能所需的所有内容并且没有其他上下文应该影响不同上下文中根的设置。

所以回答你的问题 - 任何 class 都可以是实体或根,具体取决于上下文,如果你很好地管理了你的限界上下文,你的根将很少需要相互引用。您不必在所有上下文中重复使用相同的 class。事实上,使用相同的 class 通常会导致用户 class 长达 3000 行,因为它具有管理银行账户、地址、个人资料详细信息、朋友、受益人、投资等的逻辑。None 这些东西属于一起。

回复您的问题

  1. 问:为什么 Item AR 称为 ItemManagement 而 PO AR 仅称为 PurchaseOrder?

命名空间名称反映了您所在上下文的名称。因此在项目管理的上下文中,Item 是根,它被放置在 ItemManagement 命名空间中。您还可以将 ItemManagement 视为 Aggregate,将 Item 视为此聚合的 Root。我不确定这是否能回答您的问题。

  1. 问:实体(如轻型物品)是否也应该有方法和逻辑?

这完全取决于您的上下文。如果您打算仅使用 Item 来显示价格和名称,则不可以。如果不应该在上下文中使用逻辑,则不应公开逻辑。在 Checkout 上下文示例中,Item 没有逻辑,因为它们仅用于向用户显示采购订单的组成部分。如果有一个不同的功能,例如,用户可以在结帐期间更改采购订单上的项目颜色(如 phone),您可以考虑在该上下文中的项目上添加这种类型的逻辑.

  1. AR如何访问数据库?他们应该有一个接口..比方说 IPurchaseOrderData,使用类似 void RemoveItem(int itemId)?
  2. 的方法

对不起。我假设您的系统正在使用某种 ORM,例如 (N)Hibernate 或 Entity framework。在这种 ORM 的情况下,ORM 将足够聪明,可以在根持久化时自动将集合更新转换为正确的 sql(前提是您的映射配置正确)。 在您管理自己的持久性的情况下,它会稍微复杂一些。要直接回答问题 - 您可以将数据存储接口注入根目录,但我建议不要这样做。

您可以拥有一个可以加载和保存聚合的存储库。让我们以 CheckOut 上下文中的项目为例采购订单。您的存储库可能包含以下内容:

public class PurchaseOrderRepository
{
    // ...
    public void Save(PurchaseOrder toSave)
    {
        var queryBuilder = new StringBuilder();

        foreach(var item in toSave.Items)
        {
           // Insert, update or remove the item
           // Build up your db command here for example:
           queryBuilder.AppendLine($"INSERT INTO [PurchaseOrder_Item] VALUES ([{toSave.PurchaseOrderId}], [{item.ItemId}])");

        }
    }
    // ...
}

您的 API 或服务层将如下所示:

public void RemoveItem(int purchaseOrderId, int itemId)
{
    using(var unitOfWork = this.purchaseOrderRepository.BeginUnitOfWork())
    {
        var purchaseOrder = this.purchaseOrderRepository.LoadById(purchaseOrderId);

        purchaseOrder.RemoveItem(itemId);

        this.purchaseOrderRepository.Save(purchaseOrder); 

        unitOfWork.Commit();
    }
}

在这种情况下,您的存储库可能会变得很难实施。让它删除采购订单上的项目并重新添加 PurchaseOrder 根上的项目实际上可能更容易(简单但不推荐)。 每个聚合根都有一个存储库。

题外话: 像 (N)Hibernate 这样的 ORM 将通过跟踪自加载根以来对根所做的所有更改来处理 Save(PO)。因此,它将拥有更改内容的内部历史记录,并在您保存时发出适当的命令,使您的数据库状态与根状态同步,方法是发出 SQL 以解决对根及其子项所做的每个更改。

虽然这个问题有一个公认的答案,但阅读 this article 可能会对这个问题的其他读者有所帮助。
根据文章的this section, 不要直接引用另一个聚合,而是创建一个 值对象 来包装聚合根的 ID 并将其用作引用。这使得 维护聚合一致性边界 变得更容易,因为您甚至不会意外地从另一个聚合中更改一个聚合的状态。它还防止在检索聚合时从数据存储中检索深层对象树