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:
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.
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.
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:
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:
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:
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 shippedChurningCustomersQuery– customers with no activity in 60 daysPendingApprovalTasksQuery– tasks assigned but not yet acted uponHighValueTransactionsQuery– 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:
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::Relationso 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.






Comments
Loading comments…