Rails Best Practices Core
Use this as the default baseline for Rails work. Distilled from 37signals codebases (Campfire, Fizzy) and DHH's review patterns.
Core Defaults
- Prefer clear, explicit code over clever abstractions. Abstractions must earn their keep; if you can't point to 3+ variations that need it, inline it.
- Keep controllers thin and put domain behavior in models.
- Prefer Rails conventions and built-ins before adding gems.
- Model state and behavior with domain concepts, not ad-hoc flags.
- Scope tenant/user data through ownership boundaries.
- Favor database constraints for hard invariants; only validate in AR when you need user-facing error messages.
- Keep interfaces small; don't add public methods that aren't used anywhere.
- Prefer write-time computation over expensive read-time composition (counter caches, delegated types, precomputed roll-ups,
dependent: :delete_all when no callbacks needed).
- Use
params.expect(...) for strong params in modern Rails.
- Let it crash: bang methods (
create!), handle exceptions at boundaries. Only use ! when a non-bang counterpart exists.
- Fix root causes, not symptoms (e.g.
enqueue_after_transaction_commit over retry logic for races).
- Ship tests in the same PR as behavior changes.
Modeling Patterns
- State as records, not booleans. Instead of
closed: boolean, create a Closure record with creator and timestamps. You get who/when for free, and scoping is trivial:
has_one :closure, dependent: :destroy
scope :closed, -> { joins(:closure) }
scope :open, -> { where.missing(:closure) }
- Slice large models into concerns named for capability (
Closeable, Watchable, Assignable), each self-contained (associations + scopes + methods), ~50-150 lines, cohesive. Prefer nested modules under the model's namespace (Card::Closeable in app/models/card/closeable.rb) for domain slices; reserve app/models/concerns/ for genuinely cross-model behavior. Never extract concerns containing only private methods.
- POROs live in
app/models/, not app/services/: presentation objects (Event::Description), complex operations (SystemCommenter), view-context bundles (User::Filtering). They're model-adjacent, not controller-adjacent.
- Default values via lambdas:
belongs_to :creator, class_name: "User", default: -> { Current.user }; belongs_to :account, default: -> { board.account }.
- Current attributes for request context (
Current.user, Current.account), with cascading setters (assigning session resolves identity, which resolves user for the account).
- Callbacks for setup/cleanup, not business logic. Keep callback counts low.
- Rails shortcuts to reach for:
normalizes (data cleanup before validation), store_accessor (JSON columns), delegated_type (heterogeneous collections), generates_token_for (expiring signed tokens), string enums via enum :status, %w[drafted published].index_by(&:itself), after_save_commit, touch: true chains for cache invalidation, delegate.
- Association extensions for bulk domain operations: define
grant_to/revise on the has_many proxy; use insert_all for bulk creates and dependent: :delete_all on join tables with no callbacks.
- Human-friendly URLs: override
to_param with a per-tenant number rather than exposing raw IDs/UUIDs.
Naming
- Spend time on names — naming is design.
Closure beats CardClose; Mention beats UserReference.
- Positive names:
active not not_deleted, visible not not_hidden.
- Semantic associations named for role:
belongs_to :creator, class_name: "User" not belongs_to :user.
- Domain-driven over technical:
quota.depleted? not quota.over_limit?.
- Business-focused scopes:
:active, :unassigned, :golden — not SQL-ish :without_pop.
- Consistent domain language: don't mix
source/resource/container for one concept.
REST & Routing
- Everything is CRUD: turn verbs into nouns. Close →
resource :closure (POST closes, DELETE reopens); publish → resource :publication. No custom member actions.
- Singular
resource for one-per-parent state; scope module: to group nested controllers (Cards::ClosuresController); shallow nesting for deep hierarchies.
- Resource-scoping controller concerns (
CardScoped sets @card via Current.user.accessible_cards.find_by!(...)) shared across nested controllers, including shared Turbo render helpers.
resolve "Comment" for polymorphic URL generation to the parent with an anchor.
- Same controllers serve HTML/Turbo/JSON via
respond_to — no separate API namespace.
Authorization
- No Pundit/CanCanCan: simple predicate methods on models (
card.editable_by?(user), user.can_administer_board?(board)).
- Controllers check (
head :forbidden unless ...), models define what the permission means.
- Declarative controller macros for auth posture:
allow_unauthenticated_access, ensure_can_administer.
Dependencies
Before adding a gem ask: can vanilla Rails do this? Is 50-150 lines in-repo simpler than a dependency? Commonly skipped: Devise, Pundit, ViewComponent, RSpec, FactoryBot, Redis (Solid Queue/Cache/Cable use the DB), service objects, form objects, decorators, GraphQL, SPA frameworks, Tailwind.
Review Priorities
- Correctness and data safety.
- Multi-tenant/security boundaries.
- Maintainability and readability.
- Performance hot spots.
- Style and polish.
Always Flag
- Unscoped record lookups in tenant-aware flows (
Comment.find(params[:id])).
- New dependencies without strong justification.
- In-memory filtering/sorting that belongs in SQL (and
.map(&:name) where .pluck(:name) works).
- Service objects replacing straightforward model methods.
- Non-RESTful custom actions when resource modeling is clearer.
- Boolean state columns where a record would capture who/when.
- Pages with forms using HTTP caching (
fresh_when/etag) — stale CSRF tokens cause 422s.
- String status checks (
status == "x") when predicate-style APIs are available (StringInquirer / string enums).
validates :x, uniqueness: true without a backing unique index.
- Helpers depending on implicit instance variables instead of explicit arguments.
- Unescaped interpolation into
html_safe strings — escape first: "<b>#{h(input)}</b>".html_safe.
- Metaprogramming for 2-3 cases — just write the methods.
- Private-only concerns — inline them.
Review Output
- Start with highest-severity findings.
- For each finding: issue, impact, concrete fix with file:line references.
- Be direct and practical; "This is over-engineered" is a complete sentence.
- End with either
Ship it or a short prioritized fix list.
1---2name: rails-best-practices-core3description: Apply core Ruby on Rails best practices for architecture, naming, safety, and maintainability. Use for most Rails coding, refactoring, and code review tasks so baseline standards stay consistent.4---56# Rails Best Practices Core78Use this as the default baseline for Rails work. Distilled from 37signals codebases (Campfire, Fizzy) and DHH's review patterns.910## Core Defaults1112- Prefer clear, explicit code over clever abstractions. Abstractions must earn their keep; if you can't point to 3+ variations that need it, inline it.13- Keep controllers thin and put domain behavior in models.14- Prefer Rails conventions and built-ins before adding gems.15- Model state and behavior with domain concepts, not ad-hoc flags.16- Scope tenant/user data through ownership boundaries.17- Favor database constraints for hard invariants; only validate in AR when you need user-facing error messages.18- Keep interfaces small; don't add public methods that aren't used anywhere.19- Prefer write-time computation over expensive read-time composition (counter caches, delegated types, precomputed roll-ups, `dependent: :delete_all` when no callbacks needed).20- Use `params.expect(...)` for strong params in modern Rails.21- Let it crash: bang methods (`create!`), handle exceptions at boundaries. Only use `!` when a non-bang counterpart exists.22- Fix root causes, not symptoms (e.g. `enqueue_after_transaction_commit` over retry logic for races).23- Ship tests in the same PR as behavior changes.2425## Modeling Patterns2627- **State as records, not booleans.** Instead of `closed: boolean`, create a `Closure` record with `creator` and timestamps. You get who/when for free, and scoping is trivial:2829```ruby30has_one :closure, dependent: :destroy31scope :closed, -> { joins(:closure) }32scope :open, -> { where.missing(:closure) }33```3435- **Slice large models into concerns** named for capability (`Closeable`, `Watchable`, `Assignable`), each self-contained (associations + scopes + methods), ~50-150 lines, cohesive. Prefer nested modules under the model's namespace (`Card::Closeable` in `app/models/card/closeable.rb`) for domain slices; reserve `app/models/concerns/` for genuinely cross-model behavior. Never extract concerns containing only private methods.36- **POROs live in `app/models/`**, not `app/services/`: presentation objects (`Event::Description`), complex operations (`SystemCommenter`), view-context bundles (`User::Filtering`). They're model-adjacent, not controller-adjacent.37- **Default values via lambdas:** `belongs_to :creator, class_name: "User", default: -> { Current.user }`; `belongs_to :account, default: -> { board.account }`.38- **Current attributes for request context** (`Current.user`, `Current.account`), with cascading setters (assigning `session` resolves `identity`, which resolves `user` for the account).39- **Callbacks for setup/cleanup, not business logic.** Keep callback counts low.40- **Rails shortcuts to reach for:** `normalizes` (data cleanup before validation), `store_accessor` (JSON columns), `delegated_type` (heterogeneous collections), `generates_token_for` (expiring signed tokens), string enums via `enum :status, %w[drafted published].index_by(&:itself)`, `after_save_commit`, `touch: true` chains for cache invalidation, `delegate`.41- **Association extensions for bulk domain operations:** define `grant_to`/`revise` on the `has_many` proxy; use `insert_all` for bulk creates and `dependent: :delete_all` on join tables with no callbacks.42- **Human-friendly URLs:** override `to_param` with a per-tenant `number` rather than exposing raw IDs/UUIDs.4344## Naming4546- Spend time on names — naming is design. `Closure` beats `CardClose`; `Mention` beats `UserReference`.47- Positive names: `active` not `not_deleted`, `visible` not `not_hidden`.48- Semantic associations named for role: `belongs_to :creator, class_name: "User"` not `belongs_to :user`.49- Domain-driven over technical: `quota.depleted?` not `quota.over_limit?`.50- Business-focused scopes: `:active`, `:unassigned`, `:golden` — not SQL-ish `:without_pop`.51- Consistent domain language: don't mix `source`/`resource`/`container` for one concept.5253## REST & Routing5455- Everything is CRUD: turn verbs into nouns. Close → `resource :closure` (POST closes, DELETE reopens); publish → `resource :publication`. No custom member actions.56- Singular `resource` for one-per-parent state; `scope module:` to group nested controllers (`Cards::ClosuresController`); shallow nesting for deep hierarchies.57- Resource-scoping controller concerns (`CardScoped` sets `@card` via `Current.user.accessible_cards.find_by!(...)`) shared across nested controllers, including shared Turbo render helpers.58- `resolve "Comment"` for polymorphic URL generation to the parent with an anchor.59- Same controllers serve HTML/Turbo/JSON via `respond_to` — no separate API namespace.6061## Authorization6263- No Pundit/CanCanCan: simple predicate methods on models (`card.editable_by?(user)`, `user.can_administer_board?(board)`).64- Controllers check (`head :forbidden unless ...`), models define what the permission means.65- Declarative controller macros for auth posture: `allow_unauthenticated_access`, `ensure_can_administer`.6667## Dependencies6869Before adding a gem ask: can vanilla Rails do this? Is 50-150 lines in-repo simpler than a dependency? Commonly skipped: Devise, Pundit, ViewComponent, RSpec, FactoryBot, Redis (Solid Queue/Cache/Cable use the DB), service objects, form objects, decorators, GraphQL, SPA frameworks, Tailwind.7071## Review Priorities72731. Correctness and data safety.742. Multi-tenant/security boundaries.753. Maintainability and readability.764. Performance hot spots.775. Style and polish.7879## Always Flag8081- Unscoped record lookups in tenant-aware flows (`Comment.find(params[:id])`).82- New dependencies without strong justification.83- In-memory filtering/sorting that belongs in SQL (and `.map(&:name)` where `.pluck(:name)` works).84- Service objects replacing straightforward model methods.85- Non-RESTful custom actions when resource modeling is clearer.86- Boolean state columns where a record would capture who/when.87- Pages with forms using HTTP caching (`fresh_when`/etag) — stale CSRF tokens cause 422s.88- String status checks (`status == "x"`) when predicate-style APIs are available (StringInquirer / string enums).89- `validates :x, uniqueness: true` without a backing unique index.90- Helpers depending on implicit instance variables instead of explicit arguments.91- Unescaped interpolation into `html_safe` strings — escape first: `"<b>#{h(input)}</b>".html_safe`.92- Metaprogramming for 2-3 cases — just write the methods.93- Private-only concerns — inline them.9495## Review Output9697- Start with highest-severity findings.98- For each finding: issue, impact, concrete fix with file:line references.99- Be direct and practical; "This is over-engineered" is a complete sentence.100- End with either `Ship it` or a short prioritized fix list.