# Gemfile
gem 'graphql'
# Installation
# rails generate graphql:install
# app/graphql/types/user_type.rb
module Types
class UserType < Types::BaseObject
field :id, ID, null: false
field :name, String, null: false
field :email, String, null: false
field :bio, String, null: true
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
# Association
field :posts, [Types::PostType], null: false
# Computed field
field :posts_count, Integer, null: false do
description "Total number of posts by user"
end
def posts_count
object.posts.count
end
# Field with arguments
field :recent_posts, [Types::PostType], null: false do
argument :limit, Integer, required: false, default_value: 10
end
def recent_posts(limit:)
object.posts.order(created_at: :desc).limit(limit)
end
# Authorized field
field :private_data, String, null: true do
description "Visible only to user themselves"
end
def private_data
return nil unless context[:current_user]&.id == object.id
object.private_data
end
end
end
# app/graphql/types/post_type.rb
module Types
class PostType < Types::BaseObject
field :id, ID, null: false
field :title, String, null: false
field :content, String, null: false
field :published, Boolean, null: false
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
field :user, Types::UserType, null: false
field :comments, [Types::CommentType], null: false
# Custom resolver
field :excerpt, String, null: false
def excerpt
object.content.truncate(100)
end
end
end
# app/graphql/types/query_type.rb
module Types
class QueryType < Types::BaseObject
# Fetch single user
field :user, Types::UserType, null: true do
description "Find a user by ID"
argument :id, ID, required: true
end
def user(id:)
User.find(id)
end
# Fetch all users with filtering
field :users, [Types::UserType], null: false do
argument :role, String, required: false
argument :search, String, required: false
end
def users(role: nil, search: nil)
users = User.all
users = users.where(role: role) if role.present?
users = users.where("name ILIKE ?", "%#{search}%") if search.present?
users
end
# Current user
field :current_user, Types::UserType, null: true
def current_user
context[:current_user]
end
# Posts with pagination
field :posts, Types::PostType.connection_type, null: false do
argument :published_only, Boolean, required: false, default_value: false
end
def posts(published_only:)
posts = Post.all
posts = posts.where(published: true) if published_only
posts
end
end
end
# app/graphql/mutations/create_post.rb
module Mutations
class CreatePost < BaseMutation
description "Create a new post"
# Input arguments
argument :title, String, required: true
argument :content, String, required: true
argument :published, Boolean, required: false, default_value: false
# Return fields
field :post, Types::PostType, null: true
field :errors, [String], null: false
def resolve(title:, content:, published:)
user = context[:current_user]
unless user
return {
post: nil,
errors: ["Must be logged in"]
}
end
post = user.posts.build(
title: title,
content: content,
published: published
)
if post.save
{
post: post,
errors: []
}
else
{
post: nil,
errors: post.errors.full_messages
}
end
end
end
end
# app/graphql/mutations/update_post.rb
module Mutations
class UpdatePost < BaseMutation
argument :id, ID, required: true
argument :title, String, required: false
argument :content, String, required: false
argument :published, Boolean, required: false
field :post, Types::PostType, null: true
field :errors, [String], null: false
def resolve(id:, **attributes)
post = Post.find(id)
user = context[:current_user]
unless user&.id == post.user_id
return {
post: nil,
errors: ["Not authorized"]
}
end
if post.update(attributes.compact)
{ post: post, errors: [] }
else
{ post: nil, errors: post.errors.full_messages }
end
end
end
end
# app/graphql/types/mutation_type.rb
module Types
class MutationType < Types::BaseObject
field :create_post, mutation: Mutations::CreatePost
field :update_post, mutation: Mutations::UpdatePost
field :delete_post, mutation: Mutations::DeletePost
end
end
# GraphQL controller
class GraphqlController < ApplicationController
def execute
variables = prepare_variables(params[:variables])
query = params[:query]
operation_name = params[:operationName]
context = {
current_user: current_user,
pundit_user: current_user
}
result = MyAppSchema.execute(
query,
variables: variables,
context: context,
operation_name: operation_name
)
render json: result
rescue StandardError => e
raise e unless Rails.env.development?
handle_error_in_development(e)
end
private
def prepare_variables(variables_param)
case variables_param
when String
JSON.parse(variables_param) || {}
when Hash
variables_param
when ActionController::Parameters
variables_param.to_unsafe_hash
when nil
{}
else
raise ArgumentError, "Unexpected variables: #{variables_param}"
end
end
end
# Example GraphQL queries
# Query single user:
# {
# user(id: 1) {
# id
# name
# email
# postsCount
# posts {
# id
# title
# excerpt
# }
# }
# }
# Mutation to create post:
# mutation {
# createPost(input: {
# title: "My Post"
# content: "Post content here"
# published: true
# }) {
# post {
# id
# title
# }
# errors
# }
# }
# Gemfile
gem 'graphql-batch'
# Setup in schema
class MyAppSchema < GraphQL::Schema
use GraphQL::Batch
end
# app/graphql/loaders/record_loader.rb
class RecordLoader < GraphQL::Batch::Loader
def initialize(model)
@model = model
end
def perform(ids)
records = @model.where(id: ids).index_by(&:id)
ids.each { |id| fulfill(id, records[id]) }
end
end
# app/graphql/loaders/association_loader.rb
class AssociationLoader < GraphQL::Batch::Loader
def initialize(model, association_name)
@model = model
@association_name = association_name
end
def perform(records)
# Preload association for all records at once
records_with_association = @model
.where(id: records.map(&:id))
.includes(@association_name)
.index_by(&:id)
records.each do |record|
record_with_association = records_with_association[record.id]
fulfill(record, record_with_association.public_send(@association_name))
end
end
end
# Using loaders in types
module Types
class UserType < Types::BaseObject
field :posts, [Types::PostType], null: false
def posts
AssociationLoader.for(User, :posts).load(object)
end
end
class PostType < Types::BaseObject
field :user, Types::UserType, null: false
def user
RecordLoader.for(User).load(object.user_id)
end
field :comments, [Types::CommentType], null: false
def comments
AssociationLoader.for(Post, :comments).load(object)
end
end
end
# This batches database queries:
# Without loader: N+1 queries
# posts.each { |post| post.user } # Queries for each post
# With loader: 2 queries total
# SELECT * FROM posts
# SELECT * FROM users WHERE id IN (1,2,3,4,5)
# Custom count loader
class CountLoader < GraphQL::Batch::Loader
def initialize(association_name, scope = nil)
@association_name = association_name
@scope = scope
end
def perform(records)
counts = records
.group_by(&:class)
.flat_map { |klass, records|
scope = klass.joins(@association_name)
.where(id: records.map(&:id))
.group("#{klass.table_name}.id")
scope = scope.merge(@scope) if @scope
scope.count
}
records.each do |record|
fulfill(record, counts[record.id] || 0)
end
end
end
# Usage
field :published_posts_count, Integer, null: false
def published_posts_count
CountLoader
.for(:posts, Post.where(published: true))
.load(object)
end
GraphQL enables clients to request exactly the data they need. The graphql-ruby gem implements GraphQL servers in Rails. Types define data structures—ObjectTypes for models, InputTypes for mutations. Queries fetch data; mutations modify data. Resolvers contain business logic for field resolution. I use GraphQL for flexible APIs—mobile apps request different fields than web. DataLoader batches and caches database queries, preventing N+1s. GraphQL introspection enables GraphiQL playground for testing. Subscriptions push real-time updates via ActionCable. Understanding GraphQL's query language and type system is essential. GraphQL reduces API versioning needs—add fields without breaking clients. Testing uses GraphQL execution with mocked contexts.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Money
include Comparable
attr_reader :amount, :currency
def initialize(amount, currency = 'USD')
Value objects for domain modeling
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
Share this code
Here's the card — post it anywhere.