Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 078 / 100

Rails and GraphQL: Ruby on Rails lessons from production

Flexible APIs with graphql-ruby.

Expona Admin6 min read

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.

ruby
# 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
end

Each 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:

ruby
# 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
end

This 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.

ruby
# 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
ruby
# 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
end

Now, 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:

ruby
# 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
end

This 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.

ruby
# 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
end

The 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:

ruby
# 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
end
ruby
field :email, String, null: true, guard: Guards::OwnerOrAdmin

This 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles