CtrlK
BlogDocsLog inGet started
Tessl Logo

igmarin/rails-agent-skills

Curated library of 39 AI agent skills for Ruby on Rails development. Organized by category: planning, testing, code-quality, ddd, engines, infrastructure, api, patterns, context, orchestration, and workflows. Includes 5 callable workflow skills (rails-tdd-loop, rails-review-flow, rails-setup-flow, rails-quality-flow, rails-engines-flow) for complete development cycles. Covers code review, architecture, security, testing (RSpec), engines, service objects, DDD patterns, and TDD automation.

95

1.20x
Quality

98%

Does it follow best practices?

Impact

95%

1.20x

Average score across 35 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

SKILL.mdskills/api/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

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

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.
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:

# ❌ 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:

# 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:

# 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:

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:

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:

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 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:

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

SkillWhen to chain
ddd-ubiquitous-languageType and field naming must match business language
rails-tdd-slicesChoose first failing spec (mutation vs query vs resolver unit)
rspec-best-practicesFull TDD cycle for resolvers and mutations
rails-security-reviewAuth, introspection disable, query depth/complexity limits

Assets

skills

api

rails-graphql-best-practices

README.md

tile.json