Spot the Tax · Card 16 of 20
Models that don't know their own rules
Why an Order model that's just data bag ends up with the same business rules duplicated in every caller.
The code
What will this cost you in six months?
class Order < ApplicationRecord
belongs_to :user
has_many :line_items
end
class CheckoutController < ApplicationController
def update
order = Order.find(params[:id])
if order.line_items.empty?
render json: { error: "Order is empty" }, status: 422
return
end
if order.user.suspended?
render json: { error: "Account suspended" }, status: 403
return
end
if order.total_cents > order.user.credit_limit_cents
render json: { error: "Over limit" }, status: 422
return
end
order.update!(status: "submitted", submitted_at: Time.current)
end
end The problem
The Order model is just an object that holds data. All the rules about "can this order be submitted" live in the controller. The admin panel that lets support submit orders on behalf of customers has its own copy of those rules. So does the API. So does the rake task that recovers stuck orders. Forget one rule in one place and that path will let through orders that violate the business invariants.
Take a moment. Before revealing, ask yourself where the rules really belong. If you wanted to make the rules impossible to forget, where would they live?
The solution
Move the rules into the Order class itself. The order knows what makes it valid, so it should be the one running the checks. Every caller (controller, admin panel, rake task) ends up calling the same submit! method, which has the rules baked into it.
- The rules live in one place
- Every caller goes through the same gate
- The model documents what a valid submission means
class Order < ApplicationRecord
class OrderError < StandardError; end
def submit!
raise OrderError, "Order is empty" if line_items.empty?
raise OrderError, "Account suspended" if user.suspended?
raise OrderError, "Over credit limit" if total_cents > user.credit_limit_cents
update!(status: "submitted", submitted_at: Time.current)
end
end
class CheckoutController < ApplicationController
def update
Order.find(params[:id]).submit!
head :ok
rescue Order::OrderError => e
render json: { error: e.message }, status: 422
end
end The principle at play — Models own their own rules
Martin Fowler called this the "anemic domain model" anti-pattern. A model is anemic when it has data and almost no behavior — just attribute accessors and maybe some validations. All the actual rules about what the data means and what you can do with it live somewhere else, usually scattered across controllers and services.
The cost is that every caller has to know the rules, and every new caller is a new place where someone might forget one. The model doesn't enforce its own invariants; it just hopes everyone using it remembers to do the right thing. Which works fine until the codebase grows past one developer's head.
Putting the behavior on the model isn't about making the model bigger for its own sake. It's about giving the rules a single home, so that every caller has to go through the same gate. The controller's job becomes "translate the request into a method call on the model" — not "remember every business rule and check them in the right order."