CQRS 读取模型预测:数据转换有多复杂

CQRS Read Model Projections: How complex is too complex a data transformation

我想在视图投影上检查自己的完整性,看看中间概念是否可以纯粹存在于读取模型中,同时提供命令之间的桥梁。

让我用一个人为的例子来解释。

我们下了一个引发 OrderPlaced 事件的订单。然后,工作流涉及生成用于准备装运的取货单。

可以从一个订单(或一组订单)生成取货单,而无需任何外部来源或用户提供任何附加信息。那么,取货单可以纯粹表示为读取模型是否可以接受?

所以:

PlaceOrderCommand -> OrderPlacedEvent
OrderPlacedEvent -> PickingSlipView

然后仓库经理可以查看取货单,select 他们想要装运的行,然后执行 PrepareShipment 命令。然后,ShipmentPrepared 事件将更新原始订单,并从 PickingSlipView 中删除相关行。

我知道这是一个玩具示例,但我有一个概念上相似的用例,其中一位同事认为 PickingSlip 本身应该是一个域 entity/aggregate,因为它在概念上与订单不同。所以您有 PlaceOrder、GeneratePickingSlip 和 PrepareShipment 命令。

然而,GeneratePickingSlip 命令只需要一个订单号(标识符),将订单数据转换为一个取货单实体,并保留该实体。您不能修改或删除取货单或对其执行任何操作,除非使用它来准备装运。

这感觉像是在写入模型上引入了不必要的开销,因为最终只是对现有信息进行转换以启用另一个命令。

所以(并且没有深入研究仓库和运输的问题 space)...

我提议的是读取模型的合法用例吗?

通过将一些数据转换为不同的视图,充当两个命令之间的中介。或者,正如我的同事所建议的那样,是否应该在所有情况下都在写入模型中表示每个概念?

我觉得我的方法更简单,并且避免了不必要的复杂性,但我是 CQRS 的新手,所以可能遗漏了一些东西。

编辑 - 替代示例

提供另一个例子来探索:

我们有一个类别记录簿,其中每条记录都是关于产品及其位置的信息。记录簿由外部系统填充,包含 SKU 编号,映射到可用位置:

Book of Record (Electronics)
SKU#    Location1     Location2    Location3   ...    Location 10
XXXX    Introduce     Remove       Introduce   ...    N/A
YYYY    N/A           Introduce    Introduce   ...    Remove

每一个记录簿都是一个实体,每一行都是一个值对象。

记录簿用于生成不同的任务(这些任务在任务计划中分组以分配给一个人)。该计划可能只涵盖部分地点。

有不同类型的任务:一个 TaskPlan 适用于在某个位置添加或从货架上移除库存的个人。将此称为 AllocateStock 任务。另一种类型的任务存在于管理多个位置的区域主管,以检查货架是否正确遵循商店指南,例如 CheckDisplay 任务。对于库存分配,我们对引入和移除的 SKU 都感兴趣。为了检查显示,我们只对新推出的 SKU 等感兴趣。

我们正在探索两种选择:

选项 1

创建任务的人有一个视图(阅读模型),允许他们 select 记录簿。说他们 select 电子和时尚。然后他们 select 一个或多个位置。然后他们可以提交如下命令:

GenerateCheckDisplayTasks(TaskPlanId, List<BookOfRecordId>, List<Locations>)

然后这些命令会编排记录,过滤掉我们不需要的位置,只处理 'Introduced' 项目,并为 TaskPlan 中的每个 SKU 创建相应的 CheckDisplayTasks。

选项 2

另一种选择是在生成任务之前将过滤转移到读取模型。

添加记录簿后,会维护每种任务类型的视图模型。数据可能会被转置,并且只会包含相关信息。 IE。 CheckDisplayScopeView 可能会将记录簿投影到:

Category                       SKU     Location
Electronics (BookOfRecordId)   XXXX    Location1
Electronics (BookOfRecordId)   XXXX    Location3
Electronics (BookOfRecordId)   YYYY    Location2
Electronics (BookOfRecordId)   YYYY    Location3
Fashion     (BookOfRecordId)   ...     ... etc

生成任务时,视图使用户能够select他们想要为其生成任务的类别和位置。也许他们 select 电子类别和位置 1 和 3。

命令现在是:

GenerateCheckDisplayTasks(TaskPlanId,  List<BookOfRecordId, SKU, Location>)

命令现在不再负责过滤位置、已删除和 N/A 项等所需的逻辑

因此第一个选项的命令仅提交要转换为任务的实体的 ID 以及过滤器选项,并在内部完成所有工作,可能利用域服务。

第二个选项将过滤方面卸载到视图模型,现在命令提交将生成任务的值。

注意:根据聚合不应凭空出现的指导原则,任务计划聚合将创建任务。

我正在尝试确定选项 2 是否将太多责任推给了读取模型,或者此过滤行为是否更适用于此。

抱歉,我尝试使用 PickingSlip 示例,因为我认为这将是一个更容易识别的问题 space,但现在意识到这个概念附带的含义可能混淆了水域。

在我看来,您问题的答案在很大程度上取决于您如何设计域,而不是如何实施 CQRS。你呈现它的方式,似乎所有这些操作和聚合都在同一个限界上下文中,但乍一看,我认为有 3 个(命名很难!):

  1. 订单管理或销售,下订单的地方
  2. 仓库操作,将货物打包发货
  3. 货运,包裹放入卡车并离开

当订单在订单管理中为 Placed 时,仓库会做出反应并启动包装工作流程。此时,Warehouse 应该拥有执行其逻辑所需的所有数据,不再需要 Order

The warehouse manager can then view a picking slip, select the lines they would like to ship, and then perform a PrepareShipment command.

对我来说,这清楚地表明需要一个聚合来确保不变量得到尊重。您不能 select 没有出现在取货单中的项目,您不能 select 超过指定数量的项目,您不能 select 已经打包在以前包裹中的项目等等。

A ShipmentPrepared event will then update the original order, and remove the relevant lines from the PickingSlipView.

我不明白你为什么要修改原来的顺序。此外,从视图中删除线条本身并不是一个安全的操作。例如,您希望保证并发性不会导致将单个项目放置在多个包中。您保证使用包含所有项目的聚合生成包装说明,并安全且事务性地标记每个包裹的项目。

Acting as an intermediary between two commands

聚合执行命令,它们不在两者之间。

从另一个角度来看,需要聚合的一个迹象是 PrepareShippingCommand 需要创建一个聚合(Shipping),根据 Udi Dahan 的说法,you should not create aggregate roots(out of thin空气)。相反,其他聚合根创建它们。因此,可以公平地说需要一些聚合,以确保应用创建运输的策略。

最后一点,域设计是困难的,你需要非常了解域,所以我提出的解决方案很可能是不正确的,但我希望我在每个步骤中所做的考虑对你有所帮助你想出正确的解决方案。


问题更新后更新

我阅读了几次更新后的问题并更新了几次我的答案,但每次都以非常具体的答案结束,而且我很可能遗漏了很多实际有用的细节(不过,我很乐意在另一个频道上讨论它)。因此,我想回到你的问题的第一句来添加一个我错过的重要评论:

an intermediary concept can purely exist in the read model, while providing a bridge between commands.

在我看来,读取模型是一次性的。它们不是单一的真相来源。它们是数据的表示,可以轻松满足当前的查询需求。当这些查询需求发生变化时,删除旧的读取模型并根据写入模型的数据创建新模型。

所以,仅基于此,我建议不要准备读取模型以方便您进行命令操作。

我认为你的解决方案在这里:

When a book of record is added a view model for each type of task is maintained. The data might be transposed, and would only include relevant info.

如果我没理解错的话,你在这里应该做的不是创建视图模型,而是创建一个聚合(或多个)。然后这个聚合可以接收命令,应用业务规则并改变状态。因此,不是让域服务从 "clever" 读取模型读取数据并将它们放在一起,而是有一个聚合来封装它需要的数据和业务逻辑。

我希望这是有道理的。这是一个广泛的话题,我们可能可以讨论几个小时。