What are query objects?
The 800-line model nobody wants to touch
You open app/models/user.rb on a Monday morning. It's 847 lines long, with 34 scopes, 12 class methods that build queries and a self.dashboard_stats method of 60 lines of raw SQL stitched together with string interpolation. Three developers have added # TODO: refactor this, and nobody has.
You need a new query: users who enrolled in the last 30 days, completed at least one lesson and haven't logged in for a week. Where does it go? The User model, the Enrollment model, a controller, a concern?
Every option feels wrong because the question is wrong. The better question is why query logic lives in the model at all.
The problem: fat models
Rails teaches you to put query logic in scopes, and scopes are great for simple, single-purpose filters:
class User < ApplicationRecord
scope :active, -> { where(active: true) }
scope :admins, -> { where(role: :admin) }
end
Production codebases don't stay simple, though. Requirements grow, queries compound, and before long the model looks like this:
class User < ApplicationRecord
# 15 scopes...
scope :active, -> { where(active: true) }
scope :with_recent_activity, -> { where('last_login_at > ?', 30.days.ago) }
scope :enrolled_in, ->(course) { joins(:enrollments).where(enrollments: { course_id: course.id }) }
scope :completed_course, ->(course) { ... }
scope :with_xp_above, ->(threshold) { ... }
# Class methods that build complex queries...
def self.leaderboard(period:)
joins(:experience_points)
.where(experience_points: { created_at: period })
.group(:id)
.select('users.*, SUM(experience_points.amount) as total_xp')
.order('total_xp DESC')
.limit(50)
end
def self.churn_risk_report
# 40 lines of joins, subqueries, and CTEs
end
def self.engagement_metrics(start_date:, end_date:)
# Another 30 lines
end
end
This model now breaks the single responsibility principle. It defines the User entity, with its validations and associations, and it holds business-specific query logic for leaderboards, churn reports and engagement metrics. Those concerns change for different reasons, at different times, for different people.
What changes, and who asks
Consider who asks for changes to each piece:
+---------------------------+------------------+
| What changes | Who asks for it |
+---------------------------+------------------+
| User validations | Security team |
| User associations | Data team |
| Leaderboard query | Product manager |
| Churn risk report | Growth team |
| Engagement metrics | Analytics team |
+---------------------------+------------------+
When the growth team changes how churn risk is calculated, you're editing the same file the security team edits for password validations, running the same test suite and creating merge conflicts with unrelated work. The model has become a coordination bottleneck.
The query object pattern
A query object is a plain Ruby class with one job: to hold a single query, or a family of closely related ones. It takes parameters, builds an Active Record query and returns the results.
Here's the leaderboard logic moved out of the fat model into its own class:
# app/queries/enrollment/leaderboard_query.rb (a simplified sketch; the app's real one reads snapshots and has a different shape)
module Enrollment
class LeaderboardQuery
Result = Data.define(:entries)
def self.call(period: 30.days.ago..Time.current, limit: 50)
entries = Identity::User
.joins(:experience_points)
.where(experience_points: { created_at: period })
.group('users.id')
.select('users.*, SUM(experience_points.amount) AS total_xp')
.order('total_xp DESC')
.limit(limit)
Result.new(entries: entries)
end
end
end
And here's how a controller calls it:
# In a controller
result = Enrollment::LeaderboardQuery.call(period: 7.days.ago..)
@leaderboard = result.entries
The model is clean again. The query lives in its own file, in its domain namespace, with its own tests. When the product manager changes the leaderboard, you open one file, with no merge conflicts against user validations and no risk to authentication. This app's experience_points table has no source_id column, and its source_type values are activity names such as "lesson_completion", so the example doesn't filter by course.
This app has a real Enrollment::LeaderboardQuery too, with the same name as the sketch above and a different shape. It takes the viewer and a period, reads the current leaderboard snapshot and returns the top 50 plus the viewer's neighbours. The ranking itself, a SQL window function (RANK() OVER (ORDER BY xp DESC)), runs in a background job every 15 minutes, so the database does the counting instead of loading every user.
Before and after
BEFORE (fat model) AFTER (query objects)
+------------------------+ +------------------------+
| User Model (847 lines) | | User Model (45 lines) |
| | | validations |
| validations | | associations |
| associations | | scopes (simple only) |
| 34 scopes | +------------------------+
| leaderboard query |
| churn risk report | +------------------------+
| engagement metrics | | LeaderboardQuery |
| dashboard stats | | one class, one job |
| ... | +------------------------+
+------------------------+
+------------------------+
| ChurnRiskQuery |
| one class, one job |
+------------------------+
+------------------------+
| EngagementMetricsQuery |
| one class, one job |
+------------------------+
Each query object is 20 to 50 lines, testable on its own and filed in the domain namespace it belongs to.
The contract
In this architecture, query objects follow a contract:
self.callis the single entry point, with nonewand no instance state.- They return a
Result = Data.define(...), a structured, immutable value. - They're read-only: no
create!, noupdate!, nodestroy!. - They live in
app/queries/<domain>/, namespaced by domain. - They return relations or Data objects, never raw hashes or arrays of hashes.
This app follows it almost everywhere. Nearly every file in app/queries/ defines self.call, and none of them write. The real Enrollment::LeaderboardQuery is one to read: self.call(viewer:, period:) returns a Result built with Data.define, and it only reads.
The contract makes query objects predictable. Anyone on the team knows that a class in app/queries/ is read-only, returns a Result, and is called with .call.
When scopes are enough
Query objects don't replace scopes; they complement them:
| Use a scope when... | Use a query object when... |
|---|---|
| The filter is simple, one or two conditions | The query spans several tables |
| It belongs to the model's core identity | It serves one business use case |
| It's reused in many places | It's used by one controller or service |
| It returns the same model | It aggregates, groups or transforms data |
Example: User.active |
Example: a leaderboard with XP rankings |
Scopes describe what a model is, such as active users or published courses. Query objects describe what the business needs, such as leaderboard rankings, churn reports and dashboard analytics.
Common mistakes
The first mistake is putting query logic in controllers:
# Bad — controller is doing query work
def index
@users = Identity::User
.joins(:enrollments, :experience_points)
.where(enrollments: { status: :active })
.group('users.id')
.select('users.*, SUM(amount) as xp')
.order('xp DESC')
end
That query can't be reused or tested on its own, and it bloats the controller. Extract it into a query object.
The second is a query object that returns hashes instead of relations or Result objects:
# Bad — returns raw hash, breaks chainability
def self.call
ActiveRecord::Base.connection.execute(sql).to_a
end
Raw hashes lose their types, can't be scoped further and don't follow the project's Result convention.
The third is creating a query object for User.active. Simple filters belong on the model as scopes. Query objects are for complex, business-specific queries that don't belong on the model.
How this fits Rails
Rails has no built-in query object layer, but its design supports the pattern. Active Record relations are lazy: they don't hit the database until you iterate. So a query object can build a relation, return it, and let the caller decide when to run it, after pagination, extra filters or rendering.
Zeitwerk autoloads app/queries/ like app/models/ or app/services/, with no configuration. Drop in a file, follow the naming convention, and it's available everywhere.
In this DDD architecture, query objects sit beside service objects as the two workhorses of the domain layer. Services handle writes, queries handle reads, and controllers orchestrate both.
Summary
| Concept | Key point |
|---|---|
| Fat models | Models collect query logic until they become coordination bottlenecks |
| Single responsibility | A model defines the entity; query logic belongs elsewhere |
| Query object | A plain Ruby class that holds one query, called with .call, returning a Result |
| Contract | Read-only, returns Data.define(...), lives in app/queries/<domain>/ |
| Scopes vs query objects | Scopes describe what a model is; query objects describe what the business needs |
| Place in DDD | Queries handle reads, services handle writes, controllers orchestrate both |
| Rails integration | Lazy relations and Zeitwerk autoloading make query objects easy to adopt |
Keep going with lesson 2
Building your first query object
Builds a query object with self.call, a Data.define Result, keyword arguments, conditional filters and lazy relations.
A free account also gives you the quizzes, your progress and notes, and the discussion.