DDD 在一个聚合中的实体之间以一对多而不是一对多的聚合边界

Posted

技术标签:

【中文标题】DDD 在一个聚合中的实体之间以一对多而不是一对多的聚合边界【英文标题】:DDD Aggregate boundaries one to some and not one to many between entities in one aggregate 【发布时间】:2021-12-27 14:47:17 【问题描述】:

我看过一个关于 DDD 的教程,其中说如果我有聚合根 SnackMachine,它有 30 多个子元素,则子元素应该单独聚合。例如,SnackMachine 有很多 PurshaseLog(超过 30 个),PurshaseLog 最好放在单独的聚合中。这是为什么呢?

【问题讨论】:

能否请您添加对教程的引用并最好引用促进拆分聚合的声明?可能有不同的动机...... 领域驱动设计实践作者 Vladimir Khorikov 在复数视觉 4. 使用聚合扩展有界上下文,部分:如何为聚合找到边界 【参考方案1】:

限制聚合的总体大小的原因是因为您总是将完整的聚合加载到内存中,并且总是以事务方式存储完整的聚合。非常大的聚合会导致技术问题。

也就是说,聚合设计中没有这样的“30 个子元素”规则,而且听起来很随意。例如,拥有更少的非常大的子元素在技术上可能比拥有 30 个非常轻的子元素更糟糕。存储聚合的一种好方法是作为 json 文档,因为您将始终以原子操作的形式读取和写入文档。如果你这样想,你会意识到一个聚合设计意味着一个非常大的甚至不断增长的子集合最终会导致问题。 PurhaseLog 听起来像是一个不断增长的集合。

规则的第二部分“将其放在单独的聚合中”也是不正确的。您不创建聚合,因为您需要存储一些数据并且它不适合现有聚合。你创建聚合是因为你需要实现一些业务逻辑,而这个业务逻辑需要一些数据,所以你把这两个东西放在一个聚合中。

因此,尽管您在问题中解释的是在设计聚合以避免出现技术问题时需要考虑的事项,但我建议您将注意力放在聚合的实际职责上。

在您的示例中,SnackMachine 的职责是什么?它真的需要购买日志的(完整)列表吗? SnackMachine 将公开哪些操作?假设它公开了 PurchaseProduct(productId) 和 LoadProduct(productId, quantity)。为了执行它的业务逻辑,这个聚合需要一个产品列表并计算它们的可用数量,但它不需要存储购买日志。相反,在每次购买时,它都可以发布一个事件 ProductPurchased(SnackMachineId, ProductId, Date, AvailableQuantity)。然后外部系统可以订阅此事件。一个订阅者可以注册 PurchaseLog 用于报告目的,另一个订阅者可以在库存低于 X 时派人重新加载机器。

【讨论】:

【参考方案2】:

如果 PurchaseLog 不是它自己的聚合,则意味着它只能作为 SnackMachine 的子集合的一部分进行检索或添加。

因此,每次您想要添加 PurchaseLog 时,您都需要检索 SnackMachine 及其子 PurchaseLogs,然后将 PurchaseLog 添加到它的集合中。然后保存对工作单元的更改。

您真的需要检索 30 多条多余的购买日志,以用于创建新购买日志的用例吗?

应用层 - 选项 1(PurchaseLog 是 SnackMachine 的拥有实体)

// Retrieve the snack machine from repo, along with child purchase logs
// Assuming 30 logs, this would retrieve 31 entities from the database that
// your unit of work will start tracking.
SnackMachine snackMachine = await _snackMachineRepository.GetByIdAsync(snackMachineId);

// Ask snack machine to add a new purchase log to its collection
snackMachine.AddPurchaseLog(date, quantity);

// Update
await _unitOfWork.SaveChangesAsync();

应用层 - 选项 2(PurchaseLog 是聚合根)

// Get a snackmachine from the repo to make sure that one exists
// for the provided id.  (Only 1 entity retrieved);
SnackMachine snackMachine = await _snackMachineRepository.GetByIdAsync(snackMachineId);

// Create Purhcase log
PurchaseLog purchaseLog = new(
   snackMachine,
   date,
   quantity);

await _purchaseLogRepository.AddAsync(purchaseLog);

await _unitOfWork.SaveChangesAsync()

PurchaseLog - 选项 2

class PurchaseLog

    int _snackMachineId;
    DateTimne _date;
    int _quantity;

    PurchaseLog(
        SnackMachine snackMachine,
        DateTime date,
        int quantity)
    
        _snackMachineId = snackMachine?.Id ?? throw new ArgumentNullException(nameof(snackMachine));
        _date = date;
        _quantity = quantity;
    

第二个选项更准确地遵循您的用例的轮廓,同时也减少了数据库的 I/O。

【讨论】:

以上是关于DDD 在一个聚合中的实体之间以一对多而不是一对多的聚合边界的主要内容,如果未能解决你的问题,请参考以下文章

如何运用领域驱动设计 - 聚合

同一实体之间的一对多和多对多关系

在一对多 CoreData 关系错误上调用“计数”是不是会将集合中的所有对象都带入内存?

数据表之间的关系

IOS/Objective-C/CoreData:以一对多关系编辑NSSet

4.一对多关联映射