Spot the Tax · Card 20 of 20
Mutating a child without going through the parent breaks invariants
Why letting any code change a LineItem directly leaves your Order's totals out of sync.
The code
What will this cost you in six months?
class Order < ApplicationRecord
has_many :line_items
# total_cents is a stored column on the orders table,
# supposed to mirror sum(line_items.price_cents * quantity)
end
class LineItem < ApplicationRecord
belongs_to :order
end
# Anywhere in the codebase:
LineItem.find(123).update!(quantity: 0)
# The order's total_cents column is now wrong. The problem
Anywhere in the codebase can directly change a LineItem — update its quantity, change its price, destroy it. When that happens, the order's stored total_cents doesn't get recalculated, because nobody told the order anything happened. The order's stored data no longer matches the line items it claims to have. Any rule that depends on the line items (an order has at least one item, the total is below the credit limit) gets bypassed too, because the order never got a chance to enforce them.
Take a moment. Before revealing, ask yourself: who should be allowed to change a line item? And how would you make sure the order finds out about every change?
The solution
Make the Order the only thing that can change its line items. Every change to a line item goes through a method on the Order, which can recalculate the total and check the rules. Outside code talks to the order; the order talks to its line items.
- The order's stored data stays in sync with its line items
- The rules about the order live in one place
- Outside code can't accidentally produce a broken order
class Order < ApplicationRecord
has_many :line_items
def update_line_item(id, attrs)
line_item = line_items.find(id)
line_item.update!(attrs)
recalculate_total!
end
def remove_line_item(id)
line_items.find(id).destroy!
recalculate_total!
end
private
def recalculate_total!
update!(total_cents: line_items.sum { |li| li.price_cents * li.quantity })
end
end
# All mutations go through the order:
order.update_line_item(123, quantity: 0)
order.remove_line_item(456) The principle at play — Aggregate roots
Some objects only make sense as part of a bigger whole. A line item only makes sense as part of an order; a comment only makes sense as part of a post; a payment line only makes sense as part of an invoice. The whole has rules that span the parts (the total has to match the lines, the post has to be visible for the comments to be), and only the whole can enforce them.
Domain-driven design calls this the "aggregate root". The root is the only thing outside code talks to. The root owns its parts and is responsible for keeping them consistent. If you let outside code reach past the root and modify the parts directly, the root can't enforce its invariants — it doesn't even know they were touched.
This is one of those patterns that feels like extra ceremony when the codebase is small. The day someone runs a "cleanup" rake task that updates line items in bulk and your order totals are silently off across the entire database is the day the ceremony pays for itself.