Use when building or reviewing GraphQL APIs in Rails with the graphql-ruby gem. Covers schema design, N+1 prevention with dataloaders, field-level auth, query limits, error handling, and testing resolvers/mutations with RSpec.
Installs into .claude/skills of the current project.
Are you the author of Rails Graphql Best Practices?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/aibot88-rails-graphql-best-practices)
---
name: rails-graphql-best-practices
license: MIT
description: >
Use when building or reviewing GraphQL APIs in Rails with the graphql-ruby gem.
Covers schema design, N+1 prevention with dataloaders, field-level auth, query
limits, error handling, and testing resolvers/mutations with RSpec.
---
# Rails GraphQL Best Practices
Use this skill when **designing, implementing, or reviewing GraphQL APIs** in a Rails application with the `graphql-ruby` gem.
## HARD-GATE
```text
Tests gate implementation — write specs before resolver code (see rspec-best-practices).
Before shipping a resolver/mutation slice, ALL of the following must be true (details in linked sections; do not duplicate checks in prose here):
- N+1 Prevention: use `dataloader.with(Source, Model).load(id)` — NEVER `object.association`
- Authorization: sensitive fields have field-level guards (not type-level alone).
- Type Conventions: paginated collections use Types::*Type.connection_type, not plain arrays.
- Schema safeguards: AppSchema disables introspection in production and sets max_depth / max_complexity.
- TESTING.md: specs in `spec/graphql/` use `AppSchema.execute` — **ALL spec files** (resolver specs AND mutation specs). Never use HTTP controller dispatch for GraphQL specs.
- Error Handling: mutations return `{ result, errors }` with rescue blocks — no unhandled exceptions.
- Documentation: `description:` on every field in every type.
- Resolver Structure: dedicated resolver classes, not inline field blocks.
```
## Workflow: Adding a New Resolver or Mutation
```text
1. SPEC: Write failing spec (happy path + auth + validation error case) — see TESTING.md
2. TYPE: Arguments and return types — Type Conventions for pagination shape
3. IMPLEMENT: Resolver/mutation class delegating to a service object
4. N+1 CHECK: N+1 Prevention (dataloader on every association load from GraphQL)
5. AUTH CHECK: Authorization (field-level guards where data is sensitive)
6. FINAL CHECK: Verify every HARD-GATE item above against the code you wrote — all 8 must be true
7. RUN: Full suite green before PR
```
**DO NOT proceed to step 3 before step 1 is written and failing.**
## Schema Design
### Type Conventions
- Match type and field names to domain language — do not leak internal model names.
- **Paginated collections:** use `connection_type`, never a plain array of nodes.
```ruby
field :orders, Types::OrderType.connection_type, null: false, resolver: Resolvers::Orders::ListResolver
```
### Resolver Structure
- Prefer **dedicated resolver classes** over inline field blocks for non-trivial logic.
- Keep `QueryType` and `MutationType` as entry points only — delegate: `field :summary, resolver: Resolvers::Orders::SummaryResolver`.
## N+1 Prevention
### Detection
- Enable `bullet` gem in development — treat GraphQL N+1s as **Critical** severity.
- Assert query counts with `expect { }.to make_database_queries(count: N)` using `db-query-matchers`.
### Resolution
**FORBIDDEN:** Never call `object.association` directly and never use `.includes` on the scope — every association load MUST go through the dataloader (graphql-ruby 1.12+). This applies both in type field definitions and in list resolvers:
```ruby
# ❌ causes N+1 for every record in the list
def buyer; object.buyer; end
# ✅ batches loads across all records
def buyer
dataloader.with(Sources::RecordById, Buyer).load(object.buyer_id)
end
```
List resolvers must also prime the dataloader for each association the returned records will expose:
```ruby
# app/graphql/resolvers/orders/list_resolver.rb
class Resolvers::Orders::ListResolver < Resolvers::BaseResolver
type Types::OrderType.connection_type, null: false
def resolve
orders = Order.for_user(context[:current_user])
orders.each { |order| dataloader.with(Sources::RecordById, Buyer).load(order.buyer_id) }
orders
end
end
```
**Source class definition:**
```ruby
# app/graphql/sources/record_by_id.rb
class Sources::RecordById < GraphQL::Dataloader::Source
def initialize(model_class)
@model_class = model_class
end
def fetch(ids)
records = @model_class.where(id: ids).index_by(&:id)
ids.map { |id| records[id] }
end
end
```
## Authorization
### Field-Level Authorization
Type-level auth alone is insufficient — add field-level guards for sensitive fields:
```ruby
field :internal_notes, String, null: true do
guard -> (_obj, _args, ctx) { ctx[:current_user]&.admin? }
end
```
For Pundit: `authorize! object, to: :read?, with: OrderPolicy` in the resolver's `resolve` method.
## Schema safeguards
Configure **production introspection** and **query limits** on `AppSchema` in one place:
```ruby
class AppSchema < GraphQL::Schema
disable_introspection_entry_points if Rails.env.production?
max_depth 10
max_complexity 300
end
```
Adjust depth/complexity to your API; document the chosen limits in the PR or schema comments if non-default.
## Error Handling
Mutations must return a structured response — never raise unhandled exceptions to the client:
```ruby
class Mutations::CreateOrder < Mutations::BaseMutation
argument :product_id, ID, required: true
field :order, Types::OrderType, null: true
field :errors, [String], null: false
def resolve(product_id:)
result = Orders::CreateOrder.call(user: context[:current_user], product_id: product_id)
result.success? ? { order: result.order, errors: [] } : { order: nil, errors: result.errors }
rescue ActiveRecord::RecordInvalid => e
{ order: nil, errors: e.record.errors.full_messages }
rescue StandardError => e
Rails.logger.error("Mutation failed: #{e.class}: #{e.message}")
{ order: nil, errors: ['An unexpected error occurred'] }
end
end
```
## Testing
See [TESTING.md](./TESTING.md) for the spec template, paths, and checklist (happy path, unauthenticated, unauthorized, validation `errors`, N+1 counts, limits).
## Documentation
Write `description:` inline on **every** field in every type — no field left undescribed:
```ruby
class Types::OrderType < Types::BaseObject
description "A customer order containing one or more line items."
field :id, ID, null: false, description: "Unique identifier."
field :status, String, null: false, description: "Current order status: pending, confirmed, shipped, delivered."
field :total_cents, Integer, null: false, description: "Total order amount in cents."
end
```
## Integration
| Skill | When to chain |
|-------|---------------|
| **ddd-ubiquitous-language** | Type and field naming must match business language |
| **rails-tdd-slices** | Choose first failing spec (mutation vs query vs resolver unit) |
| **rspec-best-practices** | Full TDD cycle for resolvers and mutations |
| **rails-security-review** | Auth, introspection disable, query depth/complexity limits |
## Assets
- [EXAMPLES.md](EXAMPLES.md)