Practice · SOLID · OCP · Card 7
Adding on_hold touches how many files?
Adding a new status value should be a one-line change. In a codebase where the statuses leaked everywhere, it isn't. Spot the leak.
The code
A few places where order status is mentioned, across the codebase.
# app/models/order.rb
enum status: { pending: 0, paid: 1, shipped: 2, delivered: 3, refunded: 4 }
# app/controllers/orders_controller.rb
def index
@orders = case params[:filter]
when "open" then Order.where(status: [:pending, :paid])
when "done" then Order.where(status: [:delivered, :refunded])
else Order.all
end
end
# app/services/notify_customer.rb
case order.status
when "pending" then SendOrderConfirmation.call(order)
when "shipped" then SendShippingNotice.call(order)
when "delivered" then SendThankYou.call(order)
end
# app/views/orders/_status_badge.html.erb
<% case order.status %>
<% when "pending" %><span class="badge yellow">Pending</span>
<% when "paid" %><span class="badge blue">Paid</span>
<% when "shipped" %><span class="badge purple">Shipped</span>
<% when "delivered" %><span class="badge green">Delivered</span>
<% when "refunded" %><span class="badge gray">Refunded</span>
<% end %> The question
Product asks for a new status, on_hold. What's the cost across these files, and what would prevent the cost?
Take a moment. Every file that hard-codes a list of status values is a place that has to change every time the list changes. What if the list lived in one place and every consumer asked the model?
The cost
Adding on_hold means:
- Edit
order.rbto add the enum value. - Decide if "on_hold" is "open" or "done" in
orders_controller.rb, edit the case. - Decide if "on_hold" needs its own notification in
notify_customer.rb, edit the case. - Decide the badge color in
_status_badge.html.erb, edit the case. - And every other place anyone hard-coded a list. There's no telling how many until you grep, and grep won't find symbol literals reliably.
The model is closed against change in name only. Adding one value requires touching every file that knew about the previous values. That's the OCP smell at the codebase scale.
The fix shape
Move the meaning to the model. Consumers ask the model what they need to know, instead of hard-coding the list:
class Order < ApplicationRecord
enum status: { pending: 0, paid: 1, shipped: 2,
delivered: 3, refunded: 4, on_hold: 5 }
OPEN_STATUSES = %w[pending paid on_hold].freeze
DONE_STATUSES = %w[delivered refunded].freeze
scope :open, -> { where(status: OPEN_STATUSES) }
scope :done, -> { where(status: DONE_STATUSES) }
STATUS_COLORS = {
"pending" => "yellow",
"paid" => "blue",
"shipped" => "purple",
"delivered" => "green",
"refunded" => "gray",
"on_hold" => "orange",
}.freeze
def status_color = STATUS_COLORS.fetch(status)
NOTIFICATIONS_ON = {
"pending" => SendOrderConfirmation,
"shipped" => SendShippingNotice,
"delivered" => SendThankYou,
}.freeze
def notification_class = NOTIFICATIONS_ON[status]
end
# Now: controllers/services/views just ask the model.
# Adding on_hold is one model file. Done. This is the registry pattern, applied to the values rather than to classes. The principle is the same: turn the hard-coded list into data the model owns, and let consumers ask. Every "where else does this value matter?" decision is documented as one entry in one constant.
Theory
For the registry pattern in real OSS, read SOLID · OCP · Registry.