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
endThis 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
endThe 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
endNotice 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
endThis 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
endTests 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.rbAn optional base class provides shared functionality:
# app/queries/application_query.rb
class ApplicationQuery
def self.call(...)
new.call(...)
end
endThis 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.