Spot the Tax · Card 18 of 20
One model class serving four teams
Why a single Order model used by checkout, fulfillment, support, and analytics becomes the file no one wants to touch.
The code
What will this cost you in six months?
class Order < ApplicationRecord
# Used by checkout (cart, totals)
has_many :line_items
scope :pending, -> { where(status: "pending") }
# Used by fulfillment (warehouse picks)
has_many :shipments
scope :ready_to_ship, -> { where(status: "submitted").where.not(warehouse_id: nil) }
# Used by support (refund flow)
has_many :refund_requests
scope :refundable, -> { where(status: "fulfilled").where("created_at > ?", 30.days.ago) }
# Used by analytics (cohort reports)
scope :by_cohort, ->(month) { where(created_at: month.all_month) }
# 60 columns, 30 scopes, 200 lines of model code...
end The problem
A single Order class is shared between checkout, fulfillment, support, and analytics. A change driven by one team can break another — adding a new status value for refunds breaks a checkout query that didn't account for it. The word "submitted" means slightly different things to different parts of the app. Every team works with 60 columns even when they only care about 5. The file becomes the thing nobody wants to touch because nobody fully understands what depends on it.
Take a moment. Before revealing, ask yourself whether sharing the same class across all those teams is actually what you want. What do they each need from "an order"?
The solution
Split the model by context. Sharing a database table doesn't mean sharing a class. Each context gets its own class that points at the same table but exposes only the columns and methods that context cares about. Teams can then evolve their part without coordinating with everyone else.
- A change for fulfillment can't break checkout
- Each class shows only what one team actually uses
- Teams can evolve their part without coordinating with the others
module Checkout
class Order < ApplicationRecord
self.table_name = "orders"
has_many :line_items, class_name: "Checkout::LineItem"
def submit!
# checkout-specific submission logic
end
end
end
module Fulfillment
class Order < ApplicationRecord
self.table_name = "orders"
has_many :shipments
def assign_warehouse(warehouse)
# fulfillment-specific logic
end
end
end
# Each part of the app uses the model from its context:
Checkout::Order.find(id).submit!
Fulfillment::Order.find(id).assign_warehouse(wh) The principle at play — Bounded contexts
Domain-driven design has a name for this: bounded contexts. The same word — "order", "user", "product" — can mean meaningfully different things in different parts of the business. An "order" in checkout isn't the same thing as an "order" in fulfillment. Checkout cares about whether the cart is valid and the total is right. Fulfillment cares about which warehouse, which shipment, what's been picked. They overlap, but they're not the same model.
Trying to model both with one class makes both of them worse. The class accumulates everything that any team needs, the file balloons, and every change risks affecting parts of the codebase the author didn't know existed.
Splitting by context is one of those refactors that feels expensive up front and pays off slowly. The first time you split a god model into per-context classes, you'll feel like you're duplicating things. The first time a fulfillment change ships without anyone needing to think about checkout, you'll see why.