DDD: 如何建模影响两个聚合的操作?

huangapple go评论58阅读模式
英文:

DDD: How to model an operation which affects two aggregates?

问题

领域:

我有一个名为“InventoryItem”的聚合和一个名为“Kit”的聚合。一个“InventoryItem”可以添加到一个(且仅一个)“Kit”中。一个“InventoryItem”有一个“Type”属性,它是业务使用的各种物品类型的枚举。一个“Kit”将包含多个“InventoryItems”。

我正在使用事件溯源。每个“InventoryItem”聚合都存储在自己的事件流中,每个“Kit”聚合都存储在自己的事件流中。

问题:

一个“InventoryItem”必须知道它是否属于一个“Kit”,以便执行诸如如果是的话阻止删除之类的不变性,并且不允许将其添加到另一个“Kit”。

一个“Kit”必须知道已经添加到它的多少个以及什么类型的“InventoryItems”,以便执行诸如在“Kit”要求完全满足之前不允许发货之类的不变性。

然而,一个事务只能影响一个聚合。因此,当将一个“InventoryItem”添加到一个“Kit”时,我无法使用一个事务同时更新“Kit”和“InventoryItem”以获取所需的信息。我已经考虑了几种解决方案,但每种方案似乎都存在几个问题。

使用进程管理器/传奇来将操作建模为长时间运行的过程。
对我来说,这种方法感觉不对,因为该操作实际上并不是一个长时间运行的过程。它还存在一个阶段失败并需要回滚之前阶段的风险。总的来说,这似乎是为了一个本应简单的操作而引入了很多开销。

使用领域事件来更新“Kit”或“InventoryItem”。
我可以使用初始事务更新其中一个聚合,然后发送领域事件以更新第二个聚合。然而,如果由于某种原因分派的事件失败,这将导致领域模型不同步的风险。在注意到并修复不同步模型之前,另一个用户可能再次将“InventoryItem”添加到另一个“Kit”中。再次强调,对于一个非常简单的操作,这似乎存在很多潜在问题和复杂性。

创建另一个聚合。
我考虑过引入另一个聚合,例如“KitItem”。该聚合本质上存储了“Kit”/“Inventory Item”的关系,而“Kit”和“InventoryItem”聚合将持有对它的引用。在我看来,这似乎是最合理的方法,但它再次引入了模型不同步的可能性。例如,如果删除了一个“Kit”怎么办?我们可以使用领域事件删除所有与“Kit”有关系的“KitItems”,但如果由于某种原因删除失败怎么办?另外,如果只允许“Kit”保存对“KitItem”的引用,那么“Kit”如何知道已添加到它的“InventoryItems”的类型?

有人对应该采取哪种方法有什么建议吗?或者是否有我没有考虑的其他方法?

英文:

The domain:

I have an InventoryItem aggregate and a Kit aggregate. An InventoryItem may be added to one (and only one) Kit. An InventoryItem has a Type property, which is an enum of various item types the business uses. A Kit will have multiple InventoryItems.

I am using event sourcing. Each InventoryItem aggregate is stored in its own event stream, and each Kit aggregate is stored in its own event stream.

The problem:

An InventoryItem must know whether it is part of a Kit in order to enforce invariants such as blocking deletion if it is, and not allowing it to be added to another Kit.

A Kit must know how many and what type of InventoryItems have been added to it in order to enforce invariants such as not allowing shipment until the Kit requirements are completely satisfied.

However, one transaction is supposed to affect only one aggregate. So, when an InventoryItem is added to a Kit, I'm unable to use one transaction to update both the Kit and the InventoryItem with the required information. I've been thinking through several solutions, and each one seems to present several issues.

Using a process manager/saga to model the operation as a long-running process.

This feels wrong to me, as the operation is really not a long-running process. It also runs the risk of a stage failing and requiring rollback of the previous stage. All in all, it feels like a lot of overhead for something that should be a simple operation.

Using domain events to update either the Kit or InventoryItem.

I could update one of the aggregates with the initial transaction, then dispatch a domain event to update the second aggregate. However, this runs the risk of an out-of-sync domain model if for some reason the dispatched event fails. Such a case would also introduce the possibility of another user adding the InventoryItem to another Kit before the out-of-sync model is noticed and fixed. Once again, it feels like there are a lot of potential problems and complexity for what is a very simple operation.

Creating another aggregate.

I've considered introducing another aggregate such as KitItem. This aggregate would essentially store the Kit/Inventory Item relationship, and the Kit and InventoryItem aggregates would hold references to it. This seems to me to be the most reasonable approach, and yet it once again introduces the possibility of an out-of-sync model. For example, what if a Kit is deleted? We could use a domain event to delete all KitItems with a relationship to the Kit, but what if that fails for some reason? Additionally, if the Kit is only allowed to hold a reference to the KitItem, how would the Kit know what types of InventoryItems have been added to it?

Does anyone have any advice on what approach to take here, or if there's another approach I haven't considered?

答案1

得分: 1

在工作中,我经常遇到这个问题。通常,我发现你正在定义的操作构成或属于不同的上下文。通常情况下,真正的聚合根在你的实体层次结构中高一级。与你的第三个想法没有太大区别。我不知道你的持久性/事件是如何设置的,但我认为解决方案仍然适用。

为了提供背景,确保我们对领域驱动设计(DDD)有相同的理解,你可以看看我的答案:https://stackoverflow.com/questions/50597171/can-aggregate-root-reference-another-root/50618538#50618538 我看到你关心从一个根引用另一个根,这就是我认为这个问题相关的原因。
简而言之,不要害怕为许多不同的上下文建模许多不同的根,即使它们表示相同的数据。根必须具备满足上下文需求的一切,不多也不少。

请记住,DDD的优势之一是将问题简化为易于解决的问题。所以,不要问“如何建模修改两个聚合的东西”,而应该问“为什么有两个聚合”(也许在你的情况下没有其他方法,那也是一个有效的答案)。

想象一下有一个新的有界上下文,有一个新的聚合根。我们称之为“Shop”,因为我不知道你的领域的其余部分是什么样的。实际上,它将是你实体树中的高级实体。例如,如果我们正在处理汽车(Kit)和车轮(InventoryItem),我们正在寻找工厂。

现在,让我们重新定义聚合本身:

public class Shop : IAggregateRoot
{
    public ICollection<Kit> Kits { get; private set; }

    public DomainEvent AddItemToKit(int kitId, int itemId)
    {
        if (this.Kits.Any(k => k.HasItem(itemId)))
        {
            return ItemAddFailedEvent(kitId, itemId, "Item already in another kit");
        }

        return new ItemAddedEvent(kitId, itemId);
    }
}

public class Kit : IEntity
{
    public int Id { get; private set; }
    public ICollection<int> Items { get; private set; } // Just IDs

    public bool HasItem(int itemId)
    {
        return this.Items.Contains(itemId);
    }
}

通过将根定义为整体领域关系树中的更高级别部分,你现在可以访问所有你想要的信息。管理问题变得微不足道。

我省略了你提到的一些细节,比如在套件上标识项目类型。我认为将它添加到这个起点上会很简单。只需将InventoryItem定义为实体并映射所需的内容。

现在让我们看看删除:

public class Shop : IAggregateRoot
{
    public ICollection<Kit> Kits { get; private set; }

    public DomainEvent DeleteItem(int itemId)
    {
        if (this.Kits.Any(k => k.HasItem(itemId)))
        {
            return new ItemDeleteFailedEvent(kitId, itemId, "Item is in a kit");
        }

        return new ItemDeletedEvent(itemId);
    }
}

同样,解决方案变得微不足道。有了这个根,检查我们是否可以删除变得很简单。

回到我上面链接的其他答案 - 整个聚合和这里的所有3个示例类都是全新的。不管你的系统中已经有一个叫做“Kit”的类与否都没有关系。这是一个新的上下文,有一个新的问题。你不需要冒着破坏现有功能的风险来使这个新功能工作。当然,在映射方面存在开销,但我发现几乎总是值得的。

所以,要使用这种方法使其工作,你需要:

  1. 创建新的类
  2. 映射/加载/填充所需的最小属性。不能强调这一点不够。

当然,现实往往并不那么简单。有无数关于你的系统的变量,需要花费数小时或数天来详细解释。

几乎总是问题出在领域模型和数据存储方式之间。尽量不要让这两个因素相互干扰。

尽量将领域建模得尽可能纯粹。这使得解决方案简单。

然后,尽量将数据建模得尽可能高效。这使得解决方案高性能。

然后,进行映射和妥协,直到解决方案存在。

我相信,如果有人让你独立于任何持久性机制编写领域解决方案,你会自己完成得很好,因为业务问题很简单。不幸的是,我们有时会无意中制造技术问题。

英文:

I encounter this problem a lot at work. Normally I find that the operation you are defining constitutes or belongs to a different context. And normally the real aggregate root is something one level higher up in your hierarchy of entities. Not too different from your 3rd idea. I don't know how your persistence/events are set up but I think the solution should still apply.

For context and to make sure we understand DDD the same way, you can have a look at my answer here: https://stackoverflow.com/questions/50597171/can-aggregate-root-reference-another-root/50618538#50618538 I see you are concerned about referencing one root from another, which is why I think this is relevant.
TL;DR - don't be afraid of modeling many different roots for many different contexts, even if they represent the same data. The root MUST have everything it needs to satisfy the context requirements, no more, no less.

Remember that one of the strengths of DDD is to simplify the problem to something easy. So instead of asking "how do I model something that modifies two aggregates", rather ask "why are there two aggregates" (maybe it's not feasible in your situation to have it any other way; that's a valid answer).

Imagine having a new bounded context with a new aggregate root. Let's call it Shop, because I have no idea what the rest of your domain is. Essentially it would be the entity that is one level up in your entity tree. For example, if we were working with Car (Kit) and Wheel (InventoryItem), we are looking for Factory.

Now say we redefine the aggregate itself as the following:

public class Shop : IAggregateRoot
{
    public ICollection&lt;Kit&gt; Kits {get;private set;}

    public DomainEvent AddItemToKit(int kitId, int itemId)
    {

        if(this.Kits.Any(k =&gt; k.HasItem(itemId)))
        {
            return ItemAddFailedEvent(kitId, itemId, &quot;Item already in another kit&quot;); 
        }

        return new ItemAddedEvent(kitId, itemId);
    }
}

public class Kit : IEntity
{
    public int Id {get;private set;}
    public ICollection&lt;int&gt; Items {get;private set;} // Just IDs

    public bool HasItem(int itemId)
    {
        return this.Items.Contains(itemId);
    }
}

By defining the root as something higher up in the relationship tree of your overall domain, you now have access to all the information you could want. It becomes trivial to manage the problem.

I've left out some details you have mentioned, such as identifying item types on a kit. I think it would be simple enough to add that onto this starting point. Just make InventioryItem an entity and map what you need.

Let's look at deletion:

public class Shop : IAggregateRoot
{
    public ICollection&lt;Kit&gt; Kits {get;private set;}

    public DomainEvent DeleteItem(int itemId)
    {

        if(this.Kits.Any(k =&gt; k.HasItem(itemId)))
        {
            return new ItemDeleteFailedEvent(kitId, itemId, &quot;Item is in a kit&quot;); 
        }


        return new ItemDeletedEvent(itemId);
    }
}

Again, the solution has become trivial. Given this root, it's simple to check if we can delete.

Referring back to my other answer linked above - this entire aggregate and all 3 of the example classes here would all be brand new. It doesn't matter if you already have a class called "Kit" somewhere in your system. This is a new context with a new problem. You don't need to risk breaking existing features to make this new feature work. Sure, there is overhead in mapping, but that's a price I've found is almost always worth paying.

So for you to get this to work using this approach, you need to:

  1. Make the new classes
  2. Map/load/hydrate the BARE MINIMUM properties needed. Can't stress this enough.

Of course reality is never so simple. There are countless variables that you know about your system that would take hours or days to explain in detail.

Almost always the problem is between the domain model and how the data is stored. Try to not let those 2 things interfere with each other.

Model the domain as pure as possible. This makes the solution simple.

Then model the data as efficient as possible. This makes the solution performant.

Then map and compromise until the solution exists.

I'm sure that if someone asked you to write the domain solution independent of any persistence mechanism, you would have nailed it by yourself, as the business problem is simple. Unfortunately we sometimes create technical problems by accident.

huangapple
  • 本文由 发表于 2023年6月19日 21:49:48
  • 转载请务必保留本文链接:https://go.coder-hub.com/76507280.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定