Contents

You don't need GraphQL

Introduction

If you’ve built Rails applications in the last few years, you’ve probably felt the pressure to adopt GraphQL. The sales pitch is compelling: a more flexible API, happier frontend developers, and an end to the dreaded over-fetching of data. Companies like GitHub and Shopify have embraced it, and the ecosystem of tools keeps growing. It’s easy to feel like you’re falling behind if you haven’t jumped on the GraphQL bandwagon.

But here’s the thing: while GraphQL can be a powerful tool for certain applications, it’s become a default choice without enough critical examination of its trade-offs. After spending several years building and maintaining Rails applications both with and without GraphQL, I’ve found that its carrying costs often outweigh its benefits for typical Rails applications.

This isn’t just about the initial learning curve or setup time. It’s about the ongoing maintenance burden, the hidden complexity costs, and the often-overlooked strengths of Rails’ conventional approach to API design. In many cases, the problems that GraphQL promises to solve can be addressed more simply using Rails’ built-in tools and well-established patterns.

Let’s explore why GraphQL might be more complexity than your Rails application needs, and how to make this evaluation for your own projects.

Why GraphQL seems attractive

Let’s be honest: GraphQL’s appeal is far from superficial. When you first see a well-designed GraphQL API in action, it feels like magic. Imagine you’re building a dashboard that displays user profiles with their recent orders and reviews. With a traditional REST API, you might need three or four separate endpoints, carefully orchestrated on the frontend. With GraphQL, you simply write a query that exactly matches your data needs:

query {
  user(id: 123) {
    name
    recentOrders(last: 5) {
      amount
      status
    }
    reviews(last: 3) {
      rating
      content
    }
  }
}

This kind of flexibility makes frontend developers extraordinarily happy. They get precisely the data they need, no more and no less. No more wrangling multiple API endpoints or dealing with over-fetched data. No more adding backend endpoints every time the frontend needs a slightly different data shape.

The benefits seem to stack up quickly:

First, there’s the elegant solution to the chronic problem of over-fetching. Instead of receiving every field from your User model when you only need the name, you get exactly what you asked for. In a world where mobile data usage matters, this can seem like a compelling advantage.

Then there’s the development workflow. Frontend teams can work more independently, experimenting with different data requirements without needing constant backend changes. They can iterate faster, and the self-documenting nature of GraphQL schemas means they always know exactly what data is available.

The single endpoint approach also feels cleaner than maintaining dozens of REST endpoints. Rather than debating whether something should be its own endpoint or included in an existing one, you can just add it to your schema and let clients decide when to request it.

For teams juggling multiple frontend applications – perhaps a web app, a mobile app, and a partner API – GraphQL’s flexibility seems particularly appealing. Each client can request its own specific data shape without requiring custom endpoints.

These benefits are real, and they’ve driven GraphQL’s adoption at companies dealing with complex data requirements and multiple client applications. But as we’ll explore in the next section, implementing and maintaining these capabilities comes with significant costs that aren’t immediately obvious from the demos and documentation.

The real costs of GraphQL implementation

While GraphQL’s benefits shine in demos, its costs become apparent once you start implementing it in a real Rails application. Let’s look at what actually happens when you add GraphQL to your typical Rails app.

First, there’s the initial setup. Even with helpful gems like graphql-ruby, you’re looking at significant work to get started. Every model that needs to be exposed requires a new type definition:

module Types
  class UserType < Types::BaseObject
    field :id, ID, null: false
    field :name, String, null: false
    field :email, String, null: false
    field :orders, [Types::OrderType], null: false
    field :reviews, [Types::ReviewType], null: false
    
    def orders
      BatchLoader::GraphQL.for(object.id)
        .batch do |user_ids, loader|
          Order.where(user_id: user_ids).group_by(&:user_id)
            .each { |user_id, orders| loader.call(user_id, orders) }
      end
    end
  end
end

See that orders method? That’s handling N+1 queries, one of the first problems you’ll encounter. While Active Record makes it easy to includes(:orders) in a regular Rails controller, with GraphQL you’ll need to implement batch loading for every relationship. Tools like batch-loader help, but they add another layer of complexity to your codebase.

Authorization becomes more complex too. Instead of handling permissions in your controllers, you now need to think about field-level authorization:

module Types
  class UserType < Types::BaseObject
    field :email, String, null: false do
      authorize! :admin
    end
    
    def self.authorize!(role)
      raise GraphQL::UnauthorizedError unless context[:current_user]&.has_role?(role)
    end
  end
end

But the real maintenance burden comes from keeping your schema in sync with your models. Add a field to your User model? You’ll need to update the UserType. Add a new relationship? That’s a new type definition and possibly new batch loading code. What used to be handled automatically by Active Record now requires explicit schema updates.

Testing becomes more involved as well. Instead of testing REST endpoints that map cleanly to controller actions, you’re now testing queries and mutations that can touch multiple parts of your schema. A single query might need to verify authorization, resolve relationships, and handle errors across several types:

RSpec.describe "Users Query" do
  let(:query) do
    <<~GRAPHQL
      query($id: ID!) {
        user(id: $id) {
          name
          email
          orders {
            amount
          }
        }
      }
    GRAPHQL
  end

  it "returns user data with orders" do
    user = create(:user)
    create_list(:order, 3, user: user)
    
    result = MyAppSchema.execute(
      query,
      variables: { id: user.id },
      context: { current_user: create(:admin_user) }
    )

    # Now check the deeply nested response structure
    expect(result.dig("data", "user", "orders")).to be_present
    expect(result.dig("data", "user", "orders").length).to eq(3)
  end
end

Performance monitoring becomes trickier too. Instead of clear controller actions that map to specific business operations, you’re dealing with arbitrary queries that can access your data in countless ways. Tools like Scout or New Relic need extra configuration to make sense of GraphQL operations, and debugging production issues often requires more context to understand exactly what data was being requested.

The schema documentation, while automatic, needs constant attention. As your schema grows, keeping the documentation clear and useful becomes its own maintenance task. Unlike Rails’ REST conventions, which are widely understood, GraphQL schemas need to be carefully documented to be useful to client developers.

These costs compound over time. Each new feature adds complexity to your schema, each new relationship needs careful performance consideration, and each new team member needs to understand not just Rails conventions, but your specific GraphQL implementation choices.

Rails’ built-in solutions are often sufficient

One of Rails’ greatest strengths has always been its sensible defaults and conventional solutions. While these might seem boring compared to GraphQL’s flexibility, they often solve the same problems with significantly less complexity.

Let’s tackle the common problems that drive teams to GraphQL, and look at how Rails already solves them:

“But we need to avoid N+1 queries!”

Rails has had a solution for this since the beginning. Active Record’s eager loading is both powerful and easy to use:

def index
  users = User.includes(:orders, :reviews)
    .where(active: true)
    .limit(10)
  
  render json: users
end

“But we need flexible response shapes!”

Rails serializers have you covered. Using a gem like active_model_serializers or even just plain Ruby objects, you can create multiple representations of your data:

class UserSerializer < ActiveModel::Serializer
  attributes :id, :name
  
  attribute :full_data, if: :admin?
  has_many :recent_orders
  has_many :recent_reviews
  
  def recent_orders
    object.orders.last(5)
  end
  
  def admin?
    current_user.admin?
  end
end

“But we need to combine multiple resources!”

Rails controllers can handle this elegantly:

def dashboard
  user = User.includes(:orders, :reviews).find(params[:id])
  
  render json: {
    user: UserSerializer.new(user),
    recent_activity: ActivitySerializer.new(user.recent_activity),
    account_summary: AccountSerializer.new(user.account)
  }
end

“But what about caching?”

Rails’ caching is battle-tested and works great with JSON endpoints:

class OrdersController < ApplicationController
  def index
    orders = Rails.cache.fetch(["orders", params[:page]], expires_in: 1.hour) do
      Order.page(params[:page]).to_json
    end
    
    render json: orders
  end
end

“But we need versioning!”

Rails has conventional patterns for API versioning that are well understood:

module Api
  module V1
    class UsersController < ApplicationController
      # V1 implementation
    end
  end
  
  module V2
    class UsersController < ApplicationController
      # V2 implementation with new fields
    end
  end
end

“But our frontend needs different data shapes for different screens!”

Instead of one massive GraphQL schema, consider scope-specific endpoints that map to your UI needs:

module Api
  class UserProfileController < ApplicationController
    def show
      user = User.includes(:profile_data).find(params[:id])
      render json: UserProfileSerializer.new(user)
    end
  end
  
  class UserDashboardController < ApplicationController
    def show
      user = User.includes(:dashboard_data).find(params[:id])
      render json: UserDashboardSerializer.new(user)
    end
  end
end

The beauty of these Rails solutions isn’t just their simplicity – it’s that they compose well together and follow patterns that any Rails developer can understand. They leverage Rails’ performance optimizations, work seamlessly with the asset pipeline and caching, and integrate naturally with the rest of your Rails tooling.

Most importantly, these solutions scale with your application’s actual needs. Need more complex data fetching? Add a query object. Need more sophisticated caching? Rails’ caching framework can handle it. Need to optimize payload size? Use jbuilder or custom serializers to send exactly what you need.

Hidden operational costs

While we’ve discussed the technical implementation costs of GraphQL, there’s a whole category of operational costs that often go unconsidered until they hit your team in production. Let’s explore these hidden costs that can significantly impact your team’s velocity and operational efficiency.

The learning curve tax

Every new Rails developer knows what users_controller#show does. But drop a new developer into a GraphQL codebase, and they’re facing questions like:

# What's the difference between these again?
field :total, Integer, null: true
field :total, Integer, null: false
field :total, Integer  # Wait, what's the default?

# Why isn't this working?
def orders
  object.orders  # Oops, N+1 query!
end

# How do I handle authorization here?
field :secret_data, String do
  # Is this the right place for auth?
end

Even experienced Rails developers need time to become productive with GraphQL. This learning curve isn’t just about syntax – it’s about understanding resolver patterns, type systems, schema design, and performance implications. While your senior developers might pick it up quickly, you’re adding complexity for every new hire and junior developer.

The debugging tax

When something goes wrong in a REST endpoint, the debugging process is straightforward: check the logs, find the controller action, and follow the stack trace. With GraphQL, debugging becomes more complex:

# Your logs might show this query:
{
  user(id: "123") {
    orders {
      items {
        product {
          price
        }
      }
    }
  }
}

# But where's the N+1 query coming from?
# Is it in the orders resolver?
# The items resolver?
# The product resolver?

Your APM tools like New Relic or Scout need additional configuration to make sense of GraphQL operations. Stack traces become less helpful because the entry point is always your GraphQL engine. And good luck trying to quickly reproduce an issue when you need to reconstruct the exact query that caused it.

The documentation tax

With REST, your API documentation often follows naturally from your controller actions and serializers. With GraphQL, maintaining clear documentation becomes a constant task:

module Types
  class OrderType < Types::BaseObject
    field :status, String, 
      null: false,
      description: "Order status (pending, processing, shipped, delivered)"
    
    field :total, Integer,
      null: false,
      description: "Order total in cents"
      
    # Multiply this by every field in your schema...
  end
end

While GraphQL is self-documenting in theory, in practice you need to maintain descriptions, deprecation notices, and usage examples. This documentation needs to be kept in sync with your implementation, and it needs to be detailed enough for client developers to use effectively.

The testing tax

Testing complexity increases exponentially with GraphQL. Instead of testing discrete controller actions, you’re testing combinations of fields and resolvers:

# A simple REST controller test
RSpec.describe UsersController do
  it "returns user data" do
    get :show, params: { id: user.id }
    expect(response).to be_successful
    expect(json_response[:name]).to eq user.name
  end
end

# The equivalent GraphQL test
RSpec.describe Types::QueryType do
  let(:query) do
    <<~GQL
      query($id: ID!) {
        user(id: $id) {
          name
          orders {
            status
            items {
              product {
                name
                price
              }
            }
          }
        }
      }
    GQL
  end
  
  it "returns nested user data" do
    result = execute_query(query, variables: { id: user.id })
    # Now check the deeply nested response structure
    # Don't forget to test authorization at each level
    # And error handling
    # And N+1 query protection
  end
end

The production support tax

When production issues occur, the complexity of GraphQL makes them harder to diagnose and fix:

  • Performance problems are harder to isolate because a single query might touch dozens of resolvers
  • Error reporting becomes more complex because errors can occur at multiple levels of the query
  • Capacity planning is trickier because client queries can be arbitrarily complex
  • Query complexity limits and timeouts need careful tuning to prevent DOS vulnerabilities

Each of these costs might seem manageable in isolation, but they compound over time and across team size. A five-person team might handle these overhead costs, but as your team grows, these costs scale with each new developer, each new feature, and each new client application.

When GraphQL makes sense (and when it doesn’t)

After all this criticism of GraphQL, you might be wondering if there’s ever a right time to use it. The answer is yes – GraphQL can be the right choice, but the conditions need to justify its complexity.

When GraphQL might be worth the cost

You should consider GraphQL when your application has:

  • Multiple, significantly different clients: If you’re building separate web, mobile, and partner API experiences that each need very different data shapes, GraphQL’s flexibility becomes valuable. GitHub is a perfect example – they serve their web UI, mobile apps, and third-party integrations all from the same GraphQL API.

  • Complex, nested data requirements: If your frontend frequently needs to fetch deeply nested, interconnected data that would require multiple REST roundtrips, GraphQL can simplify this orchestration. Think social networks where you need user profiles, their posts, comments on those posts, and profiles of users who commented – all in one view.

  • A large, dedicated API team: When you have the resources to properly maintain, monitor, and optimize a GraphQL implementation, its benefits become more achievable. Companies like Shopify can justify GraphQL because they have teams dedicated to their API infrastructure.

When to skip GraphQL

Stick with Rails’ conventional REST API patterns when:

  • Your application is primarily CRUD: If most of your endpoints map cleanly to resource actions (create, read, update, delete), REST already handles this perfectly. Adding GraphQL would be overengineering.
# This is all you need for most cases
resources :orders do
  resources :line_items
  member do
    post :refund
    post :ship
  end
end
  • Your data shape is relatively stable: If your frontend needs aren’t constantly changing and you don’t have radically different client requirements, REST endpoints with well-designed serializers will serve you well.

  • You have a small to medium team: If you can’t dedicate significant engineering resources to GraphQL infrastructure, the maintenance burden will likely outweigh the benefits.

Making the decision

Before adopting GraphQL, ask yourself:

  1. Can Rails’ built-in tools solve our current problems?
  2. Do we have the engineering resources to properly implement and maintain GraphQL?
  3. Are our API requirements complex enough to justify the overhead?
  4. Have we measured the actual performance impact of our current REST implementation?

Remember, you can always start with a REST API and add GraphQL later if needed. Many teams have found success with a hybrid approach – using REST for simple CRUD operations and GraphQL for more complex data requirements.

Final thoughts

GraphQL isn’t bad technology – it’s just often misapplied. For many Rails applications, the conventional REST approach, combined with thoughtful use of Rails’ built-in tools, provides a simpler, more maintainable solution. Before jumping on the GraphQL bandwagon, make sure its benefits truly outweigh its considerable carrying costs for your specific use case.

🔍 In software development, simpler solutions tend to lead to happier teams and more maintainable codebases in the long run. Sometimes the “boring” solution is exactly what your application needs.