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

25

我有点困惑。我刚刚看了Julie Lerman在Pluralsight上关于DDD的视频,以下是我的疑惑:

以一个简单的在线商店为例,其中包括: 采购订单中的商品供应商,那么这里的聚合根是什么?

从技术角度来看,采购订单是聚合根,对吧?它是针对特定的供应商并有商品。这是有意义的。

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

在这个例子中,商品是聚合根吗?


我不知道或者看过那个Pluralsight视频,但是它可以被视为一个不同的有界上下文。因此,在它自己的有界上下文中,你可以说它是一个聚合根。 - penleychan
不要过分固守于你在视频教程中看到的内容。 - Fakhar Ahmad Rasul
2
在DDD中,您可以从另一个聚合根引用聚合根。但是,您不能引用其他聚合根内部的任何内容。我认为您的聚合根可能是PurchaseOrder、Supplier和StockItem。不要混淆PurchaseOrderLine和StockItem。PurchaseOrderLine属于PurchaseOrder聚合,并将引用StockItem。 - Mark Willis
1
@IshThomas 不需要(通常也不应该)从数据库中获取AR2。同时,也不建议AR1基于AR2中包含的数据做出决策。大多数AR1 => AR2引用都将通过ID进行,并且仅在帮助应用程序服务或更少见的域服务时才会按照“指针”从已知聚合中获取相关聚合。 - guillaume31
一般来说,AR如何获取数据?简单的答案是 - 它不需要获取。它在内存中存在的那一刻就已经拥有了所有需要的数据。 - guillaume31
显示剩余6条评论
3个回答

41

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

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

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 表示。这是此上下文中唯一相关的部分。我们需要知道采购订单上有哪些物品,不关心品牌或其他任何信息。现在你可能想知道如何了解采购订单上物品的价格和描述?这是另一个上下文——查看和删除物品,类似于Web上的许多“结账”系统。在这种情况下,我们可能有以下类:
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
        }
    }
}
在这种情况下,我们有一个非常简化的item版本,因为这个上下文不允许修改物品。它仅允许查看采购订单并选择删除物品的选项。如果用户选择查看一个Item,则上下文会再次切换,并且您可以将完整的item作为聚合根加载,以显示所有相关信息。
当确定是否有库存时,我认为这是另一个具有不同聚合根的上下文。例如:
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行,因为它具有管理银行账户、地址、个人资料详情、朋友、受益人、投资等逻辑。这些东西都不应该放在一起。

回答你的问题

  1. Q:为什么Item AR被称为ItemManagement,而PO AR仅被称为PurchaseOrder?

命名空间名称反映您所在的上下文名称。因此,在项目管理的上下文中,Item是根并且放置在ItemManagement命名空间中。你也可以将ItemManagement视为聚合,Item作为该聚合的。我不确定这是否回答了你的问题。

  1. Q:实体(如轻型Item)是否也应该具有方法和逻辑?

这完全取决于你上下文的内容。如果只用Item来显示价格和名称,则不需要公开逻辑,除非它确实在上下文中使用。例如,在CheckOut上下文示例中,Item没有任何逻辑,因为它们只是用于显示用户的购买订单包含哪些物品。如果有不同的功能,例如用户可以在结账时更改购买订单中物品的颜色(例如手机),则可能会考虑在该上下文中在物品上添加此类型的逻辑。

  1. ARs如何访问数据库?它们应该有接口,例如IPurchaseOrderData,其中包含void RemoveItem(int itemId)等方法吗?

对不起,我假设你的系统使用某种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语句来解决每个更改。


谢谢您的回答。我有一些问题:1. 为什么Item AR被称为 ItemManagement,但是PO AR只被称为 PurchaseOrder?2. 实体(如轻量级 Item)是否也应该有方法和逻辑呢?3. ARs 如何访问数据库?它们应该有一个接口...比如说 IPurchaseOrderData,其中包含像 void RemoveItem(int itemId) 这样的方法吗?它们仍然不会了解数据源。我将通过接口注入这些知识。因此,PurchaserOrder AR 的构造函数将是 public class PurchaseOrder (IPurchaseOrderData poData) { ... }。你同意吗? - Ish Thomas
如果我们为所有的有界上下文使用一个数据库,那么可能会有一些重复。例如,“完整”项和“简化”项是相同的实体,但是使用不同版本的项时,我们最终会得到多个相同项的表。如果我们没有为每个有界上下文使用一个数据库,那么这是正确的决定吗? - ggeorge
1
@ggeorge 我不会复制表格。DDD 的一个规则是你的领域与你的数据是完全分离的。你可以将同一张表映射到不同的领域实体上。因此,你可以有一个有 10 个字段的表,在 A 上映射 1、3 和 5,在 B 上映射 1、4 和 7,除非将数据分开使得更有意义。 - Reasurria

18
虽然此问题已有被接受的答案,但阅读这篇文章可能有助于其他读者。根据文章中的这一部分,不要直接引用另一个聚合,而是创建一个值对象来包装该聚合根的ID并将其用作参考。这样做可以更容易地维护聚合一致性边界,因为您甚至无法意外地从另一个聚合内部更改一个聚合的状态。它也防止检索聚合时从数据存储中检索深层对象树

2
有一个带有以下内容的简单在线商店示例:带有供应商项目的采购订单,这里的聚合根是什么?
这取决于您如何对其进行建模,而这又应该取决于您认为信息随时间变化的方式。
例如,一种可能的模型是将所有这些实体放入单个聚合中。
更常见的做法是将每个采购订单与其他订单分开处理;在这种情况下,您可能会使每个订单成为聚合根。由于几个订单可能与同一供应商有关系,因此供应商可能也是一个聚合。
项目不太清楚 - 订单条目可能局限于该订单,因此您不太可能创建一个单独的一致性边界来管理它们。另一方面,产品/ SKU 可能会被多个订单重复使用,这再次表明它们是单独的聚合。
通常在这种情况下,聚合不包含彼此的引用,而是可以用于查找引用的键。
因此,我的采购订单(#12345)可能包括“2个产品(#67890)”,但如果我想知道那意味着什么,那么我必须拿起产品(#67890)并使用它来查找产品的其余数据。
如果我想要一些类似“使用我们库存中的项目执行某些操作”的采购订单逻辑,我将不得不获取该PO上的所有项目并在其上调用IsInStock()方法。 IsInStock是Item的公共方法,所以我想我没有违反DDD原则。是吗?
简短的回答:不。
更长的答案:当您有两个数据必须始终保持一致时,您需要非常小心。尝试协调不同聚合中数据的语义会变得非常混乱。

谢谢您的澄清。那么,举个例子:如果我想要一些采购订单的逻辑,比如“对我们库存中的物品做某些事情”,我需要获取该采购订单上的所有物品,并在它们上调用IsInStock()方法。IsInStockItem的公共方法,所以我想我没有违反DDD原则。是吗? - Ish Thomas
如果我想向客户发送一个包含有意义的属性(如“productName”)的PO表示形式,那么将应用程序服务加载PO AR和Product AR,然后将它们映射到DTO是否有意义,如果是这样,对不起,因为这可能是多余的。 - KissTheCoder

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