Ruby on Rails is one of the frameworks where Claude Code produces the most inconsistent output without explicit guidance. The framework’s conventions are deep, layered, and frequently misapplied by AI agents that were trained on generic Ruby examples rather than idiomatic Rails.
We analyzed CLAUDE.md files from Rails shops using Claude Code in production and catalogued the failure modes that appear most frequently. The problems are consistent: Claude forgets soft-delete patterns and generates Post.all where Post.kept is required. It picks up system Ruby instead of the asdf shim for the project’s pinned version. It writes has_many scopes without .includes(), generating N+1 queries on every request. It drops permit() calls from strong params when editing controller actions. It invents callback chains that the codebase’s existing models never use.
None of these are Claude Code bugs. They are instruction gaps. A well-structured CLAUDE.md closes them systematically.
This guide provides a production-ready CLAUDE.md template for Rails projects, explains path-scoped rules for large codebases, and documents the specific gotchas that a CLAUDE.md needs to address to get reliable output.
Why Rails Needs a Dedicated CLAUDE.md
Generic project descriptions do not help Claude Code work better in a Rails codebase. What helps is explicit instructions about the three areas where AI agents diverge from Rails conventions most often.
1. ActiveRecord Query Safety
Rails codebases frequently use soft-delete libraries (acts_as_paranoid, discard, paranoia). Claude has no way to know your codebase uses discard unless you say so. Without instruction, it generates Post.all, Post.where(status: :published), and other scopes that silently return deleted records. The same applies to Single Table Inheritance queries (missing type column scoping) and polymorphic associations where the wrong join type gets generated.
2. Environment and Toolchain
Rails projects almost universally use version managers. asdf, rbenv, and rvm all introduce shim layers that Claude Code ignores when it runs commands. The result is bundle exec resolving to system Ruby, Gemfile.lock mismatches, or commands that fail silently because the wrong gem version is loaded. Bundler binstubs (bin/bundle, bin/rspec) and the bin/dev Procfile launcher also need to be explicitly named.
3. Testing Discipline
Rails supports both RSpec and Minitest, and the two have incompatible conventions. Claude will mix fixture syntax into a factory_bot codebase, or write it blocks using Minitest’s test "..." do structure, depending on which pattern it encountered more recently in context. The testing framework, factory library, and spec structure all need to be declared explicitly.
Comparison: Rails AI Output Without vs. With CLAUDE.md
| Issue | Without CLAUDE.md | With CLAUDE.md |
|---|---|---|
| Soft-delete queries | Generates Post.all, returns deleted records | Enforces Post.kept or Post.where(deleted_at: nil) |
| Ruby version | Resolves to system Ruby, ignores asdf shims | Uses correct shim path or bundle exec prefix |
| Testing | Mixes fixture/factory style, wrong assertion syntax | Consistent factory_bot, FactoryBot.create pattern |
| Strong params | Sometimes drops permit() on nested resources | Always includes full permit() structure |
| N+1 queries | Generates bare .all without .includes() | Adds .includes() for declared associations |
| Callbacks | Invents after_save callbacks from unrelated examples | Follows existing callback pattern in the codebase |
Complete CLAUDE.md Template for Rails Projects
This template is copy-paste ready. Adjust the versions, gem names, and conventions to match your project.
# CLAUDE.md — Rails Project Instructions
## Project Overview
- **Framework**: Ruby on Rails 8.0
- **Ruby**: 3.3.4 (managed via asdf — see .tool-versions)
- **Database**: PostgreSQL 16
- **Frontend**: Hotwire (Turbo + Stimulus), no separate SPA framework
- **Testing**: RSpec 3.x + factory_bot + shoulda-matchers
- **Background Jobs**: Sidekiq 7.x
- **Soft Deletes**: discard gem (all models use `include Discard::Model`)
- **Auth**: Devise
## Build and Run Commands
```bash
# Start development server (Procfile.dev via foreman)
bin/dev
# Run all tests
bundle exec rspec
# Run a single spec file
bundle exec rspec spec/models/post_spec.rb
# Run a specific example by line number
bundle exec rspec spec/models/post_spec.rb:42
# Lint
bundle exec rubocop
bundle exec rubocop -a # auto-correct safe offenses
# Database
bundle exec rails db:migrate
bundle exec rails db:migrate:status
bundle exec rails db:rollback
# Generate migration only (do not run it yet)
bundle exec rails g migration AddDeletedAtToPosts deleted_at:datetime:index
# Console
bundle exec rails console
Ruby and Bundler
Always use bundle exec before Ruby commands. Never run ruby, rspec, or rake
bare — the asdf shim resolves the correct version, but gem paths still require bundler.
The .tool-versions file pins Ruby and Node. Do not change it without a migration plan.
ruby 3.3.4
nodejs 20.12.0
If a command fails with a Ruby version error, check asdf current before debugging further.
Soft Deletes (Critical)
ALL models use the discard gem for soft deletion. This has major query implications.
ALWAYS use — scopes that exclude discarded records:
Post.kept— returns only non-discarded recordspost.discard— soft-deletes a recordpost.undiscard— restores a record
NEVER use in application code:
Post.all— includes discarded recordsPost.find_by— may return discarded recordsPost.where(...)— always chain.keptor add.where(discarded_at: nil)
Correct pattern:
# Good
Post.kept.where(user: current_user).order(created_at: :desc)
# Bad — returns deleted posts
Post.where(user: current_user).order(created_at: :desc)
Admin controllers that intentionally access discarded records must comment their intent:
# Admin only — intentionally includes discarded records
Post.unscoped.where(user: user)
ActiveRecord Query Conventions
Association Loading
Always use .includes() for associations rendered in views or serializers.
N+1 queries are caught in development via the bullet gem — do not introduce new ones.
# Good
@posts = Post.kept.includes(:user, :tags).order(created_at: :desc)
# Bad — triggers N+1 for user and tags
@posts = Post.kept.order(created_at: :desc)
Scopes
Define named scopes for reusable query logic. Do not inline complex where clauses in controllers.
# In model
scope :published, -> { kept.where(status: :published) }
scope :recent, -> { order(created_at: :desc) }
# In controller
@posts = Post.published.recent.page(params[:page])
Testing Conventions
Test Framework: RSpec
Use RSpec exclusively. Never write Minitest-style tests. Never use test "..." do blocks.
Factories: factory_bot
Never use fixtures. All test data is created via factory_bot.
# Good
let(:user) { create(:user) }
let(:post) { create(:post, user: user) }
# Bad — fixtures
let(:user) { users(:alice) }
Factory files live in spec/factories/. One file per model.
Request Specs for APIs
Controller actions that serve JSON must have request specs, not controller specs.
# spec/requests/api/posts_spec.rb
RSpec.describe "GET /api/posts", type: :request do
before { sign_in create(:user) }
it "returns kept posts only" do
create(:post, :discarded)
post = create(:post)
get api_posts_path, as: :json
expect(response).to have_http_status(:ok)
expect(json_response["posts"].pluck("id")).to eq([post.id])
end
end
Coverage
Run bundle exec rspec --format documentation for full output.
Coverage is enforced via SimpleCov. Do not lower the threshold.
Controllers and Strong Params
Every controller action that accepts user input must use strong params.
Do not use params[:post] directly. Do not use params.to_unsafe_h.
def post_params
params.require(:post).permit(:title, :body, :status, tag_ids: [])
end
For nested resources, always permit the nested structure explicitly:
def post_params
params.require(:post).permit(
:title,
:body,
comments_attributes: [:id, :body, :_destroy]
)
end
Hotwire Conventions
Turbo Frames
Wrap partial content in <turbo-frame> tags using the dom_id helper.
Frame IDs must match between the link target and the frame definition.
<%# app/views/posts/_post.html.erb %>
<turbo-frame id="<%= dom_id(post) %>">
<%= post.title %>
<%= link_to "Edit", edit_post_path(post) %>
</turbo-frame>
Turbo Streams
Broadcast streams from models via broadcasts_to. Do not write inline stream
templates in controllers.
# In model
class Post < ApplicationRecord
broadcasts_to ->(post) { "posts" }
end
Stimulus Controllers
Keep Stimulus controllers in app/javascript/controllers/.
One controller per file. Use kebab-case for filenames matching the data-controller value.
Service Objects
Business logic that doesn’t belong in a model or controller lives in app/services/.
Service objects are plain Ruby classes with a single public method (call or named verb).
# app/services/posts/publish_service.rb
module Posts
class PublishService
def initialize(post:, user:)
@post = post
@user = user
end
def call
return false unless @user.can_publish?(@post)
@post.update!(status: :published, published_at: Time.current)
end
end
end
Routing
Use nested routes for resources that belong to a parent. Do not nest more than two levels deep.
# Good
resources :users do
resources :posts, only: [:index, :create, :new]
end
# Too deep — flatten it
resources :users do
resources :posts do
resources :comments # avoid
end
end
Always use named route helpers. Never concatenate URLs manually.
## Path-Scoped Rules for Rails
For Rails projects above a certain size, a single CLAUDE.md becomes unwieldy. Claude Code supports path-scoped rules that load only when relevant files are in the working context.
Structure your `.claude/` directory as follows:
.claude/ rules/ global.md models.md controllers.md specs.md frontend.md services.md
Each rules file uses a `paths` frontmatter field to declare when it loads. Claude Code matches these patterns against files currently in the context window.
**global.md** loads for every session:
```markdown
---
description: "Project-wide conventions"
paths: ["**/*"]
---
Use bundle exec for all Ruby commands. Ruby 3.3.4 via asdf. PostgreSQL only.
Never commit credentials. Use Rails credentials or environment variables.
models.md loads when working in app/models/:
---
description: "ActiveRecord and model conventions"
paths: ["app/models/**"]
---
All models include Discard::Model. Always use .kept scope.
Never use Post.all in application code — returns discarded records.
Scopes go in the model. Named scopes over inline where chains.
Associations always use explicit class_name and foreign_key when ambiguous.
controllers.md loads for controller files:
---
description: "Controller and strong params conventions"
paths: ["app/controllers/**"]
---
Always include strong params permit() for every action accepting input.
Use before_action for authentication. Devise current_user helper is available.
JSON responses use jbuilder templates in app/views. No render json: in controllers.
specs.md loads for spec files:
---
description: "RSpec and factory_bot conventions"
paths: ["spec/**"]
---
RSpec only. Never use Minitest syntax.
Factories via factory_bot. No fixtures.
Use let for lazy evaluation, let! for eager evaluation.
Request specs for API endpoints. System specs for full-page flows.
frontend.md loads for JavaScript files:
---
description: "Stimulus and Turbo conventions"
paths: ["app/javascript/**"]
---
Stimulus controllers in app/javascript/controllers/.
One controller per file. Kebab-case filenames.
No jQuery. No fetch() in controllers — use Turbo Streams for server responses.
Path-scoped rules reduce the instructions Claude Code processes per session and keep rule sets easier to maintain. When a controller change and a spec change are in scope simultaneously, both controllers.md and specs.md load together.
Rails-Specific Gotchas Claude Gets Wrong
These are the specific failure modes we found in real Rails codebases using Claude Code without adequate CLAUDE.md coverage.
1. Soft Deletes Generate Wrong Queries
The most impactful issue. When a codebase uses discard or acts_as_paranoid, Claude generates Model.all and Model.where(...) without the required discarded_at: nil filter. The discard gem adds .kept as a named scope, but Claude does not know your project uses discard unless you tell it.
The fix is explicit: name the gem, describe the query pattern, and provide examples of both the correct and incorrect forms.
2. asdf Path Confusion
Claude Code executes commands using the shell environment. In a terminal session started before asdf shims were loaded, bundle and ruby resolve to system versions. The safest CLAUDE.md instruction is to prefix every Ruby command with bundle exec and to note the .tool-versions file as the version source of truth.
3. STI Queries Miss the Type Column
Single Table Inheritance models share a table and distinguish records via a type column. Claude sometimes generates queries that join across the base table without scoping by type, returning all subclass records when only one subclass was intended.
# Wrong — returns Document, Contract, and Invoice when only Invoice intended
Document.where(user: current_user)
# Correct
Invoice.where(user: current_user)
Declare your STI hierarchy in CLAUDE.md and explain that each subclass scopes automatically via the type column.
4. N+1 Queries from Missing includes()
Claude generates readable ActiveRecord chains but frequently omits .includes() for associations referenced in views. The bullet gem surfaces these in development logs, but Claude does not read logs unless prompted. Naming the gem and instructing Claude to add .includes() for associations rendered in views prevents the pattern from being introduced.
5. Nested Strong Params Missing Nested Permit
When editing controller actions that use accepts_nested_attributes_for, Claude often drops the nested _attributes: structure from permit(). The nested resource ends up silently filtered and the update appears to succeed while the nested records are ignored.
# What Claude sometimes generates
params.require(:post).permit(:title, :body)
# What it should generate when post accepts nested comments
params.require(:post).permit(:title, :body, comments_attributes: [:id, :body, :_destroy])
6. Inappropriate Callback Addition
When Claude adds functionality to a model, it sometimes introduces after_save or before_validation callbacks based on patterns it encountered elsewhere. These callbacks can interfere with existing business logic, create circular dependency issues, or fire during test setup in unintended ways.
The CLAUDE.md instruction: use service objects for cross-cutting concerns. Only add callbacks if an equivalent callback already exists in the model.
Integrating with Rails MCP Tools
Two community-built MCP servers extend Claude Code’s Rails awareness at runtime:
- claude-on-rails: exposes live schema introspection, route listing, and model associations via MCP tool calls. Claude can query
schema:poststo see the current columns without readingdb/schema.rbdirectly. - rails-ai-context: provides a
rails:contexttool that returns the current Rails version, loaded gems, and environment configuration.
CLAUDE.md and MCP tools serve different roles. CLAUDE.md encodes static conventions: what testing framework to use, how soft deletes work, when to use service objects. MCP tools provide live data: the current schema, the actual route table, whether a migration has run.
The two complement each other. A CLAUDE.md rule that says “always include the correct foreign key” pairs with an MCP tool that can return the actual column names for any table. Neither replaces the other.
If you use claude-on-rails, add a note to your CLAUDE.md:
## MCP Tools Available
- `schema:<table_name>` — returns live column list for a table
- `routes:list` — returns current route table
- `model:<ModelName>` — returns associations and validations
Use these tools before generating migrations or writing queries against unfamiliar tables.
FAQ
Should I use CLAUDE.md or path-scoped rules for a large Rails app?
Use both. Start with a CLAUDE.md at the project root for conventions that apply everywhere: Ruby version, soft-delete pattern, testing framework, bundle exec prefix. Then add path-scoped rules in .claude/rules/ for domain-specific instructions. Models get ActiveRecord-specific rules. Specs get RSpec-specific rules. The root CLAUDE.md stays short and readable because the path-scoped rules handle detail.
Does CLAUDE.md work with both RSpec and Minitest?
Yes. CLAUDE.md has no preference. The instruction you include determines which framework Claude uses. Declare the framework explicitly in the CLAUDE.md and show an example of the correct test structure. If your codebase mixes frameworks (legacy Minitest + new RSpec), declare which one to use for new files and which directories contain each.
How do I stop Claude Code from using system Ruby instead of asdf?
Prefix every command in your CLAUDE.md with bundle exec, and note that the project uses asdf with a .tool-versions file. Document the asdf-managed versions: ruby 3.3.4, nodejs 20.12.0. The combination of bundle exec prefix and documented version pins resolves most environment conflicts. If you use bin/ stubs (binstubs), document those instead and explain they already include the bundle exec wrapping.
What is the ideal length for a Rails CLAUDE.md?
Aim for 100 to 200 lines of actual instructions. Short enough that Claude loads and processes the full file, long enough to cover the critical conventions. The soft-delete pattern, query conventions, testing framework, and strong params rules are non-negotiable. Code style details that Rubocop already enforces can be omitted from CLAUDE.md since Rubocop will flag violations anyway.
How do I handle multi-environment Rails configuration in CLAUDE.md?
Describe environment differences that affect Claude Code’s behavior. The most common case: seeds that only run in development, factories that only work in test, and credentials that differ per environment. A practical CLAUDE.md note: “Do not use Rails.env.production? guards in application logic; use environment-specific configuration files. Do not hard-code environment names in queries or conditions.”
Should I include database schema info in CLAUDE.md?
Include enough to prevent common mistakes, not the full schema. Declaring that a table uses deleted_at for soft deletes, that users has a role enum column, or that posts uses STI with a type column prevents a category of query errors. The full schema belongs in db/schema.rb, which Claude Code can read directly. MCP tools like claude-on-rails also provide live schema access when that level of detail is needed.
How do soft-delete patterns affect CLAUDE.md rules?
Soft deletes are one of the highest-impact things to document in a Rails CLAUDE.md. Without explicit instruction, every generated query is potentially wrong. The rule needs three parts: which gem or pattern you use (discard, acts_as_paranoid, manual deleted_at), the correct query form (.kept, .where(deleted_at: nil)), and explicit prohibition of the wrong form (Post.all, bare Post.where). Include both a “good” and “bad” example in the CLAUDE.md itself.
Can I use CLAUDE.md with Hanami or Sinatra?
Yes. CLAUDE.md is framework-agnostic. A Sinatra or Hanami CLAUDE.md would document the framework’s conventions in place of Rails-specific ones: Hanami’s slice architecture, Sinatra’s route definitions, the relevant test framework, and environment setup. The structure is the same; the contents differ. The template in this guide is Rails-specific and should be adapted rather than copied wholesale for non-Rails frameworks.
Related Articles
- CLAUDE.md Best Practices: A Complete Guide
- Claude Code Hooks: Real-World Automation Patterns
- How to Write Effective CLAUDE.md Files
- Browse AI coding rules in our gallery
Keep Rails Credentials Out of Your Repository
Rails’ encrypted credentials (config/credentials.yml.enc) protect secrets at rest, but API keys accessed by Claude Code tools and MCP servers still need runtime injection. 1Password CLI’s op run injects secrets at runtime — no .env files committed, no keys in shell history, and an audit log for every secret your AI agent touches.