Fullscript Logo
Backend Development

How we load data at Fullscript

Author

Andrew Scullion - Profile Picture
Andrew Scullion

Date Published

Share this post

Here at Fullscript, we use GraphQL to serve data to the front end of our application. It’s a great way to build a flexible API that can be customized and consumed by a variety of clients. However it comes with some complexity surrounding how to load data efficiently without incurring N+1 queries. In this post, we share how we use GraphQL loaders (RecordLoader, ForeignKeyLoader, HasManyLoader) combined with a custom lazy_load helper to avoid N+1 queries in branches, and a custom AutoEagerLoader powered by Goldiloader to resolve complex fields.

The N+1 problem

As many of our readers know, a naive GraphQL implementation can quickly lead to a lot of N+1 queries, especially when dealing with nested data. Take, for example, a user loading their order history: they expect to see each completed order and for each order, the associated line items, product information, image URLs, etc. Without proper handling, this kind of query results in a separate database call for every line item, product, and image. We solve this problem using the graphql-batch gem and loaders.

GraphQL Loaders

We use a combination of GraphQL loaders to handle different ActiveRecord relations. Namely, we use a RecordLoader for belongs_to relations, a ForeignKeyLoader for has_many relations, and a HasManyLoader for has_many :through relations. Similar solutions are well documented elsewhere (See this pganalyze blog post, and this hashrocket blog post), so we won't go into the details here, but we'll point out some differences in our solution. Here's a simplified example from our implementation:

1class ForeignKeyLoader < BaseLoader
2 class << self
3 def loader_key_for(model, column:, scope: nil)
4 [self, model, column, scope.try(:to_sql)]
5 end
6 end
7 ...
8end
9
10class ProductType < GraphQL::Schema::Object
11 field :image, ImageType do
12 argument :size, String, required: true
13 end
14
15 def image(size:)
16 scope = Image.where(size: size)
17 ForeignKeyLoader.for(Image, column: :product_id, scope: scope).load(object.id)
18 end
19end

The addition of the scope argument allows us to call the loader multiple times in a single requeset with different scopes without the results interfering with each other.

Helper methods

While using dedicated loaders, such as RecordLoader and ForeignKeyLoader, solves the N+1 problem, choosing which one to use can be tedious for experienced developers and confusing for new developers. More importantly, it's completely deterministic. As we mentioned above, belongs_to relations are always loaded with a RecordLoader, and has_many relations are always loaded with a ForeignKeyLoader. To avoid some boilerplate code in our application, we've created a helper method that always picks the right loader for a given relation. Here's an abbreviated version of our helper. The full implementation is in the appendix.

1# abbreviated version
2module Graphql
3 module LoaderHelper
4 def lazy_load(object, association, scope: nil)
5 model = object.class
6 reflection = model.reflect_on_association(association)
7 case reflection
8 when ActiveRecord::Reflection::BelongsToReflection
9 Loaders::RecordLoader.for(reflection.klass, column: reflection.foreign_key, scope: scope).load(object.public_send(reflection.foreign_key))
10 when ActiveRecord::Reflection::HasManyReflection
11 Loaders::ForeignKeyLoader.for(reflection.klass, column: reflection.foreign_key, scope: scope).load(object.public_send(reflection.active_record_primary_key))
12 when ... # more cases
13 end
14 end
15 end
16end

It’s nothing but a case statement that picks the correct loader for a given record and association using reflection. This simplifies a lot of our types to something like this:

1class ProductType < GraphQL::Schema::Object
2 field :image, ImageType do
3 argument :size, String, required: true
4 end
5 def image(size:)
6 scope = Image.where(size: size)
7 lazy_load(object, :image, scope: scope)
8 end
9end

Some of our GraphQL fields don’t map directly to an ActiveRecord relation, and require calling the loaders directly, but the lazy_load helper handles about 80% of our cases.

Complex fields

While most of our GraphQL fields map cleanly to a single ActiveRecord record and are handled by loaders like lazy_load, some fields are significantly more complex. These fields often rely on service objects, involve deeply nested associations, or require computed values. Take the example of calculating the total savings on an order:

1class OrderType < GraphQL::Schema::Object
2 field :total_savings, Float
3 def total_savings
4 SavingsCalculator.call(order: object)
5 end
6end
7
8class SavingsCalculator
9 def call(order:)
10 order.line_items.sum do |line_item|
11 line_item.applied_discounts.sum do |discount|
12 discount.amount
13 end
14 end
15 end
16end

At first glance, this looks straightforward. But under the hood, it accesses multiple nested associations: line_items and their applied_discounts. Without careful handling, this leads to a cascade of N+1 queries.

Why this is tricky

To prevent N+1s in this scenario, we’ve explored a few common approaches:

  1. Inject GraphQL loaders into the service object. We would like to avoid this because it makes the service difficult to use elsewhere.
  2. Reload the orders using the RecordLoader and the includes method with the correct relations. This can be a bit more efficient, but it requires us to reload the orders and dig into the service logic to know exactly what to preload.
  3. Use an aggregating batch loader and Rails’ AssociationPreloader to load the required fields. This avoid querying the same data twice, but still requires us to know which fields are needed upfront, making it a maintenance burden.

All of these approaches work but they have important tradeoffs, and recently we’ve been exploring a new approach.

A more flexible solution: AutoEagerLoader

To resolve complex fields in a performant way, we’ve added the Goldiloader gem to our project and created a custom loader called the AutoEagerLoader that uses it. Goldiloader is a gem that automatically eager-loads a relation for all of the ActiveRecord objects that were loaded together whenever the relation is accessed on the first record. We haven't enabled the library globally because it's too risky for an application of our size, but we've been able to apply it in parts of our GraphQL API to greatly simplify our code. Specifically, our AutoEagerLoader wraps ActiveRecord objects and enables Goldiloader locally to prevent N+1 queries in service calls. Here's a simplified version of the loader:

1class AutoEagerLoader
2 ...
3 def wrap(object, &block)
4 aggregate(object) do |objects|
5 auto_include_context = Goldiloader::AutoIncludeContext.new
6 auto_include_context.register_models(objects.flatten.compact)
7 objects.flatten.compact.each do |obj|
8 obj.auto_include_context = auto_include_context
9 end
10 end.then do |obj|
11 Goldiloader.enabled do
12 block.call(obj)
13 end
14 end
15 end
16end

The loader works in four steps:

  1. It aggregates the different records that are used in a GraphQL field by hooking into the load method of the BatchLoader.
  2. It connects the ActiveRecord objects together through a Goldiloader context object. This lets Goldiloader preload associations across the correct records as soon as one is accessed and avoids N+1 queries.
  3. It enables Goldiloader locally since it isn’t enabled globally in our application.
  4. It forwards the “wrapped” ActiveRecord object to the original block. This is where developers can call a service object or any code that needs to load nested records without having to worry about N+1s.

We then wrap this loader in a simple helper:

1module Graphql
2 module LoaderHelper
3 def auto_eager_load(object)
4 key = caller.grep(/_type.rb/).first || caller[0] # automated unique identifier
5 Loaders::AutoEagerLoader.for(key).wrap(object) do |wrapped_object|
6 yield wrapped_object
7 end
8 end
9 end
10end
11
12class OrderType < GraphQL::Schema::Object
13 field :total_savings, Float
14 def total_savings
15 auto_eager_load(object) do |order|
16 SavingsCalculator.call(order: order)
17 end
18 end
19end

Limitations

Like ActiveRecord’s includes method, Goldiloader can't eager-load associations with custom conditions (e.g., where, order, limit). In those cases, we still resort to custom loading techniques. But for most complex fields, AutoEagerLoader provides the simplicity of "just call your service" without paying the N+1 penalty.

Recap

In short, with the combination of GraphQL loaders and helpers that we’ve built, we can handle the vast majority of the N+1 queries in our GraphQL API using lazy_load for loading nested records in our GraphQL schema and auto_eager_load for evaluating complex fields. Aggregated statistics is still an open problem, but we're confident that we'll find a neat solution for this, too.

Appendix

Here’s the full implementation of our LoaderHelper module and the AutoEagerLoader class.

1module Graphql
2 module LoaderHelper
3 def lazy_load(object, association, scope: nil)
4 model = object.class
5 reflection = model.reflect_on_association(association)
6 case reflection
7 when ActiveRecord::Reflection::BelongsToReflection
8 klass, options =
9 if reflection.polymorphic?
10 [object.public_send(reflection.foreign_type).constantize, scope: scope]
11 else
12 [reflection.klass, column: reflection.join_primary_key, scope: scope]
13 end
14 Loaders::RecordLoader.for(klass, **options)
15 .load(object.public_send(reflection.foreign_key))
16 when ActiveRecord::Reflection::HasManyReflection
17 scope ||= reflection.klass.all
18 if reflection.type
19 scope = scope.where(reflection.type => model.polymorphic_name)
20 end
21 Loaders::ForeignKeyLoader.for(reflection.klass, column: reflection.foreign_key, scope: scope)
22 .load(object.public_send(reflection.active_record_primary_key))
23 when ActiveRecord::Reflection::HasAndBelongsToManyReflection, ActiveRecord::Reflection::ThroughReflection
24 Loaders::ManyToManyLoader.for(model, association_name: association, scope: scope)
25 .load(object.id)
26 when ActiveRecord::Reflection::HasOneReflection
27 scope ||= reflection.klass.all
28 if reflection.type
29 scope = scope.where(reflection.type => model.polymorphic_name)
30 end
31 if reflection.scope
32 scope = scope.instance_eval(&reflection.scope)
33 end
34 Loaders::ForeignKeyLoader.for(reflection.klass, column: reflection.foreign_key, scope: scope)
35 .load(object.public_send(reflection.active_record_primary_key)).then do |records|
36 records.first
37 end
38 else
39 raise "Relation of type #{reflection.class} is not currently supported."
40 end
41 end
42
43 def auto_eager_load(object)
44 key = caller.grep(/_type.rb/).first || caller[0]
45 Loaders::AutoEagerLoader.for(key).wrap(object) do |wrapped_object|
46 if block_given?
47 yield wrapped_object
48 else
49 wrapped_object
50 end
51 end
52 end
53 end
54end
55
56# This loader should only be used through the `auto_eager_load` helper
57module Loaders
58 class AutoEagerLoader < GraphQL::Batch::Loader
59 def initialize(record)
60 @record = record
61 end
62
63 def aggregate(key, &block)
64 @perform_callback = block
65 load(key)
66 end
67
68 def wrap(object, &block)
69 aggregate(object) do |objects|
70 auto_include_context = Goldiloader::AutoIncludeContext.new
71 auto_include_context.register_models(objects.flatten.compact)
72 objects.flatten.compact.each do |obj|
73 obj.auto_include_context = auto_include_context
74 end
75 end.then do |obj|
76 Goldiloader.enabled do
77 block.call(obj)
78 end
79 end
80 end
81
82 def perform(objects)
83 @perform_callback.call(objects)
84 objects.each do |object|
85 fulfill(object, object)
86 end
87 end
88 end
89end

Share this post