Aha! Develop is for healthy enterprise development teams — that use scrum, kanban, and SAFe frameworks


Learn more
2021-05-03

Automatically avoiding GraphQL N+1s

Some people, when faced with an API problem, think “I’ll use GraphQL!” And now they have N+1 problems.

N+1 problems occur when you want to find a deep tree of records and end up performing a SQL statement or API request for every record, instead of retrieving all records — or all records of a given type — at the same time.

In Rails, N+1 problems are simple to solve using includes:

features_with_comments = release.features.includes(comments: :created_by)

But you still have to remember to do it. At Aha!, we build complex reports and roadmaps. Even though we’re careful to avoid N+1 problems, accidental ones are still a major concern of ours.

Aha! Develop extensions

While creating Aha! Develop, our mission was to offer radical customization for developers so they can do their work in the way that’s best for them. This meant providing an extremely flexible API, making Aha! Develop extensions not only powerful, but easy to write. GraphQL is a perfect fit for this kind of client-driven functionality.

But this API flexibility comes with a cost: It’s hard to optimize queries when you don’t know in advance what the query will be. In order to avoid an explosion of queries when an extension fetches nested data (which will happen all the time!), it seemed like we would need to analyze the query and create a plan for executing it efficiently. That’s a lot of work, complex, and prone to mistakes.

This was a big problem. If we couldn’t provide a flexible API that performed well, we couldn’t provide it at all.

Looking into solutions

Despite the jokes, this is a common enough problem that there had to be some existing solutions. To provide our GraphQL API on the server side, we use graphql-ruby. This is an amazing library that feels like the best of Ruby — it has great defaults while being easy to extend and change. There are a few N+1-avoiding libraries that work well with it:

After some exploration, batch-loader seemed like the perfect solution. It was a little harder to get started with than something GraphQL-specific like graphql-batch. But it was small, flexible, and useful even outside of GraphQL. We’ve even considered using it to batch load requests to external APIs where that is supported.

Here’s what it looks like, from batch-loader’s README:

def load_posts(ids)
  Post.where(id: ids)
end

def load_user(post)
  BatchLoader.for(post.user_id).batch do |user_ids, loader|
    User.where(id: user_ids).each { |user| loader.call(user.id, user) }
  end
end

posts = load_posts([1, 2, 3])  #      Posts      SELECT * FROM posts WHERE id IN (1, 2, 3)
                               #      _ ↓ _
                               #    ↙   ↓   ↘
users = posts.map do |post|    #   BL   ↓    ↓
  load_user(post)              #   ↓    BL   ↓
end                            #   ↓    ↓    BL
                               #    ↘   ↓   ↙
                               #      ¯ ↓ ¯
puts users                     #      Users      SELECT * FROM users WHERE id IN (1, 2, 3)

It is a really small amount of code and does exactly what we want. But how could it be easily integrated with the GraphQL code?

Making it trivial

Knowing that multiple people would have to maintain the API over time, we wanted to make the right way to avoid N+1s obvious and take as little effort as possible. We also wanted to avoid the cost of preloading a field if we didn’t request it.

When developing new features, one thing we do at Aha! is define our ideal interface first and do what’s necessary behind that to provide the ideal. For this case, this is what we wanted to write in our GraphQL types:

module Types
  class FeatureType < Types::BaseObject
    field :requirements, [RequirementType], null: false, preload: :requirements
  end
end

All that’s necessary to preload the requirements when they’re fetched off of a feature is to add that preload: argument, which uses the same pattern as Rails' includes does.

Sometimes, when you dream up a clean interface, the implementation has to become more complex to support it. Thanks to graphql-ruby and batch-loader, that wasn’t the case here. This is all you need:

class Types::PreloadableField < Types::BaseField
  def initialize(*args, preload: nil, **kwargs, &block)
    @preloads = preload
    super(*args, **kwargs, &block)
  end

  def resolve(type, args, ctx)
    return super unless @preloads

    BatchLoader::GraphQL.for(type).batch(key: self) do |records, loader|
      ActiveRecord::Associations::Preloader.new.preload(records.map(&:object), @preloads)
      records.each { |r| loader.call(r, super(r, args, ctx)) }
    end
  end
end

Look how tiny it is! So what is this doing?

How it works

In graphql-ruby, a Field instance is created when you use the field method to define a field, like this from the example above:

field :requirements, [RequirementType], null: false, preload: :requirements

If you look at the PreloadableField implementation at the end of the last section, it uses that preload argument to add a little bit of behavior in the resolve method.

resolve is called on a field when GraphQL Ruby needs to get the data from that field. For example, it might be called on the requirements field when feature.requirements is called.

The type argument is the GraphQL type object that contains that field. For example, if a GraphQL FeatureType is preloading RequirementTypes, type will be an instance of FeatureType and type.object will be the Feature that FeatureType instance is wrapping.

This is where things get fun.

BatchLoader::GraphQL.for(type)...

You can think of BatchLoader::GraphQL.for(type) as adding type into a list. Remember, since type is an instance of a GraphQL type, this is like adding a FeatureType wrapping the Feature with let’s say ID 1 to that list.

Every time for is called, it adds its argument to that list. So if multiple features ask for their requirements, each feature will be added to that list.

BatchLoader::GraphQL.for(type).batch(key: self) do |records, loader| ...

batch associates the block that will eventually do the batch load with the list. The block is given two things: the final list of objects and loader, which is used to tie each set of results to the right object.

By default, batch-loader uses the source location of the block to group items together into the list. Since the block in this example is used for every field, the block will always have the same source location and that doesn’t work. Instead, key: self will use source location and the field definition instance (self) as the key, making sure that each field definition has its own list of items to batch load.

This returns a lazy/proxy object that will eventually act like the record you want.

ActiveRecord::Associations::Preloader.new.preload(records.map(&:object), @preloads)

When the records are finally needed, the block is given all of the FeatureTypes that were passed to for. Rails' ActiveRecord::Associations::Preloader does the preloading using the specified @preloads.

records.each { |r| loader.call(r, super(r, args, ctx)) }

Finally, loader.call links together the correct results with each call. For example, if r is Feature 1, loader.call needs to link all of Feature 1's requirements back to Feature 1 so that feature.requirements will return the correct set from then on. In loader.call, the first parameter is the same as the item passed into for and the second is what the return value should be. Here, the correct return value is the same as the default behavior (super) — this time, with the objects already loaded.

It seems like a lot but that’s all there is to it. And the same pattern can be used for preloading even more complex sets of objects in even more complex ways.


We started building Aha! Develop because we were unsatisfied with the other development tracking tools out there. We wanted something as flexible as the editors we use every day. Something that we, as developers, could make feel like our own. And to do that, we needed a powerful, flexible, fast API. Without it, it would have been impossible to offer the kind of radical customization we promise to developers. graphql-ruby + batch-loader + a small PreloadableField class made it easier than we ever would have expected and helped us dodge one of the most frequent GraphQL API stereotypes.

If you've also felt that dissatisfaction with other tools, give Aha! Develop a try. Our early access program is open now and there are a limited number of spots available — sign up quickly if you are interested. Learn more about Aha! Develop and how you can request access so your team can start using it: https://www.aha.io/develop/overview

Justin Weiss

Justin Weiss

Justin is a longtime Ruby on Rails developer, software writer, and open-source contributor. He is a principal software engineer at Aha! — the world’s #1 product development software. Previously, he led the research and development team at Avvo, where he helped people find the legal help they need.

Build what matters. Try Aha! free for 30 days.

Follow Aha!

Follow Justin