name: rails-best-practices-core description: Apply core Ruby on Rails best practices for architecture, naming, RESTful routing, authorisation, safety, and maintainability. Use by default for Rails coding, refactoring, debugging, feature implementation, migrations, controller/model changes, and code review tasks so baseline standards stay consistent.
Rails Best Practices Core
Use this as the default baseline for Rails work. It is distilled from 37signals codebases such as Campfire and Fizzy, plus DHH's review patterns.
Core Defaults
- Prefer clear, explicit code over clever abstractions. Abstractions must earn their keep; if you cannot point to three or more variations that need it, inline it.
- Keep controllers thin and put domain behaviour in models.
- Prefer Rails conventions and built-ins before adding gems.
- Model state and behaviour with domain concepts, not ad hoc flags.
- Scope tenant and user data through ownership boundaries.
- Favour database constraints for hard invariants; validate in Active Record when user-facing error messages are needed.
- Keep interfaces small; do not add public methods that are not used anywhere.
- Prefer write-time computation over expensive read-time composition, such as counter caches, delegated types, precomputed rollups, and
dependent: :delete_allwhen callbacks are not needed. - Use
params.expect(...)for strong params in modern Rails. - Let unexpected failures crash. Use bang methods such as
create!and handle exceptions at boundaries. Only use!when a non-bang counterpart exists. - Fix root causes, not symptoms, such as
enqueue_after_transaction_commitinstead of retry logic for transaction races. - Ship tests in the same PR as behaviour changes.
Modelling Patterns
- Model state as records, not booleans. Prefer a
Closurerecord with creator and timestamps overclosed: boolean:
has_one :closure, dependent: :destroy
scope :closed, -> { joins(:closure) }
scope :open, -> { where.missing(:closure) }
- Slice large models into concerns named for capabilities, such as
Closeable,Watchable, orAssignable. Keep each concern cohesive, self-contained, and roughly 50-150 lines. - Prefer nested modules under the model namespace, such as
Card::Closeableinapp/models/card/closeable.rb, for domain slices. Reserveapp/models/concerns/for genuinely cross-model behaviour. - Never extract concerns that contain only private methods.
- Put POROs in
app/models/, notapp/services/, when they are model-adjacent: presentation objects (Event::Description), complex operations (SystemCommenter), and view-context bundles (User::Filtering). - Use default lambdas for contextual associations, such as
belongs_to :creator, class_name: "User", default: -> { Current.user }. - Use
Currentattributes for request context (Current.user,Current.account) and cascading setters when one context value resolves another. - Use callbacks for setup and cleanup, not core business logic. Keep callback counts low.
- Reach for Rails shortcuts:
normalizes,store_accessor,delegated_type,generates_token_for, string enums,after_save_commit,touch: true, anddelegate. - Use association extensions for bulk domain operations. Put operations like
grant_toorreviseon thehas_manyproxy; useinsert_allfor bulk creates. - Prefer human-friendly URLs. Override
to_paramwith a per-tenant number when that fits better than exposing raw IDs or UUIDs.
Naming
- Treat naming as design.
ClosurebeatsCardClose;MentionbeatsUserReference. - Prefer positive names:
activeovernot_deleted,visibleovernot_hidden. - Name semantic associations by role, such as
belongs_to :creator, class_name: "User", notbelongs_to :user. - Prefer domain language over technical phrasing:
quota.depleted?overquota.over_limit?. - Use business-focused scopes such as
:active,:unassigned, and:golden, not SQL-ish names such as:without_pop. - Keep domain language consistent. Do not mix
source,resource, andcontainerfor one concept.
REST And Routing
- Treat everything as CRUD. Turn verbs into nouns: close becomes
resource :closure; publish becomesresource :publication. Avoid custom member actions when resource modelling is clearer. - Use singular
resourcefor one-per-parent state. - Use
scope module:to group nested controllers, such asCards::ClosuresController. - Prefer shallow nesting for deep hierarchies.
- Use resource-scoping controller concerns, such as
CardScoped, to set parent records through the current ownership boundary. - Use
resolve "Comment"when polymorphic URL generation should point to a parent with an anchor. - Let the same controllers serve HTML, Turbo, and JSON via
respond_towhen possible. Do not create a separate API namespace unless the API has distinct semantics.
Authorisation
- Avoid Pundit and CanCanCan by default. Prefer simple predicate methods on models, such as
card.editable_by?(user)anduser.can_administer_board?(board). - Controllers enforce access; models define what the permission means.
- Prefer declarative controller macros for authentication posture, such as
allow_unauthenticated_accessandensure_can_administer.
Dependencies
Before adding a gem, ask whether vanilla Rails can do it and whether 50-150 lines in-repo would be simpler than a dependency. Commonly skipped defaults include Devise, Pundit, ViewComponent, RSpec, FactoryBot, Redis when Solid Queue/Cache/Cable can use the database, service objects, form objects, decorators, GraphQL, SPA frameworks, and Tailwind when the app has a different styling convention.
Review Priorities
- Correctness and data safety.
- Multi-tenant and security boundaries.
- Maintainability and readability.
- Performance hot spots.
- Style and polish.
Always Flag
- Unscoped record lookups in tenant-aware flows, such as
Comment.find(params[:id]). - New dependencies without strong justification.
- In-memory filtering or sorting that belongs in SQL, and
.map(&:name)where.pluck(:name)works. - Service objects replacing straightforward model methods.
- Non-RESTful custom actions when resource modelling is clearer.
- Boolean state columns where a record would capture who and when.
- Pages with forms using HTTP caching (
fresh_whenor ETags), because stale CSRF tokens cause 422s. - String status checks (
status == "x") when predicate-style APIs are available. validates :x, uniqueness: truewithout a backing unique index.- Helpers depending on implicit instance variables instead of explicit arguments.
- Unescaped interpolation into
html_safestrings. Escape first:"<b>#{h(input)}</b>".html_safe. - Metaprogramming for two or three cases. Just write the methods.
- Private-only concerns. Inline them.
Review Output
- Start with the highest-severity findings.
- For each finding, include the issue, impact, concrete fix, and file:line reference.
- Be direct and practical.
- End with either
Ship itor a short prioritised fix list.