A Case for Query Objects in Rails

Thiago Araújo Silva
This article is also available in: Português

You may have heard of query objects before: their main purpose is to encapsulate a database query as a reusable, potentially composable, and parameterizable unit. When is it best to reach for them and how can we structure them? Let’s dive into this topic.

Reusing query filters

In Rails, it’s very easy to create database queries. Provided that we’ve defined an Active Record model, we can easily construct an ad-hoc database query:

ServiceOffering
  .where(state: "CA")
  .joins(:vendor)
  .where(vendors: {education_level: "Kindergarten"})

If we spread queries like that throughout our application but need to change how the “education level” filter works, there will be many touchpoints to update. The simplest fix for that is to represent our filters with class methods to make them reusable:

class ServiceOffering < ApplicationRecord
  def self.by_state(state)
    where(state: state)
  end

  def self.by_education_level(education_level)
    joins(:vendor)
      .where(vendors: {education_level: education_level})
  end

  # ...
end

And call our query like this:

ServiceOffering
  .by_state("CA")
  .by_education_level("Kindergarten")

Handling optional filters

What if our filters are optional? Let’s make by_state optional:

def self.by_state(state)
  where(state: state) if state.present?
end

Unfortunately, that breaks our filter chain. In the example below, if the value of params[:state] is blank, we get an error:

# undefined method `by_education_level' for nil:NilClass
ServiceOffering
  .by_state(params[:state])
  .by_education_level(params[:education_level])

And the solution for that is Active Record scopes, which are lenient to nil return values as to preserve the chainability of our scopes when they return nil:

class ServiceOffering < ApplicationRecord
  scope :by_state, ->(state) { where(state: state) if state.present? }

  scope :by_education_level, ->(education_level) do
    if education_level.present?
      joins(:vendor)
        .where(vendors: {education_level: education_level})
    end
  end

  # ...
end

A case for query objects: domain-specific queries

Our situation has certainly improved from the first code snippet, but the latest version of our query still has a few smells.

  1. It’s a chainable query, so it’s very likely that our filters will always be used together; we want to make sure our filters are tested in the same combinations they will actually be used in production, but no proper encapsulation exists;

  2. Our filters are optional; the logic to skip a filter is very specific and may not make sense in the general context of our ServiceOffering model. If we reuse a scope like that, we may inadvertently introduce a bug in our application if we’re not counting with the possibility of blank filters;

  3. We are joining with other tables, which feels outside of our model’s responsibility; whenever our query spans more than one table or reaches a certain complexity threshold, it’s a sign we could represent it with a query object.

The main problem with the default Rails mindset is that whenever we need a new filter, the easiest way around is to add a new method to our model. Over time, our model tends to get littered with inexpressive filter bits and its body doesn’t form a coherent whole.

Sometimes, a subset of filters will only be used (or reused) in a particular subdomain, so it makes sense to group them together as a coherent unit with a single purpose and leave our models alone. And it gets even better if we name our queries after something that makes sense within our domain.

Building a query object

If the service offerings belong to the marketplace context of our application, we could create a MarketplaceItems class to represent it. Today, MarketplaceItems returns ServiceOffering objects, but tomorrow it may return something else – so abstracting our operation as a proper domain entity is surely beneficial.

# For simplicity's sake, we are not applying a namespace to this class
class MarketplaceItems
  def self.call(filters)
    scope = ServiceOffering.all

    if filters[:state].present?
      scope = scope.where(state: filters[:state])
    end

    if filters[:education_level].present?
      scope = scope
        .joins(:vendor)
        .where(vendors: {education_level: filters[:education_level]})
    end

    scope
  end
end

Calling this query object is very simple:

MarketplaceItems.call(state: "CA", education_level: "Kindergarten")

Our query object works, but is not very scalable. Repeatedly reassigning a local variable under an if condition is tiring and muddles our code, especially when more than a few filters are involved. Would private methods improve our semantics? Let’s see:

class MarketplaceItems
  class << self
    def call(filters)
      scope = ServiceOffering.all
      scope = by_state(scope, filters[:state])
      scope = by_education_level(scope, filters[:education_level])
      scope
    end

    private

    def by_state(scope, state)
      return scope if state.blank?

      scope.where(state: state)
    end

    def by_education_level(scope, education_level)
      return scope if education_level.blank?

      scope
        .joins(:vendor)
        .where(vendors: {education_level: filters[:education_level]})
    end
  end
end

Not by much. We still need to pass the scope around and keep track of a local variable. That’s relatively manageable and not entirely bad, but there’s a better way around it.

A better query object

Luckily, Ruby is a dynamic language and contrary to what some people believe, extending an object at runtime is not expensive. Rails provides us with the extending method to extend an ActiveRecord::Relation at runtime, which we can use to our advantage. Let’s refactor our query object with that trick in mind:

class MarketplaceItems
  module Scopes
    def by_state(state)
      return self if state.blank?

      where(state: state)
    end

    def by_education_level(education_level)
      return self if education_level.blank?

      joins(:vendor)
        .where(vendors: {education_level: education_level})
    end
  end

  def self.call(filters)
    ServiceOffering
      .extending(Scopes)
      .by_state(filters[:state])
      .by_education_level(filters[:education_level])
  end
end

Notice how much cleaner our query object feels! The call method is only concerned with building our query and filter chaining, and our scopes are neatly separated in a module. We still need to return self in our scopes on blank parameters, but that does not nullify the merits of the approach we were able to come up so far.

That approach really shines when dealing with a considerable number of filters.

Ways to structure a query object

There are many ways to structure a query object, for example:

  • Injecting a scope in the initializer to provide more flexibility to the caller:
class MarketplaceItems
  def self.call(scope, filters)
    new(scope).call(filters)
  end

  def initialize(scope = ServiceOffering.all)
    @scope = scope
  end

  def call(filters = {})
    # ...
  end
end

# Scope marketplace items to a particular vendor
MarketplaceItems.call(vendor.service_offerings, filters)
  • Making it return raw data rather than an Active Record scope, which is useful when dealing with performance-sensitive queries:
class MarketplaceItems
  COLUMNS = [:title]

  # An alternative is to have a second method to return
  # raw data, in addition to a main method that returns
  # an ActiveRecord::Relation
  def self.call(filters)
    ServiceOffering
      .extending(Scopes)
      .by_state(filters[:state])
      .by_education_level(filters[:education_level])
      .pluck(*COLUMNS)
      .map { |row| COLUMNS.zip(row).to_h }
  end
end
  • Using raw SQL instead of the Active Record query builder.

And many others! What’s important is that each option is used with a purpose that serves the app without overengineering, unless a hard convention exists on your project.

Bonus: Scopes with Rails-like behavior

If we’re bothered with returning self when a filter is blank, we can easily solve that problem with a Scopeable module:

module Scopeable
  def scope(name, body)
    define_method name do |*args, **kwargs|
      relation = instance_exec(*args, **kwargs, &body)
      relation || self
    end
  end
end

Now we can extend our Scopes module with Scopeable and shorten our scopes a little bit:

module Scopes
  extend Scopeable

  scope :by_state, ->(state) { state && where(state: state) }

  scope :by_education_level, ->(education_level) do
    education_level && joins(:vendor)
      .where(vendors: {education_level: education_level})
  end
end

I’m particularly not a fan of that approach (and of Active Record scopes in general) because it may hinder the discoverability of our code, so I’m generally biased towards normal Ruby methods. I’d even prefer to explicitly mark the methods I want to behave as scopes with a scope annotation, as follows:

module Scopes
  extend Scopeable

  def by_state(state)
    state.present? && where(state: state)
  end
  scope :by_state # Decorate by_state to make it behave like a scope

  # ...
end

But that is left as an exercise to the reader.

Wrapup

I’m a fan of domain-driven design and single purpose objects, so I usually prefer to keep my models clean of specific cruft that does not pertain to the general model entity.

Active Record models with query methods are definitely bearable when dealing with generic and non-chainable filters, otherwise a well-named query object is a great alternative to consider!