GraphQL in Ruby: schema-first APIs
GraphQL lets the client ask for exactly the data it needs, solving REST's over- and under-fetching. A deep-dive on graphql-ruby — types, queries, mutations, resolvers — the N+1 trap it creates, and an honest take on GraphQL versus REST.
REST has served us well, but it has two persistent frustrations, and they get worse the more
clients you have. Over-fetching: an endpoint returns a fat object when the screen needs three
fields. Under-fetching: the screen needs data from several resources, so the client makes
three or four round-trips. GraphQL — increasingly mainstream in 2018, with a mature
graphql-ruby gem — attacks both by letting the client specify exactly the data it wants, in
one request. Here is how it works in Ruby, the trap it sets, and when it is actually the right
choice.
The core idea: the client asks for the shape
In REST, the server decides what each endpoint returns. In GraphQL, the client does. You expose a single endpoint and a schema describing your types and their relationships, and the client sends a query describing precisely the fields and nested associations it wants — getting back exactly that shape, nothing more, nothing less:
query {
product(id: 42) {
name
price
reviews(first: 3) {
rating
author { name }
}
}
}
One request, exactly the fields the screen needs, including nested data from related types. No over-fetching (you asked for three review fields, you get three), no under-fetching (the product, its reviews, and the reviewers’ names all came in one round-trip). For a UI assembling a view from several resources — and especially for mobile, where round-trips are expensive — this is a genuine improvement over orchestrating multiple REST calls.
Schema-first: types, queries, mutations
GraphQL is typed, and the schema is the contract. In graphql-ruby you define types that mirror
your domain:
class Types::Product < Types::BaseObject
field :id, ID, null: false
field :name, String, null: false
field :price, Float, null: false
field :reviews, [Types::Review], null: false do
argument :first, Integer, required: false
end
end
Reads happen through queries, rooted in a query type; writes happen through mutations, which are explicit, named operations:
class Mutations::AddReview < Mutations::BaseMutation
argument :product_id, ID, required: true
argument :rating, Integer, required: true
argument :body, String, required: true
field :review, Types::Review, null: true
field :errors, [String], null: false
def resolve(product_id:, rating:, body:)
review = Review.new(product_id: product_id, rating: rating, body: body)
if review.save
{ review: review, errors: [] }
else
{ review: nil, errors: review.errors.full_messages }
end
end
end
The schema being strongly typed is a real benefit: it is self-documenting (tools like GraphiQL let clients explore it interactively), it validates queries before they run, and front-end tooling can generate types from it. The contract between client and server is explicit and machine-readable in a way REST endpoints rarely are.
The trap: N+1 queries in resolvers
Here is the thing nobody warns you about, and it bites every GraphQL beginner. Each field can have a resolver — a function that fetches its data — and because the client nests queries freely, naive resolvers produce catastrophic N+1s. Consider the query above asking for 50 products and each one’s reviews: the products resolver runs one query, then the reviews field resolver runs once per product, then the author field runs once per review. The flexible nesting that makes GraphQL powerful makes the N+1 problem structural, not occasional.
The solution is batching: collect the IDs needed across all the sibling resolutions and load
them in one query. In Ruby this is graphql-batch (or a dataloader pattern):
field :author, Types::User, null: false
def author
# batches all author lookups across the whole query into one SELECT ... WHERE id IN (...)
Loaders::RecordLoader.for(User).load(object.author_id)
end
This is essential, not optional: a GraphQL API without batching will fall over the moment a client sends a deeply nested query, because the resolver tree explodes into hundreds of individual queries. Understanding this — and wiring in batching from the start — is the single most important thing to get right in a Ruby GraphQL server.
The other costs GraphQL brings
Honesty matters, because GraphQL is fashionable and oversold. What it costs you:
- Caching is harder. REST’s great strength is HTTP caching — a
GET /products/42is trivially cached by browsers, CDNs, and proxies. GraphQL is typically onePOSTendpoint with the query in the body, so standard HTTP caching does not apply; you cache at the resolver/data layer instead, which is more work. - Complexity and security. A client can send an arbitrarily deep, expensive query — a malicious or careless one can ask for deeply nested data that costs you enormously. You must add query depth and complexity limits to prevent denial-of-service, which REST’s fixed endpoints do not require.
- It is more machinery. Types, resolvers, mutations, batching, complexity analysis — there is simply more to build and understand than a handful of REST endpoints.
When GraphQL, and when REST
The honest decision framework:
- GraphQL shines when you have many diverse clients with different data needs (web, iOS, Android, partners) hitting the same backend, when you have deeply nested/related data that REST makes you fetch in many round-trips, and when clients evolve faster than you can revise endpoints — they just ask for new fields. The more varied your clients, the more GraphQL pays off.
- REST is still right for a simple API with one known client, where you want trivial HTTP caching, or where the machinery of GraphQL is overkill for what is really a handful of CRUD endpoints. Do not adopt GraphQL because it is modern; adopt it because your client diversity or data shape genuinely needs it.
Verdict
GraphQL is a genuine solution to REST’s over- and under-fetching: a typed, schema-first contract
where the client asks for exactly the data it needs in one request, which is a real win for apps
with many clients and richly nested data. graphql-ruby makes it pleasant to build — but go in
knowing the trap (resolver N+1s are structural; wire in graphql-batch from day one) and the costs
(HTTP caching no longer just works; you must cap query depth and complexity against abuse). Reach
for it when client diversity or nested data justifies the extra machinery, and stay with REST when
a simple, cacheable API serves a single client well. Used for the right problem, GraphQL is
excellent; adopted as a fashion over a simple REST need, it is a lot of complexity for a problem
you did not have.