Overview
GraphQL in Ruby provides a type-safe query language implementation for building APIs that allow clients to request exactly the data they need. The graphql
gem serves as the primary Ruby implementation, offering a complete GraphQL specification-compliant framework for creating schemas, defining types, and executing queries.
The core GraphQL Ruby architecture centers around several key classes: GraphQL::Schema
defines the API structure, GraphQL::ObjectType
represents data models, GraphQL::Field
defines available operations, and resolver classes handle business logic. The gem supports the full GraphQL specification including queries, mutations, subscriptions, and introspection.
Ruby GraphQL schemas use a class-based approach where types inherit from base classes like GraphQL::Schema::Object
and GraphQL::Schema::Resolver
. Fields are defined using the field
method with type information, arguments, and resolver logic. The schema compilation process validates type definitions and builds an executable schema that can process GraphQL queries.
class Types::UserType < GraphQL::Schema::Object
description "A user in the system"
field :id, ID, null: false
field :email, String, null: false
field :name, String, null: true
field :posts, [Types::PostType], null: true
end
class Schema < GraphQL::Schema
query Types::QueryType
mutation Types::MutationType
end
The execution engine parses incoming GraphQL query strings, validates them against the schema, and executes resolver functions to fetch data. Ruby's GraphQL implementation handles complex scenarios like field aliases, fragments, variables, and nested selections while maintaining type safety throughout the execution pipeline.
Basic Usage
Creating a GraphQL API in Ruby begins with defining a schema that declares available types and operations. The schema acts as a contract between the client and server, specifying what data can be queried and how it can be modified.
Type definitions use Ruby classes that inherit from GraphQL::Schema::Object
. Each field declaration includes the field name, return type, nullability, and optional description. Ruby GraphQL supports scalar types (String, Int, Float, Boolean, ID), object types, list types, and custom scalar types.
# Define object types
class Types::PostType < GraphQL::Schema::Object
field :id, ID, null: false
field :title, String, null: false
field :content, String, null: true
field :author, Types::UserType, null: false
field :comments, [Types::CommentType], null: true
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
end
# Define query root type
class Types::QueryType < GraphQL::Schema::Object
field :user, Types::UserType, null: true do
argument :id, ID, required: true
end
field :posts, [Types::PostType], null: true do
argument :limit, Int, required: false, default_value: 10
end
def user(id:)
User.find(id)
end
def posts(limit:)
Post.limit(limit)
end
end
Resolvers handle the business logic for fetching data. Simple field resolvers can be defined as methods on the type class, while complex logic often uses dedicated resolver classes. The resolver context provides access to the current user, database connections, and other application state.
class Resolvers::PostResolver < GraphQL::Schema::Resolver
type [Types::PostType], null: true
argument :author_id, ID, required: false
argument :published, Boolean, required: false, default_value: true
def resolve(author_id: nil, published: true)
posts = Post.all
posts = posts.where(author_id: author_id) if author_id
posts = posts.where(published: published)
posts.order(created_at: :desc)
end
end
# Use resolver in query type
class Types::QueryType < GraphQL::Schema::Object
field :posts, resolver: Resolvers::PostResolver
end
Mutations modify server state and follow similar patterns to queries but with additional input validation and error handling. Input types define the shape of mutation arguments, while payload types structure the mutation response.
class Types::CreatePostInput < GraphQL::Schema::InputObject
argument :title, String, required: true
argument :content, String, required: false
argument :author_id, ID, required: true
end
class Mutations::CreatePost < GraphQL::Schema::Mutation
argument :input, Types::CreatePostInput, required: true
field :post, Types::PostType, null: true
field :errors, [String], null: true
def resolve(input:)
post = Post.new(input.to_h)
if post.save
{ post: post, errors: [] }
else
{ post: nil, errors: post.errors.full_messages }
end
end
end
Advanced Usage
Advanced GraphQL Ruby patterns involve custom scalar types, interfaces, unions, and sophisticated resolver architectures. Custom scalars handle domain-specific data types like email addresses, URLs, or encrypted values with validation and serialization logic.
class Types::EmailType < GraphQL::Schema::Scalar
description "A valid email address"
def self.coerce_input(input_value, context)
return unless input_value.is_a?(String)
return unless input_value.match?(/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i)
input_value.downcase
end
def self.coerce_result(ruby_value, context)
ruby_value.to_s
end
end
Interfaces define common fields shared across multiple types, enabling polymorphic queries. Types implement interfaces by including the interface module and defining required fields. Union types group different object types under a single field, requiring type resolution logic.
module Types::ContentInterface
include GraphQL::Schema::Interface
field :id, ID, null: false
field :title, String, null: false
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
definition_methods do
def resolve_type(object, context)
case object
when Post then Types::PostType
when Video then Types::VideoType
else raise "Unexpected object: #{object}"
end
end
end
end
class Types::PostType < GraphQL::Schema::Object
implements Types::ContentInterface
field :content, String, null: true
field :author, Types::UserType, null: false
end
class Types::VideoType < GraphQL::Schema::Object
implements Types::ContentInterface
field :duration, Int, null: false
field :url, String, null: false
end
class Types::ContentUnion < GraphQL::Schema::Union
possible_types Types::PostType, Types::VideoType
def self.resolve_type(object, context)
case object
when Post then Types::PostType
when Video then Types::VideoType
end
end
end
Sophisticated resolver patterns include field-level authorization, argument validation, and lazy loading. The GraphQL::Schema::Resolver
base class provides hooks for these cross-cutting concerns through method overrides and modules.
class Resolvers::BaseResolver < GraphQL::Schema::Resolver
private
def authorized?(action: :read)
return false unless context[:current_user]
case action
when :read then context[:current_user].can_read?(object)
when :write then context[:current_user].can_write?(object)
else false
end
end
def validate_arguments(**args)
args.each do |key, value|
next unless value.is_a?(String)
next if value.length <= 1000
raise GraphQL::ExecutionError, "#{key} exceeds maximum length"
end
end
end
class Resolvers::UserPostsResolver < Resolvers::BaseResolver
type [Types::PostType], null: true
argument :status, Types::PostStatusEnum, required: false
argument :limit, Int, required: false, default_value: 20
def resolve(**args)
return nil unless authorized?
validate_arguments(**args)
posts = object.posts
posts = posts.where(status: args[:status]) if args[:status]
posts.limit(args[:limit]).order(created_at: :desc)
end
end
Schema-level customizations include custom directives, middleware, and query analysis. Directives modify field behavior based on arguments, while middleware provides request-level processing like authentication, logging, and caching.
class Schema < GraphQL::Schema
query Types::QueryType
mutation Types::MutationType
use GraphQL::Execution::Interpreter
use GraphQL::Analysis::AST
# Custom middleware for authentication
use GraphQL::Schema::Middleware::Chain.new([
GraphQL::Schema::Middleware.new(
resolve: ->(type, field, obj, args, ctx, next_middleware) {
if field.metadata[:auth_required] && !ctx[:current_user]
raise GraphQL::ExecutionError, "Authentication required"
end
next_middleware.call
}
)
])
# Query complexity analysis
max_complexity 1000
max_depth 10
def self.resolve_type(type, obj, ctx)
raise GraphQL::RequiredImplementationMissingError
end
end
Error Handling & Debugging
GraphQL Ruby provides multiple error handling mechanisms for different failure scenarios. Execution errors surface to clients as part of the GraphQL response, while system errors typically terminate query processing entirely.
Resolver methods raise GraphQL::ExecutionError
to indicate field-level failures that should appear in the response's errors array. These errors can include path information, error codes, and custom metadata for client consumption.
class Resolvers::PostResolver < GraphQL::Schema::Resolver
def resolve(id:)
post = Post.find_by(id: id)
unless post
raise GraphQL::ExecutionError.new(
"Post not found",
extensions: { code: "POST_NOT_FOUND", id: id }
)
end
unless can_read?(post)
raise GraphQL::ExecutionError.new(
"Access denied",
extensions: { code: "ACCESS_DENIED" }
)
end
post
rescue ActiveRecord::ConnectionTimeoutError => e
raise GraphQL::ExecutionError.new(
"Database unavailable",
extensions: { code: "DATABASE_ERROR", retry_after: 30 }
)
end
end
Validation errors occur during query parsing and schema validation phases. The GraphQL Ruby engine automatically handles syntax errors, type mismatches, and schema violations. Custom validation rules can be added to enforce business-specific constraints.
class CustomValidationRule
include GraphQL::StaticValidation::Message
def validate(context)
super
context.visitor[GraphQL::Language::Nodes::Field] << ->(node, parent) {
if node.name == "expensiveField" && context.query.depth > 5
context.errors << create_error(
"expensiveField cannot be nested deeper than 5 levels",
nodes: [node]
)
end
}
end
end
class Schema < GraphQL::Schema
validate_with CustomValidationRule
end
Debugging GraphQL queries requires understanding the execution flow and introspection capabilities. The GraphQL Ruby gem provides detailed tracing information through instrumentation hooks and query analyzers.
# Enable detailed tracing
class Schema < GraphQL::Schema
trace_with GraphQL::Tracing::ScoutTracing if Rails.env.production?
trace_with GraphQL::Tracing::DataDogTracing if Rails.env.production?
# Custom tracing for development
trace_with GraphQL::Tracing::CallLegacyTracers
tracer GraphQL::Tracing::CallLegacyTracers.new(
trace: ->(key, data) {
if Rails.env.development?
puts "GraphQL trace: #{key}, duration: #{data[:duration]}ms"
puts " Query: #{data[:query]}" if data[:query]
puts " Variables: #{data[:variables]}" if data[:variables]
end
}
)
end
Error recovery patterns handle partial failures gracefully while maintaining query execution for successful fields. Union types and nullable fields provide structure for representing both success and error states in mutations.
class Types::PostResult < GraphQL::Schema::Union
possible_types Types::PostType, Types::PostError
def self.resolve_type(object, context)
case object
when Post then Types::PostType
when PostError then Types::PostError
end
end
end
class Types::PostError < GraphQL::Schema::Object
field :message, String, null: false
field :code, String, null: false
field :path, [String], null: true
end
class Mutations::CreatePost < GraphQL::Schema::Mutation
field :result, Types::PostResult, null: false
def resolve(**args)
post = Post.new(args)
if post.save
post
else
PostError.new(
message: post.errors.full_messages.join(", "),
code: "VALIDATION_ERROR",
path: ["createPost"]
)
end
end
end
Production Patterns
Production GraphQL Ruby deployments require careful consideration of performance, security, and monitoring. Schema organization typically separates types, resolvers, and mutations into dedicated directories with clear naming conventions and module structures.
# app/graphql/types/base_object.rb
class Types::BaseObject < GraphQL::Schema::Object
edge_type_class(Types::BaseEdge)
connection_type_class(Types::BaseConnection)
field_class Types::BaseField
end
# app/graphql/types/base_field.rb
class Types::BaseField < GraphQL::Schema::Field
argument_class Types::BaseArgument
def initialize(*args, auth_required: false, **kwargs, &block)
@auth_required = auth_required
super(*args, **kwargs, &block)
end
def authorized?(obj, args, ctx)
return super unless @auth_required
return false unless ctx[:current_user]
super
end
end
Authentication and authorization integrate with existing application security systems through context objects and field-level permissions. The context hash carries user information, permissions, and request metadata throughout query execution.
# In your controller
class GraphqlController < ApplicationController
def execute
context = {
current_user: current_user,
current_ability: current_ability,
request: request,
session: session
}
result = Schema.execute(
params[:query],
variables: params[:variables],
context: context,
operation_name: params[:operationName]
)
render json: result
end
private
def current_ability
@current_ability ||= Ability.new(current_user)
end
end
# Authorization in resolvers
class Resolvers::BaseResolver < GraphQL::Schema::Resolver
def authorized_user?(user = nil)
user ||= context[:current_user]
return false unless user
context[:current_ability].can?(:read, object)
end
def require_authentication!
return if context[:current_user]
raise GraphQL::ExecutionError, "Authentication required"
end
end
Caching strategies for GraphQL require field-level granularity since clients request arbitrary data combinations. Rails fragment caching integrates with GraphQL through custom field extensions and resolver decorators.
class Types::BaseField < GraphQL::Schema::Field
def initialize(*args, cache: false, cache_key: nil, **kwargs, &block)
@cache_enabled = cache
@cache_key_proc = cache_key
super(*args, **kwargs, &block)
end
def resolve_field(obj, args, ctx)
return super unless @cache_enabled
cache_key = build_cache_key(obj, args, ctx)
Rails.cache.fetch(cache_key, expires_in: 5.minutes) do
super
end
end
private
def build_cache_key(obj, args, ctx)
if @cache_key_proc
@cache_key_proc.call(obj, args, ctx)
else
"graphql:#{obj.class.name}:#{obj.id}:#{name}:#{args.to_h.hash}"
end
end
end
# Usage in type definitions
class Types::UserType < Types::BaseObject
field :profile, Types::ProfileType,
null: true,
cache: true,
cache_key: ->(user, args, ctx) { "user_profile:#{user.id}:#{user.updated_at.to_i}" }
end
Monitoring and observability require custom instrumentation for GraphQL-specific metrics like query complexity, execution time per field, and error rates. Integration with APM tools provides production visibility into GraphQL performance characteristics.
class GraphqlInstrumentation
def initialize
@query_counter = Prometheus::Client::Counter.new(
:graphql_queries_total,
docstring: 'Total GraphQL queries executed',
labels: [:operation_name, :operation_type]
)
@query_duration = Prometheus::Client::Histogram.new(
:graphql_query_duration_seconds,
docstring: 'GraphQL query execution duration',
labels: [:operation_name, :operation_type]
)
end
def trace(key, data)
case key
when 'execute_query'
operation = data[:query].operation_name || 'anonymous'
operation_type = data[:query].operation_type || 'query'
@query_counter.increment(
labels: { operation_name: operation, operation_type: operation_type }
)
start_time = Time.current
result = yield
duration = Time.current - start_time
@query_duration.observe(
duration,
labels: { operation_name: operation, operation_type: operation_type }
)
result
else
yield
end
end
end
class Schema < GraphQL::Schema
trace_with GraphqlInstrumentation
end
Performance & Memory
GraphQL Ruby performance optimization focuses on resolving the N+1 query problem, managing query complexity, and efficient data loading patterns. The graphql-batch
gem provides batched loading capabilities that consolidate database queries across resolver executions.
Database query optimization requires careful attention to association loading and query batching. The GraphQL Ruby execution model can trigger numerous database queries if resolvers naively access associated records without preloading.
# Install graphql-batch gem and configure
class Schema < GraphQL::Schema
use GraphQL::Batch
end
# Create batch loaders for efficient data access
class UserLoader < GraphQL::Batch::Loader
def initialize(scope: User.all)
@scope = scope
end
def perform(user_ids)
users = @scope.where(id: user_ids).index_by(&:id)
user_ids.each { |id| fulfill(id, users[id]) }
end
end
class PostsByUserLoader < GraphQL::Batch::Loader
def perform(user_ids)
posts_by_user_id = Post.where(user_id: user_ids)
.includes(:comments, :tags)
.group_by(&:user_id)
user_ids.each do |user_id|
fulfill(user_id, posts_by_user_id[user_id] || [])
end
end
end
# Use loaders in resolvers
class Types::UserType < Types::BaseObject
field :posts, [Types::PostType], null: true
def posts
PostsByUserLoader.load(object.id)
end
end
class Types::PostType < Types::BaseObject
field :author, Types::UserType, null: false
def author
UserLoader.load(object.user_id)
end
end
Query complexity analysis prevents resource-intensive queries from overwhelming the server. The GraphQL Ruby gem includes configurable complexity analysis that assigns costs to fields and limits total query complexity.
class Schema < GraphQL::Schema
# Set maximum query complexity
max_complexity 1000
max_depth 15
# Custom complexity calculation
def self.type_error(err, query_ctx)
case err
when GraphQL::ComplexityError
Rails.logger.warn "Complex query blocked: #{err.message}"
GraphQL::ExecutionError.new(
"Query too complex",
extensions: { code: "QUERY_TOO_COMPLEX", max_complexity: max_complexity }
)
else
super
end
end
end
# Assign custom complexity to expensive fields
class Types::UserType < Types::BaseObject
field :posts, [Types::PostType], null: true, complexity: 5
field :followers, [Types::UserType], null: true, complexity: 10
field :activity_feed, [Types::ActivityType], null: true do
complexity ->(ctx, args, child_complexity) {
# Dynamic complexity based on arguments
limit = args[:limit] || 20
limit * child_complexity * 2
}
end
end
Memory usage optimization involves careful object allocation and garbage collection considerations. Large result sets and deep nesting can consume significant memory during GraphQL query execution.
# Streaming large datasets with cursor-based pagination
class Types::BaseConnection < GraphQL::Types::Relay::BaseConnection
# Override to implement efficient counting
def count
if object.respond_to?(:count_estimate)
object.count_estimate
else
object.count
end
end
end
# Lazy loading for expensive computations
class Types::UserType < Types::BaseObject
field :expensive_calculation, String, null: true
def expensive_calculation
@expensive_calculation ||= LazyLoader.new do
# Expensive operation here
perform_complex_calculation(object)
end
end
end
class LazyLoader
def initialize(&block)
@block = block
@loaded = false
end
def load
return @result if @loaded
@result = @block.call
@loaded = true
@result
end
def to_s
load.to_s
end
end
Profiling GraphQL queries requires specialized tools that understand the execution model. Custom instrumentation provides detailed timing information for individual field resolutions and database queries.
class DetailedTracing
def initialize
@field_timings = {}
@db_queries = []
end
def trace(key, data)
case key
when 'execute_field'
field_name = "#{data[:owner]}.#{data[:field].name}"
start_time = Time.current
result = yield
execution_time = Time.current - start_time
@field_timings[field_name] = execution_time
Rails.logger.debug "Field #{field_name}: #{execution_time.round(3)}s"
result
when 'execute_query'
ActiveSupport::Notifications.subscribe 'sql.active_record' do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
@db_queries << {
sql: event.payload[:sql],
duration: event.duration,
name: event.payload[:name]
}
end
result = yield
Rails.logger.info "Query executed in #{@field_timings.values.sum.round(3)}s"
Rails.logger.info "#{@db_queries.length} database queries"
result
else
yield
end
end
end
Reference
Core Classes
Class | Description | Key Methods |
---|---|---|
GraphQL::Schema |
Root schema definition | execute , query , mutation , subscription |
GraphQL::Schema::Object |
Base class for object types | field , implements |
GraphQL::Schema::Resolver |
Base resolver class | resolve , authorized? , ready? |
GraphQL::Schema::InputObject |
Input type definition | argument |
GraphQL::Schema::Enum |
Enumeration type | value |
GraphQL::Schema::Scalar |
Custom scalar type | coerce_input , coerce_result |
GraphQL::Schema::Interface |
Interface definition | field , resolve_type |
GraphQL::Schema::Union |
Union type | possible_types , resolve_type |
Field Definition Options
Option | Type | Description | Example |
---|---|---|---|
null |
Boolean | Field nullability | null: false |
description |
String | Field documentation | description: "User email" |
deprecation_reason |
String | Deprecation message | deprecation_reason: "Use newField" |
complexity |
Integer/Proc | Query complexity cost | complexity: 5 |
authorize |
Symbol/Proc | Authorization check | authorize: :admin |
resolver |
Class | Resolver class | resolver: UserResolver |
resolver_method |
Symbol | Method name | resolver_method: :find_user |
hash_key |
Symbol | Object key accessor | hash_key: :user_name |
Argument Definition Options
Option | Type | Description | Example |
---|---|---|---|
required |
Boolean | Argument requirement | required: true |
default_value |
Any | Default when not provided | default_value: 10 |
as |
Symbol | Parameter name mapping | as: :user_id |
prepare |
Proc | Argument preprocessing | prepare: ->(val, ctx) { val.strip } |
validate |
Hash | Validation rules | validate: { length: { minimum: 5 } } |
Built-in Scalar Types
Type | Ruby Class | Description |
---|---|---|
String |
String | UTF-8 character sequences |
Int |
Integer | 32-bit signed integers |
Float |
Float | IEEE 754 double precision |
Boolean |
Boolean | true/false values |
ID |
String/Integer | Unique identifiers |
GraphQL::Types::ISO8601DateTime |
Time | ISO 8601 datetime strings |
GraphQL::Types::JSON |
Hash/Array | JSON objects |
GraphQL::Types::BigInt |
Integer | Arbitrary precision integers |
Execution Context Methods
Method | Returns | Description |
---|---|---|
context[:current_user] |
Object | Currently authenticated user |
context[:request] |
ActionDispatch::Request | HTTP request object |
context.query |
GraphQL::Query | Current query object |
context.schema |
GraphQL::Schema | Schema being executed |
context.field |
GraphQL::Schema::Field | Current field definition |
context.path |
Array | Field execution path |
context.ast_node |
GraphQL::Language::Nodes::Field | Query AST node |
Schema Configuration Options
Option | Type | Description | Default |
---|---|---|---|
max_complexity |
Integer | Maximum query complexity | nil (unlimited) |
max_depth |
Integer | Maximum nesting depth | nil (unlimited) |
validate_timeout |
Numeric | Validation timeout seconds | nil |
default_page_size |
Integer | Connection page size | nil |
default_max_page_size |
Integer | Maximum page size | nil |
introspection |
Boolean | Enable introspection | true |
query_execution_strategy |
Class | Execution strategy | GraphQL::Execution::Interpreter |
Error Classes
Exception | Inherits From | Use Case |
---|---|---|
GraphQL::ExecutionError |
StandardError | Field-level errors |
GraphQL::RequiredImplementationMissingError |
NotImplementedError | Missing interface methods |
GraphQL::InvalidNullError |
ExecutionError | Non-null field returns null |
GraphQL::ComplexityError |
StandardError | Query too complex |
GraphQL::AnalysisError |
StandardError | Query analysis failure |
GraphQL::CoercionError |
TypeError | Type coercion failure |
GraphQL::UnauthorizedError |
ExecutionError | Authorization failure |
Query Analysis Classes
Class | Purpose | Usage |
---|---|---|
GraphQL::Analysis::QueryComplexity |
Calculate complexity | Schema complexity limits |
GraphQL::Analysis::QueryDepth |
Calculate nesting depth | Schema depth limits |
GraphQL::Analysis::FieldUsage |
Track field usage | Analytics and deprecation |
GraphQL::Analysis::MaxQueryDepth |
Enforce depth limits | Query validation |
Instrumentation Hooks
Hook | Data Available | Use Case |
---|---|---|
execute_query |
query, variables, context | Request-level metrics |
execute_field |
field, object, arguments | Field-level timing |
execute_field_lazy |
field, object, lazy_value | Lazy field resolution |
authorized |
object, type, field | Authorization logging |
authorized_lazy |
object, type, lazy_auth | Lazy authorization |
resolve_type |
object, type, context | Type resolution |