If you've ever asked Claude Code to "add a feature" to a Rails app, you know the failure mode: a 200-line controller, validations duplicated in three places, callbacks chained four-deep, and an N+1 so loud the bullet gem has a panic attack. The model isn't broken — it just doesn't know which Rails you're using. Rails 3 patterns and Rails 7 patterns share syntax but almost nothing else.
A CLAUDE.md at the repo root fixes this. It's the file Claude reads on every turn, and it's where you encode the conventions that turn "generic Ruby" into "the way this app is built."
Here's a battle-tested ruleset for Ruby on Rails projects. Drop it in your repo and watch generated code stop looking like a 2014 tutorial.
Start with project-type detection
Rails has three personalities: classic MVC fullstack, Hotwire-first (Rails 7+ with Turbo + Stimulus), and API mode. Each one rejects patterns that are normal in the others. A Turbo Frame in an --api app is a bug; a respond_to :json block in a Hotwire app is a smell.
The first rule in CLAUDE.md should force the assistant to read Gemfile, config/application.rb, and app/javascript/ before suggesting anything. No assumption — confirmation.
Skinny controllers, fat models, fatter service objects
The single biggest source of Rails decay is controllers that do business logic. Every condition you add to create is a test you have to write three times — controller spec, request spec, integration test.
# Controller stays at three lines
def create
@order = Orders::Create.call(user: current_user, params: order_params)
redirect_to @order, notice: "Order created"
end
Service objects live in app/services/, named as verb phrases (Orders::Create, Payments::Refund), with one public class method: .call. The rule isn't dogma — it's that AI generates dramatically cleaner code when there's an obvious bucket for "non-CRUD logic."
ActiveRecord patterns: scopes, validations, no callbacks
Three rules that compound:
-
Scopes over class methods. Scopes return relations and survive
none. Class methods that return arrays break the chain — and AI will absolutely break the chain if you let it. -
Validations on the model, mirrored by DB constraints. A
validates :email, uniqueness: truewithout a unique index is a race condition waiting for production traffic. -
Callbacks are a code smell.
after_create :charge_card, :send_receipt, :notify_warehouseis how Rails apps become unmaintainable. Side effects belong in service objects where they're explicit, observable, and testable in isolation.
class User < ApplicationRecord
validates :email, presence: true,
uniqueness: { case_sensitive: false },
format: { with: URI::MailTo::EMAIL_REGEXP }
scope :active, -> { where(status: :active) }
scope :premium, -> { where(plan: :premium) }
end
Authorization with Pundit, jobs with Sidekiq
Two rules that prevent the most expensive bugs:
Pundit keeps authorization in app/policies/. No current_user.admin? in views, ever. Add verify_authorized and verify_policy_scoped in ApplicationController so any controller missing an authorize call fails loudly in development.
Sidekiq via ActiveJob for everything that touches a network. Workers must be idempotent — pass IDs, never AR objects, and assume every job runs at least twice:
def perform(order_id)
order = Order.find_by(id: order_id) or return
return if order.receipt_sent_at?
OrderMailer.receipt(order).deliver_now
order.update!(receipt_sent_at: Time.current)
end
That find_by(id:) or return pattern is the difference between "retry-safe" and "your customer got seven receipts because the queue replayed a batch."
Testing: RSpec + FactoryBot, real records
No fixtures, no mocking ActiveRecord. Factories per model in spec/factories/, request specs over controller specs, and stubs reserved for genuine third-party boundaries (Stripe, S3). When AI starts writing allow(User).to receive(:find).and_return(...) it's a sign the test pyramid is upside-down — push it back to factories.
Performance: kill N+1 by default
includes, preload, eager_load — every list that renders associations needs one. Add the bullet gem to development and treat warnings as build failures. Pick the right tool:
-
includes— let Rails decide -
preload— force two queries -
eager_load— force a LEFT OUTER JOIN (needed when filtering on the association)
Rails credentials, not dotenv
Rails 7+ ships encrypted credentials per environment. There is no reason to add dotenv-rails to a new app. Secrets in config/credentials/production.yml.enc, decrypted with RAILS_MASTER_KEY from the host. Done.
EDITOR="code --wait" bin/rails credentials:edit --environment production
Zeitwerk: file path = constant name
Rails 6+ autoloads through Zeitwerk. app/services/orders/create.rb MUST define Orders::Create. No require_relative, no manual autoload. Run bin/rails zeitwerk:check before shipping — it loads the whole app and crashes on naming mismatches.
For acronyms, configure inflections so app/services/api/client.rb defines API::Client instead of Api::Client. AI gets this wrong half the time when it doesn't know your inflections.
Hotwire over JSON-for-frontend
If the app is fullstack Rails, default to Turbo Frames + Turbo Streams + Stimulus. Do NOT add a JSON endpoint to feed a fetch() call from a Stimulus controller — that's the worst of both worlds. Server renders, browser enhances:
<%= turbo_frame_tag post do %>
<article><%= post.title %></article>
<%= link_to "Edit", edit_post_path(post) %>
<% end %>
Asset pipeline: pick one
Propshaft + import maps for light JS, vite_rails for React islands or TypeScript. Never both. Webpacker is retired — don't let AI bring it back because it saw a 2021 tutorial in training data.
API mode: serializers explicit
In --api apps, use jsonapi-serializer, alba, or blueprinter. Never override as_json — it silently leaks every column you add. Version from day one (/api/v1/...), inherit from ActionController::API, skip the view layer.
Migrations: reversible, indexed, concurrent
Every migration reversible. On Postgres, indexes on large tables use algorithm: :concurrently with disable_ddl_transaction!. Add the strong_migrations gem and let it block dangerous operations in CI before they reach staging.
class AddIndexOnOrdersUserId < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def change
add_index :orders, :user_id, algorithm: :concurrently
end
end
The full file
Full ruleset (14 rules, with code examples for every one) as a copy-paste-ready Gist:
→ CLAUDE-ruby-rails.md on GitHub Gist
Drop it at the root of your Rails repo as CLAUDE.md, commit, and your next AI-assisted PR will look like Rails — not like Rails 3.
If this saved you a code review, you'll like the full pack: language-specific CLAUDE.md rules for Go, TypeScript, Python, Java, Kotlin, Scala, Flutter, C++, and more. Drop-in files, no fluff.
Get the full pack: https://oliviacraftlat.gumroad.com/l/skdgt
Free starter sample: https://oliviacraftlat.gumroad.com/l/pomoo
Top comments (0)