Dynamic product price validation
The following example showcases a simple application that allows to purchase products, with a twist
Challenge
The goal is an application that allows customers to purchase products, ensuring that the displayed price is taken into account – if it is valid:
- The product prices that are shown to the customer must be used for processing the order
- If a displayed product price is not/no longer valid, the order must fail
- Product prices can be changed at any time
- If a product price was changed, the previous price(s) must be valid for a configurable grace period
Traditional approaches
There are several potential strategies to solve this without DCB:
-
Aggregate Pattern: For the single product use case an Aggregates could be used
That only works if product change and -purchase Events are stored in the same Event Stream, which is unlikely to be a good idea
-
Eventual consistency: Use the Read Model to verify the prices in the command handler
That works, but the last requirement (changing prices) forces the model to keep track of historic data even though there might be no (other) use case for it to be kept. A separate, dedicated, model could be created of course but that adds complexity
DCB approach
With DCB the challenge can be solved without any specific Tags (except for the ProductId:<id> tag):
Feature 1: Order single product
If only a single product with a fixed price can be purchased at a time, the implementation is pretty simple:
The OrderProduct command compares the displayed price with the current price of the product and only records the order if they match. As the "Consistency boundary" tab shows, the Query only covers ProductDefined Events tagged with the ordered product's ProductId:
model "Dynamic product price"
// Types
tag type ProductId = string
type Money = number { minimum: 0 }
// Events
event ProductDefined { productId: ProductId, price: Money }
event ProductOrdered { productId: ProductId, price: Money }
// Entities
entity Product {
price = ProductPrice
}
// Projections
projection ProductPrice(productId: ProductId): Money = null {
on ProductDefined => set event.data.price
}
// Commands
command OrderProduct(productId: ProductId, displayedPrice: Money) {
read product = Product[productId]
require product.price == displayedPrice
emit ProductOrdered { productId, price: displayedPrice }
scenario "Order product with invalid displayed price" {
given ProductDefined { productId: "p1", price: 123 }
when OrderProduct { productId: "p1", displayedPrice: 100 }
then rejected by product.price == displayedPrice saw 123, 100
}
scenario "Order product with valid displayed price" {
given ProductDefined { productId: "p1", price: 123 }
when OrderProduct { productId: "p1", displayedPrice: 123 }
then ProductOrdered { productId: "p1", price: 123 }
}
}
OrderProduct reads 1 type, 1 tag
| Query Item | Event Types | Tags |
|---|---|---|
product | ProductDefined | ProductId:{productId} |
AppendCondition: failIfEventsMatch the Query above, after the position of the last Event read
Tags of the appended events: ProductId:{productId}
Feature 2: Changing product prices
Complexity increases if the product price can be changed and previous prices shall be valid for a specified amount of time:
The ProductPrice projection now determines all prices that are valid at the time of the order: the price that was in effect 10 minutes ago, and every price that was set since then. Because that depends on the age of each Event, it is written as a scripted projection that receives the current time as an argument. The Query now covers ProductPriceChanged Events, too – so a price change that happens in the meantime makes the order fail.
Note
The playground has neither a clock nor Event metadata, so time is part of the data in this example: ProductDefined and ProductPriceChanged carry the minute they were recorded at (at), and the OrderProduct command is passed the current minute (now). Typically, a timestamp representing the Event's recording time is stored within the Event's payload or metadata, and it is compared to the current date to determine the Event's age in the decision model.
model "Dynamic product price (grace period)"
// Types
tag type ProductId = string
type Money = number { minimum: 0 }
type Minute = integer
// Events
event ProductDefined { productId: ProductId, price: Money, at: Minute }
event ProductOrdered { productId: ProductId, price: Money }
event ProductPriceChanged { productId: ProductId, newPrice: Money, at: Minute }
// Entities
entity Product {
validPrices = ProductPrice
}
// Projections
projection ProductPrice: Money[] {
script(productId: ProductId, now: Minute)
tagFilter ["ProductId:{productId}"]
initialState []
on ProductDefined => ```[event.data.price]```
on ProductPriceChanged => ```args.now - event.data.at <= 10 ? [...state, event.data.newPrice] : [event.data.newPrice]```
}
// Commands
command OrderProduct(productId: ProductId, displayedPrice: Money, now: Minute) {
read product = Product[productId] with (now)
require product.validPrices contains displayedPrice
emit ProductOrdered { productId, price: displayedPrice }
scenario "Order product with invalid displayed price" {
given ProductDefined { productId: "p1", price: 123, at: 100 }
when OrderProduct { productId: "p1", displayedPrice: 100, now: 100 }
then rejected by product.validPrices contains displayedPrice saw [123], 100
}
scenario "Order product with valid displayed price" {
given ProductDefined { productId: "p1", price: 123, at: 100 }
when OrderProduct { productId: "p1", displayedPrice: 123, now: 100 }
then ProductOrdered { productId: "p1", price: 123 }
}
scenario "Order product with a displayed price that was never valid" {
given ProductDefined { productId: "p1", price: 123, at: 80 }
when OrderProduct { productId: "p1", displayedPrice: 100, now: 100 }
then rejected by product.validPrices contains displayedPrice saw [123], 100
}
scenario "Order product with a price that was changed more than 10 minutes ago" {
given ProductDefined { productId: "p1", price: 123, at: 80 }
given ProductPriceChanged { productId: "p1", newPrice: 134, at: 80 }
when OrderProduct { productId: "p1", displayedPrice: 123, now: 100 }
then rejected by product.validPrices contains displayedPrice saw [134], 123
}
scenario "Order product with initial valid price" {
given ProductDefined { productId: "p1", price: 123, at: 80 }
when OrderProduct { productId: "p1", displayedPrice: 123, now: 100 }
then ProductOrdered { productId: "p1", price: 123 }
}
scenario "Order product with a price that was changed less than 10 minutes ago" {
given ProductDefined { productId: "p1", price: 123, at: 80 }
given ProductPriceChanged { productId: "p1", newPrice: 134, at: 91 }
when OrderProduct { productId: "p1", displayedPrice: 123, now: 100 }
then ProductOrdered { productId: "p1", price: 123 }
}
scenario "Order product with valid new price" {
given ProductDefined { productId: "p1", price: 123, at: 80 }
given ProductPriceChanged { productId: "p1", newPrice: 134, at: 91 }
when OrderProduct { productId: "p1", displayedPrice: 134, now: 100 }
then ProductOrdered { productId: "p1", price: 134 }
}
}
OrderProduct reads 2 types, 1 tag
| Query Item | Event Types | Tags |
|---|---|---|
product | ProductDefined, ProductPriceChanged | ProductId:{productId} |
AppendCondition: failIfEventsMatch the Query above, after the position of the last Event read
Tags of the appended events: ProductId:{productId}
Feature 3: Multiple products (shopping cart)
The previous stages could be implemented with a traditional Event-Sourced Aggregate in theory. But with the requirement to be able to order multiple products at once with a dynamic price, the flexibility of DCB shines.
The OrderProducts command replaces OrderProduct: it reads the Product once for every item in the cart and checks each displayed price against the valid prices of that product. The "Consistency boundary" tab shows the result: one Query Item per ordered product, and a ProductsOrdered Event that is tagged with the ProductId of every product it contains. All products are covered by a single decision – if the price of any of them changes in the meantime, the whole order fails:
model "Dynamic product price (shopping cart)"
// Types
tag type ProductId = string
type Money = number { minimum: 0 }
type Minute = integer
record Item { productId: ProductId, price: Money }
// Events
event ProductDefined { productId: ProductId, price: Money, at: Minute }
event ProductPriceChanged { productId: ProductId, newPrice: Money, at: Minute }
event ProductsOrdered { items: Item[] }
// Entities
entity Product {
validPrices = ProductPrice
}
// Projections
projection ProductPrice: Money[] {
script(productId: ProductId, now: Minute)
tagFilter ["ProductId:{productId}"]
initialState []
on ProductDefined => ```[event.data.price]```
on ProductPriceChanged => ```args.now - event.data.at <= 10 ? [...state, event.data.newPrice] : [event.data.newPrice]```
}
// Commands
command OrderProducts(items: Item[], now: Minute) {
read product = Product[items.productId] with (now)
require product.validPrices contains items.price
emit ProductsOrdered { items }
scenario "Order product with a displayed price that was never valid" {
given ProductDefined { productId: "p1", price: 123, at: 80 }
when OrderProducts { items: [{ productId: "p1", price: 100 }], now: 100 }
then rejected by product.validPrices contains items.price saw [123], 100 at 0
}
scenario "Order product with a price that was changed more than 10 minutes ago" {
given ProductDefined { productId: "p1", price: 123, at: 80 }
given ProductPriceChanged { productId: "p1", newPrice: 134, at: 80 }
when OrderProducts { items: [{ productId: "p1", price: 123 }], now: 100 }
then rejected by product.validPrices contains items.price saw [134], 123 at 0
}
scenario "Order product with initial valid price" {
given ProductDefined { productId: "p1", price: 123, at: 80 }
when OrderProducts { items: [{ productId: "p1", price: 123 }], now: 100 }
then ProductsOrdered { items: [{ productId: "p1", price: 123 }] }
}
scenario "Order product with a price that was changed less than 10 minutes ago" {
given ProductDefined { productId: "p1", price: 123, at: 80 }
given ProductPriceChanged { productId: "p1", newPrice: 134, at: 91 }
when OrderProducts { items: [{ productId: "p1", price: 123 }], now: 100 }
then ProductsOrdered { items: [{ productId: "p1", price: 123 }] }
}
scenario "Order multiple products with valid prices" {
given ProductDefined { productId: "p1", price: 123, at: 80 }
given ProductPriceChanged { productId: "p1", newPrice: 134, at: 91 }
given ProductDefined { productId: "p2", price: 321, at: 92 }
when OrderProducts { items: [{ productId: "p1", price: 123 }, { productId: "p2", price: 321 }], now: 100 }
then ProductsOrdered { items: [{ productId: "p1", price: 123 }, { productId: "p2", price: 321 }] }
}
}
OrderProducts reads 2 types, 1 tag
| Query Item | Event Types | Tags |
|---|---|---|
product (one per element) | ProductDefined, ProductPriceChanged | ProductId:{items.productId} per element |
AppendCondition: failIfEventsMatch the Query above, after the position of the last Event read
Tags of the appended events: ProductId:{items.productId} per element
Conclusion
This example demonstrates the possibility to enforce consistency for a very dynamic set of entities

