Rails 8 Query Objects That Scale

As Rails applications grow, Active Record scopes multiply. Models become bloated with dozens of chained query methods, conditional logic spreads across controllers, and testing becomes painful. Query Objects solve this by extracting complex queries into dedicated, single-purpose classes.

The Problem with Scope Sprawl

Consider a typical e-commerce application where orders need filtering by multiple criteria:

# app/models/order.rb
class Order < ApplicationRecord
  scope :recent, -> { where('created_at > ?', 30.days.ago) }
  scope :completed, -> { where(status: 'completed') }
  scope :high_value, -> { where('total_cents > ?', 10000) }
  scope :by_region, ->(region) { joins(:address).where(addresses: { region: region }) }
  scope :with_discount, -> { where.not(discount_code: nil) }
  scope :requiring_review, -> { where(flagged: true, reviewed_at: nil) }
  # ... 20 more scopes
end

This approach creates several issues: the model file grows unwieldy, complex queries with conditional logic don't fit the scope pattern well, and testing requires loading the entire model. Query Objects provide a cleaner architecture.

Building a Basic Query Object

A Query Object is a plain Ruby class that encapsulates a single query or family of related queries. The pattern uses dependency injection for testability and returns an Active Record relation for chainability:

# app/queries/orders/requiring_attention_query.rb
module Orders
  class RequiringAttentionQuery
    def initialize(relation = Order.all)
      @relation = relation
    end

    def call(days_old: 7, minimum_value_cents: 5000)
      @relation
        .where(status: %w[pending processing])
        .where('created_at < ?', days_old.days.ago)
        .where('total_cents >= ?', minimum_value_cents)
        .where(assigned_to: nil)
        .includes(:customer, :line_items)
        .order(created_at: :asc)
    end
  end
end

The query object accepts an optional relation, defaulting to Order.all. This enables composition—pass in a pre-filtered relation to narrow results further. The call method accepts parameters with sensible defaults, making the query flexible without requiring callers to specify every option.

Using Query Objects in Controllers

Controllers become cleaner when query logic moves to dedicated objects:

# app/controllers/admin/orders_controller.rb
module Admin
  class OrdersController < ApplicationController
    def attention_needed
      @orders = Orders::RequiringAttentionQuery
        .new
        .call(days_old: params[:days]&.to_i || 7)
        .page(params[:page])

      respond_to do |format|
        format.html
        format.turbo_stream
      end
    end

    def regional_report
      base_scope = Order.where(created_at: reporting_period)
      
      @orders = Orders::RegionalPerformanceQuery
        .new(base_scope)
        .call(region: params[:region])
    end

    private

    def reporting_period
      start_date = params[:start_date]&.to_date || 30.days.ago
      end_date = params[:end_date]&.to_date || Time.current
      start_date..end_date
    end
  end
end

Notice how the controller focuses on HTTP concerns—parameter handling, pagination, response formats—while the query object handles database logic. This separation makes both easier to test and modify.

Composable Query Objects

Query Objects shine when building complex filtering systems. Create small, focused query objects that combine cleanly:

# app/queries/orders/filter_query.rb
module Orders
  class FilterQuery
    SORTABLE_COLUMNS = %w[created_at total_cents status].freeze
    DEFAULT_SORT = 'created_at'.freeze

    def initialize(relation = Order.all)
      @relation = relation
    end

    def call(params = {})
      result = @relation
      result = filter_by_status(result, params[:status])
      result = filter_by_date_range(result, params[:start_date], params[:end_date])
      result = filter_by_customer(result, params[:customer_id])
      result = filter_by_value_range(result, params[:min_value], params[:max_value])
      result = apply_sorting(result, params[:sort], params[:direction])
      result
    end

    private

    def filter_by_status(relation, status)
      return relation if status.blank?
      
      statuses = Array(status).select(&:present?)
      return relation if statuses.empty?
      
      relation.where(status: statuses)
    end

    def filter_by_date_range(relation, start_date, end_date)
      relation = relation.where('orders.created_at >= ?', start_date.to_date.beginning_of_day) if start_date.present?
      relation = relation.where('orders.created_at <= ?', end_date.to_date.end_of_day) if end_date.present?
      relation
    end

    def filter_by_customer(relation, customer_id)
      return relation if customer_id.blank?
      
      relation.where(customer_id: customer_id)
    end

    def filter_by_value_range(relation, min_value, max_value)
      relation = relation.where('total_cents >= ?', min_value.to_i * 100) if min_value.present?
      relation = relation.where('total_cents <= ?', max_value.to_i * 100) if max_value.present?
      relation
    end

    def apply_sorting(relation, sort_column, direction)
      column = SORTABLE_COLUMNS.include?(sort_column) ? sort_column : DEFAULT_SORT
      dir = direction&.downcase == 'asc' ? :asc : :desc
      relation.order(column => dir)
    end
  end
end

This filter query handles multiple optional parameters safely, validates sort columns to prevent SQL injection, and applies sensible defaults. Each private method handles one filter concern, making the logic easy to follow and modify.

Testing Query Objects

Query Objects simplify testing because they're plain Ruby classes without controller or view dependencies:

# spec/queries/orders/requiring_attention_query_spec.rb
require 'rails_helper'

RSpec.describe Orders::RequiringAttentionQuery do
  describe '#call' do
    let!(:old_pending_order) do
      create(:order, status: 'pending', created_at: 10.days.ago, 
             total_cents: 6000, assigned_to: nil)
    end
    
    let!(:recent_pending_order) do
      create(:order, status: 'pending', created_at: 2.days.ago,
             total_cents: 6000, assigned_to: nil)
    end
    
    let!(:old_completed_order) do
      create(:order, status: 'completed', created_at: 10.days.ago,
             total_cents: 6000, assigned_to: nil)
    end
    
    let!(:assigned_order) do
      create(:order, status: 'pending', created_at: 10.days.ago,
             total_cents: 6000, assigned_to: create(:admin_user))
    end

    it 'returns old unassigned pending orders' do
      results = described_class.new.call(days_old: 7, minimum_value_cents: 5000)
      
      expect(results).to include(old_pending_order)
      expect(results).not_to include(recent_pending_order)
      expect(results).not_to include(old_completed_order)
      expect(results).not_to include(assigned_order)
    end

    it 'accepts a custom base relation' do
      specific_orders = Order.where(id: old_pending_order.id)
      results = described_class.new(specific_orders).call
      
      expect(results).to contain_exactly(old_pending_order)
    end

    it 'respects the minimum value parameter' do
      low_value_order = create(:order, status: 'pending', created_at: 10.days.ago,
                                total_cents: 1000, assigned_to: nil)
      
      results = described_class.new.call(minimum_value_cents: 5000)
      
      expect(results).not_to include(low_value_order)
    end
  end
end

Tests focus purely on query behavior without HTTP concerns. The dependency injection pattern allows testing with specific relations, verifying the query composes correctly with pre-filtered data.

Directory Structure and Conventions

Organize query objects by the primary model they query:

app/
  queries/
    application_query.rb
    orders/
      filter_query.rb
      requiring_attention_query.rb
      regional_performance_query.rb
    customers/
      churn_risk_query.rb
      high_value_query.rb
    products/
      low_stock_query.rb
      trending_query.rb

An optional base class provides shared functionality:

# app/queries/application_query.rb
class ApplicationQuery
  def self.call(...)
    new.call(...)
  end
end

This allows both Orders::FilterQuery.new(relation).call(params) and the shorter Orders::FilterQuery.call(params) syntax when the default relation suffices.

When to Extract a Query Object

Query Objects add value when queries involve multiple conditions with optional parameters, queries appear in multiple locations, testing complex queries requires excessive setup, or models accumulate more than ten scopes. For simple queries that fit naturally as scopes, keep them on the model. Query Objects complement scopes rather than replacing them entirely.

Summary

Query Objects extract complex database queries into focused, testable classes. They accept an optional base relation for composition, use parameters with defaults for flexibility, and return Active Record relations for further chaining. This pattern keeps models thin, makes complex queries testable in isolation, and provides a clear home for query logic that outgrows simple scopes. Start with one complex query, extract it, and expand the pattern as the application grows.

10 claps
← Back to Blog