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.
- name
rails-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.
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
| Version | Defaults worth knowing |
|---|---|
| Rails 7.1 | normalizes, generates_token_for, Dockerfile generated, async queries (load_async) |
| Rails 7.2 | Dev container, browser version guard, YJIT on by default with Ruby 3.3, brakeman/rubocop in new apps |
| Rails 8.0 | Solid Queue, Solid Cache, Solid Cable as defaults (database-backed), Propshaft, Kamal 2 + Thruster, bin/rails generate authentication |
| Rails 8.1 | Job 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: trueplus 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 needs | Load with |
|---|---|
| Association rendered per row | includes(:assoc) |
| Filter on the association | includes(:assoc).references(:assoc) or eager_load |
| Force separate queries | preload(:assoc) |
| Count per row | counter_cache: true on belongs_to, or a left_joins + group query |
| Iterate many records | find_each(batch_size: 1000) |
| Only IDs/columns | pluck(: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
changewith reversible operations; writeup/downwhen not reversible. - On Postgres large tables:
add_index :orders, :status, algorithm: :concurrentlywithdisable_ddl_transaction!. - Add
null: falseto existing columns in steps: add nullable, backfill in batches (a job orin_batches), thenchange_column_nullin a later migration. Postgres:add_check_constraint ... validate: falsethenvalidate_check_constraint. - The
strong_migrationsgem 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
| Need | Tool |
|---|---|
| Replace part of the page on navigation or form submit | turbo_frame_tag with matching IDs |
| Update several page regions after a form | format.turbo_stream in respond_to + a .turbo_stream.erb view using turbo_stream.replace/append/remove |
| Broadcast to other users | broadcasts_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 scroll | Turbo 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_allskip validations and callbacks.default_scopeleaks into every query and association; avoid it.has_many dependent: :destroyon big associations loads and destroys row by row; use:delete_allor a background job.Time.nowignores the app time zone; useTime.current,Date.current.rescue => ein jobs swallows retries; let errors raise soretry_onworks.
Verification
bin/rails test(andbin/rails test:systemfor Hotwire flows) orbundle exec rspecpasses.- Development log shows no N+1 (bullet clean, or strict_loading raising nothing) for the touched pages.
bin/rails db:migrate:statusclean; migration rolls back and re-applies.bundle exec rubocopandbundle exec brakeman --no-pagerreport 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.