/ Tags: RAILS / Categories: RAILS

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 pass Article.published as 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.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More