DDD:两个不同聚合之间的多对多关系

DDD: many-to-many relationship between two different aggregates

我有两个 "big" 实体或聚合,它们有自己的业务逻辑 - 它们在单独的事务中保存、更新和销毁。它们有自己的子实体,通过这些聚合根进行操作。但问题是,这两个聚合之间必须是多对多关系。从用户界面的角度来看,有一种 UI,其中将第二个聚合的一个已经存在的实例添加到第一个聚合中。在数据库方面,有一个 table 保存第一个和第二个聚合 table 的外键

entity_one_id | entity_two_id
1             | 2
1             | 3
1             | 4

在上面的示例中,第一个聚合的实例包含对第二个聚合的引用。

我的问题是,从领域驱动设计的角度来看,如果在保存第一个聚合时加载第二个聚合的实例并将其添加到第一个聚合中是否可以。在伪代码中它可能看起来像:

aggregateOne = aggregateOneRepository->getById(1);
....
aggregate2 = aggregateTwoRepository->getById(2);
aggregate3 = aggregateTwoRepository->getById(3);
aggregate4 = aggregateTwoRepository->getById(4);
aggregateOne->addChildAggregate(aggregate2);
aggregateOne->addChildAggregate(aggregate3);
aggregateOne->addChildAggregate(aggregate4);
aggregateOneRepository->update(aggregateOne);

在这个交易中我似乎没有改变第二个聚合而只改变一个聚合。但是我不确定 DDD 理论是否允许在保存一个聚合时加载多个不同的聚合。那么,这种代码是否违反了理论呢?

引用另一个聚合(多对多或其他)并在同一事务中更新它,实际上违反了聚合设计的基本原则。聚合是一个一致性单元,符合它自己的一致性边界。事务应该更新,从而确保只有一个聚合的一致性。

自然需要跨聚合、跨一致性边界的更新。对于此类更新,DDD 推荐的方法是 最终一致性 :稍后在不同的事务中异步更新它们。聚合通过持有其 标识符 来引用另一个聚合,而不是具有关系的字段(在您的情况下为多对多)。每当需要更新其他聚合时,请在提交当前事务之前留下包含已发布的其他聚合 ID 的 域事件 。域事件订阅者异步获取事件,使用 id 检索聚合,进行必要的更新并存储它。这就是大致的基本思路。

聚合根不应包含其他聚合根的实例。例如,当调用一个方法但它不保留该引用时,聚合可能会传递一个 transient 引用。只在通话中使用。

你的例子实际上比你想象的更常见。如果我们必须更改为 OrderProduct 聚合,我们就会有一个多对多的关系。 OrderItem 表示该关系,最好将其定义为值对象。

当您发现需要 "reference" 另一个聚合时,则只使用 id 或至少包含另一个聚合 id 的某个值对象。

我对交易的看法略有不同。聚合根是一个一致性边界,因此非常适合事务边界。应尽一切努力在事务中保持单个聚合,但您也需要务实。如果您需要高度的一致性并且最终一致性可能不是一个选项,那么我愿意屈服并在事务中包含多个聚合的"rule"。一个例子可能是处理日记帐交易,其中金额从我系统中的一个帐户转移到另一个帐户。当您有不同的系统时,最终的一致性将是必需的,"rolling" 返回将需要补偿操作。

首先,我同意 Eben 的观点,不要在一个聚合中对另一个聚合进行对象引用,而是使用仅保存其他聚合 ID 的值对象。在数据库中,这个 id 只是一个字符串或整数(或者你在数据库中用作 id 类型的任何东西)而不是外键。

并始终问自己,您真正需要其他聚合的哪些数据,以及对于新聚合的哪些操作,您到底需要什么样的数据?

在大多数情况下,事实证明只需将从第一个聚合收集的所需数据传递给在新聚合上调用的方法就足够了。

如果这甚至发生在相同的有界上下文中,我倾向于务实。我通过它的存储库收集我需要数据的聚合,然后将其作为参数传递给新聚合的方法。或者只是其中的一部分。我通常在应用程序服务中执行此操作。

这样一来,您不需要在新聚合中保留旧聚合的任何其他信息,而不是它的 id,但您始终可以在任何需要的地方获得旧聚合的最新状态。这个概念甚至与领域驱动设计无关,但通常是最佳实践,只在真正需要的地方使用依赖项。

如果您不想依赖旧聚合的结构,只需创建某种新值对象,您可以在应用层中使用旧聚合的数据填充该对象。因此,您甚至不需要从旧聚合的存储库中收集数据,而是简单地拥有一些只直接从存储中读取所需数据的服务。但如果性能是您的问题,我只会推荐这个...

关于在单体应用程序中使用数据库外键的最后一条评论:

如果您曾计划在某个时候拆分单体,如果您从另一个限界上下文中引用某些内容,请不要使用外键。使用逻辑引用,而不是将其视为某种远程 ID 并在应用程序层解析它们。否则,为您喜欢从单体中提取的不同服务分离数据库可能会成为一场噩梦。