How to GraphQL with Ruby, Rails, Active Record, and no N+1


You work on a mature web application that cleanly separates backend and frontend. The server-side code, written in Ruby, is mostly responsible for translating HTTP requests into SQL statements (with the help of an ORM) through rich and well-documented API. You choose GraphQL over REST to streamline your endpoints, but your database is not happy with all the extra queries. After much searching, you find an exhaustive hands-on guide on fighting N+1 from a fellow GraphQL-ing Rubyist… Here it comes!

GraphQL can do wonders in a backend-only Rails application, giving your clients (whether a frontend framework or other API consumers) a single endpoint for fetching data in any shapes and sizes they might need.

There’s only one catch, but it’s a big one. N+1 big.

As the list of the associations to load is always determined at the runtime, it is very hard to be smart about querying the database.

You can either accept the sad reality of one query for parent record + one query for each association (hence “N+1”, even though the stricter term will be “1+N”)—or you can load all possible associations in advance with fancy SQL statements. But if you have a rich schema, and that’s the reason to switch to GraphQL in the first place, preloading can put an even bigger strain on a database than letting N+1 run amock. Luckily, there are tools in the Ruby-GraphQL world that allow us to be more selective and smarter about what we load, when, and how.

It’s always better to have an example

To not be unfounded, let’s draw up a practical example of a simple schema for a simple “Twitter clone” application. The goal here is not to be original but to be able to relate to types right away. They are Tweet, User, and Viewer. The Viewer is the user who views the feed of other user’s tweets. We created a separate type for a “current user” because it may expose properties otherwise inaccessible on “general” users.

class Types::BaseObject < GraphQL::Schema::Object
  def current_user
    context[:current_user]
  end
end

class Types::Tweet < Types::BaseObject
  field :content, String, null: false
  field :author, Types::User, null: false
end

class Types::User < Types::BaseObject
  field :nickname, String, null: false
end

class Types::Viewer < Types::BaseObject
  field :feed, [Types::Tweet], null: false

  def feed
    # In this case, FeedBuilder is a query object
    # that returns a Tweet relation based on passed params
    FeedBuilder.for(current_user)
  end
end

class Types::Query < Types::BaseObject
  field :viewer, Types::Viewer, null: true, resolver_method: :current_user
end

class GraphqlSchema < GraphQL::Schema
  query Types::Query
end

I have also prepared a gist that contains our whole Rails “application” in a single file. You can’t run it, but it’s functional enough to pass the included specs for comparing different optimization methods that we discuss in this article. To view the code and run the specs, you can run the following in your terminal in any temporary folder:

curl "https://gist.githubusercontent.com/DmitryTsepelev/d0d4f52b1d0a0f6acf3c5894b11a52ca/raw/cba338548f3f87c165fc7ec07eb2c5b55120f7a2/2_demo.rb" > demo.rb

createdb nplusonedb # To create a PostgreSQL test database, requires Postgres installation

rspec demo.rb # to run tests that compare different N+1-fighting techniques

This code contains an N+1 problem right away. Querying the feed that includes nicknames of tweet authors will trigger a single query for tweets tables, and N queries in users.

{
  query {
    viewer {
      feed {
        content
        author {
          nickname
        }
      }
    }
  }
}

Solution #0: Load all the associations!

Let’s start by cleaning up our code and extracting feed loading to a resolver—a special class that encapsulates our database-querying logic.


class Resolvers::FeedResolver < Resolvers::BaseResolver
  type [Types::Tweet], null: false

  def resolve
    FeedBuilder.for(current_user)
  end
end

class Types::Viewer < Types::BaseObject
  field :feed, resolver: Resolvers::FeedResolver
end

If you’re interested, here’s the definition for our FeedBuilder module that abstracts out some Active Record calls:

module FeedBuilder
  module_function

  def for(user)
    Tweet.where(author: user.followed_users)
         .order(created_at: :desc)
         .limit(10)
  end
end

Extracting logic to a resolver allows us to create alternative resolvers and hot-swap them to compare results. Here’s a resolver that solves the N+1 problem by preloading all associations:

class Resolvers::FeedResolverPreload < Resolvers::BaseResolver
  type [Types::Tweet], null: false

  def resolve
    FeedBuilder.for(current_user).includes(:author) # Use AR eager loading magic
  end
end

This solution is most obvious, but not ideal: we will make an extra SQL query to preload users no matter what, even if we request just the tweets and don’t care about their authors (I know, it’s hard to imagine, but let’s say it’s for the anonymized data-mining operation).

Also, we have to define a list of associations on the top level (in Query type or inside resolvers that belong to it). It’s easy to forget to add a new association to the list when a new nested field appears deep inside the graph.

However, this approach is helpful when you know that client does ask for the author data most of the time (for instance, when you control the frontend code).

Solution #1: Lookaheads

While resolving a query, the GraphQL’s execution engine knows which data was requested, so it’s possible to find out what should be loaded at the runtime. The graphql-ruby gem comes with a handy Lookahead feature that can tell us in advance if a specific field was requested. Let’s try it out in a separate resolver:

class Resolvers::FeedResolverLookahead < Resolvers::BaseResolver
  type [Types::Tweet], null: false
  extras [:lookahead]

  def resolve(lookahead:)
    FeedBuilder.for(current_user)
               .merge(relation_with_includes(lookahead))
  end

  private

  def relation_with_includes(lookahead)
    # .selects?(:author) returns true when author field is requested
    return Tweet.all unless lookahead.selects?(:author)

    Tweet.includes(:author)
  end
end

In this case, we make the query in the users table only when the client asks for the author field. This approach works fine only in case associations are minimal and not nested. If we take a more complex data model where users have avatars and tweets have likes, then our resolver can get out of hand real quick:

class Resolvers::FeedResolverLookahead < Resolvers::BaseResolver
  type [Types::Tweet], null: false
  extras [:lookahead]

  def resolve(lookahead:)
    scope =
      Tweet.where(user: User.followed_by(current_user))
           .order(created_at: :desc)
           .limit(10)

    scope = with_author(scope, lookahead) if lookahead.selects?(:author)
    scope = with_liked_by(scope, lookahead) if lookahead.selects?(:liked_by)

    scope
  end

  private

  def with_author(scope, lookahead)
    if lookahead.selection(:author).selects?(:avatar)
      scope.includes(user: :avatar_attachment)
    else
      scope.includes(:user)
    end
  end

  def with_liked_by(scope, lookahead)
    if lookahead.selection(:liked_by).selects?(:user)
      if lookahead.selection(:liked_by).selection(:user).selects?(:avatar)
        scope.includes(likes: { user: :avatar_attachment })
      else
        scope.includes(likes: :user)
      end
    else
      scope.includes(:likes)
    end
  end
end

You’re right, that’s not elegant at all! What if there was a way to load associations only when they are accessed? Lazy preloading can help us!

Solution #2: Lazy preloading (by Evil Martians)

With some help from my Evil Martian colleagues, I’ve written a little gem called ar_lazy_preload that lets us fall back to the preloading solution but makes it smarter without any additional effort. It makes a single request to fetch all associated objects only after the association was accessed for the first time. Of course, it works outside of GraphQL examples too and can be really handy in REST APIs or while building server-rendered views. All you need is to add gem "ar_lazy_preload" to your Gemfile, run bundle install, and then you’ll be able to write your resolver like so:

class Resolvers::FeedResolverLazyPreload < Resolvers::BaseResolver
  type [Types::Tweet], null: false

  def resolve
    FeedBuilder.for(current_user).lazy_preload(:author)
  end
end

The gem is created with laziness in mind, so if you feel lazy even to type .lazy_preload all the time, you can enable it globally for all Active Record calls by adding a line of configuration:

ArLazyPreload.config.auto_preload = true

However, this approach has some downsides:

  • we finally brought the first external dependency;
  • we do not have much control over queries that are made and it will be hard to customize them;
  • if lazy preloading is not turned on, we still have to list all possible associations at the top level;
  • if one table is referenced from two places, we will make twice the database requests.

Solution #2-1: preload automagically using extensions

On one of the recent projects, we used an improved version of the previous approach.

We couldn’t configure lazy preloading by default due to the following corner case: imagine some model Account describing a user associated with the list of available services (Service). When querying a user, we would like to display the list of the custom services along with the default ones (like getting a welcome consultation). We don’t want to keep it in the database, but instead, we want to add the list of unsaved services before returning the response:

account = Account.find(id)

Service::DEFAULTS.each do |service_params|
  account.services.new(service_params)
end

If auto preloading was turned on for any query, this trick wouldn’t work; we would always only get the persisted data, without any possibility to turn this behavior off.

Another case where automatic preloading breaks things is nested mutations (when we try to update the record along with some of its associations).

So, we wanted a way to have lazy preloading applied out of the box to our queries, but with the possibility to turn it off when necessary. This (and much more) is possible using field extensions.

Without further ado, let’s look at the code:

# The extension checks the type of the returned object
# and adds the lazy preloading when applicable
class LazyPreloadExtension < GraphQL::Schema::FieldExtension
  def resolve(object:, arguments:, **rest)
    result = yield(object, arguments)

    # This check is necessary because in some cases we could return `nil`,
    # in some others we could also return `[]`,
    # or a hard-coded list instead of a relation.
    return result unless result.is_a?(ActiveRecord::Relation)

    result.ar_lazy_preload
  end
end

# This is how the extension is added to the field definition
class Types::BaseField < GraphQL::Schema::Field
  def initialize(name, type, *args, **kwargs, &block)
    # We only need it for list types like `[Service]`
    # The `preload: false` turns the lazy preloading off.
    preload = kwargs.delete(:preload) != false && type.is_a?(Array)
    super

    # Add the extension when necessary
    extension(LazyPreloadExtension)
  end
end

# And finally, don't forget to customize the field of the type
class Types::BaseObject < GraphQL::Schema::Object
  field_class BaseField
end

With this configuration, we don’t need any additional settings to turn preloading on, we only need to explicitly turn it off for some queries:

field :services, [Service], 'The list of available services', preload: false

With this solution, we still need the external dependency as before, but, for most cases, all of the previous concerns have been resolved.

What else can we do?

Solution #3: graphql-ruby lazy resolvers

The graphql-ruby gem that makes GraphQL possible in our Ruby apps comes bundles with a way to use lazy execution:

  • instead of returning data, you can return a special lazy object (this object should remember the data it replaced);
  • when a lazy value is returned from a resolver, the execution engine stops further processing of the current subtree;
  • when all non-lazy values are resolved, the execution engine asks the lazy object to resolve;
  • lazy object loads the data it needs to resolve and returns it for each lazy field.

It takes some time to wrap your head around this, so let’s implement a lazy resolver step by step. First of all, we can reuse the initial FeedResolver that is not aware of associations:

class Resolvers::FeedResolver < Resolvers::BaseResolver
  type [Types::Tweet], null: false

  def resolve
    FeedBuilder.for(current_user)
  end
end

Then, we should return a lazy object from our Tweet type. We need to pass the ID of the user and a query context because we will use it to store a list of IDs to load:

class Types::Tweet < Types::BaseObject
  field :content, String, null: false
  field :author, Types::User, null: false

  def author
    Resolvers::LazyUserResolver.new(context, object.user_id)
  end
end

Each time a new object is initialized, we add a pending user ID to the query context, and, when #user is called for the first time, we make a single database request to get all the users we need. After that, we can fill user data for all lazy fields. Here is how we can implement it:

class Resolvers::LazyUserResolver
  def initialize(context, user_id)
    @user_id = user_id

    @lazy_state = context[:lazy_user_resolver] ||= {
      user_ids: Set.new,
      users_cache: nil
    }

    @lazy_state[:user_ids] << user_id
  end

  def user
    users_cache[@user_id]
  end

  private

  def users_cache
    @lazy_state[:users_cache] ||=
      begin
        user_ids = @lazy_state[:user_ids].to_a
        @lazy_state[:user_ids].clear
        User.where(id: user_ids).index_by(&:id)
      end
  end
end

Wondering how the execution engine can tell the difference between regular and lazy objects? We should define lazy resolver in the schema:

class GraphqlSchema < GraphQL::Schema
  lazy_resolve(Resolvers::LazyUserResolver, :user)

  query Types::Query
end

It tells the execution engine to stop resolving users when the Resolvers::LazyUserResolver object is returned and only come back to it after all the other, non-lazy fields are resolved.

That works, but it’s quite a bit of boilerplate code that you might have to repeat often. Plus, the code can become quite convoluted when our lazy resolvers need to resolve other lazy objects. Fortunately, there exists a less verbose alternative.

Solution #4.1: Batch loading

The gem graphql-batch from Shopify uses the same lazy mechanism of graphql-ruby but hides the ugly boilerplate part. All we need to do is inherit from GraphQL::Batch::Loader and implement the perform method:

class RecordLoader < GraphQL::Batch::Loader
  def initialize(model)
    @model = model
  end

  def perform(ids)
    @model.where(id: ids).each { |record| fulfill(record.id, record) }
    ids.each { |id| fulfill(id, nil) unless fulfilled?(id) }
  end
end

This loader (taken from the examples directory in the official repo) expects a model class in the initializer (to decide where the data should be loaded from). #perform method is responsible for fetching data, #fulfill method is used to associate a key with the loaded data.

Batch loader usage is similar to the lazy version. We pass User to the initializer and ID of the user to load lazily (this ID will be used as a key to fetch the associated user):

class Types::Tweet < Types::BaseObject
  field :content, String, null: false
  field :author, Types::User, null: false

  def author
    RecordLoader.for(::User).load(object.author_id)
  end
end

As usual, we need to turn on lazy loading in our schema:

class GraphqlSchema < GraphQL::Schema
  query Types::Query
  use GraphQL::Batch
end

How does this work? When use GraphQL::Batch is added to the schema, Promise#sync method is registered to resolve lazily (it uses Promise.rb under the hood). When #load method is called on a class that inherits from GraphQL::Batch::Loader, it returns a Promise object—that is why the execution engine treats it as a lazy value.

This approach has a useful side-effect—you can do chain loading in the following way:

def product_image(id:)
  RecordLoader.for(Product).load(id).then do |product|
    RecordLoader.for(Image).load(product.image_id)
  end
end

Solution #4.2: Dataloader

A new release of graphql-ruby, 1.12, was shipped with Dataloader module. It implements the same approach as graphql-batch: instead of loading associations in-place, it collects keys and loads all data using a single query later. However, there is a significant difference in the implementation: while graphql-batch uses Promise.rb, Dataloader uses fibers, a Ruby primitive for lightweight cooperative concurrency.

Fibers are similar to threads (they can be paused and resumed), but the difference is that fiber does not start automatically: someone should start it explicitly. Sounds very similar to promises we discussed in the previous section! Here is a plan: we collect some keys to load, wait for everything else to resolve, run a fiber for each data source and keep resolving them until there are no pending fibers.

No more words, let’s try it out! As before, we need to define a class for loading records in batch:

class Sources::ActiveRecord < GraphQL::Dataloader::Source
  def initialize(model_class)
    @model_class = model_class
  end

  def fetch(ids)
    records = @model_class.where(id: ids).index_by(&:id)
    records.slice(*ids).values
  end
end

Then, we should change our field resolver to use dataloader:

class Types::Tweet < Types::BaseObject
  field :content, String, null: false
  field :author, Types::User, null: false

  def author
    dataloader.with(::Sources::ActiveRecord, ::User).load(object.author_id)
  end
end

Finally, we should change the schema to use dataloader:

class GraphqlSchema < GraphQL::Schema
  query Types::Query
  use GraphQL::Dataloader
end

Heads up! As mentioned in docs, this feature is experimental and the API is a subject to change.

Solution #5: Better schema design

But even with all the advanced techniques we described above, it is still possible to end up with N+1. Imagine that we are adding an admin panel where you can see a list of users. When a user is selected, a user profile pops up, and you can see a list of their followers. In GraphQL world, where data should be accessed from the place it belongs to, we could do something like this:

class Types::User < Types::BaseObject
  field :nickname, String, null: false
  field :followers, [User], null: false do
    argument :limit, Integer, required: true, default_value: 2
    argument :cursor, Integer, required: false
  end

  def followers(limit:, cursor: nil)
    scope = object.followers.order(id: :desc).limit(limit)
    scope = scope.where("id < cursor", cursor) if cursor
    scope
  end
end

class Types::Query < Types::BaseObject
  field :users, [User], null: false
  field :user, User, null: true do
    argument :user_id, ID, required: true
  end

  def users
    ::User.all
  end

  def user(user_id:)
    ::User.find(user_id)
  end
end

The list of users can be fetched using the following query:

query GetUsers($limit: Int) {
  users(limit: $limit) {
    nickname
  }
}

A list of users who follow a specific user can be loaded like so:

query GetUser($userId: ID, $followersLimit: Int, $followersCursor: ID) {
  user(userId: $userId) {
    followers(limit: $limit, cursor: $followersCursor) {
      nickname
    }
  }
}

The problem appears when someone tries to load a list of users with their followers in the same query:

query GetUsersWithFollowers(
  $limit: Int
  $followersLimit: Int
  $followersCursor: ID
) {
  users(limit: $limit) {
    nickname
    followers(limit: $limit, cursor: $followersCursor) {
      nickname
    }
  }
}

In this case, we cannot get rid of N+1 at all: we have to make a database call for each user because of cursor pagination. To handle such a case, we could to use the less elegant solution and move pagination to the top level:

class Types::Query < Types::BaseObject
  field :users, [User], null: false

  field :user, User, null: true do
    argument :user_id, ID, required: true
  end

  field :user_followers, [User], null: false do
    argument :limit, Integer, required: true, default_value: 2
    argument :cursor, Integer, required: false
  end

  def users
    ::User.all
  end

  def user(user_id:)
    ::User.find(user_id)
  end

  def user_followers(user_id:, limit:, cursor: nil)
    scope = UserConnection.where(user_id: user_id).order(user_id: :desc).limit(limit)
    scope = scope.where("user_id < cursor", cursor) if cursor
    scope
  end
end

This design still makes it possible to load users and their followers, but it turns out that we move from N+1 on the server side to N+1 HTTP requests. The solution looks fine, but hey, we love GraphQL for its logical schema structure! We want to fetch followers from the User type!

No problem. We can to restrict fetching the followers field when multiple users are requested. Let’s return an error when it happens:

class Types::Query < Types::BaseObject
  field :users, [User], null: false, extras: [:lookahead]
  field :user, User, null: true do
    argument :user_id, ID, required: true
  end

  def users(lookahead:)
    if lookahead.selects?(:followers)
      raise GraphQL::ExecutionError, "followers can be accessed in singular association only"
    end

    ::User.all
  end

  def user(user_id:)
    ::User.find(user_id)
  end
end

With this schema, it’s still possible to fetch followers of a singular user, and we have completely prevented the unwanted scenario. Don’t forget to mention it in the docs!

That’s it! You’ve made it to the end of our little guide, and now you have at least six different approaches to try out in your Ruby-GrapgQL code to make your application N+1 free.

Don’t forget to check out other articles on GraphQL and N+1 problem in our blog: from the beginner-friendly code-along tutorial on building a Rails GraphQL application with React frontend in three parts (start here) to the more specific use-cases of using GraphQL with Active Storage Direct Upload, dealing with persisted queries coming from Apollo, and reporting non-nullable violations in graphql-ruby.

We also have a couple of gems to make dealing with N+1 easier in “classic” Rails apps and a couple of articles to go along with them: Squash N+1 queries early with n_plus_one_control test matchers for Ruby and Rails and Fighting the Hydra of N+1 queries.

Over the past few years, our team has invested a lot of effort, including building open source, for making GraphQL a first-class citizen in Rails applications. If you think of introducing a GraphQL API in your Ruby backend—feel free to give us a shout.

Changelog

1.2.0 (2023-07-25)

  • Solution #2-1: preload automagically using extensions section

1.1.0 (2021-02-24)

  • Dataloader section

Join our email newsletter

Get all the new posts delivered directly to your inbox. Unsubscribe anytime.