Rails with GraphQL

Rails with GraphQL cover image

A Rails API and a GraphQL schema solve different parts of the same problem. Rails gives the application a durable home for routing, database work, configuration, and authentication. GraphQL Ruby gives clients a typed way to ask for the fields they need.

This note started as a small setup for a Rails API backed by PostgreSQL. What still holds up is the division of work: Rails owns the application, while GraphQL describes the client-facing operations. The commands below take one query all the way from a new app to an HTTP response.

Version note — August 30, 2026: This article was first published on January 27, 2025. The original bootstrap used Ruby 3.0.1 and an unpinned gem install rails. Keep that as historical context, not as a new-app recipe: Rails 8.0 and 8.1 require Ruby 3.2.0 or newer. The walkthrough below uses Rails 8.1.3.1, Ruby 3.4.10, and GraphQL Ruby ~> 2.6. Check the Rails API-only guide and the GraphQL Ruby generator guide if you choose different versions.

Create an API-only Rails app

Pick an exact Ruby patch version that your deployment supports and record it in .ruby-version. The version below is the one used for this dated example:

brew install rbenv ruby-build
rbenv install 3.4.10
rbenv shell 3.4.10

Install and select the Rails version explicitly. Pinning the generator matters because Rails can change generated files between minor releases.

gem install rails --version 8.1.3.1
rails _8.1.3.1_ new library_api --api --database=postgresql
cd library_api
rbenv local 3.4.10

The --api flag creates a smaller Rails stack. ApplicationController inherits from ActionController::API, and Rails leaves out browser-oriented middleware, views, helpers, and assets unless you add them back. That is a useful starting point for a service whose public surface is JSON and GraphQL.

Before adding GraphQL, make sure the generated app can reach PostgreSQL:

bin/rails db:create
bin/rails server

Stop the server after checking the app boots. The rest of the setup uses bin/rails so every command runs against this application's bundle.

Add GraphQL Ruby

GraphQL Ruby is distributed as the graphql gem. Add the 2.6 minor series to the bundle; Bundler will resolve and record the exact patch version in Gemfile.lock.

bundle add graphql --version "~> 2.6.0"
bin/rails generate graphql:install --api
bundle install

The install generator creates the app/graphql tree, a schema, base type classes, query and mutation roots, a controller, and the POST /graphql route. On an API-only app, the --api option keeps the generated stack small instead of adding the browser-based GraphiQL setup. You can still use a desktop GraphiQL client or another GraphQL client during development.

At this point, inspect the generated files before writing fields:

  • app/graphql/types/query_type.rb
  • app/graphql/types/mutation_type.rb
  • app/graphql/library_api_schema.rb
  • app/controllers/graphql_controller.rb
  • config/routes.rb

The generator is a useful starting point, not an architectural decision. Read the controller to see how request parameters become a schema execution, and read the schema to see where query and mutation roots are attached.

Define one query end to end

Create a model so the first field has a real database behind it:

bin/rails generate model Book title:string
bin/rails db:migrate
bin/rails runner 'Book.create!(title: "Practical GraphQL")'

Now define the public shape of a book. A GraphQL type is an API contract; it is not a dump of every Active Record column.

# app/graphql/types/book_type.rb
module Types
  class BookType < Types::BaseObject
    description "A book in the library"
 
    field :id, ID, null: false
    field :title, String, null: false
  end
end

Expose a collection from the query root:

# app/graphql/types/query_type.rb
module Types
  class QueryType < Types::BaseObject
    field :books, [Types::BookType], null: false,
      description: "Books ordered by title"
 
    def books
      ::Book.order(:title)
    end
  end
end

The install generator has already connected the query root to the schema. For an app named library_api, the relevant part of the generated schema looks like this:

# app/graphql/library_api_schema.rb
class LibraryApiSchema < GraphQL::Schema
  query Types::QueryType
end

Start the server and send a query to the generated route:

bin/rails server
 
curl http://localhost:3000/graphql \
  --request POST \
  --header 'Content-Type: application/json' \
  --data '{"query":"{ books { id title } }"}'

The response has the shape requested by the client:

{
  "data": {
    "books": [{ "id": "1", "title": "Practical GraphQL" }]
  }
}

That small example shows the division of labor. Active Record loads the record; the GraphQL schema decides which fields are public and how the result is shaped. Adding another client view does not require another endpoint, but the schema still needs deliberate design.

Put application rules in the right layer

Rails should remain responsible for request authentication, database transactions, model validations, and shared business rules. The GraphQL controller can load the current user and pass it into the schema execution context. Fields and mutation classes can then use that context when they need an API-specific check.

For rules that should apply everywhere, keep the rule in application code rather than hiding it in one resolver. For example, Book.visible_to(user) can be reused by a REST endpoint, a background job, and a GraphQL field. GraphQL Ruby also has visibility and authorization hooks when the schema itself must differ by viewer.

The same principle applies to mutations. A mutation named publishBook tells a client what the application does. A mutation that simply mirrors an arbitrary books.update call leaks persistence details and makes validation and authorization harder to reason about.

Add limits before the schema grows

Nested GraphQL selections can be surprisingly expensive. A schema that is easy to query locally can become a database or downstream-service problem when a client asks for deep relationships or a large list. GraphQL Ruby provides depth and complexity limits, a built-in dataloader, and an execution timeout plugin:

# app/graphql/library_api_schema.rb
class LibraryApiSchema < GraphQL::Schema
  query Types::QueryType
 
  use GraphQL::Dataloader
  max_depth 10
  max_complexity 200
  use GraphQL::Schema::Timeout, max_seconds: 2
end

Those numbers are starting points, not universal safe values. Measure real queries and tune them with the product requirements. Dataloader is useful when many returned objects need related records because it can batch loads within one query. The timeout plugin stops scheduling new fields after its limit; it does not interrupt a resolver that is already blocked in a database or HTTP call. Give those external clients their own operation-specific timeouts as well. The plugin uses Ruby's Timeout API, so test it with your adapters rather than treating it as a replacement for I/O-specific timeouts.

Error handling deserves the same attention. GraphQL validation errors appear in a top-level errors array, and a response may contain both data and errors for a partial result. For a mutation, domain failures such as an invalid transition are often easier for clients to use as typed fields on the mutation payload. Reserve unhandled exceptions for actual failures, and avoid turning a database exception or stack trace into a public API message.

The part worth keeping small

The first version of a Rails and GraphQL service does not need a type for every table or a resolver abstraction for every field. Start with a few queries and mutations that describe product operations. Keep the generated controller visible, keep authorization close to the business rule, and add batching and limits when the shape of real queries justifies them.

That is the reason this setup remains useful years after the original note: Rails provides the application conventions, GraphQL Ruby provides the typed boundary, and the code between them stays inspectable.