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

Posted

技术标签:

【中文标题】聚合根可以引用另一个根吗?【英文标题】:Can aggregate root reference another root? 【发布时间】:2018-11-08 20:46:20 【问题描述】:

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

技术上采购订单,对吗?它适用于特定的供应商,上面有项目。这是有道理的。

但是.. Item 也是聚合根吗?它还有其他“子对象”,例如“品牌”、“设计师”、“颜色”、“类型”等……您的 SOA 系统中可能有一个单独的应用程序来编辑和管理项目(没有 PO)。所以..在这种情况下,您将不得不访问聚合根的组件 - 这是不允许的。

这个例子中的 Item 聚合根是否存在?

【问题讨论】:

我不知道或看过那个 Pluralsight 视频,但那将被视为不同的有界上下文。所以在它自己的有界上下文中,你可以说它是和聚合根。 不要太拘泥于您在视频教程中观看的内容 在 DDD 中,您可以从另一个聚合引用聚合根。您不能做的是引用其他聚合根中的任何内容。我认为您的聚合根可能是 PurchaseOrder、Supplier 和 StockItem。不要在 PurchaseOrderLine 和 StockItem 之间混淆。 PurchaseOrderLine 属于 PurchaseOrder 聚合并将引用 StockItem。 @Mark so.. 如果我错了,请纠正我,但我认为我需要将数据持久性对象注入我的聚合根。例如:在StockItem 对象中我有GetOnHand 方法。此方法必须转到数据库。使用具有GetOnHand 方法的IItemData 接口的实现来初始化域实体(聚合根)是一种好习惯吗? @IshThomas 不,AR1 不必(通常也不应该)从数据库中获取 AR2。也不建议 AR1 基于 AR2 中包含的数据做出决定。大多数 AR1=>AR2 引用都将使用 ID,并且仅用于帮助应用程序服务,或者更罕见的领域服务,通过从他们已经知道的聚合中遵循“指针”来获取相关聚合。 【参考方案1】:

这取决于您所处的上下文。我将尝试用几个不同的上下文示例进行解释,并在最后回答问题。

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

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 不仅不是聚合根,而且理想情况下它甚至不是同一个类。为什么?因为品牌、颜色和所有逻辑在这种情况下很可能完全不相关。下面是一些示例代码:

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 表示。这是在这种情况下唯一相关的部分。我们需要知道采购订单上有哪些项目。我们不关心品牌或其他任何事情。现在您可能想知道您如何知道采购订单上物品的价格和描述?这是另一个上下文 - 查看和删除项目,类似于网络上的许多“结帐”系统。在这种情况下,我们可能有以下类:

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
        
    

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

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

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
        
    

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

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

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

回答您的问题

    问:为什么 Item AR 被称为 ItemManagement 而 PO AR 被称为 PurchaseOrder?

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

    问:实体(如轻物品)也应该有方法和逻辑吗?

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

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

我很抱歉。我假设您的系统正在使用某种 ORM,例如 (N)Hibernate 或 Entity 框架。在这种 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 来处理对根及其子项所做的每项更改时,它将具有更改的内部历史记录并发出适当的命令,以使您的数据库状态与您的根状态同步。

【讨论】:

感谢您的回答。我有几个问题: 1. 为什么Item AR 叫ItemManagement 而PO AR 叫PurchaseOrder? 2. 实体(如lightItem)也应该有方法和逻辑吗? 3. ARs如何访问数据库?他们是否应该有一个接口.. 比如说IPurchaseOrderData,使用像void RemoveItem(int itemId) 这样的方法?他们仍然对数据源一无所知。我将通过界面注入这些知识。那么PurchaserOrder AR 的构造函数就是public class PurchaseOrder (IPurchaseOrderData poData) ... 你同意吗?【参考方案2】:

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

【讨论】:

【参考方案3】:

有一个简单的在线商店示例:带有供应商项目的采购订单,这里的聚合根是什么?

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

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

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

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

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

所以我的采购订单 (#12345) 可能包含“2 个产品单位 (#67890)”,但如果我想知道那是什么意思,那么我必须拿下产品(# 67890) 并使用它来查找产品的其余数据。

如果我想要一些 PO 的逻辑,例如“对我们有库存的物品做一些事情”,我必须获取该 PO 上的所有物品并在它们上调用 IsInStock() 方法。 IsInStock 是 Item 的公共方法,所以我想我没有违反 DDD 原则。我是吗?

简短回答:不。

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

【讨论】:

感谢您的澄清。所以..例如:如果我想要一些 PO 的逻辑,比如“对我们库存的物品做一些事情”,我必须获取该 PO 上的所有物品并在它们上调用 IsInStock() 方法。 IsInStockItem 的公共方法,所以我想我没有违反 DDD 原则。我是吗?

以上是关于聚合根可以引用另一个根吗?的主要内容,如果未能解决你的问题,请参考以下文章

DDD领域驱动设计实战-聚合(Aggregate)和聚合根(AggregateRoot)

如何在弹性搜索的过滤器聚合中引用多个嵌套级别?

一系列简单的聚合根问题(领域驱动设计)

领域驱动设计中实体和聚合之间的区别

DDD:我需要多少个聚合根?

Mongodb聚合函数引用另一个集合