Rails and GraphQL: Ruby on Rails lessons from production
When we moved from REST to GraphQL in a production e-commerce platform, we stopped fighting with versioning, over-fetching, and rigid endpoint structures. GraphQL let our frontend teams query exactly what they needed, while our Rails backend could evolve without breaking clients. We learned that GraphQL isn't a silver bullet—it's a different contract between client and server that demands thoughtful implementation, especially around types, resolvers, and authorization.
Why GraphQL matters in Rails
REST endpoints force a choice: return everything the client might want (over-fetching), or create dozens of specialized endpoints (API sprawl). GraphQL flips this. Clients ask for specific fields, and your Rails backend returns only what's requested. For a SaaS platform with mobile and web clients, this reduces bandwidth by 30–40% in typical workloads.
But flexibility comes with cost. You need to think about query performance differently. A simple REST endpoint to fetch a user with their posts is straightforward; a GraphQL query that fetches users with deeply nested comments, replies, and reaction counts can become a performance nightmare without proper batching and caching strategies.
Types and resolvers: The foundation
In graphql-ruby, a type is a Ruby class that describes a shape of data. A resolver is a method that fetches or computes that data. Let's say we're building a travel booking platform.
# app/graphql/types/trip_type.rb
module Types
class TripType < BaseObject
field :id, ID, null: false
field :destination, String, null: false
field :start_date, GraphQL::Types::ISO8601Date, null: false
field :end_date, GraphQL::Types::ISO8601Date, null: false
field :bookings, [BookingType], null: false
field :hotel_count, Integer, null: false
def hotel_count
object.bookings.where(booking_type: 'hotel').count
end
end
endEach field maps to a column or a method. The resolver runs when the client requests that field. Notice bookings—if the client asks for the trip with all its bookings, each booking fetches from the database. If the client asks for 100 trips and their bookings, that's a classic N+1 query.
In your mutation or query resolver, you return the model instance, and GraphQL calls field resolvers as needed:
# app/graphql/types/query_type.rb
module Types
class QueryType < BaseObject
field :trip, TripType, null: false do
argument :id, ID, required: true
end
def trip(id:)
Trip.find(id)
end
end
endThis is clean and readable. But scale it: the client asks for trips { bookings { user { name email } } }, and you're running dozens of queries. This is where batch loading enters the conversation.
Batch loading to avoid N+1
Batch loading (or dataloaders) is a GraphQL pattern that collects all the IDs you need to fetch, groups them, and runs a single query instead of many. graphql-ruby ships with GraphQL::Batch.
# app/graphql/loaders/user_loader.rb
module Loaders
class UserLoader < GraphQL::Batch::Loader
def load(user_id)
fulfill(user_id, User.find(user_id))
rescue ActiveRecord::RecordNotFound
fulfill(user_id, nil)
end
def cache_key(user_id)
user_id
end
end
end# app/graphql/types/booking_type.rb
module Types
class BookingType < BaseObject
field :id, ID, null: false
field :user, UserType, null: true
def user
Loaders::UserLoader.load(object.user_id)
end
end
endNow, when GraphQL resolves a query for 100 bookings and their users, it batches all user_id lookups into a single query: User.where(id: [1, 2, 3, ..., 100]). This transforms an N+1 query from 100+ queries to just a couple. In production ride-hailing systems, batch loaders reduced query counts by 80–90%.
You can also batch across associations:
# app/graphql/loaders/association_loader.rb
module Loaders
class AssociationLoader < GraphQL::Batch::Loader
def initialize(model, association)
@model = model
@association = association
end
def load(record_id)
fulfill(record_id, @model.find(record_id).send(@association))
rescue ActiveRecord::RecordNotFound
fulfill(record_id, nil)
end
def cache_key(record_id)
[@model, @association, record_id]
end
end
endThis generic loader works for has_many, has_one, and belongs_to`. Set it up once, reuse everywhere.
Authorization per field
REST APIs often authorize at the controller level: if you can access GET /users/123, you see everything. GraphQL lets you authorize at the field level. A user might see their own email and salary, but not a colleague's.
# app/graphql/types/user_type.rb
module Types
class UserType < BaseObject
field :id, ID, null: false
field :name, String, null: false
field :email, String, null: true do
guard ->(obj, args, ctx) { obj.id == ctx[:current_user]&.id }
end
field :salary, Integer, null: true do
guard ->(obj, args, ctx) { ctx[:current_user]&.admin? }
end
def email
object.email
end
def salary
object.salary
end
end
endThe guard runs before the resolver. If it returns falsy, the field resolves to null and an error is added to the response. Clients see a clear pattern: some fields exist in the schema but return null if not authorized.
For complex rules, extract guards into modules:
# app/graphql/guards/owner_or_admin.rb
module Guards
class OwnerOrAdmin
def self.call(obj, args, ctx)
obj.id == ctx[:current_user]&.id || ctx[:current_user]&.admin?
end
end
endfield :email, String, null: true, guard: Guards::OwnerOrAdminThis scales. You define authorization once and apply it to dozens of fields. In a CRM system, we used this to hide sensitive customer data from junior support staff while exposing it to supervisors—all enforced at the GraphQL layer.
Use when clients need flexibility
GraphQL shines when you have multiple clients (web, mobile, desktop) or when clients frequently ask for different data shapes. It's overkill for internal tools with a single client or simple, homogeneous data needs.
We adopted GraphQL on a trading platform where web and mobile clients had different screen real estate and data requirements. Web needed full order history; mobile needed just today's top 5. With REST, we'd create separate endpoints or return everything and let the client filter. With GraphQL, each client queries exactly what it displays.
That said, if your API is stable and your client is internal and monolithic, GraphQL adds complexity without clear wins. It's a tool for specific problems, not a universal upgrade.
Common mistakes
- Forgetting batching: Adding GraphQL without batch loaders is like adding indexes after your database is slow. N+1 queries multiply because each field resolver can spawn queries. Plan batching from day one.
- Resolving everything: Every field doesn't need a custom resolver. If a field maps directly to an attribute, GraphQL handles it automatically. Only write resolvers for computed fields, associations, and authorization checks.
- Inadequate error handling: REST errors are HTTP status codes. GraphQL returns 200 with errors in the response. Clients must parse the errors array. Define error types in your schema so clients know what they're handling.
- Exposing too much: Because GraphQL schemas are introspectable, clients can discover every query and field. If you expose a field in your schema, assume someone will query it. Don't rely on obscurity for security; use guards.
- Testing mutations poorly: GraphQL mutations can have side effects. Test them with the same rigor as controller actions. Mock loaders and authorization contexts in tests.
Key takeaways
- GraphQL in Rails excels when you need client flexibility: different clients requesting different data shapes from the same backend.
- Types and resolvers are your schema and logic. Keep them simple; let GraphQL's automatic field resolution handle direct attributes.
- Batch loading is not optional. Without it, GraphQL queries become query waterfalls. Implement batch loaders early, reuse them across resolvers.
- Field-level authorization is a GraphQL superpower. Define guards per field, extract common rules into modules, and test them as you would controller-level auth.
- GraphQL adds complexity: schema management, query cost analysis, and testing overhead. Use it when the flexibility payoff justifies that cost; don't use it as a default.






Comments
Loading comments…