Offloading write side with a read model
… and check why 5600+ Rails engineers read also this
Offloading write side with a read model
Imagine the following business requirement:
All the products should be reserved for a customer on order submission. Simply adding items to the cart does not guarantee product availability. However, the customer should not be able to add a product that is already unavailable.
Actually, it is not any fancy requirement. I used to work on e-commmerce project with such a feature. When diving deeper into Domain-driven design, I started thinking about how to properly meet this requirement with DDD building blocks.
At first, the rule “the customer should not be able to add to the cart a product which is already out of stock” sounded like an intuitive invariant to me.
I have even implemented a Inventory::CheckAvailability
command which was invoked on Inventory::InventoryEntry
aggregate root.
def check_availability!(desired_quantity)
return unless stock_level_defined?
raise InventoryNotAvailable if desired_quantity > availability
end
In fact, It was doing nothing with the aggregate’s internal state. This method was just raising an error if the product was out of stock. It was a terrible candidate for a command. It obfuscated the aggregate’s code, which should stay minimalistic, and did no changes within the system.
When I realized that my command made nothing but the read, I started looking for a solution in the read model. An efficient read model is eventually consistent. It is not a problem in our case. In fact, placing an order just after checking availability directly on the aggregate root neither guarantees success. Just 1 ms after checking, it could change. That’s just because that command did not affect the aggregate’s state.
So, I prepared ProductsAvailability
read model, which subscribes to Inventory::AvailabilityChanged
events.
I use it as a kind of validation if invoking Ordering::AddItemToBasket
command makes any sense.
def add_item
if Availability::Product.exists?(["uid = ? and available < ?", params[:product_id], params[:quantity]])
redirect_to edit_order_path(params[:id]),
alert: "Product not available in requested quantity!" and return
end
command_bus.(Ordering::AddItemToBasket.new(order_id: params[:id], product_id: params[:product_id]))
head :ok
end
Lessons that I have learned:
- Started to distinguish hard business rules which go together with some state change within the system. Requirements of this kind are, in fact, good candidates for aggregates invariants.
- Noticed that some requirements improve user experience but are not so critical to affecting aggregate design. Checking those, we do not care for 100% consistency with a write side.
- It is OK to have some read models that are not strict for viewing purposes.