如何掌握DDD聚合设计? - SSENSE


在本文中,将深入研究领域驱动设计(DDD) 以及许多困惑和讨论的主题:聚合设计。
首先简要概述什么是聚合,然后继续揭开业务不变量的神秘面纱,并在您必须打破聚合时提供实际考虑因素。

什么是聚合?
DDD 中提出的第一个战术元素是实体和值对象。每个都有我们可以用来区分它们的特征:

  • 值对象不具有身份,是不可变的,并且始终有效。
  • 实体拥有身份,是可变的,并且它们的有效性与它们的状态相关联。它们还可以包含值对象。

我们结合这些构建块来创建聚合的概念,Eric Evans 将其定义为“我们将其视为一个单元以进行数据更改的关联对象的集群”。这意味着两件事:
  1. 包含身份的聚合将具有其他实体,并且对这些实体的访问必须通过聚合传递。
  2. 聚合定义了事务边界。这意味着对聚合的任何更改要么全部成功,要么都不成功。

让我们看看如何在代码中表达:

const purchaseOrder = purchaseOrderRepository.findById('123');
const item = purchaseOrder.items.find('ABC');
item.quantity = 10;
[url=http://itemrepository.save/]itemRepository.save[/url](item); // <-- 不应该这么做
// 取而代之的是,通过purchaseOrder的方法更新数量
purchaseOrder.updateItemQuantity('ABC', 10);
[url=http:
//pruchaseorderrepository.save/]pruchaseOrderRepository.save[/url](purchaseOrder);

在此示例中,我没有将PurchaseOrder和PurchaseOrderItem作为原子单元处理。
如果我有两个连接的实体,这意味着我有一个聚合,对吗?不一定是这样,让我们​​了解原因。

让我们澄清一下:关系并不意味着聚合
如果我们重新审视聚合的定义,我们可以提出以下问题:
为什么我需要将这组对象视为一个单元?
经常给出的答案是:当您拥有管理这些对象关系的业务规则(也称为业务不变量)时,您就有了一个聚合。

让我们深入研究一个示例,看看它是如何工作的:
想象一下,作为采购域的一部分,您有一个由请求者下的采购订单,其中包含一个或多个将由供应商提供的采购订单项目。一旦获得批准,就不能通过添加或删除任何采购订单项目来修改采购订单。下图说明了这种关系中涉及的一些概念:

图 1. 采购订单关系

从 DDD 的角度对此进行建模时,最初的方法是将所有这些对象放在一起。相比之下,图 2 显示了一个边界,该边界定义了所有访问都通过其根的聚合。

图 2。聚合跨越关系的所有对象部分

在第二个示例中,请求者和产品也是聚合的一部分。这种方法有两个主要问题:

  1. 性能不佳:由于事务边界是整个聚合,即使您对其中的一小部分进行更改,这也意味着锁定所有对象。
  2. 不必要的耦合:由于没有真正的业务规则来管理具有采购订单的请求者的生命周期,因此将其作为聚合的一部分不会增加业务价值。

更好的方法是将请求者Requester与采购订单PurchaseOrder分开,同时将采购订单项目保持在采购订单边界内。

图 3. 没有请求和产品实体的修订采购订单

在这种方法中,您将RequesterId和ProductId保留为值对象,并确保采购订单知道它们,但不负责控制它们的生命周期。这解决了我们上面确定的两个问题。

业务不变量并不完全相同
我们已经确定,业务不变量是您用来确定聚合的事务性(即一致性)边界的东西。然而,不变量的某些特性需要以不同的方式处理。
在发现不变量时,区​​分真正的不变量(原子的)和那些不是的(最终的)是很重要的。

使用真正的不变量,您不能推迟验证对聚合的更改。在前面的示例中,如果采购订单项目不是采购订单的一部分,我们可能会遇到这样一种情况,即采购订单正在转换到已批准状态,而其他人正在对项目进行更改。

图 4. 未满足业务规则

这是一个原子变体的例子。我们需要在执行交易之前验证更改。对于最终的不变量,我们能够最终满足这种一致性,或者在更糟糕的情况下,如果它们无法协调,就会触发一些补偿行为。

现在让我们想象另一个场景。想象一下,您有一个客户订单域,并且您希望在所有购买的商品都发货后,订单状态会转换为已发货。

图 5. 边界内装运的客户订单聚合模型

虽然您可以制作图 5 中的聚合,但采用图 6 中概述的方法是完全可以的。

图 6. 边界内没有装运的客户订单汇总

这意味着最终订单的状态会变成我们期望的状态。现在让我们看看每一种情况的实际实现。

强一致性
真正的业务不变量是原子的。因此,如果您有多个更改,则必须同时执行它们以避免在聚合中出现无效(中间)状态。
如果我们重新查看我们的采购订单示例,我们已经确定在采购订单被批准后我们不能对项目进行修改。我们也知道没有与请求者相关的不变量。让我们把它翻译成代码:

// Omitting other accessor methods and validation for simplicity
class ProductId {
    constructor(private readonly id: string) {}
}

class RequesterId {
    constructor(private readonly id: string) {}
}

class PurchaseOrderItem {
    constructor(private readonly id: string, private quantity: number, private readonly productId: ProductId) {}
}

class PurchaseOrder {
    private readonly id: string;
    private status: 'approved' | 'cancelled' | 'draft';
    private items: PurchaseOrderItem[];
    private readonly requesterId: RequesterId;

    constructor(requesterId: RequesterId, items: {productId: string; quantity: number}[]) {
        this.id = uuid();
        this.status = 'draft';
        this.items = items.map(item => new PurchaseOrderItem(uuid(), item.quantity, new ProductId(item.productId)));
        this.requesterId = requesterId;
    }
}

我们没有包含请求者的实际实例(其中包含姓名、电子邮件和其他详细信息),而是有一个RequesterId。这个值对象代表了我们与请求者的关系,但举例说明了它的生命周期如何不受采购订单的控制。

class PurchaseOrder {
    // ... omitting the rest of the code

    static place(requesterId: RequesterId, items: {productId: string; quantity: number}[]) {
        return new PurchaseOrder(requesterId, items);
    }

    approve() {
       
// in reality this would check the current status using a Finite State Machine
        this.status = 'approved';
    }

    addItem(item: {productId: string; quantity: number}) {
        if (this.status !== 'draft') {
            throw new Error('Cannot add item to a purchase order that is not in draft status');
        }

       
// ... in practice use some check if the same product has already been added
        this.items.push(new PurchaseOrderItem(uuid(), item.quantity, new ProductId(item.productId)));
    }
}

const order = PurchaseOrder.place(new RequesterId('123'), [{productId: 'ABC', quantity: 10}]);
order.addItem({productId: 'DEF', quantity: 20});  
// OK
order.approve();                                  
// OK
order.addItem({productId: 'GHI', quantity: 30});  
// Error


请注意项目如何在聚合之外不被操作,以及我们如何通过以原子方式检查状态来保证规则。现在让我们来说明持久性:

class DynamoDbPurchaseOrderRepository {
    constructor(private readonly dynamodb: DynamoDB, private readonly tableName: string) {}

    async save(purchaseOrder: PurchaseOrder): Promise<any> {

        // In practice would add error checking for size of the transaction, existence and optimistic concurrency
        const params = {
            TransactItems: [
                {
                    Put: {
                        TableName: this.tableName,
                        Item: {
                            PK: {
                                S: `PURCHASEORDER#${purchaseOrder.id}`
                            },
                            SK: {
                                S: `PURCHASEORDER#${purchaseOrder.id}`
                            },
                            version: {
                                N: `${purchaseOrder.version}`
                            },
                            status: {
                                S: `${purchaseOrder.status}`
                            },
                            requesterId: {
                                S: purchaseOrder.requesterId.toString()
                            },
                        },
                    },
                },
                ...purchaseOrder.items.map(item => ({
                    Put: {
                        TableName: this.tableName,
                        Item: {
                            PK: {
                                S: `PURCHASEORDER#${purchaseOrder.id}`
                            },
                            SK: {
                                S: `PURCHASEORDERITEM#${403 Forbidden}`
                            },
                            quantity: {
                                N: `${item.quantity}`
                            },
                            productId: {
                                S: item.productId.toString()
                            },
                        },
                    },
                })),
            ],
        };

        return this.dynamodb.transactWriteItems(params).promise();
    }
}

在这种简单的方法中,我们利用事务支持同时保存所有更改。关系方法与最后的开始事务/提交/回滚类似。

为了防止并发更改发生冲突,我们使用乐观并发锁。


图 7. 并发控制防止更改

处理聚合的最终一致性
我们的客户订单示例希望在商品发货后反映状态。在这种情况下,没有真正的不变量,因此我们已经定义运输实体不是聚合的一部分。

class CustomerOrderItem {
    constructor(private readonly _productId: ProductId, private _quantity: number) {}
}

class CustomerOrder {
    // ... omitting for simplicity

    private constructor() {
        this._id = uuid();
        this._status = 'placed';
    }

    public addItem(item: CustomerOrderItem) {
        this._items.push(item);
    }

    public static create(items: {productId: string; quantity: number}[]): CustomerOrder {
        const order = new CustomerOrder();
        items.forEach(item => order.addItem(new CustomerOrderItem(new ProductId(item.productId), item.quantity)));
        return order;
    }
}


class ShipmentItem {
    constructor(private readonly _productId: ProductId, private _quantity: number) {}
}

class Shipment {
    private _items: ShipmentItem[] = [];

    private constructor(private readonly customerOrderId: string, private readonly _trackingId: string, items: {productId: string; quantity: number}[]) {
        this._items = items.map(item => new ShipmentItem(new ProductId(item.productId), item.quantity));
    }

    public static create(customerOrderId: string, trackingId: string, items: {productId: string; quantity: number}[]): Shipment {
        return new Shipment(customerOrderId, trackingId, items);
    }
}

我们有两个聚合:CustomerOrder 和 Shipment,每个都有其生命周期。为了确保更新客户订单,让我们看一下两种方法,一种使用 saga,另一种使用事件。

图 8. 使用 Saga 来协调所涉及的步骤(未描述的是 saga 在失败的情况下将执行的补偿操作。)

这个的事件版本可以按照下面的代码来实现:

class ShipmentShipped {
    public readonly type = 'ShipmentShipped';
    public readonly id = uuid();

    constructor(public readonly customerOrderId: string, public readonly trackingId: string, public readonly items: {productId: string; quantity: number}[], public readonly occurredAt: Date){}
}

type ShipmentEvent = ShipmentShipped;

class Shipment {
  // ... omitting the accessor methods

    private constructor(private readonly customerOrderId: string, private readonly _trackingId: string, items: {productId: string; quantity: number}[]) {
        this._items = items.map(item => new ShipmentItem(new ProductId(item.productId), item.quantity));
        const occurredAt = new Date();
        this._events.push(new ShipmentShipped(customerOrderId, this._trackingId, items, occurredAt));  
// ADDING THE EVENT HERE THAT WILL BE USED AS A TRIGGER FOR THE INTEGRATION
    }
}

class DynamoDBShipmentRepository {
    public async save(shipment: Shipment): Promise<void> {
        const params = {
            TransactItems: [
                {
                    Put: {
                        TableName: this.tableName,
                        Item: {
                            PK: {
                                S: `SHIPMENT#${shipment.id}`
                            },
                            SK: {
                                S: `SHIPMENT#${shipment.id}`
                            },
                            trackingId: {
                                S: shipment.trackingId
                            },
                            items: {
                                L: shipment.items.map(item => ({
                                    M: {
                                        productId: {
                                            S: item.productId.toString()
                                        },
                                        quantity: {
                                            N: item.quantity.toString()
                                        }
                                    }
                                )),
                            }
                        },
                    },
                },
               
// Using a single event to illustrate
                {
                    Put: {
                        TableName: this.eventStoreTableName,
                        Item: {
                            PK: {
                                S: `EVENT#${shipment.id}`
                            },
                            SK: {
                                S: `SHIPMENT#${shipment.id}`
                            },
                            eventData: {
                                S: JSON.stringify(shipment.event)
                            },
                        },
                    },
                },
            ],
        };

        return this.dynamodb.transactWriteItems(params).promise();
    }
}


上述方法使用事务发件箱CDC 来确保事件和当前状态都被持久化,我们将利用 DynamoDB 流来触发事件的传播。


图 9. 使用事件触发客户订单的更新。

这个方案最终是一致的,业务规则还是有保障的。

结论
聚合关注真正的业务不变量。理解这一点有助于定义它应该有多大或多小。起初,将所有关系视为聚合的一部分可能很诱人,这样开始是可以的。
但是,您必须通过与领域专家交谈发现的业务规则来挑战第一个版本,就像我们在客户订单示例中所做的那样。一定要理解为什么你必须保证它们,以及它们是否是真正的不变量(原子的或最终的)。使用它来重新访问您的初始版本并分离不具有由真正不变量控制的关系的实体。
从实际的角度来看,有很多方法可以保证不变量,从持久层的传统事务控制到事件的编排或编排。
除此之外,您还可以考虑利用Step Functions或 DynamoDB 流等服务,这可能会为您减轻一些必要的工作。