← All posts

May 4, 2026

Query objects in Rails: when scopes stop scaling

An opinionated take on the query object pattern: the shape that earns its file, the bar I apply, and the scopes that should stay scopes.

rails patterns architecture query-objects active-record

Open any Rails app a few years older than its team and look at app/models/post.rb. Count the scopes. Twenty? Forty? Then read the ones that take arguments — scope :written_between, ->(start, finish) { where(created_at: start..finish) } — and the ones that wrap lambdas around four lines of where/joins/group/having. That is where I find the model carrying weight that does not belong to it.

The query object is the pattern I reach for when the scope file has stopped scaling. This post is what I actually believe about when it earns its file and when it is ceremony, with the rule I apply at the keyboard.

What a query object actually is

A query object is a Ruby class whose one job is to build a query. The constructor takes the inputs the query depends on. The single public method — #call, #relation, or #results, pick one and stay with it — returns an ActiveRecord::Relation (or sometimes a raw array of rows when you have fallen through to SQL). The class does not render, does not iterate, does not side-effect. It builds the query and hands it back.

That last bit matters. A query object that returns a relation composes. The caller can paginate, count, eager-load, or merge it with another scope. A query object that calls to_a inside #call and returns an array has thrown that away, and the next person who needs the result paginated will end up loading every record into memory before reaching for .first(20).

The shape

Here is the shape I default to:

# illustrative
class StaleSubscribersQuery
  def initialize(scope: Subscriber.all, inactive_for: 30.days)
    @scope = scope
    @inactive_for = inactive_for
  end

  def call
    @scope
      .where(active: true)
      .where("last_login_at < ?", @inactive_for.ago)
      .where.missing(:recent_payments)
      .order(last_login_at: :asc)
  end
end

Two things are deliberate. The constructor takes a scope: defaulting to Subscriber.all. That is the composition seam — the caller can pass current_account.subscribers and the query runs scoped to that tenant without the class knowing anything about multi-tenancy. And #call returns the relation, not an array. If a controller wants to paginate, it can. If a background job wants to find_each over it, it can.

The where.missing(:recent_payments) is a Rails 6.1 helper that generates a LEFT OUTER JOIN ... WHERE association_id IS NULL. It is the case the SQL-curious developer would otherwise write by hand and the next reader would otherwise not understand at a glance. Naming it in the query object — and naming the query object after the question it answers — is the win.

The bar I apply

A scope is a one-liner on the model. A query object is a file. So the bar has to be high enough that the file earns its slot in app/queries. I extract a query object when at least one of these is true:

  1. The query depends on inputs that are not a single column. Date ranges, search strings, a hash of filters. Scopes that take three or four arguments and conditionally chain wheres on each one are a query object trying to escape.
  2. The query crosses three or more associations, especially with joins, group, having, or window functions. Once you are writing SQL that a reader has to map back to associations in their head, the explanation belongs in a class with a name.
  3. The same query is used in two places with one parameter different. A controller index and a CSV export, a dashboard and a digest email. Duplicating the chain across both sites is how subtle drift starts.

If none of those apply — if it is Post.published — leave it as a scope. A scope file with a dozen named scopes, each one line, is a feature, not a smell. You only have a problem when the scopes themselves grew bodies.

Composition

The trick to a useful query object is making it composable. Two patterns I rely on.

Take a scope, return a relation. The example above does this. Any caller can pre-filter — StaleSubscribersQuery.new(scope: account.subscribers).call.limit(50) — and the query object never has to know.

Use merge for cross-model scopes. When the query joins another model and you want to apply that model’s scopes, merge is the glue:

# illustrative
def call
  Order
    .joins(:customer)
    .merge(Customer.in_good_standing)
    .where(placed_at: @range)
end

Customer.in_good_standing is defined once on Customer, and the query object pulls it in without the cross-model condition leaking into Order’s scope file.

Testing

The spec for a query object is the spec for a SQL statement. I create the relevant records, call the query, and assert which records came back. No mocks. Three contexts I always write:

  1. The happy path — records that should match come back.
  2. The negative path — records that should be excluded do not come back.
  3. The composition path — passing a pre-filtered scope narrows the result.
# illustrative
describe StaleSubscribersQuery do
  it "returns subscribers inactive past the threshold" do
    fresh = create(:subscriber, last_login_at: 1.day.ago)
    stale = create(:subscriber, last_login_at: 90.days.ago)

    expect(described_class.new.call).to contain_exactly(stale)
    expect(described_class.new.call).not_to include(fresh)
  end
end

If the query object is doing its job, the spec is short, hits the database, and reads as documentation for what the class is asking the database to do.

Anti-patterns

Three shapes I push back on in code review:

  • The one-line wrapper. class ActivePostsQuery; def call = Post.where(active: true); end. That is a scope. Putting it in a class adds a file, a test file, and an indirection — and saves nothing.
  • The query object that loads. def call; Post.where(...).to_a; end. The caller cannot paginate, cannot count without re-running, cannot find_each. Return the relation; let the caller decide.
  • The query object that renders. I have seen #call return view-shaped hashes. That is two responsibilities — a query and a presenter — wedged into one class. Split them: the query returns a relation, the presenter (or the serializer) takes the relation and shapes it for the view.

The takeaway

Extract a query object when the query depends on runtime inputs, crosses three or more associations, or is used in more than one place with variation — and not before. Most “queries” are scopes. Most scopes should not move. The point of the pattern is to give the queries that have outgrown their model file a place to live; it is not to give every where clause its own class.