如何正确设计 DDD 中的聚合,事件溯源
How do I properly design Aggregate in DDD, Event-sourcing
假设我想做一个电子商务系统。我这里有 2 个聚合 ProductAggregate 和 UserAggregate。产品聚合包含产品 ID、价格。用户聚合包含 userId 和余额。这就是问题所在,在事件溯源中我们不应该依赖读取模型,因为可能存在最终一致性问题。好的,我猜我们应该依赖命令模型吧?但这两个命令模型是不同的。我从其他地方读到他们告诉我聚合应该只依赖于它的状态。假设用户想要购买产品,我必须检查他是否有足够的余额,为此我需要知道产品的价格。所以不允许读取模型,不允许聚合查询。我在这里有什么选择?
const ProductAggregate = {
state: {
productId: "product-1",
price: 100
}
}
const UserAggregate = {
state: {
userId: "userId-1",
balance: 50
},
handlePurchase: ({ userId, productId }) => {
// todo I got productId from the client, but how can I retrieve its price ?
if (this.state.balance < price) {
throw "Insufficient balance bro."
}
}
}
所以我认为一定是我糟糕的聚合设计导致 UserAggregate 需要来自其上下文之外的状态。那么在这种情况下,我该如何正确设计用户和产品的聚合。
已编辑:
我整天都在思考解决方案,我想出了这个方法。因此,我没有将购买命令放在 UserAggregate 中,而是将其放在 ProductAggregate 中,并将其称为 OrderProductCommand对我来说有点奇怪,因为产品本身无法创建订单,但用户可以(它似乎可以正常工作,我什至不知道?)。因此,通过这种方法,我现在可以检索价格并发送另一个命令 DeductBalanceCommand,这将从用户那里扣除金额。
const ProductAggregate = {
state: {
productId: "product-1",
price: 100
},
handleOrder: ({productId, userId}) => {
await commandBus.send({
command: "handleDeduct",
params: {
userId: userId,
amount: this.state.price
}
})
.then(r => eventBus.publish({
event: "OrderCreated",
params: {
productId: productId,
userId: userId
}
}))
.catch(e => {
throw "Unable to create order due to " + e.message
})
}
}
const UserAggregate = {
state: {
userId: "userId-1",
balance: 50
},
handleDeduct: ({ userId, amount }) => {
if (this.state.balance < amount) {
throw "Insufficient balance bro."
}
eventBus.publish({
event: "BalanceDeducted",
params: {
userId: userId,
amount: amount
}
})
}
}
使用这种方法是否正确?这对我来说有点奇怪,或者这可能只是 DDD 世界中的一种思维方式?
ps。我添加了 javascript 标签,这样我的代码就可以有颜色并且易于阅读。
首先,关于你的句柄,你不傻:)
几点:
在许多情况下,即使存在最终一致性,您也可以查询读取模型。如果你拒绝了一个命令,如果一个挂起的更新在读取模型中变得可见,那么该命令本来会被接受,通常可以重试。如果您接受本来会被拒绝的命令,通常可以在事后采取补偿措施(例如,订购实物产品和交付该产品之间的延迟)。
有几个有用的模式。一种是 saga 模式,您可以在其中模拟购买过程。而不是“用户 A 购买产品 X”,您可能有一个对应于“用户 A 尝试购买产品 X”的聚合,它验证并保留用户 A 能够购买 X 并且 X 能够被购买。
每个具有聚合的写入模型都意味着该聚合存在一个足够一致的读取模型。因此可以针对写入模型定义查询或“read-only”命令。 CQRS (IMO) 不应被解释为“不查询写入模型”,而是“在尝试优化读取的写入模型(无论是易用性、性能等)之前,强烈考虑使用读取来处理该查询model”:也就是说,如果你正在查询写入模型,你就放弃了一些抱怨查询缓慢或困难的权利。根据您实施聚合的方式,此选项可能容易也可能不容易做到。
假设我想做一个电子商务系统。我这里有 2 个聚合 ProductAggregate 和 UserAggregate。产品聚合包含产品 ID、价格。用户聚合包含 userId 和余额。这就是问题所在,在事件溯源中我们不应该依赖读取模型,因为可能存在最终一致性问题。好的,我猜我们应该依赖命令模型吧?但这两个命令模型是不同的。我从其他地方读到他们告诉我聚合应该只依赖于它的状态。假设用户想要购买产品,我必须检查他是否有足够的余额,为此我需要知道产品的价格。所以不允许读取模型,不允许聚合查询。我在这里有什么选择?
const ProductAggregate = {
state: {
productId: "product-1",
price: 100
}
}
const UserAggregate = {
state: {
userId: "userId-1",
balance: 50
},
handlePurchase: ({ userId, productId }) => {
// todo I got productId from the client, but how can I retrieve its price ?
if (this.state.balance < price) {
throw "Insufficient balance bro."
}
}
}
所以我认为一定是我糟糕的聚合设计导致 UserAggregate 需要来自其上下文之外的状态。那么在这种情况下,我该如何正确设计用户和产品的聚合。
已编辑:
我整天都在思考解决方案,我想出了这个方法。因此,我没有将购买命令放在 UserAggregate 中,而是将其放在 ProductAggregate 中,并将其称为 OrderProductCommand对我来说有点奇怪,因为产品本身无法创建订单,但用户可以(它似乎可以正常工作,我什至不知道?)。因此,通过这种方法,我现在可以检索价格并发送另一个命令 DeductBalanceCommand,这将从用户那里扣除金额。
const ProductAggregate = {
state: {
productId: "product-1",
price: 100
},
handleOrder: ({productId, userId}) => {
await commandBus.send({
command: "handleDeduct",
params: {
userId: userId,
amount: this.state.price
}
})
.then(r => eventBus.publish({
event: "OrderCreated",
params: {
productId: productId,
userId: userId
}
}))
.catch(e => {
throw "Unable to create order due to " + e.message
})
}
}
const UserAggregate = {
state: {
userId: "userId-1",
balance: 50
},
handleDeduct: ({ userId, amount }) => {
if (this.state.balance < amount) {
throw "Insufficient balance bro."
}
eventBus.publish({
event: "BalanceDeducted",
params: {
userId: userId,
amount: amount
}
})
}
}
使用这种方法是否正确?这对我来说有点奇怪,或者这可能只是 DDD 世界中的一种思维方式?
ps。我添加了 javascript 标签,这样我的代码就可以有颜色并且易于阅读。
首先,关于你的句柄,你不傻:)
几点:
在许多情况下,即使存在最终一致性,您也可以查询读取模型。如果你拒绝了一个命令,如果一个挂起的更新在读取模型中变得可见,那么该命令本来会被接受,通常可以重试。如果您接受本来会被拒绝的命令,通常可以在事后采取补偿措施(例如,订购实物产品和交付该产品之间的延迟)。
有几个有用的模式。一种是 saga 模式,您可以在其中模拟购买过程。而不是“用户 A 购买产品 X”,您可能有一个对应于“用户 A 尝试购买产品 X”的聚合,它验证并保留用户 A 能够购买 X 并且 X 能够被购买。
每个具有聚合的写入模型都意味着该聚合存在一个足够一致的读取模型。因此可以针对写入模型定义查询或“read-only”命令。 CQRS (IMO) 不应被解释为“不查询写入模型”,而是“在尝试优化读取的写入模型(无论是易用性、性能等)之前,强烈考虑使用读取来处理该查询model”:也就是说,如果你正在查询写入模型,你就放弃了一些抱怨查询缓慢或困难的权利。根据您实施聚合的方式,此选项可能容易也可能不容易做到。