Kushagra Singh
When a model needs to access and query columns from another table to answer questions about itself, it can be a sign an abstraction (a domain concept) is missing from our ActiveRecord model. That concept can be named and reused.
We'll step through an example in a Restaurant management application, where restaurant managers need to disable Dishes in the menu and call Suppliers to replenish unavailable Ingredients.
Let's start with the migrations:
# db/migrate/20261020195100_create_dishes.rb
class CreateDishes < ActiveRecord::Migration[8.1]
def change
create_table :dishes do |t|
t.string :name, null: false
t.timestamps
end
end
end
# db/migrate/20261020195101_create_suppliers.rb
class CreateSuppliers < ActiveRecord::Migration[8.1]
def change
create_table :suppliers do |t|
t.string :name, null: false
t.timestamps
end
end
end
# db/migrate/20261020195102_create_ingredients.rb
class CreateIngredients < ActiveRecord::Migration[8.1]
def change
create_table :ingredients do |t|
t.string :name, null: false
t.references :dish, null: false, foreign_key: true
t.references :supplier, null: false, foreign_key: true
t.boolean :out_of_stock, null: false, default: false
t.datetime :expires_at
t.timestamps
end
end
end
Let's start with what the models look like:
# app/models/ingredient.rb
class Ingredient < ApplicationRecord
belongs_to :dish
belongs_to :supplier
end
# app/models/dish.rb
class Dish < ApplicationRecord
has_many :ingredients
end
# app/models/supplier.rb
class Supplier < ApplicationRecord
has_many :ingredients
end
To make the examples concrete, we'll use a small seeded menu. The rows that matter are:
# db/seeds.rb (excerpt)
fresh_dairy = Supplier.create!(name: "Fresh Dairy")
green_grocers = Supplier.create!(name: "Green Grocers")
pantry_co = Supplier.create!(name: "Pantry Co")
margherita = Dish.create!(name: "Margherita Pizza")
caesar_salad = Dish.create!(name: "Caesar Salad")
mushroom_risotto = Dish.create!(name: "Mushroom Risotto")
Ingredient.create!(
name: "Mozzarella",
dish: margherita,
supplier: fresh_dairy,
out_of_stock: true
)
Ingredient.create!(
name: "Romaine",
dish: caesar_salad,
supplier: green_grocers,
out_of_stock: true
)
Ingredient.create!(
name: "Arborio Rice",
dish: mushroom_risotto,
supplier: pantry_co,
out_of_stock: false,
expires_at: 2.days.ago
)
The First Scope
A new requirement comes in:
Patrons are ordering dishes whose ingredients have run out. Managers want to disable dishes on the menu when they aren't available.
We can solve that using a scope:
# app/models/dish.rb
class Dish < ApplicationRecord
has_many :ingredients
scope :unavailable, -> {
joins(:ingredients)
.where(ingredients: { out_of_stock: true }).distinct
}
end
A dish with two unavailable ingredients can appear twice in the result, we'll use `distinct` to avoid that.
Let's try that out in the console:
# rails console
> Dish.unavailable.order(:name).pluck(:name)
Dish Pluck (0.4ms) SELECT DISTINCT "dishes"."name"
FROM "dishes"
INNER JOIN "ingredients" ON "ingredients"."dish_id" = "dishes"."id"
WHERE "ingredients"."out_of_stock" = TRUE
ORDER BY "dishes"."name" ASC
=> ["Caesar Salad", "Margherita Pizza"]
Mushroom Risotto is missing from the unavailable list, because its rice is not marked out of stock.
This code works for now, but it introduces a dependency on how Ingredient represents availability.
Dish knows that availability is stored in the out_of_stock column and represented as a boolean. If that implementation changes, for example, if availability is represented by a status column instead, Dish would need to change as well.
The problem isn't that Dish queries associated records. It's that Dish owns knowledge about a rule that belongs to Ingredient.
The Second Scope
Then another requirement comes in:
Purchase managers want to call suppliers only for ingredients that are out of stock.
So we have something like this:
# app/models/supplier.rb
class Supplier < ApplicationRecord
has_many :ingredients
scope :to_call_for_unavailable_ingredients, -> {
joins(:ingredients)
.where(ingredients: { out_of_stock: true }).distinct
}
end
Testing our scope in the console looks like:
# rails console
> Supplier.to_call_for_unavailable_ingredients.order(:name).pluck(:name)
Supplier Pluck (0.4ms) SELECT DISTINCT "suppliers"."name"
FROM "suppliers"
INNER JOIN "ingredients" ON "ingredients"."supplier_id" = "suppliers"."id"
WHERE "ingredients"."out_of_stock" = TRUE
ORDER BY "suppliers"."name" ASC
=> ["Fresh Dairy", "Green Grocers"]
This works fine as well. We add some specs and deploy to production.
The same issues are in the codebase. When we ask an agent to introduce that change, or a human who does not have complete context of the project, or me, three months after writing Dish.unavailable, we tend to relearn and repeat the context we just learned. The code has the same issues as above, but it works, and the managers are happy.
Shotgun Surgery
Another feature request:
Now managers also want to call for ingredients that have expired, not only for ingredients that are out of stock.
We go ahead and make the change so that suppliers are also shown when their ingredients are out of stock or expired.
An agent might make the change in the file already within its context, without realizing that the same rule is duplicated elsewhere. Agents make shotgun surgery cheaper because changing several files is no longer as much manual work.
Let's make the change locally, so now Supplier becomes:
class Supplier < ApplicationRecord
has_many :ingredients
scope :to_call_for_unavailable_ingredients, -> {
- joins(:ingredients)
- .where(ingredients: { out_of_stock: true }).distinct
+ joins(:ingredients)
+ .where(ingredients: { out_of_stock: true })
+ .or(
+ joins(:ingredients)
+ .where(ingredients: { expires_at: ...Time.current })
+ ).distinct
}
end
Let's look at the console:
# rails console
> Supplier.to_call_for_unavailable_ingredients.order(:name).pluck(:name)
Supplier Pluck (0.4ms) SELECT DISTINCT "suppliers"."name"
FROM "suppliers"
INNER JOIN "ingredients" ON "ingredients"."supplier_id" = "suppliers"."id"
WHERE ("ingredients"."out_of_stock" = TRUE OR "ingredients"."expires_at" < '2026-10-20 15:28:04')
ORDER BY "suppliers"."name" ASC
=> ["Fresh Dairy", "Green Grocers", "Pantry Co"]
Shotgun surgery is a code smell where a single change requires edits across multiple parts of a codebase.
The number of files changed isn't the fundamental problem. The problem is that one piece of behaviour is spread across multiple places, so a change to the rule requires us to find and update every copy.
Agents make shotgun surgery cheaper because changing several files is no longer as much manual work. But making the edits cheaper doesn't remove the underlying coupling or the risk of missing one of the places where the rule lives.
Great. We made the change, and now the managers are happy: they know which supplier to call each day.
The Bug
A few days pass, and the kitchen starts complaining about receiving orders with dishes whose ingredients are no longer in the inventory. This causes a bit of chaos in the restaurant, with patrons, waiters and managers.
Let's look into why that happened. In the Dish model, we had defined an unavailable dish as one that has an out_of_stock ingredient. But when we started clearing out inventory as ingredients expired, we never went back and updated the Dish model.
# rails console
> Dish.unavailable.order(:name).pluck(:name)
Dish Pluck (0.4ms) SELECT DISTINCT "dishes"."name"
FROM "dishes"
INNER JOIN "ingredients" ON "ingredients"."dish_id" = "dishes"."id"
WHERE "ingredients"."out_of_stock" = TRUE
ORDER BY "dishes"."name" ASC
=> ["Caesar Salad", "Margherita Pizza"]
>
> Supplier.to_call_for_unavailable_ingredients.order(:name).pluck(:name)
Supplier Pluck (0.4ms) SELECT DISTINCT "suppliers"."name"
FROM "suppliers"
INNER JOIN "ingredients" ON "ingredients"."supplier_id" = "suppliers"."id"
WHERE ("ingredients"."out_of_stock" = TRUE OR "ingredients"."expires_at" < '2026-10-20 09:12:37')
ORDER BY "suppliers"."name" ASC
=> ["Fresh Dairy", "Green Grocers", "Pantry Co"]
Supplier calls Pantry Co for the expired rice, but Dish still says Mushroom Risotto is available. The same rule now lives in two places, and the two copies have drifted.
Rewinding
Now let's rewind to the moment we were asked to introduce the supplier change:
Purchase managers want to call suppliers only for ingredients that are out of stock.
Let's make the change easy to implement, then implement the easy change. To do that, we'll pull the code up a level of abstraction.
There are clues in the code that point to an abstraction:
- The same `where` clause appears in more than one model.
- A model knows another model's table or column names.
- A change to one rule requires edits in several places.
Let's start with the model that owns the data we are querying: Ingredient. We'll add a scope that answers whether an ingredient is unavailable:
# app/models/ingredient.rb
class Ingredient < ApplicationRecord
belongs_to :dish
belongs_to :supplier
scope :unavailable, -> { where(out_of_stock: true) }
end
We'll test out the new addition:
# rails console
> Ingredient.unavailable.order(:name).pluck(:name)
Ingredient Pluck (0.4ms) SELECT "ingredients"."name"
FROM "ingredients"
WHERE "ingredients"."out_of_stock" = TRUE
ORDER BY "ingredients"."name" ASC
=> ["Mozzarella", "Romaine"]
This is much better now that data and behaviour live together in the same class. If only there were a way to use our newly introduced unavailable scope from Dish.
Introducing .merge
merge combines the conditions of two ActiveRecord relations. We can use it to make the coupling between the two objects slightly looser. We'll modify the Dish model now:
class Dish < ApplicationRecord
has_many :ingredients
scope :unavailable, -> {
- joins(:ingredients).where(ingredients: { out_of_stock: true }).distinct
+ joins(:ingredients).merge(Ingredient.unavailable).distinct
}
end
Trying it out in the console:
# rails console
> Dish.unavailable.order(:name).pluck(:name)
Dish Pluck (0.4ms) SELECT DISTINCT "dishes"."name"
FROM "dishes"
INNER JOIN "ingredients" ON "ingredients"."dish_id" = "dishes"."id"
WHERE "ingredients"."out_of_stock" = TRUE
ORDER BY "dishes"."name" ASC
=> ["Caesar Salad", "Margherita Pizza"]
Great. The SQL is identical to the previous version. This works as expected, and the code is much cleaner in terms of the story it tells.
Dish no longer needs to know which column represents availability or how that condition is expressed. Instead, it depends on a named scope: Ingredient.unavailable.
The dependency hasn't disappeared. Dish still depends on Ingredient and its scope, but it now depends on a domain concept rather than the details of its implementation. If the way availability is represented changes, the rule can change in one place.
The Supplier Change
Now let's see how we would solve the new supplier request:
Managers also want to call for ingredients that have expired, not only for ingredients that are out of stock.
That change is much simpler now:
class Supplier < ApplicationRecord
has_many :ingredients
scope :to_call_for_unavailable_ingredients, -> {
- joins(:ingredients).where(ingredients: { out_of_stock: true }).distinct
+ joins(:ingredients).merge(Ingredient.unavailable).distinct
}
end
Looking at the console we get:
# rails console
> Supplier.to_call_for_unavailable_ingredients.order(:name).pluck(:name)
Supplier Pluck (0.4ms) SELECT DISTINCT "suppliers"."name"
FROM "suppliers"
INNER JOIN "ingredients" ON "ingredients"."supplier_id" = "suppliers"."id"
WHERE "ingredients"."out_of_stock" = TRUE
ORDER BY "suppliers"."name" ASC
=> ["Fresh Dairy", "Green Grocers"]
Our SQL stays the same but now Supplier collaborates with Ingredient to answer the same question.
Making the easy change
Now let's look at the last requirement that came in.
Now managers also want to call for ingredients that have expired, not only for ingredients that are out of stock.
Implementing it is as simple as modifying the Ingredient class:
class Ingredient < ApplicationRecord
belongs_to :dish
belongs_to :supplier
- scope :unavailable, -> { where(out_of_stock: true) }
+ scope :unavailable, -> {
+ where(out_of_stock: true)
+ .or(where(expires_at: ...Time.current))
+ }
end
...Time.current is a beginless exclusive range. Rails turns it into expires_at < now.
# rails console
> Dish.unavailable.order(:name).pluck(:name)
Dish Pluck (0.4ms) SELECT DISTINCT "dishes"."name"
FROM "dishes"
INNER JOIN "ingredients" ON "ingredients"."dish_id" = "dishes"."id"
WHERE ("ingredients"."out_of_stock" = TRUE OR "ingredients"."expires_at" < '2026-10-20 15:33:34')
ORDER BY "dishes"."name" ASC
=> ["Caesar Salad", "Margherita Pizza", "Mushroom Risotto"]
>
> Supplier.to_call_for_unavailable_ingredients.order(:name).pluck(:name)
Supplier Pluck (0.4ms) SELECT DISTINCT "suppliers"."name"
FROM "suppliers"
INNER JOIN "ingredients" ON "ingredients"."supplier_id" = "suppliers"."id"
WHERE ("ingredients"."out_of_stock" = TRUE OR "ingredients"."expires_at" < '2026-10-20 15:33:34')
ORDER BY "suppliers"."name" ASC
=> ["Fresh Dairy", "Green Grocers", "Pantry Co"]
The SQL now contains the OR condition in both queries, even though neither Dish nor Supplier had to change to accommodate the new behaviour.
Wrapping up
I recently heard a line that stuck with me:
Abstractions are about minimizing the occupancy of an idea in our little human context window.
- James Cowling, Abstract by Convex
The idea resonated with me, and somewhere made me write this post.Dish and Supplier no longer need to know how ingredient availability is implemented. They can rely on Ingredient.unavailable and let that abstraction carry the complexity.
When a rule is scattered across a codebase, every change requires us to remember where that rule lives and update every copy. An abstraction gives that knowledge a home.
And that's becoming even more interesting as we write more code with agents.
Good abstractions aren't just about writing less code. They're about making complex systems easier to understand, change, and get right. Even when the person or agent making the next change doesn't have the entire system in their context window.