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