SRP Series ยท 3 of 7
Query Objects
Where the pattern comes from, why scopes stop scaling at a certain size, and how Mastodon's Trends::Query keeps complex filter logic out of its models.
Where this rule comes from
ActiveRecord ships with scopes for a reason. A scope is a small named query attached to a model, User.active, Post.published, Order.recent. For one-condition queries, scopes are the right tool. They are short, they compose with chaining, and they live next to the model whose data they describe.
The problem is that scopes have no natural limit. The same syntax that gives you scope :active, -> { where(active: true) } also lets you write a thirty-line scope with seven joins, an optional authorization filter, dynamic ordering, and a special case for unconfirmed users. The scope still lives on the model, but at that size it stops being "a small named query" and starts being a whole query-building system.
The query-object pattern, popularized in the same Helmkamp post that named form objects in 2012, says: once the query is non-trivial, it deserves its own class. The model still owns "what a row in this table is." A separate class owns "how to ask this table for the rows that match a particular need." Both can still talk to the same ActiveRecord relation API. They do not need to be the same class.
The underlying SRP move is the same one we keep meeting. "Describe the data" and "find the right subset of the data for this caller" are different responsibilities. They have different reasons to change. The day a new authorization rule lands, the query object changes; the model does not. The day the schema changes, the model changes; the query object might not. Separating them means each change touches the file that owns that responsibility, and nothing else.
The anti-pattern
Picture a Mastodon-style app with a "trending tags" feature. Tags appear on the trending page if they are recent, if they are not banned, if the moderation team has not locked them, and if the current viewer is allowed to see them. The team starts with a scope. The scope grows:
class Tag < ApplicationRecord
scope :listable_for, ->(account) do
relation = where(listable: true)
relation = relation.where("created_at > ?", 7.days.ago)
relation = relation.where(locked: false) unless account&.moderator?
relation = relation.joins(:trend).where(trend: { allowed: true })
relation = relation.where("trend.score > ?", 0.5) unless account&.confirmed?
relation = relation.order("trend.score DESC")
relation
end
scope :discoverable_for, ->(account, limit:, offset:) do
listable_for(account).limit(limit).offset(offset)
end
# ... 12 more scopes that share most of these conditions
end The Tag model now has three reasons to change. The first is "what is a tag in our database", its columns, its associations, its callbacks. The second is "what is allowed to be shown right now to whom", an authorization rule that depends on whether the viewer is a moderator, whether the viewer is confirmed, whether the trend score is high enough. The third is "how do we paginate this list", limit, offset, ordering. Each one has a different team behind it: the data team owns the first, the safety team owns the second, the product team owns the third.
The same query shape is about to spread. When trending statuses arrive next month, someone will copy these scopes onto the Status model. When trending links arrive after that, someone will copy them onto Link. Three copies, three subtly diverging implementations, and a single bug-fix that needs to land in three places at once.
How Mastodon solves it
Mastodon's solution is a class whose only job is "find trending records of a given type for a given viewer." It lives at app/models/trends/query.rb. The class is generic over the record type, so the same code handles trending tags, statuses, and links:
# mastodon/app/models/trends/query.rb ยท @bdad4f78
# License: AGPL-3.0
class Trends::Query
include Enumerable
attr_reader :klass, :loaded
alias loaded? loaded
def initialize(_prefix, klass)
@klass = klass
@records = []
@loaded = false
@allowed = false
@account = nil
@limit = nil
@offset = nil
end
def allowed!
@allowed = true
self
end
end Notice four design choices:
It is a plain Ruby class, not a model or a concern. The constructor takes a prefix (used for caching) and the ActiveRecord class it queries against. Same query object, three different subjects, no inheritance.
It includes Enumerable. That single line lets callers iterate over a query like a regular collection, query.each do |tag| ... end, query.map(&:id), query.to_a, without the class itself needing to know which underlying records will be returned.
The filter methods are builder-style. allowed!, filtered_for!(account), limit(n), offset(n) all mutate state and return self, so the caller can chain them or call them conditionally. This is the same shape an ActiveRecord relation has, but without the gravitational pull of the model.
Execution is lazy. The @loaded flag tracks whether records have actually been fetched from the database. Setting up the query is free; running it happens on first iteration, exactly once.
A caller in a controller or a service uses it like this:
# In a controller or service
trends = Trends::Query.new("tags", Tag)
.allowed!
.filtered_for!(current_account)
.limit(20)
.offset(params[:offset].to_i)
trends.each do |tag|
# query executes here, on first iteration, once
end Why this design holds up
Four wins come out of the extraction, mirroring the four problems on the fat-Tag version.
The Tag model is back to describing a tag. The thirty lines of trend-filter logic moved out, and so did the dependence on whether the viewer is a moderator. The model file shrinks. The schema is what changes the model file, not authorization decisions.
The same query object serves three models. Mastodon uses Trends::Query for Tag, Status, and Link. The shared logic lives once. A bug fix or a new authorization rule lands in one place and applies to all three subjects automatically.
The query becomes testable in isolation. A test creates a few fixtures and calls Trends::Query.new("tags", Tag).limit(10).to_a. No controller cycle, no view rendering, no factory cascade of every association on Tag. The test runs fast, and it covers exactly the query's behavior.
Conditional filters become composable. The caller decides which builder methods to apply based on the request: query.allowed! if current_user.confirmed?, query.limit(20) if request.format.html?. No more deeply nested conditionals inside a scope lambda. The decision logic moves out to the caller, where it actually belongs.
When you should reach for one
Three signals tell you a scope has outgrown its home:
- The scope is longer than five lines, or has more than two branching conditions. A scope with an
unless account&.moderator?hidden in the middle is doing two jobs. - Multiple controllers or services build the same shape of filtered list using overlapping scopes. If three pages all start with
Tag.listable_for(...).then(...).then(...), the common prefix is begging to be extracted. - The query has an authorization dimension. "What is this user allowed to see" is not the model's job. Scopes that take a user as an argument are usually query objects in disguise.
Do not extract query objects for one-liners. If your scope is where(active: true), keep it as a scope. The query-object pattern earns its weight when the same multi-condition query gets reused, when authorization is part of the query, or when the query is large enough that a future reader cannot hold all of it in their head at once.
The principle at play
The model and the query are answering different questions. The model answers "what is a tag", a database row, a set of columns, a few associations. The query answers "given a viewer and a moment in time, which tags are interesting right now?" The first question barely changes; the second one changes every time the safety team or the product team wants to tune the rules.
The deeper principle, the one that goes back to David Parnas in the 1970s, is that good module boundaries match the boundaries of change in the world. A change that comes from one team should touch one file. A query object turns "viewer permissions for trending content" into a single file that one team owns, instead of a thirty-line scope on a model that ten teams touch.
The pattern is also a small admission that ActiveRecord's scope feature was designed for a world where queries stay simple. Once a query has its own state, its own builder methods, and its own subject (it can run against multiple tables), it has become a domain concept in its own right. Give that concept a class, give it a name, and let the model go back to being a model.
Practice exercise
- Run
grep -rn "scope " app/models | awk -F: '{print $1}' | sort | uniq -c | sort -rn | head -5to find the models with the most scopes. - Open the worst offender. Find the scope with the most chained
.where(...).where(...).joins(...). - Count how many different kinds of decisions that scope makes. Filter? Sort? Authorize? Paginate? If the count is three or more, that scope is a query-object candidate.
- Sketch the query object: its name (
{Subject}sQueryor a domain-specific name likeTrendsQuery), its builder methods, what its initializer should accept. - Bonus: grep your controllers and services for the same scope name (
grep -rn "ScopeName" app/controllers app/services). The more callers, the bigger the payoff from extracting it once.