Rails Conventions skill for Claude Code

Code that follows the Rails grain instead of fighting it. Claude writes Rails features with generators, resourceful routes and controllers that only find records through the current user. Models carry validations backed by database constraints, views are eager-loaded and guarded by strict_loading, migrations are reversible and safe on big tables, and Turbo and Active Job are used the way Rails 8 defaults expect.

  • namerails-conventions
  • size169 lines · ≈1,800 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Writes Rails 7 and 8 code the Rails way - RESTful resources, thin controllers, Active Record associations, validations, scopes and eager loading, safe migrations, Hotwire with Turbo Frames, Turbo Streams and Stimulus, Active Job with Solid Queue or Sidekiq, credentials, and Minitest or RSpec tests. Use when adding features to a Rails app, reviewing Rails controllers, models or migrations, fixing N+1 queries in views, or wiring Hotwire interactions and background jobs.

Try asking

  • Add order cancellation to this Rails app with a Turbo Stream update
  • This index page fires 200 queries. Fix the N+1
  • Review my Rails migration and job for production safety

Use it when

  • Adding a feature to a Rails 7 or 8 application
  • Reviewing Rails controllers, models, migrations or jobs
  • Fixing N+1 queries in Rails views
  • Wiring Turbo Frames, Turbo Streams or Stimulus

Skip it when

  • The question is plain Ruby style outside Rails — use ruby-idioms
  • You need queue design beyond Active Job basics — use background-jobs

How to check the result

Tests and system tests pass, bullet or strict_loading reports no lazy loads on touched pages, migrations roll back cleanly, and brakeman finds nothing new.

The common mistake it prevents

Looking up records with Order.find(params[:id]) instead of through the current user's association, which exposes other users' data.

~/.claude/skills/rails-conventions/SKILL.md

--- name: rails-conventions description: Writes Rails 7 and 8 code the Rails way - RESTful resources, thin controllers, Active Record associations, validations, scopes and eager… license: MIT ---

Rails rewards following its grain: resourceful routes, models that own their rules, controllers that only translate HTTP, views that never trigger queries, and generators used so files land where every Rails developer expects them.

Workflow

- [ ] 1. Read Gemfile.lock (rails, ruby, test framework, job backend) and config/application.rb
- [ ] 2. Read routes (bin/rails routes -g <name>) and the models involved
- [ ] 3. Generate, do not hand-create (bin/rails g model/controller/migration)
- [ ] 4. Model rules first: validations, associations, scopes, callbacks (sparingly)
- [ ] 5. Thin controller + view/Turbo response
- [ ] 6. Tests: model, request/integration, system for JS interactions
- [ ] 7. Check queries, run bin/rails test (or rspec), rubocop, brakeman

Version notes

VersionDefaults worth knowing
Rails 7.1normalizes, generates_token_for, Dockerfile generated, async queries (load_async)
Rails 7.2Dev container, browser version guard, YJIT on by default with Ruby 3.3, brakeman/rubocop in new apps
Rails 8.0Solid Queue, Solid Cache, Solid Cable as defaults (database-backed), Propshaft, Kamal 2 + Thruster, bin/rails generate authentication
Rails 8.1Job continuations, local CI via bin/ci, deprecated associations

Do not introduce Sidekiq/Redis into an app that already runs Solid Queue unless the user wants it; follow what config.active_job.queue_adapter says.

Routes and controllers

# config/routes.rb
resources :orders, only: %i[index show create] do
  resource :cancellation, only: :create      # POST /orders/:order_id/cancellation
end

Custom verbs become nested singular resources (cancellation, publication) handled by their own controller with standard actions. Controllers:

class OrdersController < ApplicationController
  before_action :set_order, only: :show

  def index
    @orders = Current.user.orders.includes(:line_items).order(created_at: :desc).limit(50)
  end

  def create
    @order = Current.user.orders.build(order_params)
    if @order.save
      redirect_to @order, notice: "Order placed"
    else
      render :new, status: :unprocessable_entity     # Turbo requires 422 to re-render
    end
  end

  private

  def set_order = @order = Current.user.orders.find(params[:id])    # scoped: no IDOR
  def order_params = params.expect(order: [:shipping_address, line_items_attributes: [[:sku, :quantity]]])
end

params.expect (Rails 8) replaces params.require(...).permit(...) and returns 400 instead of 500 on malformed params; use require/permit on Rails 7.x. Always find records through the current user or tenant association.

Models and Active Record

  • Validations mirror database constraints: validates :email, uniqueness: true plus a unique index (the validation alone races).
  • Prefer scopes and class methods for queries; keep callbacks to data normalization on the same record. Side effects (emails, API calls, jobs) belong in the controller or a plain Ruby object, triggered after commit.
  • enum :status, { pending: 0, paid: 1 }, validate: true (Rails 7.1+ syntax).
View needsLoad with
Association rendered per rowincludes(:assoc)
Filter on the associationincludes(:assoc).references(:assoc) or eager_load
Force separate queriespreload(:assoc)
Count per rowcounter_cache: true on belongs_to, or a left_joins + group query
Iterate many recordsfind_each(batch_size: 1000)
Only IDs/columnspluck(:id), select(:id, :name)

Turn on config.active_record.strict_loading_by_default = true in development/test, or strict_loading per association, so lazy loads raise instead of silently querying. The bullet gem reports N+1 and unused eager loading.

Migrations

bin/rails g migration AddStatusToOrders status:integer
bin/rails db:migrate && bin/rails db:rollback && bin/rails db:migrate   # prove reversibility
  • Use change with reversible operations; write up/down when not reversible.
  • On Postgres large tables: add_index :orders, :status, algorithm: :concurrently with disable_ddl_transaction!.
  • Add null: false to existing columns in steps: add nullable, backfill in batches (a job or in_batches), then change_column_null in a later migration. Postgres: add_check_constraint ... validate: false then validate_check_constraint.
  • The strong_migrations gem catches unsafe operations in CI.
  • Never edit a migration already run in production; removing columns uses self.ignored_columns += ["legacy"] first, drop next deploy. Confirm drops with the user.

Hotwire

NeedTool
Replace part of the page on navigation or form submitturbo_frame_tag with matching IDs
Update several page regions after a formformat.turbo_stream in respond_to + a .turbo_stream.erb view using turbo_stream.replace/append/remove
Broadcast to other usersbroadcasts_to / broadcast_append_later_to in the model (via Action Cable/Solid Cable)
Small client behavior (toggle, copy, autosubmit)Stimulus controller with data-controller, targets, values
Whole-page refresh with preserved scrollTurbo 8 morphing: turbo_refreshes_with method: :morph, scroll: :preserve

Failed form submissions must render with status 422 or Turbo will not display them. After PATCH or DELETE, redirect with status: :see_other (303); a 302 can make the browser repeat the original method against the redirect target.

Jobs

class ChargeOrderJob < ApplicationJob
  queue_as :default
  retry_on Stripe::APIConnectionError, wait: :polynomially_longer, attempts: 8
  discard_on ActiveJob::DeserializationError

  def perform(order)
    return if order.charged?          # idempotent: jobs can run twice
    order.charge!
  end
end

Enqueue after commit (after_commit -> { ChargeOrderJob.perform_later(self) } or in the controller after save) so the job never runs before the row is visible. Pass records (GlobalID) or IDs, never large hashes.

Configuration

Secrets via bin/rails credentials:edit --environment production; access with Rails.application.credentials.dig(:stripe, :secret_key). config/master.key and config/credentials/*.key are never committed.

Gotchas

  • update_all, delete_all, insert_all skip validations and callbacks.
  • default_scope leaks into every query and association; avoid it.
  • has_many dependent: :destroy on big associations loads and destroys row by row; use :delete_all or a background job.
  • Time.now ignores the app time zone; use Time.current, Date.current.
  • rescue => e in jobs swallows retries; let errors raise so retry_on works.

Verification

  1. bin/rails test (and bin/rails test:system for Hotwire flows) or bundle exec rspec passes.
  2. Development log shows no N+1 (bullet clean, or strict_loading raising nothing) for the touched pages.
  3. bin/rails db:migrate:status clean; migration rolls back and re-applies.
  4. bundle exec rubocop and bundle exec brakeman --no-pager report no new offenses.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc