$npx -y skills add marckohlbrugge/37signals-skills --skill rails-best-practices-coreApply 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.
| 1 | # Rails Best Practices Core |
| 2 | |
| 3 | Use this as the default baseline for Rails work. Distilled from 37signals codebases (Campfire, Fizzy) and DHH's review patterns. |
| 4 | |
| 5 | ## Core Defaults |
| 6 | |
| 7 | - 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. |
| 8 | - Keep controllers thin and put domain behavior in models. |
| 9 | - Prefer Rails conventions and built-ins before adding gems. |
| 10 | - Model state and behavior with domain concepts, not ad-hoc flags. |
| 11 | - Scope tenant/user data through ownership boundaries. |
| 12 | - Favor database constraints for hard invariants; only validate in AR when you need user-facing error messages. |
| 13 | - Keep interfaces small; don't add public methods that aren't used anywhere. |
| 14 | - Prefer write-time computation over expensive read-time composition (counter caches, delegated types, precomputed roll-ups, `dependent: :delete_all` when no callbacks needed). |
| 15 | - Use `params.expect(...)` for strong params in modern Rails. |
| 16 | - Let it crash: bang methods (`create!`), handle exceptions at boundaries. Only use `!` when a non-bang counterpart exists. |
| 17 | - Fix root causes, not symptoms (e.g. `enqueue_after_transaction_commit` over retry logic for races). |
| 18 | - Ship tests in the same PR as behavior changes. |
| 19 | |
| 20 | ## Modeling Patterns |
| 21 | |
| 22 | - **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: |
| 23 | |
| 24 | ```ruby |
| 25 | has_one :closure, dependent: :destroy |
| 26 | scope :closed, -> { joins(:closure) } |
| 27 | scope :open, -> { where.missing(:closure) } |
| 28 | ``` |
| 29 | |
| 30 | - **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. |
| 31 | - **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. |
| 32 | - **Default values via lambdas:** `belongs_to :creator, class_name: "User", default: -> { Current.user }`; `belongs_to :account, default: -> { board.account }`. |
| 33 | - **Current attributes for request context** (`Current.user`, `Current.account`), with cascading setters (assigning `session` resolves `identity`, which resolves `user` for the account). |
| 34 | - **Callbacks for setup/cleanup, not business logic.** Keep callback counts low. |
| 35 | - **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`. |
| 36 | - **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. |
| 37 | - **Human-friendly URLs:** override `to_param` with a per-tenant `number` rather than exposing raw IDs/UUIDs. |
| 38 | |
| 39 | ## Naming |
| 40 | |
| 41 | - Spend time on names — naming is design. `Closure` beats `CardClose`; `Mention` beats `UserReference`. |
| 42 | - Positive names: `active` not `not_deleted`, `visible` not `not_hidden`. |
| 43 | - Semantic associations named for role: `belongs_to :creator, class_name: "User"` not `belongs_to :user`. |
| 44 | - Domain-driven over technical: `quota.depleted?` not `quota.over_limit?`. |
| 45 | - Business-focused scopes: `:active`, `:unassigned`, `:golden` — not SQL-ish `:without_pop`. |
| 46 | - Consistent domain language: don't mix `source`/`resource`/`container` for one concept. |
| 47 | |
| 48 | ## REST & Routing |
| 49 | |
| 50 | - Everything is CRUD: turn verbs into nouns. Close → `resource :closure` (POST closes, DELETE reopens); publish → `resource :publication`. No custom member actions. |
| 51 | - Singular `resource` for one-per-parent state; `scope module:` to group nested controllers (`Cards::ClosuresController`); shallow nesting for deep hierarchies. |
| 52 | - Resource-scoping controller concerns (`CardScoped` sets `@card` via `Current.user.accessible_cards.find_by!(...)`) shared across nested controllers, including shared Turbo render helpers. |
| 53 | - `resolve "Comment"` for polymorphic URL generation to the parent with an anchor. |
| 54 | - Same controllers serve HTML/Turbo/JSON via `respond_to` — no separate API namespace. |
| 55 | |
| 56 | ## Authorization |
| 57 | |
| 58 | - No Pundit/CanCanCan: simple predicate methods on models (`card.editable_by?(user)`, `user.can_a |