Rails GraphQL: Productie-opzet met graphql-ruby, Batch Loading en Persisted Queries
Rails GraphQL met graphql-ruby goed gedaan — schema-ontwerp, N+1 voorkomen met batch loading, persisted queries en afwegingen tegenover REST APIs.
Een CTO van een scaleup belde mij afgelopen december met een probleem dat zijn team al zes maanden bloed kostte. Ze hadden de REST API van hun mobiele app gemigreerd naar GraphQL omdat hun iOS-team klaagde over over-fetching, en de migratie was soepel verlopen — totdat het verkeer tijdens Black Friday verdubbelde. Hun Rails-app begon time-outs te geven. Hun database-CPU schoot naar 100 procent. Skylight liet zien dat individuele GraphQL-requests uitwaaierden in honderden kleine queries. Het team had een prachtig schema gebouwd, blootgesteld aan clients en was recht in de meest voorspelbare valkuil van GraphQL gelopen. Ik opende hun app/graphql/ map, besteedde veertig minuten aan het toevoegen van twee gems en het herschrijven van zes resolvers, en hun P95-latency daalde van 4,1 seconden naar 180 milliseconden. Het schema hoefde niet te veranderen. De manier waarop ze data laadden wel.
Na negentien jaar Rails heb ik een sterke mening over Rails GraphQL: het is een uitstekend gereedschap als je het nodig hebt en een valstrik als dat niet zo is. Deze post is het productie-draaiboek dat ik aan teams geef wanneer ze me vertellen dat ze op het punt staan graphql-ruby uit te rollen — wat in te stellen, wat te vermijden en waar het echte performance-werk zit. Als je GraphQL afweegt tegen REST voor een nieuwe API, dekken mijn aantekeningen over Rails API rate limiting en de Rails technical due diligence checklist de omringende context.
Waarom Rails GraphQL de complexiteit waard is (soms)
GraphQL is geen betere REST. Het is een ander soort API met andere afwegingen. De clients winnen omdat ze precies de velden vragen die ze nodig hebben en de server precies dat teruggeeft. Het server-team draagt de complexiteit van het efficiënt resolveren van willekeurige queryvormen, het valideren van diepte en complexiteit, en het individueel beveiligen van elk veld. Als je API door één of twee clients wordt geconsumeerd die je zelf beheert, is REST met een paar goed ontworpen endpoints bijna altijd minder werk. Wordt je API gebruikt door meerdere front-end teams, native mobile clients en derde partijen — en evolueren die clients op verschillende release-cycli — dan verdient GraphQL zijn huur.
In de praktijk doen de Rails GraphQL-teams waarmee ik werk één van drie dingen: een mobiele app aansturen die strikte vormcontrole nodig heeft, een publieke API bouwen voor partners die flexibiliteit willen, of meerdere interne services verenigen achter één gateway. De eerste twee passen uitstekend. De derde is meestal een federatie-probleem, en federatie in Ruby is nog ruwer dan de marketing suggereert — wees voorzichtig.
graphql-ruby opzetten in een moderne Rails-app
De de facto library is graphql-ruby, onderhouden door Robert Mosolgo en in productie gehard bij GitHub, Shopify en eigenlijk elke Rails-shop die GraphQL op schaal draait. Installatie is echt een one-liner, maar de standaard scaffold laat je op meerdere manieren kwetsbaar in productie.
# Gemfile
gem "graphql", "~> 2.3"
gem "graphql-batch", "~> 0.6"
bundle install
bin/rails generate graphql:install
De generator maakt app/graphql/, een controller, het schema en basisklassen voor Object, Query, Mutation, InputObject en Enum. De controller die wordt geleverd is prima voor development. Voor productie zet ik die vanaf dag één op slot.
# app/controllers/graphql_controller.rb
class GraphqlController < ApplicationController
protect_from_forgery with: :null_session
def execute
result = MyAppSchema.execute(
params[:query],
variables: prepare_variables(params[:variables]),
context: {
current_user: current_user,
request_id: request.request_id
},
operation_name: params[:operationName]
)
render json: result
rescue StandardError => e
raise e unless Rails.env.production?
Rails.logger.error("[graphql] #{e.class}: #{e.message}")
render json: { errors: [{ message: "Internal server error" }] }, status: 500
end
private
def prepare_variables(input)
case input
when String then input.present? ? JSON.parse(input) : {}
when Hash, ActionController::Parameters then input
else {}
end
end
end
De twee dingen om op te merken: context draagt de huidige gebruiker en request-ID mee in elke resolver (geen Thread.current-hacks), en we lekken nooit interne exception-details naar clients in productie.
Het N+1-probleem is het hele spel
De reden dat de eerder genoemde scaleup hun database aan het opbranden was, is de meest voorkomende Rails GraphQL-bug: elke resolver vuurt zijn eigen query af. Met REST schrijf je één controller-actie, je preload je associaties en je bent klaar. Met GraphQL kan één client-query tien verschillende resolvers raken, die elk onafhankelijk de database vragen om de kinderen van hetzelfde parent record.
query {
orders(last: 50) {
nodes {
id
total
customer { id name email }
lineItems { id product { id name sku } }
}
}
}
Naïeve resolvers zullen één query uitvoeren voor orders, vijftig voor customers, vijftig voor line items en nog eens N voor products. Dat is het hele n+1-probleem vermenigvuldigd over elke associatie in het schema. Een .includes(...) toevoegen in de orders-resolver helpt niet — GraphQL weet op het hoogste niveau niet welke associaties de query heeft opgevraagd.
Het juiste gereedschap is een batch loader. graphql-batch (van Shopify) is degene waarnaar ik als eerste grijp. Het groepeert individuele loads binnen één resolutie-pass in één SQL-query per associatie.
# app/graphql/loaders/record_loader.rb
class Loaders::RecordLoader < GraphQL::Batch::Loader
def initialize(model)
super()
@model = model
end
def perform(ids)
@model.where(id: ids).each { |record| fulfill(record.id, record) }
ids.each { |id| fulfill(id, nil) unless fulfilled?(id) }
end
end
# app/graphql/loaders/association_loader.rb
class Loaders::AssociationLoader < GraphQL::Batch::Loader
def initialize(model, association_name)
super()
@model = model
@association_name = association_name
end
def perform(records)
ActiveRecord::Associations::Preloader
.new(records: records, associations: @association_name)
.call
records.each { |record| fulfill(record, record.public_send(@association_name)) }
end
end
Vervolgens in je types:
# app/graphql/types/order_type.rb
class Types::OrderType < Types::BaseObject
field :id, ID, null: false
field :total, Float, null: false
field :customer, Types::CustomerType, null: false
field :line_items, [Types::LineItemType], null: false
def customer
Loaders::RecordLoader.for(Customer).load(object.customer_id)
end
def line_items
Loaders::AssociationLoader.for(Order, :line_items).load(object)
end
end
En koppel het schema:
# app/graphql/my_app_schema.rb
class MyAppSchema < GraphQL::Schema
use GraphQL::Batch
query Types::QueryType
mutation Types::MutationType
end
Die wijziging alleen — directe associatie-toegang vervangen door batch loaders — was de hele fix voor de scaleup. Veertig minuten werk, een orde van grootte in latency, en de database-CPU-grafiek die avond zag eruit alsof iemand de stekker eruit had getrokken.
Rails GraphQL-autorisatie is een per-veld probleem
REST-autorisatie is handig omdat elk endpoint een duidelijke scope heeft: je controleert de gebruiker in de controller, je scoped de records en je geeft terug. GraphQL maakt dat vlak. Eén request kan tientallen velden raken over verschillende typen, en elk veld moet onafhankelijk geautoriseerd worden. Als je alleen op het ingangspunt van de resolver controleert, kan een slimme client via associaties navigeren naar data die ze nooit zouden mogen zien.
graphql-ruby geeft je drie plekken om te autoriseren: op type-niveau (authorized?), op veld-niveau (authorized?) en op resolver-niveau (authorized?). Ik gebruik Pundit voor de onderliggende policy-logica en laat graphql-ruby het aanroepen.
class Types::OrderType < Types::BaseObject
def self.authorized?(object, context)
super && OrderPolicy.new(context[:current_user], object).show?
end
field :internal_notes, String, null: true do
def authorized?(_object, _args, context)
context[:current_user]&.staff?
end
end
end
Controles op type-niveau voorkomen dat het hele object wordt gerenderd. Controles op veld-niveau verbergen individuele velden. De combinatie is wat je daadwerkelijk nodig hebt in een multi-tenant SaaS waar de gebruiker van klant A nooit de order van klant B mag zien, en alleen staff het internal_notes-veld op een order mag zien.
De valkuil hier is vergeten dat graphql-ruby null teruggeeft voor niet-geautoriseerde velden, tenzij ze non-nullable zijn — in welk geval het hele parent-object wegvalt. Audit je schema op onbedoelde nulls die geïnterpreteerd worden als “de klant heeft geen notities” in plaats van “je mag dit niet zien.” Ik heb sales-teams dashboards zien bouwen op deze valse nulls en daar erg verkeerde conclusies aan zien verbinden.
Query Complexity, Depth en de DoS-vector
Een REST endpoint heeft vaste kosten. Een GraphQL endpoint heeft kosten die afhangen van wat de client opvraagt. Zonder limieten kan één kwaadwillende of nieuwsgierige client een query opstellen die je hele schema aan elkaar joint en je database in één HTTP-request op de knieën krijgt. Dit is niet theoretisch. Ik heb in de afgelopen drie jaar op twee incidenten gereageerd waarbij een klantenservice-medewerker een diep geneste query in GraphiQL uitvoerde en daarmee productie opblies.
graphql-ruby heeft ingebouwde bescherming. Gebruik het.
class MyAppSchema < GraphQL::Schema
use GraphQL::Batch
max_depth 12
max_complexity 300
default_max_page_size 50
query Types::QueryType
mutation Types::MutationType
end
max_depth begrenst hoe diep een query mag nesten. max_complexity is een gewogen score — elk veld heeft een complexiteitswaarde, en de planner telt ze op vóór uitvoering. default_max_page_size is de belangrijkste voor elk veld dat een connection teruggeeft. Zonder dat kan een client first: 100000 vragen en zal je gehoorzaam 100.000 records proberen te serialiseren.
Stem complexity per veld af waar het ertoe doet. Een veld dat een recursieve boomdoorgang activeert moet meer kosten dan een scalar.
field :descendants, [Types::CategoryType], null: false, complexity: 10
Persisted Queries: de productie-winst waar niemand over praat
Als je een publiek GraphQL-endpoint uitrolt zonder persisted queries, accepteer je twee problemen. Ten eerste wordt het query-body bij elke request over de lijn gestuurd, wat verspilling is op mobiele netwerken. Ten tweede kan elke client — inclusief die je niet beheert — elke query sturen, en je kunt kwaadaardige vormen niet vooraf blokkeren.
Persisted queries lossen beide op. Tijdens de build extraheert client-tooling elke GraphQL-operatie uit de codebase, hasht ze en registreert ze bij de server. Tijdens runtime stuurt de client alleen de hash en de variabelen. De server zoekt de operatie op via de hash en voert hem uit. Onbekende hashes worden geweigerd.
Het patroon met graphql-ruby ziet er zo uit:
# app/graphql/persisted_queries.rb
class PersistedQueries
def self.lookup(hash)
Rails.cache.fetch("graphql:persisted:#{hash}", expires_in: 1.day) do
PersistedQuery.find_by(hash: hash)&.body
end
end
end
# app/controllers/graphql_controller.rb
def execute
query = if params[:query].present?
raise "Ad-hoc queries disabled" if Rails.env.production? && !current_user&.staff?
params[:query]
else
PersistedQueries.lookup(params[:queryId]) or raise "Unknown query"
end
result = MyAppSchema.execute(
query,
variables: prepare_variables(params[:variables]),
context: { current_user: current_user },
operation_name: params[:operationName]
)
render json: result
end
Apollo Client, Relay en graphql-client ondersteunen allemaal automatische persisted queries aan de client-kant. De migratie is voornamelijk een wijziging in de build-pipeline, en de latency-verbetering op mobiel is direct. Ik zou geen publieke Rails GraphQL-API in productie draaien zonder dit.
Tracing, metrics en weten wat je clients doen
Zodra je GraphQL-endpoint echt draait, is de volgende operationele pijn dat al het verkeer door één route loopt. Je APM-dashboard laat zien dat POST /graphql gemiddeld 800ms duurt, wat je niets zegt. Je hebt per-operatie tracing nodig.
graphql-ruby heeft first-class instrumentatiehaken. De simpelste productie-opzet is om elke request te taggen met de operatienaam en een metric te emitten.
class MyAppSchema < GraphQL::Schema
use GraphQL::Tracing::ActiveSupportNotificationsTracing
end
ActiveSupport::Notifications.subscribe("execute_query.graphql") do |_name, started, finished, _id, payload|
duration_ms = (finished - started) * 1000
operation = payload[:query].operation_name || "anonymous"
StatsD.timing("graphql.execute.#{operation}", duration_ms)
Rails.logger.info("[graphql] #{operation} #{duration_ms.round(1)}ms")
end
Combineer dat met OpenTelemetry op Rails 8 en je kunt eindelijk vertellen of de trage request de OrdersDashboard-query was of de MobileFeed-query, zonder door logs te hoeven graven.
Wanneer Rails GraphQL het verkeerde antwoord is
Ik zou je een slechte dienst bewijzen als ik dit niet noemde. Na een dozijn Rails-teams te hebben geholpen GraphQL uit te rollen, heb ik er minstens drie teruggelopen naar REST. De signalen dat GraphQL verkeerd is voor een specifieke codebase:
- Eén Rails-team bezit zowel de API als de enige client, en het client-team is tevreden met de REST-endpoints die ze hebben. GraphQL lost een coördinatieprobleem op dat ze niet hebben.
- Het datamodel wordt gedomineerd door complexe aggregaties, rapporten en analytics-queries. Die zijn onhandig in GraphQL en natuurlijk in een paar goed ontworpen REST-endpoints.
- Het team heeft geen zin in het operationele werk — persisted queries, complexity-limieten, batch loaders, per-veld autorisatie. GraphQL is geen minder werk dan REST. Het is een ander soort werk.
Als één van deze op jou van toepassing is, adopteer GraphQL dan niet omdat het hip is. Lever een schone REST-API met goede rate limiting en heroverweeg de vraag wanneer je de pijn die het oplost echt voelt.
FAQ
Moet ik GraphQL of REST gebruiken voor mijn Rails-API in 2026?
REST voor interne APIs met één of twee clients die je beheert. GraphQL als je meerdere front-ends hebt — web, iOS, Android, derde partijen — die op verschillende release-cycli evolueren en klagen over over-fetching. De complexiteit die GraphQL toevoegt aan de Rails-kant betaalt zich alleen terug als client-diversiteit echt is. Als je iOS-team en React-team tevreden zijn met REST, migreer dan niet.
Hoe voorkom ik N+1-queries in graphql-ruby?
Gebruik graphql-batch van Shopify. Wrap individuele record-lookups in een RecordLoader en associatie-lookups in een AssociationLoader. graphql-ruby zal alle loads binnen één resolutie-pass verzamelen en uitvoeren als één query per associatie. Roep nooit object.some_association direct aan in een resolver in productiecode — dat is het N+1-pad.
Is graphql-ruby productie-klaar?
Ja. Het draait GitHub, Shopify, Gusto, Toast en een lange lijst grote Rails-shops. De library wordt actief onderhouden, de documentatie is uitstekend en de patronen voor batching, autorisatie, complexity-limieten en persisted queries zijn volwassen. De vraag is niet of de library klaar is — het is of jouw team de operationele discipline heeft die GraphQL vereist.
Wat is het verschil tussen max_depth en max_complexity in graphql-ruby?
max_depth begrenst hoeveel niveaus diep een query mag nesten — handig om oneindige doorgang door zelfreferentiële typen te voorkomen. max_complexity is een gewogen score over de hele query — handig om totaal werk te begrenzen zelfs als de diepte ondiep is. Je wilt beide. Een query kan ondiep en duur zijn (10.000 records op één niveau teruggeven) of diep en goedkoop (één scalar vijf niveaus diep teruggeven).
Hulp nodig bij het uitrollen van een Rails GraphQL-API die Black Friday overleeft? TTB Software is gespecialiseerd in Rails-performance, API-ontwerp en de operationele discipline die GraphQL vereist. We doen dit al negentien jaar.
Related Articles
Rails Postgres EXPLAIN ANALYZE: Query Plans Lezen om Trage Rails Queries op te Lossen
Rails Postgres EXPLAIN ANALYZE laat zien waar queries hun tijd besteden. Lees plans, spot Seq Scans en repareer N+1's...
Claude-antwoorden Streamen in Rails: SSE, Turbo Streams en Real-Time AI-chat
Stream Claude-antwoorden in Rails met SSE en Turbo Streams. Token-voor-token AI-chat, backpressure, reconnects en pro...
Rails Multi-Tenancy: Schemas, Row-Level en Aparte Databases — De Juiste SaaS-aanpak Kiezen
Rails multi-tenancy patronen: row-level scoping, Postgres schemas, aparte databases. Wanneer elk werkt, wanneer het b...