Ruby on Rails is the framework that popularized "convention over configuration." There's a right place for everything — models in app/models, controllers in app/controllers, business logic in service objects, background work in Active Jobs. The framework has strong opinions, and those opinions have decades of community consensus behind them.
AI tools don't know those opinions. They know Rails exists. What they don't know is which version of Rails you're on, whether you're using Hotwire or React, whether you prefer service objects or concerns, and what test framework your project uses.
The result: generated code that runs but fights the framework. Fat controllers. Direct database calls in views. Background jobs that ignore Rails' error handling. Callbacks in models that create invisible side effects.
A CLAUDE.md file gives Claude the Rails context it needs. Here are 13 rules that prevent the most common AI-generated Rails mistakes.
Rule 1: Declare your Rails version, Ruby version, and key gem versions
## Stack
- Rails: 7.2.x (NOT 6.x or 8.x — routing, Hotwire, and async defaults differ)
- Ruby: 3.3
- Database: PostgreSQL 16
- Key gems: Devise 4.9, Sidekiq 7.x, RSpec 3.13, Hotwire (Turbo + Stimulus)
- Asset pipeline: Propshaft (NOT Sprockets)
Rails 6, 7, and 8 have different defaults for asset pipelines, JavaScript bundling, and background job integrations. Specify the version or Claude generates code for whichever it encountered most in training.
Rule 2: Fat controllers are the enemy — logic belongs in service objects
## Architecture
Controllers do ONE thing: accept a request, call a service object, return a response.
Business logic goes in app/services/ as plain Ruby objects (POROs).
Never put database queries, calculations, or multi-step operations in controllers.
# CORRECT
class OrdersController < ApplicationController
def create
result = Orders::CreateService.call(order_params, current_user)
if result.success?
redirect_to result.order, notice: 'Order created'
else
render :new, status: :unprocessable_entity
end
end
end
# WRONG
def create
@order = Order.new(order_params)
@order.user = current_user
@order.calculate_totals
@order.apply_discount_if_eligible
@order.save
OrderMailer.confirmation(@order).deliver_later
# ... 40 more lines
end
Without this rule, Claude stuffs everything into controller actions. Specify the service object pattern your team uses.
Rule 3: Active Record callbacks are a last resort — not a default
## Active Record Callbacks
Callbacks (before_save, after_create, etc.) are ONLY for data integrity concerns
that must always apply regardless of context (e.g., normalizing a field value).
NEVER use callbacks for:
- Sending emails or notifications
- Calling external APIs
- Triggering background jobs
- Business logic that depends on context
# WRONG — callback couples model to mailer
after_create :send_welcome_email
private
def send_welcome_email
UserMailer.welcome(self).deliver_later
end
# CORRECT — call explicitly from service/controller
Orders::CreateService.call(params).tap do |result|
OrderMailer.confirmation(result.order).deliver_later if result.success?
end
AI-generated Rails code loves after_create callbacks for sending emails. These fire in tests, from seeds, from rake tasks — anywhere the model is saved — creating invisible side effects that are painful to debug.
Rule 4: Scopes for query logic — never raw SQL in controllers or views
## Querying
All query logic goes in model scopes or query objects, not in controllers.
Use named scopes for reusable conditions.
Use query objects in app/queries/ for complex multi-table queries.
NEVER write where("column = ?") directly in controllers or views.
# CORRECT — model scope
class Order < ApplicationRecord
scope :pending, -> { where(status: 'pending') }
scope :for_user, ->(user) { where(user: user) }
scope :recent, -> { order(created_at: :desc).limit(10) }
end
# In controller
@orders = Order.pending.for_user(current_user).recent
Without this rule, Claude writes raw where clauses directly in controller actions. These can't be composed, tested in isolation, or reused.
Rule 5: Background jobs for everything that leaves the request cycle
## Background Jobs
ANY operation that sends email, calls an external API, processes files, or takes
>200ms MUST be performed in a background job using Sidekiq (or your configured
adapter). Never process these synchronously in controllers.
# CORRECT
OrderFulfillmentJob.perform_later(order.id)
WelcomeEmailJob.perform_later(user.id)
# WRONG
OrderFulfillmentService.call(order) # blocks the HTTP response
UserMailer.welcome(user).deliver_now # synchronous email
Specify your job adapter (Sidekiq, GoodJob, Solid Queue). Each has different retry semantics and Claude will use generic defaults otherwise.
Rule 6: Strong parameters in controllers — never mass-assign raw params
## Strong Parameters
ALL controller actions that accept user input MUST use explicit strong parameters.
Never pass params directly to model methods.
List every permitted attribute explicitly — no permit!
# CORRECT
def order_params
params.require(:order).permit(:product_id, :quantity, :shipping_address_id)
end
# WRONG
Order.create(params[:order]) # mass-assignment vulnerability
Order.create(params.permit!) # permits everything — never do this
Claude sometimes generates params.permit! for convenience or passes params[:model] directly. Both are security vulnerabilities.
Rule 7: Turbo and Hotwire conventions (if applicable)
## Hotwire / Turbo (Rails 7+)
This app uses Hotwire (Turbo + Stimulus). Do NOT generate React, Vue, or
jQuery-based UI patterns.
Controller actions that handle Turbo requests respond with:
- Turbo Stream responses for partial page updates
- Turbo Frame responses for in-frame navigation
- Standard redirects for full-page transitions
Format responses explicitly:
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.replace('orders', ...) }
format.html { redirect_to orders_path }
end
Without this rule, Claude generates React components or jQuery AJAX for interactivity in a Hotwire app. Specify your frontend stack explicitly.
Rule 8: Concerns only for truly cross-cutting behavior
## Concerns
Rails concerns (ActiveSupport::Concern) are ONLY for behavior that is genuinely
shared across multiple unrelated models or controllers.
Do NOT use concerns as a way to split up a large model — extract to a service object instead.
# CORRECT use case — Auditable concern shared by Order, Product, User
module Auditable
extend ActiveSupport::Concern
included do
has_many :audit_logs, as: :auditable
end
end
# WRONG — concern is just a fat model split across files
module OrderPricing # Only used by Order — should be a service object
extend ActiveSupport::Concern
...
end
Claude defaults to concerns for extracting logic. Concerns work for cross-cutting behavior; service objects work for domain logic. Without this distinction, you end up with Order including seven concerns that are all only used by Order.
Rule 9: Use current_user from Devise — never roll auth
## Authentication
This app uses Devise for authentication.
- current_user is available in all controllers and views
- Use before_action :authenticate_user! to protect actions
- NEVER implement custom session management or password handling
- User model: app/models/user.rb (Devise modules: :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable)
# CORRECT
before_action :authenticate_user!
def create
@order = current_user.orders.build(order_params)
end
# WRONG — custom auth
def authenticate
session[:user_id] = User.find_by(email: params[:email])&.id
end
Specifying your auth gem (Devise, Rodauth, etc.) prevents Claude from inventing custom session handling that bypasses your security layer.
Rule 10: RSpec for all tests — no Minitest
## Testing
This project uses RSpec (NOT Minitest/Test::Unit).
Test structure:
- spec/models/ — unit tests for model validations, scopes, methods
- spec/requests/ — integration tests for controller/routing
- spec/services/ — unit tests for service objects
- spec/jobs/ — unit tests for background jobs
Use FactoryBot for fixtures. Never use Rails fixtures (test/fixtures/).
Use Shoulda Matchers for model validation tests.
# CORRECT
RSpec.describe Order, type: :model do
let(:order) { build(:order) }
it { is_expected.to belong_to(:user) }
it { is_expected.to validate_presence_of(:status) }
end
Without specifying the test framework, Claude generates Minitest tests for an RSpec project. Specify FactoryBot or Claude will generate Order.create(...) test setup without factories.
Rule 11: Database constraints mirror model validations
## Database Constraints
Every model validation that enforces data integrity MUST have a corresponding
database constraint in the migration.
Validations alone are insufficient — they can be bypassed.
# Model
validates :email, presence: true, uniqueness: true
# Migration — REQUIRED matching constraint
add_index :users, :email, unique: true
add_column :users, :email, :string, null: false
Claude generates model validations without corresponding database constraints. A uniqueness validation without a database unique index will fail under concurrent requests.
Rule 12: i18n for all user-facing strings
## Internationalization
ALL user-facing strings (flash messages, error messages, email subjects, UI labels)
MUST use Rails i18n helpers. Never hardcode English strings in controllers or views.
# CORRECT
flash[:notice] = t('orders.create.success')
# In config/locales/en.yml: orders.create.success: "Order created successfully"
# WRONG
flash[:notice] = "Order created successfully"
Hardcoded strings scattered through controllers and views become a maintenance problem the moment localization is needed. Establishing the pattern from the start costs nothing.
Rule 13: Eager load associations to prevent N+1 queries
## Query Performance
ALWAYS use eager loading (includes, preload, eager_load) when accessing
associations in views or serializers.
Before adding any collection query, identify all associations accessed in the
view/serializer and include them in the query.
# CORRECT
@orders = Order.includes(:user, :items => :product).pending.recent
# WRONG — N+1 in the view
@orders = Order.pending.recent
# View: orders.each { |o| o.user.name } # fires 1 query per order
N+1 queries are the most common Rails performance bug. Claude generates collection queries without eager loading unless explicitly told to check associations.
Putting it together
Rails has one of the most opinionated architectures in web development — and that's the point. The conventions exist so that any Rails developer can navigate any Rails codebase. AI tools that don't know your specific version, your auth gem, your test framework, and your service layer pattern generate code that technically runs while violating every convention your team has agreed on.
A CLAUDE.md file that encodes your Rails conventions means generated code fits your project's architecture instead of fighting it.
The full CLAUDE.md template — covering 25 other frameworks — is in the CLAUDE.md Rules Pack.
→ oliviacraftlat.gumroad.com/l/skdgt — $27, instant download
Top comments (0)