Fixing N+1 Queries in GraphQL

Martin Lipták
4 min readFeb 27, 2020

GraphQL makes building APIs a breeze. Just define types, resolvers and mutations and GraphQL takes care of the REST 😏 ActiveRecord, the most popular Ruby ORM, is famous for its N+1 queries problem unless you correctly preload associations. Can we make ActiveRecord work with GraphQL efficiently without annoying boilerplate?

Looks familiar?

Problem

Imagine the following GraphQL schema.

module Types
class ArticleType < Types::BaseObject
field :id, GraphQL::Types::ID, null: false
field :title, String, null: false
field :description, String, null: false
# Article belongs to author so Article#author
# can resolve the field.
field :author, Types::AuthorType, null: false
# Article has many and belongs to many tags through
# a joining table so Article#tags can resolve the field.
field :tags, [Types::TagType], null: false
end
end
module Types
class AuthorType < Types::BaseObject
field :id, GraphQL::Types::ID, null: false
field :name, String, null: false
field :avatarUrl, String, null: false
end
end
module Types
class TagType < Types::BaseObject
field :id, GraphQL::Types::ID, null: false
field :name, String, null: false
end
end

What happens if we run the following query?

{
articles {
id
title
description
author {
id
name
avatarUrl
}
tags {
id
name
}
}
}

You're right! We've got an N+1 queries problem. For each article, we have to load its author and all its tags separately.

Solution

Update: I’ve tried batch-loader on a recent project and liked it more than graphql-batch. I didn’t bother creating a universal loader, but used the loader directly in GraphQL types and gradually extracted repetitive code into base class.

Batching effectively solves this problem by putting children queries off until all parent records have been loaded and we know their id's. The most popular gems for batching are graphql-batch and batch-loader. I use graphql-batch, but the same strategies can be implemented with the other gem too.

class ApplicationSchema < GraphQL::Schema
query QueryType
mutation MutationType

# We have to add the middleware that performs batching.
use GraphQL::Batch
end
# A basic loader per `graphql-batch` docs.
class RecordLoader < GraphQL::Batch::Loader
def initialize(model)
@model = model
end

def perform(ids)
@model.where(id: ids).each do|record|
fulfill(record.id, record)
end
ids.each { |id| fulfill(id, nil) unless fulfilled?(id) }
end
end
module Types
class ArticleType < Types::BaseObject
...
# And now, voilà...
field :author, Types::AuthorType, null: false
def author
RecordLoader.for(Author).load(object.author_id)
end
# RecordLoader can only load belongs to associations.
field :tags, [Types::TagType], null: false
end
end

After all articles have been loaded, author id's are passed to RecordLoader which loads all associated authors in a single query. What about tags? We would probably need a loader specific for this has many and belongs to many association. Unless we can come up with some magic…

Ufff, I hope you've been able to follow all this. We're really just making a smart loader that can load any association. Well, as long as it has inverse associations.

class Tag < ActiveRecord::Base
has_many :article_tags
has_many :articles, through: :article_tags, inverse_of: :tags
end
class Article < ActiveRecord::Base
has_many :article_tags
has_many :tags, through: :article_tags, inverse_of: :articles
end

And now we can do this…

module Types
class ArticleType < Types::BaseObject
...
field :author, Types::AuthorType, null: false
def author
AssociationLoader
.for(context.user, Article, :author).load(object.id)
end
field :all_tags, [Types::TagType], null: false
def all_tags
AssociationLoader
.for(context.user, Article, :tags).load(object.id)
end
field :visible_tags, Integer, null: false
def visible_tags
AssociationLoader.for(context.user, Article, :tags, {
apply: [:without_deleted]
}).load(object.id)
end
field :tag_count, Integer, null: false
def tag_count
AssociationLoader.for(context.user, Article, :tags, {
aggregate: :count
}).load(object.id)
end
end
end

Ruby programmers don't like boilerplate so let's improve it.

module Types
class BaseObject < GraphQL::Schema::Object
...
def self.association_field(
field_name,
field_type,
association: {},
**field_args,
)
field field_name, field_type, **field_args
association_name = association[:name] || field_name
association_args = association.except(:name)
define_method field_name do
# Call loader with the current object.
Loaders::AssociationLoader
.for(context.user, object.class,
association_name, **association_args)
.load(object.id)
end
end
...
end
end
module Types
class ArticleType < Types::BaseObject
...
association_field :author, Types::AuthorType, null: false association_field :all_tags, [Types::TagType], {
null: false,
association: {
name: :tags
}
}
association_field :visible_tags, [Types::TagType], {
null: false,
association: {
name: :tags,
apply: [:without_deleted]
}
}
association_field :tag_count, Integer, {
null: false,
association: {
aggregate: :count
}
}
end

If you now run the query for articles, all their associations will be loaded effectively. Power of Ruby could be used even further to infer type names and nullability, but sometimes it's better to keep things more explicit.

Conclusion

I've been successfully using this approach in production for over a year now without a need for a single custom association-specific loader. The only downside is having to add inverse associations to all target models even when they're only needed because of AssociationLoader. Also, if you need to load, for example, top 3 tags per article, you might want to check out window key loader. How do you load ActiveRecord associations for GraphQL? Do you use a similar trick?

Note: Check out my previous article on why I use GraphQL.

--

--

Martin Lipták

I'm a software developer who loves creating applications that improve people's lives. I also enjoy travelling, learning languages and meeting people.