Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 090 / 100

Query objects: concepts, trade-offs and production lessons

Encapsulate complex queries.

Expona Admin7 min read

Query objects: concepts, trade-offs and production lessons

As Rails applications grow, the logic sprawling across your models and controllers becomes harder to test and reuse. Query objects offer a clean pattern to encapsulate complex queries, but like any abstraction, they come with trade-offs we've learned the hard way. After shipping features across e-commerce platforms and SaaS products, we've found that query objects shine when applied deliberately—and falter when treated as a silver bullet.

The problem: where do complex queries live?

Consider a typical e-commerce scenario. You need to fetch active orders placed in the last 30 days by customers in specific regions, excluding cancelled items, sorted by recent activity. Your first instinct might be to pile scopes onto the model:

ruby
class Order < ApplicationRecord
  scope :active, -> { where(status: 'confirmed') }
  scope :recent, -> { where('created_at > ?', 30.days.ago) }
  scope :by_region, ->(regions) { joins(:customer).where(customers: { region: regions }) }
  scope :excluding_cancelled_items, -> { joins(:items).where.not(items: { status: 'cancelled' }) }
  scope :sorted_by_activity, -> { order(updated_at: :desc) }
end

# Usage
Order.active.recent.by_region(['US', 'CA']).excluding_cancelled_items.sorted_by_activity

This works for simple cases. But add pagination, add timezone handling, add nested includes for performance, add a filter for minimum order value, and your model balloons. More importantly, testing these scopes becomes tedious—you're spinning up the database constantly, and the logic feels scattered across multiple lines that don't read as a cohesive intention.

Query objects solve this by giving complex queries their own home.

Plain classes returning relations

A query object is a plain Ruby class that encapsulates a query and returns an ActiveRecord::Relation. This is the critical detail: it returns a relation, not an array. That means the returned value is composable—callers can still chain scopes, add limit, apply select, or use pluck.

ruby
class RecentActiveOrdersQuery
  def initialize(regions:, days: 30)
    @regions = regions
    @days = days
  end

  def call
    orders
      .where(status: 'confirmed')
      .where('orders.created_at > ?', @days.days.ago)
      .joins(:customer)
      .where(customers: { region: @regions })
      .joins(:items)
      .where.not(items: { status: 'cancelled' })
      .order(updated_at: :desc)
      .distinct
  end

  private

  def orders
    Order.all
  end
end

# Usage
query = RecentActiveOrdersQuery.new(regions: ['US', 'CA'])
results = query.call
# Still chainable
paginated = results.page(1).per(20)
# Still testable for count
count = query.call.count

The clarity here is immediate. Someone reading RecentActiveOrdersQuery knows exactly what it does. No digging through eight scopes to understand the intent.

Composable and testable

One of the biggest production wins with query objects is testability. You can unit test the query in isolation without spinning up expensive fixtures or relying on controller tests that bundle together authentication, authorization, and filtering logic.

ruby
describe RecentActiveOrdersQuery do
  let(:us_customer) { create(:customer, region: 'US') }
  let(:ca_customer) { create(:customer, region: 'CA') }
  let(:uk_customer) { create(:customer, region: 'UK') }

  before do
    create(:order, customer: us_customer, status: 'confirmed', created_at: 10.days.ago)
    create(:order, customer: ca_customer, status: 'confirmed', created_at: 5.days.ago)
    create(:order, customer: uk_customer, status: 'confirmed', created_at: 2.days.ago)
    create(:order, customer: us_customer, status: 'cancelled', created_at: 3.days.ago)
  end

  it 'returns orders from specified regions only' do
    query = RecentActiveOrdersQuery.new(regions: ['US', 'CA'])
    expect(query.call).to match_array(
      Order.where(customer: [us_customer, ca_customer])
    )
  end

  it 'excludes orders outside the day range' do
    query = RecentActiveOrdersQuery.new(regions: ['US'], days: 7)
    expect(query.call.count).to eq(1)
  end

  it 'excludes cancelled orders' do
    query = RecentActiveOrdersQuery.new(regions: ['US'])
    expect(query.call).not_to include(Order.where(status: 'cancelled'))
  end
end

Composability shines in controllers and services. Instead of embedding the query logic inline, you instantiate the query object and chain additional logic if needed:

ruby
class OrdersController < ApplicationController
  def export_recent
    orders = RecentActiveOrdersQuery.new(regions: current_user.allowed_regions)
      .call
      .includes(:customer, :items)
      .order(created_at: :desc)
    
    # Pass to background job for CSV generation
    ExportOrdersJob.perform_later(orders.pluck(:id))
  end
end

The query object handles its specific responsibility. The controller handles HTTP concerns. Each is testable independently.

Keep models smaller

Models naturally accumulate responsibility. Add scopes, add validations, add callbacks, add associations, and suddenly your Order model is 500 lines. Query objects help push query logic out, leaving the model lean and focused on domain logic.

Before:

ruby
class Order < ApplicationRecord
  # ... 50 lines of associations and validations
  
  scope :active, -> { where(status: 'confirmed') }
  scope :recent, -> { where('created_at > ?', 30.days.ago) }
  scope :by_region, ->(regions) { joins(:customer).where(customers: { region: regions }) }
  scope :excluding_cancelled_items, -> { joins(:items).where.not(items: { status: 'cancelled' }) }
  scope :sorted_by_activity, -> { order(updated_at: :desc) }
  
  # ... 20 more scopes
end

After:

ruby
class Order < ApplicationRecord
  # ... 50 lines of associations and validations
  
  # A few key scopes that model behavior, not complex queries
  scope :confirmed, -> { where(status: 'confirmed') }
  scope :pending, -> { where(status: 'pending') }
end

Your model is readable. Complex queries have their own namespace. New team members don't have to wade through 100 scopes to find what they need.

Name by purpose

The naming convention matters enormously. Classes named ActiveOrdersQuery or OrderFilterQuery tell you nothing. Classes named RecentActiveOrdersQuery, HighValueCustomerOrdersQuery, or PendingFulfillmentOrdersQuery communicate intent immediately.

A good rule: the query object name should answer "what business problem does this solve?" not "what table does this query?"

Some examples from production codebases:

  • UnshippedOrdersQuery – orders that have been paid but not yet shipped
  • ChurningCustomersQuery – customers with no activity in 60 days
  • PendingApprovalTasksQuery – tasks assigned but not yet acted upon
  • HighValueTransactionsQuery – transactions over a region-specific threshold

This naming discipline also prevents query objects from becoming a dumping ground. If you're struggling to name it, it's probably doing too much.

Common mistakes

Mistake 1: Returning arrays instead of relations. If your query object calls .to_a or returns an array, you've lost composability. The caller can't add pagination or further filtering. Always return a relation.

Mistake 2: Bloating a single query object. A query object that handles five different filter combinations with lots of optional parameters often signals you need multiple, focused query objects. One query object, one clear purpose.

Mistake 3: Putting business logic inside query objects. Query objects should encapsulate how to fetch records. They shouldn't contain what to do with them. Calculations, transformations, and side effects belong in services or the domain layer.

Mistake 4: Not testing edge cases. Test with empty results, test with large datasets, test with nil parameters, test composed queries. Query objects feel simple but can hide subtle bugs in joins or conditions.

Mistake 5: Over-engineering trivial queries. If your query is a simple single-table filter, a scope is fine. Query objects are for multi-table logic, complex conditions, and queries you'll test and reuse repeatedly. Not every database lookup needs one.

Production lessons

In e-commerce systems handling thousands of orders, we've learned that query objects scale better than scattered scopes. They make performance optimization straightforward—you optimize one place, not ten. They make audit trails easier to follow. And they catch bugs early because you're testing the query logic directly.

In SaaS products with permission checks, query objects make it simple to embed authorization. You compose the base query, then layer on visibility filters:

ruby
class CustomerOrdersQuery
  def initialize(customer:, current_user:)
    @customer = customer
    @current_user = current_user
  end

  def call
    orders.where(customer: @customer)
  end

  private

  def orders
    if @current_user.admin?
      Order.all
    else
      Order.joins(:customer).where(customers: { account_id: @current_user.account_id })
    end
  end
end

Over 18 months, we've also learned that query objects don't replace the need for proper database indexing, query analysis, or N+1 prevention. They make your code cleaner, but they don't fix bad schema design.

Key takeaways

  • Query objects encapsulate complex database logic in focused, testable classes that return relations—not arrays.
  • They keep models lean and shift the naming burden from scopes (often cryptic) to classes (which can be descriptive).
  • Always return ActiveRecord::Relation so callers can compose further queries, apply pagination, or adjust sorting.
  • Name query objects by business purpose, not implementation detail.
  • Use them for genuinely complex queries; don't over-engineer single-table filters.
  • Test query objects in isolation with focused unit tests, catching edge cases early.
  • Query objects reduce scatter and make performance optimization, authorization layering, and feature changes easier to manage at scale.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles