Skip to content
rails-graphql logo

Rails GraphQL Specialist

rails-graphql

GraphQL specialist for Rails applications. Use when building GraphQL schemas, resolvers, mutations, subscriptions, or implementing DataLoader for N+1 prevention. Alternative to REST APIs with flexible querying capabilities.

SKILL.md

Full skill instructions

Rails GraphQL Specialist

Build flexible, efficient GraphQL APIs with Rails.

When to Use This Skill

  • Creating GraphQL schemas and types
  • Implementing query resolvers
  • Building mutations (create, update, delete)
  • Setting up subscriptions (real-time updates)
  • Preventing N+1 queries with DataLoader
  • GraphQL authentication and authorization
  • Query complexity analysis
  • Testing GraphQL endpoints

Setup

# Gemfile
gem 'graphql'
gem 'graphiql-rails', group: :development  # GraphQL IDE

# Install
bundle install
rails generate graphql:install

# This creates:
# app/​graphql/
#   types/
#   mutations/
#   my_app_schema.rb
# app/​controllers/​graphql_controller.rb

Schema Design

Type Definitions

# app/​graphql/​types/​user_type.rb
module Types
  class UserType < Types::BaseObject
    description "A user in the system"

    field :id, ID, null: false
    field :email, String, null: false
    field :name, String, null: true
    field :avatar_url, String, null: true
    field :created_at, GraphQL::Types::ISO8601DateTime, null: false

    # Associations
    field :posts, [Types::PostType], null: true
    field :comments, [Types::CommentType], null: true

    # Computed fields
    field :posts_count, Integer, null: false

    def posts_count
      object.posts.count
    end

    def avatar_url
      object.avatar.attached? ? Rails.application.routes.url_helpers.url_for(object.avatar) : nil
    end
  end
end

Query Type

# app/​graphql/​types/​query_type.rb
module Types
  class QueryType < Types::BaseObject
    # Single resource
    field :user, Types::UserType, null: true do
      description "Find a user by ID"
      argument :id, ID, required: true
    end

    def user(id:)
      User.find_by(id: id)
    end

    # Collection with pagination
    field :users, [Types::UserType], null: false do
      description "List all users"
      argument :limit, Integer, required: false, default_value: 20
      argument :offset, Integer, required: false, default_value: 0
      argument :search, String, required: false
    end

    def users(limit:, offset:, search: nil)
      scope = User.all
      scope = scope.where("name ILIKE ?", "%#{search}%") if search.present?
      scope.limit(limit).offset(offset)
    end

    # Current user
    field :me, Types::UserType, null: true do
      description "Get currently authenticated user"
    end

    def me
      context[:current_user]
    end
  end
end

Mutations

Base Mutation

# app/​graphql/​mutations/​base_mutation.rb
module Mutations
  class BaseMutation < GraphQL::Schema::RelayClassicMutation
    argument_class Types::BaseArgument
    field_class Types::BaseField
    input_object_class Types::BaseInputObject
    object_class Types::BaseObject

    def current_user
      context[:current_user]
    end

    def authenticate!
      raise GraphQL::ExecutionError, "Authentication required" unless current_user
    end

    def authorize!(record, action)
      policy = Pundit.policy!(current_user, record)
      raise GraphQL::ExecutionError, "Not authorized" unless policy.public_send("#{action}?")
    end
  end
end

Create Mutation

# app/​graphql/​mutations/​create_post.rb
module Mutations
  class CreatePost < BaseMutation
    description "Create a new post"

    argument :title, String, required: true
    argument :body, String, required: true
    argument :published, Boolean, required: false, default_value: false

    field :post, Types::PostType, null: true
    field :errors, [String], null: false

    def resolve(title:, body:, published:)
      authenticate!

      post = current_user.posts.build(
        title: title,
        body: body,
        published: published
      )

      if post.save
        # Trigger subscription
        MyAppSchema.subscriptions.trigger('postCreated', {}, post)

        { post: post, errors: [] }
      else
        { post: nil, errors: post.errors.full_messages }
      end
    end
  end
end

Update Mutation

# app/​graphql/​mutations/​update_post.rb
module Mutations
  class UpdatePost < BaseMutation
    argument :id, ID, required: true
    argument :title, String, required: false
    argument :body, String, required: false
    argument :published, Boolean, required: false

    field :post, Types::PostType, null: true
    field :errors, [String], null: false

    def resolve(id:, **attributes)
      authenticate!

      post = Post.find(id)
      authorize!(post, :update)

      if post.update(attributes.compact)
        { post: post, errors: [] }
      else
        { post: nil, errors: post.errors.full_messages }
      end
    rescue ActiveRecord::RecordNotFound
      { post: nil, errors: ["Post not found"] }
    end
  end
end

Delete Mutation

# app/​graphql/​mutations/​delete_post.rb
module Mutations
  class DeletePost < BaseMutation
    argument :id, ID, required: true

    field :success, Boolean, null: false
    field :errors, [String], null: false

    def resolve(id:)
      authenticate!

      post = Post.find(id)
      authorize!(post, :destroy)

      if post.destroy
        { success: true, errors: [] }
      else
        { success: false, errors: post.errors.full_messages }
      end
    rescue ActiveRecord::RecordNotFound
      { success: false, errors: ["Post not found"] }
    end
  end
end

DataLoader (N+1 Prevention)

Record Loader

# app/​graphql/​loaders/​record_loader.rb
module Loaders
  class RecordLoader < GraphQL::Dataloader::Source
    def initialize(model, column: model.primary_key, where: nil)
      @model = model
      @column = column.to_s
      @column_type = model.type_for_attribute(@column)
      @where = where
    end

    def fetch(keys)
      records = @model.where(@column => keys)
      records = records.merge(@where) if @where

      key_to_record = records.index_by { |record| @column_type.cast(record.public_send(@column)) }
      keys.map { |key| key_to_record[@column_type.cast(key)] }
    end
  end
end

Association Loader

# app/​graphql/​loaders/​association_loader.rb
module Loaders
  class AssociationLoader < GraphQL::Dataloader::Source
    def initialize(model, association_name)
      @model = model
      @association_name = association_name
    end

    def fetch(records)
      ActiveRecord::Associations::Preloader.new(
        records: records,
        associations: @association_name
      ).call

      records.map { |record| record.public_send(@association_name) }
    end
  end
end

Usage in Types

module Types
  class PostType < Types::BaseObject
    field :author, Types::UserType, null: false

    def author
      # Instead of: object.author (causes N+1)
      dataloader.with(Loaders::RecordLoader, User).load(object.user_id)
    end

    field :comments, [Types::CommentType], null: false

    def comments
      # Instead of: object.comments (causes N+1)
      dataloader.with(Loaders::AssociationLoader, Post, :comments).load(object)
    end
  end
end

Subscriptions

Subscription Type

# app/​graphql/​types/​subscription_type.rb
module Types
  class SubscriptionType < Types::BaseObject
    field :post_created, Types::PostType, null: false do
      description "Subscribe to new posts"
      argument :author_id, ID, required: false
    end

    def post_created(author_id: nil)
      return object unless author_id
      object if object.user_id.to_s == author_id
    end

    field :comment_added, Types::CommentType, null: false do
      argument :post_id, ID, required: true
    end

    def comment_added(post_id:)
      object if object.post_id.to_s == post_id
    end
  end
end

Triggering Subscriptions

# app/​models/​post.rb
class Post < ApplicationRecord
  after_create_commit :notify_subscribers

  private

  def notify_subscribers
    MyAppSchema.subscriptions.trigger('postCreated', {}, self)
  end
end

# Or manually in mutations
MyAppSchema.subscriptions.trigger(
  'commentAdded',
  { post_id: comment.post_id },
  comment
)

Authentication & Authorization

Controller Setup

# app/​controllers/​graphql_controller.rb
class GraphqlController < ApplicationController
  skip_before_action :verify_authenticity_token

  def execute
    result = MyAppSchema.execute(
      params[:query],
      variables: ensure_hash(params[:variables]),
      context: {
        current_user: current_user,
        current_ability: current_ability,
        request: request
      },
      operation_name: params[:operationName]
    )

    render json: result
  rescue StandardError => e
    raise e unless Rails.env.development?
    handle_error_in_development(e)
  end

  private

  def current_user
    return nil unless request.headers['Authorization'].present?

    token = request.headers['Authorization'].split(' ').last
    decoded = JWT.decode(token, Rails.application.credentials.secret_key_base)[0]
    User.find(decoded['user_id'])
  rescue JWT::DecodeError, ActiveRecord::RecordNotFound
    nil
  end

  def ensure_hash(ambiguous_param)
    case ambiguous_param
    when String
      ambiguous_param.present? ? JSON.parse(ambiguous_param) : {}
    when Hash, ActionController::Parameters
      ambiguous_param
    when nil
      {}
    else
      raise ArgumentError, "Unexpected parameter: #{ambiguous_param}"
    end
  end
end

Field-Level Authorization

module Types
  class UserType < Types::BaseObject
    field :email, String, null: false do
      description "User email (only visible to self or admins)"
    end

    def email
      if context[:current_user] == object || context[:current_user]&.admin?
        object.email
      else
        raise GraphQL::ExecutionError, "Not authorized to view email"
      end
    end

    field :private_notes, String, null: true

    def private_notes
      return nil unless context[:current_user] == object
      object.private_notes
    end
  end
end

Query Complexity & Rate Limiting

# app/​graphql/​my_app_schema.rb
class MyAppSchema < GraphQL::Schema
  mutation(Types::MutationType)
  query(Types::QueryType)
  subscription(Types::SubscriptionType)

  # Limit query complexity
  max_complexity 300
  max_depth 15

  # Validate queries before execution
  validate_max_errors 50

  # Custom complexity analysis
  def self.max_complexity_count_for(query_or_multiplex)
    if query_or_multiplex.context[:current_user]&.admin?
      500  # Higher limit for admins
    else
      300
    end
  end

  # Rate limiting per user
  def self.max_query_count_per_minute_for(query_or_multiplex)
    user = query_or_multiplex.context[:current_user]
    user&.premium? ? 1000 : 100
  end
end

Testing

# spec/​graphql/​mutations/​create_post_spec.rb
RSpec.describe Mutations::CreatePost, type: :graphql do
  let(:user) { create(:user) }
  let(:context) { { current_user: user } }

  let(:mutation) do
    <<~GQL
      mutation($title: String!, $body: String!) {
        createPost(input: { title: $title, body: $body }) {
          post {
            id
            title
            body
          }
          errors
        }
      }
    GQL
  end

  it 'creates a post' do
    variables = { title: "Test Post", body: "Post content" }

    expect {
      result = MyAppSchema.execute(mutation, variables: variables, context: context)
      expect(result.dig('data', 'createPost', 'errors')).to be_empty
    }.to change(Post, :count).by(1)
  end

  it 'returns errors for invalid data' do
    variables = { title: "", body: "" }

    result = MyAppSchema.execute(mutation, variables: variables, context: context)
    errors = result.dig('data', 'createPost', 'errors')

    expect(errors).to be_present
    expect(errors).to include("Title can't be blank")
  end

  it 'requires authentication' do
    result = MyAppSchema.execute(mutation, variables: {}, context: {})
    expect(result['errors']).to be_present
  end
end

Best Practices

✅ Do

  • Use DataLoader to prevent N+1 queries
  • Implement query complexity limits
  • Add field-level authorization
  • Use descriptive field names and descriptions
  • Version your schema with deprecations
  • Test resolvers and mutations
  • Monitor query performance
  • Document your schema

❌ Don't

  • Expose internal database structure directly
  • Allow unlimited query depth/​complexity
  • Skip authorization checks
  • Return raw database errors
  • Create overly nested types
  • Forget to handle null cases
  • Expose sensitive data without checks

Remember: GraphQL gives clients flexibility but requires careful attention to performance, security, and schema design. Always use DataLoader and implement proper authorization.

For detailed examples and advanced patterns, see graphql-reference.md.

More skills from boisenoise

database-cloud-optimization-cost-optimize logo
boisenoise/skills-collections

database-cloud-optimization-cost-optimize

You are a cloud cost optimization expert specializing in reducing infrastructure expenses while maintaining performance and reliability. Analyze cloud spending, identify savings opportunities, and implement cost-effective architectures across AWS, Azure, and GCP.

10 0
View
postmark-automation logo
boisenoise/skills-collections

postmark-automation

Automate Postmark email delivery tasks via Rube MCP (Composio): send templated emails, manage templates, monitor delivery stats and bounces. Always search tools first for current schemas.

10 0
View
game-development logo
boisenoise/skills-collections

game-development

Game development orchestrator. Routes to platform-specific skills based on project needs.

10 0
View
test-fixing logo
boisenoise/skills-collections

test-fixing

Systematically identify and fix all failing tests using smart grouping strategies. Use when explicitly asks to fix tests (\"fix these tests\", \"make tests pass\"), reports test failures (\"tests are failing\", \"test suite is broken\"), or completes implementation and wants tests passing.

10 0
View
notebooklm logo
boisenoise/skills-collections

notebooklm

Interact with Google NotebookLM to query documentation with Gemini's source-grounded answers. Each question opens a fresh browser session, retrieves the answer exclusively from your uploaded documents, and closes.

10 0
View
agent-evaluation logo
boisenoise/skills-collections

agent-evaluation

Testing and benchmarking LLM agents including behavioral testing,

10 0
View
clickup-automation logo
boisenoise/skills-collections

clickup-automation

Automate ClickUp project management including tasks, spaces, folders, lists, comments, and team operations via Rube MCP (Composio). Always search tools first for current schemas.

10 0
View
wordpress-penetration-testing logo
boisenoise/skills-collections

wordpress-penetration-testing

Assess WordPress installations for common vulnerabilities and WordPress 7.0 attack surfaces.

10 0
View
ln-761-secret-scanner logo
boisenoise/skills-collections

ln-761-secret-scanner

Scans codebase for hardcoded secrets with severity classification and remediation guidance. Use when auditing a project for leaked credentials.

10 0
View
azure-monitor-ingestion-java logo
boisenoise/skills-collections

azure-monitor-ingestion-java

Azure Monitor Ingestion SDK for Java. Send custom logs to Azure Monitor via Data Collection Rules (DCR) and Data Collection Endpoints (DCE).

10 0
View
ln-513-agent-reviewer logo
boisenoise/skills-collections

ln-513-agent-reviewer

Runs parallel external agent reviews (Codex + Gemini) on code changes. Process-as-arrive, critical verification with debate. Returns filtered suggestions.

10 0
View
drip-jobs-automation logo
boisenoise/skills-collections

drip-jobs-automation

Automate Drip Jobs tasks via Rube MCP (Composio). Always search tools first for current schemas.

10 0
View

Popular AI tools

Kaiber logo
Video

Kaiber

Generate, edit, and beat-sync AI video with leading models in one workspace.

Paid
View
Vimcal logo
Productivity

Vimcal

The world's fastest calendar for remote work

Free
View

Transform Your Design with AI Designer by ImgCreator.ai

Freemium
View
Akool AI logo
Content & writing

Akool AI

Revolutionizing Video Production with AI-Powered Creativity

Paid
View

Extend an image past the frame and let AI fill the new aspect ratio.

Freemium
View
StarByFace logo
Security

StarByFace

Discover your celebrity doppelgänger with StarByFace!

Free
View
C

ChainClarity explains 700+ crypto whitepapers in plain English, with layered summaries, comparisons, research tools, alerts, and a $4.99 Pro plan.

Freemium
View
Opus Clip logo
Coding & apps

Opus Clip

Opus.ai: Revolutionize Your Web Experience

Free
View