How we load data at Fullscript
Author

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 < BaseLoader2 class << self3 def loader_key_for(model, column:, scope: nil)4 [self, model, column, scope.try(:to_sql)]5 end6 end7 ...8end910class ProductType < GraphQL::Schema::Object11 field :image, ImageType do12 argument :size, String, required: true13 end1415 def image(size:)16 scope = Image.where(size: size)17 ForeignKeyLoader.for(Image, column: :product_id, scope: scope).load(object.id)18 end19end
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 version2module Graphql3 module LoaderHelper4 def lazy_load(object, association, scope: nil)5 model = object.class6 reflection = model.reflect_on_association(association)7 case reflection8 when ActiveRecord::Reflection::BelongsToReflection9 Loaders::RecordLoader.for(reflection.klass, column: reflection.foreign_key, scope: scope).load(object.public_send(reflection.foreign_key))10 when ActiveRecord::Reflection::HasManyReflection11 Loaders::ForeignKeyLoader.for(reflection.klass, column: reflection.foreign_key, scope: scope).load(object.public_send(reflection.active_record_primary_key))12 when ... # more cases13 end14 end15 end16end
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::Object2 field :image, ImageType do3 argument :size, String, required: true4 end5 def image(size:)6 scope = Image.where(size: size)7 lazy_load(object, :image, scope: scope)8 end9end
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::Object2 field :total_savings, Float3 def total_savings4 SavingsCalculator.call(order: object)5 end6end78class SavingsCalculator9 def call(order:)10 order.line_items.sum do |line_item|11 line_item.applied_discounts.sum do |discount|12 discount.amount13 end14 end15 end16end
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:
- Inject GraphQL loaders into the service object. We would like to avoid this because it makes the service difficult to use elsewhere.
- 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.
- 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 AutoEagerLoader2 ...3 def wrap(object, &block)4 aggregate(object) do |objects|5 auto_include_context = Goldiloader::AutoIncludeContext.new6 auto_include_context.register_models(objects.flatten.compact)7 objects.flatten.compact.each do |obj|8 obj.auto_include_context = auto_include_context9 end10 end.then do |obj|11 Goldiloader.enabled do12 block.call(obj)13 end14 end15 end16end
The loader works in four steps:
- It aggregates the different records that are used in a GraphQL field by hooking into the load method of the BatchLoader.
- 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.
- It enables Goldiloader locally since it isn’t enabled globally in our application.
- 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 Graphql2 module LoaderHelper3 def auto_eager_load(object)4 key = caller.grep(/_type.rb/).first || caller[0] # automated unique identifier5 Loaders::AutoEagerLoader.for(key).wrap(object) do |wrapped_object|6 yield wrapped_object7 end8 end9 end10end1112class OrderType < GraphQL::Schema::Object13 field :total_savings, Float14 def total_savings15 auto_eager_load(object) do |order|16 SavingsCalculator.call(order: order)17 end18 end19end
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 Graphql2 module LoaderHelper3 def lazy_load(object, association, scope: nil)4 model = object.class5 reflection = model.reflect_on_association(association)6 case reflection7 when ActiveRecord::Reflection::BelongsToReflection8 klass, options =9 if reflection.polymorphic?10 [object.public_send(reflection.foreign_type).constantize, scope: scope]11 else12 [reflection.klass, column: reflection.join_primary_key, scope: scope]13 end14 Loaders::RecordLoader.for(klass, **options)15 .load(object.public_send(reflection.foreign_key))16 when ActiveRecord::Reflection::HasManyReflection17 scope ||= reflection.klass.all18 if reflection.type19 scope = scope.where(reflection.type => model.polymorphic_name)20 end21 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::ThroughReflection24 Loaders::ManyToManyLoader.for(model, association_name: association, scope: scope)25 .load(object.id)26 when ActiveRecord::Reflection::HasOneReflection27 scope ||= reflection.klass.all28 if reflection.type29 scope = scope.where(reflection.type => model.polymorphic_name)30 end31 if reflection.scope32 scope = scope.instance_eval(&reflection.scope)33 end34 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.first37 end38 else39 raise "Relation of type #{reflection.class} is not currently supported."40 end41 end4243 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_object48 else49 wrapped_object50 end51 end52 end53 end54end5556# This loader should only be used through the `auto_eager_load` helper57module Loaders58 class AutoEagerLoader < GraphQL::Batch::Loader59 def initialize(record)60 @record = record61 end6263 def aggregate(key, &block)64 @perform_callback = block65 load(key)66 end6768 def wrap(object, &block)69 aggregate(object) do |objects|70 auto_include_context = Goldiloader::AutoIncludeContext.new71 auto_include_context.register_models(objects.flatten.compact)72 objects.flatten.compact.each do |obj|73 obj.auto_include_context = auto_include_context74 end75 end.then do |obj|76 Goldiloader.enabled do77 block.call(obj)78 end79 end80 end8182 def perform(objects)83 @perform_callback.call(objects)84 objects.each do |object|85 fulfill(object, object)86 end87 end88 end89end
Share this post
Related Posts

Upgrading to Ruby 3 in small steps
Use a test ratchet to fix all the Ruby 3 kwarg errors and stop the bleeding

Upgrading Ruby and Rails at Fullscript
With Rails 7 being released at the end of last year, today I want to talk about our process for upgrading to Ruby 2.7 and Rails 6.1, and…