如何在 DDD、事件溯源中正确设计聚合

发布于 2025-01-12 04:06:07 字数 2342 浏览 0 评论 0原文

假设我想做一个电子商务系统。我这里有 2 个聚合 ProductAggregateUserAggregate。产品聚合包含productId、价格。用户聚合包含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 世界中的一种思维方式?

附:我添加了 javascript 标签,这样我的代码就可以有颜色并且易于阅读。

Suppose I want to make an e-commerce system. I have 2 aggregates here ProductAggregate and UserAggregate. Product aggregate contains productId, price. User aggregate contains userId and balance. Here's the problem, in event-sourcing we should not rely on the read model since there might be eventual consistency problem. Ok so we should rely on the command model right I guess?, but this two command model is different. I read from somewhere else they told me that aggregate should only rely on its state. Let's say the user want to buy a product I have to check if he has enough balance and in order to do that I need to know the price of product. So read model not allowed, aggregate query not allowed. what options do I have here?

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."
        }
    }
}

So I though it must be my bad aggregate design which makes UserAggregate requires state from outside of its context. So in this situation how do I properly design an Aggregate for User and Product.

edited:

I have been thinking all day long for the solution and I came up with this approach. So instead of putting purchase command in the UserAggregate I put it in the ProductAggregate and call it OrderProductCommand which is a bit weird for me since the product itself can't create an order, but the user can (it seems to work anyway I don't even know?). So with this approach I can now retrieve the price and send another command DeductBalanceCommand which will deduct amount of money from the user.

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
            }
        })
    }
}

Is it fine and correct to use this approach? it's a bit weird for me or maybe it's just a way of thinking in DDD world?

ps. I added javascript tag so my code can have colors and easy to read.

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(1

皇甫轩 2025-01-19 04:06:07

首先,关于你的句柄,你并不愚蠢:)

几点:

  • 在许多情况下,即使存在最终一致性,你也可以查询读取模型。如果您拒绝在读取模型中显示挂起的更新时本应接受的命令,则通常可以重试该命令。如果您接受本来会被拒绝的命令,则通常可以在事后应用补偿操作(例如,订购实物产品和交付该产品之间的延迟)。

  • 有一些有用的模式。一种是传奇模式,您可以在其中对购买过程进行建模。您可能拥有与“用户 A 尝试购买产品 X”相对应的聚合,而不是“用户 A 购买产品 X”,该聚合验证并保留用户 A 能够购买 X 并且 X 能够被购买。

  • 每个具有聚合的写入模型都意味着该聚合存在一个足够一致的读取模型。因此,我们可以针对写入模型定义查询或“只读”命令。 CQRS(IMO)不应被解释为“不要查询写入模型”,而是“在尝试优​​化读取的写入模型(无论是易用性、性能等)之前,请充分考虑使用读取来处理该查询model”:即,如果您正在查询写入模型,您就放弃了一些抱怨查询缓慢或困难的权利。根据您实现聚合的方式,此选项可能很容易实现,也可能不容易实现。

First of all, regarding your handle, you're not stupid :)

A few points:

  • In many situations you can query the read model even though there's eventual consistency. If you reject a command that would have been accepted had a pending update become visible in the read model, that can typically be retried. If you accept a command that would have been rejected, there's often a compensating action that can be applied after the fact (e.g. a delay between ordering a physical product and that product being delivered).

  • There are a couple of patterns that can be useful. One is the saga pattern where you would model the process of a purchase. Rather than "user A buys product X", you might have an aggregate corresponding to "user A's attempt to purchase product X", which validates and reserves that user A is able to buy X and that X is able to be purchased.

  • Every write model with an aggregate implies the existence of one sufficiently consistent read model for that aggregate. One can thus define queries or "read-only" commands against the write model. CQRS (IMO) shouldn't be interpreted as "don't query the write model" but "before trying to optimize the write model for reads (whether ease, performance, etc.), give strong consideration to handling that query with a read model": i.e. if you're querying the write model, you give up some of the right to complain about the queries being slow or difficult. Depending on how you're implementing aggregates this option may or may not be easy to do.

~没有更多了~
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文