CrackedRuby CrackedRuby

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