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.

You might also like