ActiveRecord Scopes and Query Objects — Keeping Your Queries Organized
Queries scattered across controllers, models, and background jobs are one of the most common maintainability problems in Rails apps. A where clause written inline in a controller is invisible to the next developer who needs the same data, and duplicated conditions drift apart over time. ActiveRecord scopes and query objects are two complementary tools for organizing query logic — scopes for simple, reusable conditions, query objects for complex multi-step queries that have outgrown model methods.
Scopes — Named, Chainable Queries
A scope is a named query fragment defined in a model. It returns an ActiveRecord relation, which means scopes are chainable:
Example:
class Article < ApplicationRecord
scope :published, -> { where(status: "published") }
scope :recent, -> { order(created_at: :desc) }
scope :by_author, -> (author) { where(author: author) }
scope :featured, -> { where(featured: true).limit(5) }
scope :in_category, -> (cat) { where(category: cat) }
end
Example:
# Chainable — each scope adds to the query
Article.published.recent.limit(10)
Article.published.by_author("alice").in_category("ruby")
Article.featured.published
Scopes with arguments take a lambda with parameters. Named scopes read like plain English in controllers and other models.
Scopes vs. class methods
Scopes and class methods that return relations are functionally equivalent. The difference: scopes handle nil arguments gracefully (a scope with a nil argument returns all records instead of raising), and scopes are more discoverable because Rails documents them on the model.
Example:
# Scope — nil-safe, returns all records if status is nil
scope :by_status, -> (status) { where(status: status) if status.present? }
# Class method — same behavior, more control over logic
def self.by_status(status)
status.present? ? where(status: status) : all
end
For simple conditions, use scopes. For conditional logic that requires branching, class methods are clearer.
Default Scopes — Use With Caution
default_scope applies a condition to every query on the model. It’s convenient and frequently regretted:
Example:
# Seems helpful
class Article < ApplicationRecord
default_scope { where(deleted_at: nil) }
end
# The problem surfaces immediately
Article.all # => WHERE deleted_at IS NULL
Article.unscoped.all # => WHERE 1=1 (all records)
# Creating a record also sets deleted_at — this sets deleted_at: nil explicitly
Article.create!(title: "Test") # => INSERT ... deleted_at = NULL
default_scope applies to all queries, including create, which can cause subtle data initialization bugs. When you need soft-delete behavior, gems like paranoia or discard handle it more predictably. Use default_scope sparingly, if ever.
Query Objects — For Complex Queries
When a query requires multiple conditions, joins, subqueries, or business logic to determine what to query, it has outgrown a scope. A query object is a plain Ruby class that encapsulates the full query:
Example:
# app/queries/articles_query.rb
class ArticlesQuery
def initialize(relation = Article.all)
@relation = relation
end
def call(filters = {})
@relation
.then { |r| apply_status(r, filters[:status]) }
.then { |r| apply_author(r, filters[:author]) }
.then { |r| apply_date_range(r, filters[:from], filters[:to]) }
.then { |r| apply_sorting(r, filters[:sort]) }
end
private
def apply_status(relation, status)
status.present? ? relation.where(status: status) : relation
end
def apply_author(relation, author)
author.present? ? relation.where(author: author) : relation
end
def apply_date_range(relation, from, to)
relation = relation.where("published_at >= ?", from) if from.present?
relation = relation.where("published_at <= ?", to) if to.present?
relation
end
def apply_sorting(relation, sort)
case sort
when "oldest" then relation.order(created_at: :asc)
when "popular" then relation.order(view_count: :desc)
else relation.order(created_at: :desc)
end
end
end
Example:
# In controller — clean, readable, testable
def index
@articles = ArticlesQuery.new.call(
status: params[:status],
author: params[:author],
from: params[:from_date],
to: params[:to_date],
sort: params[:sort]
)
end
The query object takes an optional base relation, which makes it composable with scopes and easy to test:
Example:
# Compose with a scope
ArticlesQuery.new(Article.featured).call(status: "published")
# Test with a specific subset
ArticlesQuery.new(Article.where(category: "ruby")).call(sort: "popular")
Testing Query Objects
Query objects are easy to test in isolation — they’re plain Ruby classes:
Example:
# spec/queries/articles_query_spec.rb
RSpec.describe ArticlesQuery do
let!(:published_recent) { create(:article, status: "published", created_at: 1.day.ago) }
let!(:published_old) { create(:article, status: "published", created_at: 1.year.ago) }
let!(:draft) { create(:article, status: "draft") }
describe "#call" do
it "filters by status" do
result = described_class.new.call(status: "published")
expect(result).to include(published_recent, published_old)
expect(result).not_to include(draft)
end
it "sorts by oldest when specified" do
result = described_class.new.call(status: "published", sort: "oldest")
expect(result.first).to eq(published_old)
end
end
end
Tests are focused, readable, and don’t require controller setup.
Organizing Query Objects
app/queries/
articles_query.rb
users_query.rb
orders/
pending_orders_query.rb
revenue_summary_query.rb
Rails autoloads app/queries/ — no require statements needed. Namespace by domain for larger apps.
Pro-Tip: Accept a base relation in the query object constructor rather than hardcoding
Model.all. This makes the query object composable — you can passArticle.publishedas the base and the query object layers additional filters on top. It also makes the object testable against any subset of data, not just the full table.
When to Use Scopes vs. Query Objects
| Situation | Tool |
|---|---|
Simple reusable condition (where, order, limit) |
Scope |
| Condition depends on a parameter | Scope with lambda |
| Combining 3+ conditions with conditional logic | Query object |
| Reusing complex query logic across multiple contexts | Query object |
| Query involves subqueries or complex joins | Query object |
| Filter UI with multiple optional params | Query object |
Scopes and query objects compose well — a query object can be initialized with a scope as its base relation.
Conclusion
Scopes handle the common case: simple, named, chainable conditions that belong on the model. Query objects handle the complex case: multi-condition filters with business logic that would turn a scope into an unreadable mess. Both keep query logic out of controllers and background jobs, make it testable, and prevent the slow accumulation of duplicate where clauses scattered through the codebase. Start with scopes; graduate to query objects when the complexity warrants it.
FAQs
Q1: Can I use scopes in associations?
Yes: has_many :published_articles, -> { where(status: "published") }, class_name: "Article". This creates a scoped association — user.published_articles returns only published articles for that user.
Q2: Do scopes affect counter cache?
No. Counter caches count all records in the association regardless of scopes. Scoped counts require a separate database query.
Q3: How do I merge scopes from different models?
relation.merge(OtherModel.some_scope) applies another model’s scope to the current relation, useful when joining tables: Article.joins(:author).merge(Author.verified).
Q4: Should query objects inherit from a base class?
Generally no. A shared base class for query objects typically provides little value and creates unnecessary coupling. If you find real shared logic, extract it to a module instead.
Q5: How do I handle pagination with query objects?
Return the relation from the query object and let the controller (or wherever you paginate) apply page and per_page. Query objects should return an ActiveRecord relation, not an array — this keeps pagination, counting, and further chaining possible.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀