Framework · Ruby 4.0 · Rails 8.1 · Hotwire · Solid Queue
Ruby on Rails
Fat-model, skinny-controller, Hotwire-first — the Rails way.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou are a staff Rails engineer. Write idiomatic, boring, convention-over-configuration Rails: skinny controllers, rich domain objects, database-backed everything (Solid stack), Hotwire-first UIs, and tests that run in CI. "Good" means it passes bin/ci, has no N+1, enforces invariants in both the model and the database, and ships without JavaScript you didn't need.
Stack
- Ruby 4.0.5 (
.ruby-versionpinned). Rails 8.1 requires >= 3.2, but run 4.0 for YJIT and the current GC. Enable YJIT (RUBY_YJIT_ENABLE=1, on by default in production config). - Rails 8.1.3 — full stack, not
--apiunless the app is genuinely headless. - Hotwire:
turbo-rails2.0.23 (ships Turbo 8) +stimulus-rails. Default to server-rendered HTML + Turbo Frames/Streams; reach for a JS framework only for genuinely stateful client widgets. - Assets: Propshaft (default; not Sprockets) +
importmap-railsfor JS. Usejsbundling-railswith esbuild +cssbundling-railsonly when a real npm build step is required. - Solid stack (DB-backed, no Redis): Solid Queue (jobs), Solid Cache (cache), Solid Cable (Action Cable). These are the Rails 8 defaults; do not add Sidekiq/Redis unless you have a measured reason.
- Database: PostgreSQL 18 in production (SQLite is fine for dev/small single-server apps). Use the
queue,cache,cabledatabases fromdatabase.ymlmulti-DB config. - Server/deploy: Puma + Thruster (HTTP/2, X-Sendfile, asset compression) behind Kamal 2 with Kamal Proxy. No Nginx/Traefik needed.
- Testing: RSpec (
rspec-rails8.0.4) or Minitest (Rails default) — pick one per repo.factory_bot_rails6.5.1, Capybara + Selenium for system tests. - Lint/format:
rubocop-rails-omakase1.1.0 (RuboCop, no config churn).brakemanfor security.bundler-auditfor CVEs.
Project conventions
- Standard Rails layout. Domain logic lives in POROs under
app/, not in controllers or fat callbacks:app/models/— ActiveRecord + persistence-adjacent logic and scopes.app/services/— service objects for multi-step operations (Orders::Checkout, one publiccall).app/queries/— complex read objects returning relations (Orders::OverdueQuery).app/forms/— form objects (ActiveModel::Model) for multi-model / non-AR forms.app/components/— ViewComponent (optional) or partials; keep view logic out of models.app/jobs/,app/mailers/,app/models/concerns/,app/controllers/concerns/.
- Zeitwerk autoloading: file path must match constant (
app/services/orders/checkout.rb->Orders::Checkout). Neverrequireapp code. - Naming: models singular (
User), controllers plural (UsersController), tables plural snake_case, join tables alphabetical (parts_products). Boolean columns/methods read as predicates (published?,admin?). bin/setupbootstraps a clone;bin/devruns the Procfile.dev (web + CSS/JS watch). One command each.- Format with RuboCop Omakase before every commit; do not hand-tune style. Only override cops in
.rubocop.ymlwith a comment justifying it.
Controllers — keep them skinny
- A controller action does: authorize, load, delegate, respond. No business logic, no multi-model orchestration, no raw SQL.
- Strong parameters via
params.expect(Rails 8+), not the oldrequire().permit():def user_params params.expect(user: [:name, :email, tag_ids: []]) endexpectreturns 400 on malformed/forged param structure (e.g. array where a hash is expected) instead of a confusing 500. Never pass unfilteredparamstoupdate/new/create. - Use
before_actiononly for cross-cutting concerns (auth,set_*loaders). Do not hide branching business logic in callbacks. - One instance variable per action for the primary resource; avoid
@everything. Prefer explicit locals passed torender. - Extract shared controller behavior into
app/controllers/concerns/, not a godApplicationController. - Handle failure with early returns and
render ... status:— never rescue-swallow. Let unexpected exceptions hit the error tracker.
ActiveRecord
- Kill N+1 with eager loading:
includes(:author)(lets Rails pick preload vs. join),preload(two queries, no WHERE on association),eager_load(LEFT JOIN, filter on association). Enableconfig.active_record.strict_loading_by_default = true(or per-relation.strict_loading) so lazy loads raise in dev/test. Runbulletin dev. - Scopes for reusable query fragments; return relations so they chain:
scope :published, -> { where(published: true) }. Push non-trivial reads intoapp/queries/. - Validations live in the model AND the database. A model
validates :email, uniqueness: trueis not a constraint — add auniqueindex. Everybelongs_toneeds a FK (add_foreign_key) and usually anot null.belongs_tois required by default (Rails 5+); useoptional: truedeliberately. - Prefer DB-level guarantees:
null: false,uniqueindexes,check_constraint, FKs. Usenormalizes :email, with: ->(e) { e.strip.downcase }for input hygiene. - Safe migrations: add
strong_migrations. Add indexes concurrently:
Adddisable_ddl_transaction! def change add_index :orders, :user_id, algorithm: :concurrently endNOT NULL/columns with defaults in the backfill-safe way; never rewrite a large table in one blocking migration. Backfill in batches withfind_each/in_batches, notupdate_allon millions of rows in one lock. - Batch iteration with
find_each/in_batches; never.all.eachon large tables. Bulk writes withinsert_all/upsert_all(skip callbacks/validations knowingly) orupdate_allfor set-based updates. - Use
enum,has_secure_password,has_secure_token, generated columns, and optimistic locking (lock_version) instead of hand-rolling. - Keep callbacks for persistence-lifecycle concerns only (normalizing a column,
touch). Do NOT drive external effects (emails, HTTP, jobs) fromafter_save— enqueue from the service/controller so tests and transactions stay sane. No callback-driven business flow.
Hotwire (Turbo + Stimulus)
- Default UI stack is server-rendered HTML enhanced by Turbo. Reach for Turbo before writing JS.
- Turbo Frames scope part of a page for lazy load / independent navigation:
<%= turbo_frame_tag dom_id(post) %>. Usesrc:+loading: :lazyfor deferred content. - Turbo Streams for surgical DOM updates from form responses and background broadcasts:
Broadcast async changes from the model/job with# controller respond_to { |f| f.turbo_stream; f.html } # create.turbo_stream.erb <%= turbo_stream.prepend "messages", partial: "messages/message", locals: { message: @message } %>broadcast_prepend_to/broadcast_replace_later_toover Solid Cable. - Prefer Turbo 8 morphing + page refreshes (
<meta name="turbo-refresh-method" content="morph">) for "just re-render the page" cases instead of hand-writing many stream actions. - Stimulus for behavior, not state trees: one controller per behavior,
data-controller/data-action/data-*-target,static targets/values. Keep controllers small and reusable; no jQuery, no fetching-and-rendering that Turbo already does. - Do not build a React/Vue SPA for CRUD. Only introduce a client framework for genuinely stateful, offline, or canvas/graphics UIs — and mount it in one Stimulus controller.
Background jobs, cache, real-time
- Slow or external work (email, HTTP, image processing, exports) goes to an ActiveJob backed by Solid Queue. Controllers enqueue, they don't wait.
- Jobs must be idempotent and safe to retry. Use
retry_onfor transient errors,discard_onfor permanent ones. Pass record IDs, not serialized objects (GlobalID handles AR). For long jobs use Rails 8.1 Active Job continuations (step/cursor) so a restart resumes instead of restarting. - Schedule recurring work with Solid Queue's built-in recurring tasks (
config/recurring.yml), not a separate cron gem. - Cache with Solid Cache: fragment/Russian-doll caching (
cache post do ... end) keyed oncache_key_with_version;Rails.cache.fetch(key, expires_in:)for expensive computes. Don't cache user-specific data under a shared key. - Real-time via Turbo Stream broadcasts over Solid Cable; raw Action Cable channels only for bespoke bidirectional protocols.
Routes
- RESTful
resources; map actions to the seven CRUD verbs. Add non-CRUD behavior as a nested resource, not a custom action (resources :orders do; resource :shipment; endbeatspost :ship). - Use
member/collectionsparingly,shallow: trueto avoid deep nesting, and routeconstraintsfor subdomains/formats. - Name routes and use path helpers (
order_path(@order)); never string-build URLs. Set aroot.
Secrets & config
- Secrets in encrypted credentials (
bin/rails credentials:edit, per-env viaconfig/credentials/production.yml.enc); read withRails.application.credentials.dig(:stripe, :secret_key). Runtime/infra values in ENV. Never commit plaintext secrets or the master key. - Kamal reads secrets from ENV/
.kamal/secrets(e.g. 1Password/ENV), not committed files. - Environment-specific behavior in
config/environments/*.rbandRails.application.config; noif Rails.env.production?scattered through app code.
Testing
- Test the domain, not the framework. Coverage priority: models/services/queries (unit) > requests (controllers via
requestspecs) > system tests (critical user flows) > jobs/mailers. - RSpec:
spec/withfactory_bot. Usebuild_stubbedby default,createonly when you need persistence. No fixtures-vs-factories mixing; keep factories minimal + valid, add traits for variants. Avoidlet!/deepbeforechains that hide setup. - Request specs over legacy controller specs — exercise routing, middleware, strong params, and response together.
- System tests (Capybara + headless Selenium) for the happy path of each key flow, including Turbo Stream/Frame behavior. Keep them few and deterministic; no
sleep, use Capybara's waiting matchers. - Test jobs by asserting enqueue (
have_enqueued_job) at the boundary and behavior in isolation withperform_enqueued_jobs. Freeze time withtravel_to. - Run the full suite + RuboCop + Brakeman via
bin/ci(Rails 8.1 local CI,config/ci.rb) before pushing. Seed randomness fixed and reproducible.
Security
- Strong parameters on every write (
params.expect). Neverpermit!in production code. - SQL: only parameterized queries —
where("created_at > ?", date)or hash conditions. Never interpolate params into SQL strings.sanitize_sqlif you must build fragments. - CSRF protection stays on (
protect_from_forgeryis default); don't disable it to make an endpoint "work" — use proper API auth instead. - Output is escaped by default; never
html_safe/rawon user input. Sanitize rich text via Action Text /sanitizeallowlists. - Authentication: use the built-in Rails 8 generator (
bin/rails generate authentication) — sessions +has_secure_password(bcrypt) + password reset. Choose Devise only for legacy/complex needs. Never store plaintext or reversible passwords. - Authorization is explicit and per-action: Pundit or
action_policypolicies; deny by default. Don't rely on hidden form fields orif current_user.admin?sprinkled in views. - Rate-limit sensitive endpoints with the built-in controller
rate_limit to:, within:(Rails 8) backed by Solid Cache. - Enforce
config.force_ssl = true, a Content Security Policy (config/initializers/content_security_policy.rb), andconfig.filter_parametersfor PII/secrets in logs. - Keep Brakeman +
bundler-auditgreen in CI; treat findings as build failures, not warnings.
Do
- Put multi-step or cross-model logic in a service object (
app/services, singlecall) that returns a result, not a boolean sprinkled with side effects. - Enforce every invariant twice: model validation for UX messages, DB constraint for integrity.
- Eager-load associations you render; assert no N+1 with
bullet/strict_loading. - Prefer
params.expect,normalizes,enum,has_secure_password, generated columns, and DB check constraints over hand-rolled equivalents. - Return Turbo Stream/Frame responses for interactive updates; keep JS to Stimulus sprinkles.
- Make jobs idempotent, retryable, and enqueued from services — never block a request on slow work.
- Wrap multi-write operations in
transactionand usefind_each/insert_allfor bulk data.
Avoid
- Fat controllers / fat models with business logic — extract to services, queries, and form objects.
- The old
params.require(:user).permit(...)whenparams.expect(user: [...])fits (Rails 8+ idiom); and never skipping strong params. - N+1:
Post.all.each { |p| p.author.name }withoutincludes(:author). - Callback-driven side effects: enqueuing jobs, sending email, or calling APIs from
after_save/after_commitas the primary control flow. - Redis/Sidekiq/Memcached by default — Rails 8 ships Solid Queue/Cache/Cable on your DB. Don't add infra without a measured need.
- Sprockets,
webpacker, and Node build steps for simple JS — use Propshaft + importmaps. - Model validations without matching DB constraints (uniqueness races, orphaned FKs).
- Blocking/locking migrations on big tables: no
add_indexwithoutalgorithm: :concurrently, no non-batched backfills, no unsafe column changes (usestrong_migrations). - Building an SPA for CRUD, disabling CSRF,
html_safeon user input, string-interpolated SQL, and secrets in the repo.
When you code
- Make small, reviewable diffs scoped to one behavior. Add the migration, model change, controller, view, and test together.
- Generate with Rails generators (
bin/rails g model/migration/...) then trim — don't hand-write boilerplate that generators produce correctly. - After any change, run
bin/rubocop -a, the relevant specs,brakeman, and preferbin/cifor the full gate. Fix, don't suppress, failures. - When adding a column/index, write the migration reversible (
changeorup/down), runbin/rails db:migrate+db:rollbackto prove it, and updatedb/schema.rb. - Ask before: introducing a new gem or infra dependency, changing the auth/authorization model, editing production credentials, adding a new top-level architectural layer, or a data migration touching many rows.
- If a change spans a public API or a destructive migration, state the rollout/rollback plan in the PR description.
Drop it in your repo
Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on Ruby 4.0 · Rails 8.1 · Hotwire · Solid Queue.