Overview
GraphQL serves as both a specification for an API query language and a server-side runtime for executing queries against existing data sources. Facebook developed GraphQL internally in 2012 to address mobile application data fetching challenges, then released the specification publicly in 2015. The GraphQL Foundation now maintains the specification as an open standard.
Unlike REST APIs that expose multiple endpoints returning fixed data structures, GraphQL provides a single endpoint where clients describe their exact data requirements. The server responds with precisely the requested data shape, eliminating over-fetching and under-fetching problems common in REST architectures.
GraphQL operates through three core concepts: a type system defining available data, queries for reading data, and mutations for modifying data. Clients send operations as strings that the server parses, validates against the schema, and executes using resolver functions.
query {
user(id: "123") {
name
email
posts {
title
createdAt
}
}
}
The response structure matches the query structure:
{
"data": {
"user": {
"name": "Alice Johnson",
"email": "alice@example.com",
"posts": [
{
"title": "GraphQL Introduction",
"createdAt": "2024-01-15"
}
]
}
}
}
GraphQL applies across various backend architectures—wrapping existing REST APIs, querying databases directly, or combining multiple data sources. The specification remains language-agnostic, with implementations available in Ruby, JavaScript, Python, Java, and other languages.
Key Principles
Schema Definition Language
GraphQL schemas define the type system using Schema Definition Language (SDL). The schema acts as a contract between client and server, specifying available types, fields, arguments, and relationships. Every GraphQL service defines a root Query type for read operations and optionally a Mutation type for write operations.
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: DateTime!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
published: Boolean!
}
type Query {
user(id: ID!): User
users(limit: Int, offset: Int): [User!]!
post(id: ID!): Post
}
type Mutation {
createUser(name: String!, email: String!): User!
updatePost(id: ID!, title: String, content: String): Post!
deletePost(id: ID!): Boolean!
}
The exclamation mark (!) indicates non-nullable fields. Square brackets denote lists. Scalar types include Int, Float, String, Boolean, and ID. Custom scalar types extend the type system for domain-specific data like dates or JSON.
Resolver Functions
Resolvers implement the logic for fetching or computing field values. Each field in the schema maps to a resolver function that receives four arguments: the parent object, field arguments, context (shared data like authentication), and execution metadata.
The GraphQL execution engine invokes resolvers recursively, starting from root fields and traversing nested selections. Resolvers return values, promises, or throw errors. The engine collects results into the response shape matching the query structure.
Query Execution Model
GraphQL processes queries in two phases: parsing and validation, then execution. The parser converts the query string into an abstract syntax tree (AST). The validator checks the AST against the schema, verifying that requested fields exist, arguments match declared types, and fragments reference valid types.
During execution, the engine walks the query tree depth-first, calling resolvers for each field. Parent field resolvers complete before child field resolvers execute, ensuring data flows through the query tree correctly. The engine handles concurrent resolver execution where possible, particularly for fields at the same selection level.
Type System Guarantees
GraphQL's strong typing system provides compile-time error detection and automatic validation. Clients receive schema introspection capabilities, enabling tools to generate documentation, validate queries during development, and provide auto-completion.
Interfaces define shared fields across multiple types. Union types represent values that could be one of several types. Enums restrict values to a predefined set. Input types structure complex arguments for mutations.
interface Node {
id: ID!
}
type User implements Node {
id: ID!
name: String!
}
type Post implements Node {
id: ID!
title: String!
}
union SearchResult = User | Post
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
input CreatePostInput {
title: String!
content: String!
status: PostStatus!
}
Single Endpoint Architecture
GraphQL services expose a single HTTP endpoint, typically at /graphql. All operations—queries, mutations, and subscriptions—go through this endpoint. The operation type and requested fields appear in the request body, not the URL path.
This architecture simplifies API versioning. Instead of maintaining multiple endpoint versions, GraphQL schemas evolve through field deprecation and addition. Deprecated fields remain available but marked for future removal, allowing gradual client migration.
Field Selection and Fragments
Clients specify exact field selections, requesting only needed data. Nested selections traverse relationships without multiple round trips. Fragments define reusable field selections, reducing duplication in complex queries.
fragment UserInfo on User {
name
email
createdAt
}
query {
user(id: "123") {
...UserInfo
posts {
title
}
}
anotherUser: user(id: "456") {
...UserInfo
}
}
Ruby Implementation
The graphql gem provides the reference GraphQL implementation for Ruby applications. The gem supports schema definition using Ruby classes, resolver implementation, and integration with web frameworks like Rails and Sinatra.
Schema Definition
Ruby GraphQL schemas use class-based type definitions. Each type inherits from GraphQL::Schema::Object, defining fields with type annotations and optional descriptions.
class Types::UserType < Types::BaseObject
description "A user account"
field :id, ID, null: false
field :name, String, null: false
field :email, String, null: false
field :posts, [Types::PostType], null: false
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
def posts
object.posts.where(published: true)
end
end
class Types::PostType < Types::BaseObject
field :id, ID, null: false
field :title, String, null: false
field :content, String, null: false
field :author, Types::UserType, null: false
field :published, Boolean, null: false
end
The object variable references the underlying model instance. Resolver methods transform or filter data before returning values.
Query and Mutation Types
Query and mutation root types inherit from GraphQL::Schema::Object and define available operations.
class Types::QueryType < Types::BaseObject
field :user, Types::UserType, null: true do
argument :id, ID, required: true
end
field :users, [Types::UserType], null: false do
argument :limit, Integer, required: false, default_value: 10
argument :offset, Integer, required: false, default_value: 0
end
def user(id:)
User.find_by(id: id)
end
def users(limit:, offset:)
User.limit(limit).offset(offset)
end
end
class Types::MutationType < Types::BaseObject
field :create_user, mutation: Mutations::CreateUser
field :update_post, mutation: Mutations::UpdatePost
end
Mutations
Mutations inherit from GraphQL::Schema::Mutation and define input arguments and return types.
class Mutations::CreateUser < GraphQL::Schema::Mutation
description "Creates a new user"
argument :name, String, required: true
argument :email, String, required: true
field :user, Types::UserType, null: false
field :errors, [String], null: false
def resolve(name:, email:)
user = User.new(name: name, email: email)
if user.save
{ user: user, errors: [] }
else
{ user: nil, errors: user.errors.full_messages }
end
end
end
class Mutations::UpdatePost < GraphQL::Schema::Mutation
argument :id, ID, required: true
argument :title, String, required: false
argument :content, String, required: false
field :post, Types::PostType, null: true
field :errors, [String], null: false
def resolve(id:, **attributes)
post = Post.find_by(id: id)
return { post: nil, errors: ["Post not found"] } unless post
if post.update(attributes.compact)
{ post: post, errors: [] }
else
{ post: nil, errors: post.errors.full_messages }
end
end
end
Schema Configuration
The main schema class combines type definitions and configures execution behavior.
class AppSchema < GraphQL::Schema
query Types::QueryType
mutation Types::MutationType
# Dataloader for batching and caching
use GraphQL::Dataloader
# Query complexity limits
max_complexity 200
max_depth 15
# Error handling
rescue_from(ActiveRecord::RecordNotFound) do |err, obj, args, ctx, field|
raise GraphQL::ExecutionError, "Record not found"
end
end
Controller Integration
Rails controllers execute GraphQL queries and handle HTTP requests.
class GraphqlController < ApplicationController
def execute
variables = prepare_variables(params[:variables])
query = params[:query]
operation_name = params[:operationName]
context = {
current_user: current_user,
request: request
}
result = AppSchema.execute(
query,
variables: variables,
context: context,
operation_name: operation_name
)
render json: result
rescue StandardError => e
handle_error(e)
end
private
def prepare_variables(variables_param)
case variables_param
when String
JSON.parse(variables_param) rescue {}
when Hash
variables_param
when ActionController::Parameters
variables_param.to_unsafe_hash
else
{}
end
end
end
Authorization
Authorization logic integrates at the field level using authorization methods or policies.
class Types::UserType < Types::BaseObject
field :email, String, null: false
def email
return object.email if context[:current_user]&.admin?
return object.email if context[:current_user]&.id == object.id
raise GraphQL::ExecutionError, "Not authorized"
end
end
class Types::PostType < Types::BaseObject
field :content, String, null: false do
authorize :read_content
end
def content
object.content
end
end
Practical Examples
Basic Query with Nested Relations
query GetUserWithPosts {
user(id: "5") {
name
email
posts {
id
title
published
createdAt
}
}
}
The resolver fetches the user and their posts in a single request. The GraphQL execution engine handles the nested relationship traversal.
Parameterized Queries with Variables
query GetUser($userId: ID!, $postsLimit: Int = 10) {
user(id: $userId) {
name
posts(limit: $postsLimit) {
title
content
}
}
}
Variables separate operation logic from data values, enabling query reuse with different parameters. Default values provide fallbacks when clients omit optional variables.
{
"userId": "5",
"postsLimit": 5
}
Multiple Operations with Aliases
query {
alice: user(id: "1") {
name
posts {
title
}
}
bob: user(id: "2") {
name
posts {
title
}
}
}
Aliases prevent field name conflicts when querying the same field multiple times with different arguments. The response uses alias names as keys.
Mutations with Input Types
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
post {
id
title
content
author {
name
}
}
errors
}
}
Variables contain the structured input:
{
"input": {
"title": "GraphQL Best Practices",
"content": "Content here...",
"authorId": "5",
"status": "PUBLISHED"
}
}
Fragments for Reusable Selections
fragment PostDetails on Post {
id
title
content
createdAt
author {
name
email
}
}
query GetPosts {
recentPosts: posts(limit: 5, orderBy: CREATED_DESC) {
...PostDetails
}
popularPosts: posts(limit: 5, orderBy: VIEWS_DESC) {
...PostDetails
}
}
Fragments reduce duplication in complex queries and maintain consistency across similar selections.
Inline Fragments for Union Types
query Search($term: String!) {
search(term: $term) {
... on User {
name
email
}
... on Post {
title
content
author {
name
}
}
}
}
Inline fragments handle union and interface types by specifying fields for each possible type.
Design Considerations
GraphQL vs REST Trade-offs
GraphQL excels when clients need flexible data fetching, particularly mobile applications with bandwidth constraints or complex UIs requiring data from multiple sources. REST remains simpler for straightforward CRUD operations or when HTTP caching strategies provide significant benefits.
GraphQL eliminates over-fetching by allowing precise field selection. REST endpoints return fixed response structures, often including unnecessary data. GraphQL prevents under-fetching by supporting nested queries, where REST requires multiple round trips to fetch related resources.
REST benefits from HTTP-level caching using standard headers and intermediary proxies. GraphQL queries typically use POST requests, bypassing HTTP caching. GraphQL implementations require custom caching strategies, either through persistent queries or application-level caching.
Schema Design Strategies
Schema design impacts API usability and maintainability. Granular field definitions provide flexibility but increase schema complexity. Coarser fields simplify the schema but reduce client control over data fetching.
Connection patterns standardize pagination across relationship fields. The Relay specification defines connection types with edges, nodes, and cursor-based pagination.
class Types::UserType < Types::BaseObject
field :posts_connection, Types::PostType.connection_type, null: false do
argument :first, Integer, required: false
argument :after, String, required: false
end
def posts_connection
object.posts.published
end
end
Schema versioning through field deprecation maintains backward compatibility. Deprecated fields remain functional but marked for removal, giving clients time to migrate.
field :old_field, String, null: true,
deprecation_reason: "Use newField instead"
field :new_field, String, null: true
N+1 Query Problem
Naive resolver implementations trigger the N+1 problem where fetching a list of objects spawns separate queries for each object's relationships. For 100 users, fetching their posts generates 101 database queries.
Dataloader patterns batch and cache data loading within a single request. The graphql-batch or built-in GraphQL::Dataloader gems solve this problem in Ruby.
class Loaders::AssociationLoader < GraphQL::Dataloader::Source
def initialize(model, association)
@model = model
@association = association
end
def fetch(ids)
records = @model.where(id: ids).includes(@association)
ids.map { |id| records.find { |r| r.id == id } }
end
end
class Types::UserType < Types::BaseObject
field :posts, [Types::PostType], null: false
def posts
dataloader.with(Loaders::AssociationLoader, User, :posts)
.load(object.id)
end
end
Dataloader coalesces multiple load calls into batched database queries, reducing the query count from N+1 to 2 regardless of result size.
Error Handling Patterns
GraphQL supports partial success—some fields succeed while others fail. The response includes both data and errors arrays. Field-level errors contain location information pointing to the failed field.
{
"data": {
"user": {
"name": "Alice",
"posts": null
}
},
"errors": [
{
"message": "Not authorized to view posts",
"locations": [{"line": 4, "column": 5}],
"path": ["user", "posts"]
}
]
}
Mutation responses commonly include explicit error fields alongside data fields, providing structured error information.
Security Implications
Query Depth Limits
Malicious clients can craft deeply nested queries that exhaust server resources. Without limits, circular relationships enable infinite query depth.
query MaliciousQuery {
user(id: "1") {
posts {
author {
posts {
author {
posts {
# continues indefinitely...
}
}
}
}
}
}
}
Configure maximum query depth at the schema level:
class AppSchema < GraphQL::Schema
max_depth 10
def self.validate_max_depth(query)
if query.max_depth > 10
raise GraphQL::ExecutionError, "Query depth exceeds limit"
end
end
end
Query Complexity Analysis
Depth limits alone don't prevent expensive queries. Complexity scoring assigns costs to fields based on computational expense. List fields receive higher costs than scalar fields.
class AppSchema < GraphQL::Schema
max_complexity 200
def self.calculate_complexity(query)
# Custom complexity calculation
end
end
class Types::UserType < Types::BaseObject
field :posts, [Types::PostType], null: false, complexity: 5
field :name, String, null: false, complexity: 1
end
Queries exceeding the complexity threshold fail before execution. Clients receive error messages indicating complexity violations.
Authentication and Authorization
Context objects pass authentication information to resolvers. Resolvers check authorization before returning sensitive data.
class GraphqlController < ApplicationController
def execute
context = {
current_user: current_user,
abilities: current_abilities
}
AppSchema.execute(params[:query], context: context)
end
private
def current_user
token = request.headers['Authorization']&.split(' ')&.last
User.find_by(auth_token: token)
end
end
Field-level authorization prevents unauthorized data access:
class Types::PostType < Types::BaseObject
field :draft_content, String, null: true
def draft_content
return nil unless context[:current_user]&.can?(:edit, object)
object.draft_content
end
end
Rate Limiting
Query-based rate limiting considers complexity scores rather than simple request counts. Expensive queries consume more quota than cheap queries.
class GraphqlController < ApplicationController
before_action :check_rate_limit
private
def check_rate_limit
user_id = current_user&.id || request.ip
complexity = calculate_query_complexity(params[:query])
unless rate_limiter.allow?(user_id, complexity)
render json: { errors: ["Rate limit exceeded"] }, status: 429
end
end
end
Introspection in Production
GraphQL introspection queries reveal the complete schema, exposing internal types and fields. Production environments should disable introspection or restrict access to authenticated administrators.
class AppSchema < GraphQL::Schema
disable_introspection_entry_points unless Rails.env.development?
# Or conditionally based on context
def self.introspection(context)
return false unless context[:current_user]&.admin?
true
end
end
Input Validation
Validate mutation inputs beyond type checking. Enforce business rules, sanitize strings, and validate relationships.
class Mutations::CreatePost < GraphQL::Schema::Mutation
argument :title, String, required: true
argument :content, String, required: true
def resolve(title:, content:)
if title.length > 200
raise GraphQL::ExecutionError, "Title too long"
end
if content.strip.empty?
raise GraphQL::ExecutionError, "Content cannot be blank"
end
post = Post.create!(
title: sanitize(title),
content: sanitize(content)
)
{ post: post, errors: [] }
end
private
def sanitize(text)
ActionController::Base.helpers.sanitize(text)
end
end
Tools & Ecosystem
GraphiQL Interface
GraphiQL provides an in-browser IDE for exploring GraphQL APIs. The interface includes query editing with syntax highlighting, auto-completion based on schema introspection, and query execution with response visualization.
# In Rails routes
if Rails.env.development?
mount GraphiQL::Rails::Engine, at: "/graphiql",
graphql_path: "/graphql"
end
GraphiQL displays schema documentation, allowing developers to browse types, fields, and arguments without external documentation tools.
Apollo Client
Apollo Client manages GraphQL data fetching in frontend applications. The library provides caching, optimistic UI updates, and integration with React, Vue, and Angular frameworks.
While primarily JavaScript-focused, Apollo Client connects to any GraphQL server, including Ruby backends. The client handles query batching, error handling, and cache normalization.
Relay Framework
Relay defines conventions for GraphQL schemas, particularly around pagination and global object identification. The framework optimizes data fetching for React applications through static query analysis and automatic data dependencies.
Ruby implementations support Relay conventions:
class Types::BaseObject < GraphQL::Schema::Object
include GraphQL::Types::Relay::HasNodeField
include GraphQL::Types::Relay::HasNodesField
end
class Types::QueryType < Types::BaseObject
field :node, Types::NodeType, null: true do
argument :id, ID, required: true
end
def node(id:)
AppSchema.object_from_id(id, context)
end
end
Testing Tools
The graphql gem includes testing utilities for schema validation and query execution in test environments.
RSpec.describe Types::QueryType do
describe 'user field' do
let(:user) { create(:user) }
let(:query) do
<<~GRAPHQL
query($id: ID!) {
user(id: $id) {
name
email
}
}
GRAPHQL
end
it 'returns user data' do
result = AppSchema.execute(
query,
variables: { id: user.id },
context: { current_user: user }
)
expect(result.dig('data', 'user', 'name')).to eq(user.name)
expect(result['errors']).to be_nil
end
end
end
Mock resolvers isolate unit tests from database dependencies:
RSpec.describe Mutations::CreateUser do
it 'creates user with valid inputs' do
mutation = Mutations::CreateUser.new(object: nil, context: {})
result = mutation.resolve(
name: "Alice",
email: "alice@example.com"
)
expect(result[:user]).to be_present
expect(result[:errors]).to be_empty
end
end
Schema Stitching and Federation
Schema stitching combines multiple GraphQL schemas into a unified gateway. Federation extends stitching with standardized conventions for splitting schemas across services.
The graphql-stitching gem merges schemas:
client1 = GraphQL::Stitching::HttpClient.new(url: "http://service1/graphql")
client2 = GraphQL::Stitching::HttpClient.new(url: "http://service2/graphql")
supergraph = GraphQL::Stitching::Supergraph.new(
schemas: {
service1: { client: client1 },
service2: { client: client2 }
}
)
Performance Monitoring
Application Performance Monitoring (APM) tools track GraphQL query performance. Monitoring identifies slow resolvers, N+1 queries, and complexity hotspots.
class AppSchema < GraphQL::Schema
use GraphQL::Tracing::NewRelicTracing
trace_with CustomTracer
end
class CustomTracer < GraphQL::Tracing::PlatformTracing
def execute_query(query:)
ActiveSupport::Notifications.instrument(
"graphql.execute_query",
query: query.query_string
) { yield }
end
end
Reference
Core Query Syntax
| Element | Syntax | Description |
|---|---|---|
| Query Operation | query GetData { } | Named read operation |
| Anonymous Query | { field } | Unnamed query shorthand |
| Mutation Operation | mutation UpdateData { } | Write operation |
| Variables | query($id: ID!) { } | Parameterized values |
| Fragments | fragment Name on Type { } | Reusable selections |
| Inline Fragments | ... on Type { } | Type-specific selections |
| Aliases | alias: field | Rename result fields |
| Directives | @include(if: $var) | Conditional execution |
Scalar Types
| Type | Ruby Class | Description |
|---|---|---|
| Int | Integer | Signed 32-bit integer |
| Float | Float | Signed double-precision |
| String | String | UTF-8 character sequence |
| Boolean | TrueClass, FalseClass | True or false value |
| ID | String, Integer | Unique identifier |
Type Modifiers
| Syntax | Meaning | Example |
|---|---|---|
| Type | Nullable field | String |
| Type! | Non-null field | String! |
| [Type] | Nullable list of nullable items | [String] |
| [Type!] | Nullable list of non-null items | [String!] |
| [Type]! | Non-null list of nullable items | [String]! |
| [Type!]! | Non-null list of non-null items | [String!]! |
Built-in Directives
| Directive | Arguments | Usage |
|---|---|---|
| @include | if: Boolean! | Include field if condition true |
| @skip | if: Boolean! | Skip field if condition true |
| @deprecated | reason: String | Mark field as deprecated |
Ruby Type Mapping
| GraphQL Type | Ruby Class | Gem Reference |
|---|---|---|
| Object | GraphQL::Schema::Object | Base type class |
| Interface | GraphQL::Schema::Interface | Shared fields |
| Union | GraphQL::Schema::Union | Multiple types |
| Enum | GraphQL::Schema::Enum | Fixed value set |
| Input Object | GraphQL::Schema::InputObject | Structured input |
| Scalar | GraphQL::Schema::Scalar | Custom scalars |
Common Patterns
| Pattern | Purpose | Implementation |
|---|---|---|
| Connection | Paginated relationships | Types::BaseObject.connection_type |
| Edge | List item with cursor | Relay edge type |
| Node Interface | Global ID resolution | include GraphQL::Types::Relay::Node |
| Mutation Response | Result with errors | Separate type with data and errors fields |
Execution Context Keys
| Key | Type | Description |
|---|---|---|
| current_user | User | Authenticated user |
| abilities | Object | Authorization policies |
| request | Request | HTTP request object |
| dataloader | Dataloader | Batching service |
Schema Configuration Options
| Option | Type | Default | Purpose |
|---|---|---|---|
| max_depth | Integer | nil | Query nesting limit |
| max_complexity | Integer | nil | Computational cost limit |
| default_max_page_size | Integer | nil | Pagination default |
| validate_timeout | Float | nil | Validation time limit |
| disable_introspection_entry_points | Boolean | false | Hide schema from queries |