Promptheus/rules53 rule sets · CC0Promptheus hub ↗

Framework · Ruby 4.0 · Rails 8.1 · Hotwire · Solid Queue

Ruby on Rails

Fat-model, skinny-controller, Hotwire-first — the Rails way.

rubyrailshotwireactiverecord

Updated 5 Jul 2026 · CC0

AGENTS.mdrepo root

You 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-version pinned). 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 --api unless the app is genuinely headless.
  • Hotwire: turbo-rails 2.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-rails for JS. Use jsbundling-rails with esbuild + cssbundling-rails only 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, cable databases from database.yml multi-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-rails 8.0.4) or Minitest (Rails default) — pick one per repo. factory_bot_rails 6.5.1, Capybara + Selenium for system tests.
  • Lint/format: rubocop-rails-omakase 1.1.0 (RuboCop, no config churn). brakeman for security. bundler-audit for 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 public call).
    • 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). Never require app 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/setup bootstraps a clone; bin/dev runs 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.yml with 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 old require().permit():
    def user_params
      params.expect(user: [:name, :email, tag_ids: []])
    end
    
    expect returns 400 on malformed/forged param structure (e.g. array where a hash is expected) instead of a confusing 500. Never pass unfiltered params to update/new/create.
  • Use before_action only 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 to render.
  • Extract shared controller behavior into app/controllers/concerns/, not a god ApplicationController.
  • 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). Enable config.active_record.strict_loading_by_default = true (or per-relation .strict_loading) so lazy loads raise in dev/test. Run bullet in dev.
  • Scopes for reusable query fragments; return relations so they chain: scope :published, -> { where(published: true) }. Push non-trivial reads into app/queries/.
  • Validations live in the model AND the database. A model validates :email, uniqueness: true is not a constraint — add a unique index. Every belongs_to needs a FK (add_foreign_key) and usually a not null. belongs_to is required by default (Rails 5+); use optional: true deliberately.
  • Prefer DB-level guarantees: null: false, unique indexes, check_constraint, FKs. Use normalizes :email, with: ->(e) { e.strip.downcase } for input hygiene.
  • Safe migrations: add strong_migrations. Add indexes concurrently:
    disable_ddl_transaction!
    def change
      add_index :orders, :user_id, algorithm: :concurrently
    end
    
    Add NOT NULL/columns with defaults in the backfill-safe way; never rewrite a large table in one blocking migration. Backfill in batches with find_each/in_batches, not update_all on millions of rows in one lock.
  • Batch iteration with find_each / in_batches; never .all.each on large tables. Bulk writes with insert_all/upsert_all (skip callbacks/validations knowingly) or update_all for 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) from after_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) %>. Use src: + loading: :lazy for deferred content.
  • Turbo Streams for surgical DOM updates from form responses and background broadcasts:
    # controller
    respond_to { |f| f.turbo_stream; f.html }
    # create.turbo_stream.erb
    <%= turbo_stream.prepend "messages", partial: "messages/message", locals: { message: @message } %>
    
    Broadcast async changes from the model/job with broadcast_prepend_to/broadcast_replace_later_to over 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_on for transient errors, discard_on for 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 on cache_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; end beats post :ship).
  • Use member/collection sparingly, shallow: true to avoid deep nesting, and route constraints for subdomains/formats.
  • Name routes and use path helpers (order_path(@order)); never string-build URLs. Set a root.

Secrets & config

  • Secrets in encrypted credentials (bin/rails credentials:edit, per-env via config/credentials/production.yml.enc); read with Rails.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/*.rb and Rails.application.config; no if Rails.env.production? scattered through app code.

Testing

  • Test the domain, not the framework. Coverage priority: models/services/queries (unit) > requests (controllers via request specs) > system tests (critical user flows) > jobs/mailers.
  • RSpec: spec/ with factory_bot. Use build_stubbed by default, create only when you need persistence. No fixtures-vs-factories mixing; keep factories minimal + valid, add traits for variants. Avoid let!/deep before chains 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 with perform_enqueued_jobs. Freeze time with travel_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). Never permit! in production code.
  • SQL: only parameterized queries — where("created_at > ?", date) or hash conditions. Never interpolate params into SQL strings. sanitize_sql if you must build fragments.
  • CSRF protection stays on (protect_from_forgery is default); don't disable it to make an endpoint "work" — use proper API auth instead.
  • Output is escaped by default; never html_safe/raw on user input. Sanitize rich text via Action Text / sanitize allowlists.
  • 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_policy policies; deny by default. Don't rely on hidden form fields or if 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), and config.filter_parameters for PII/secrets in logs.
  • Keep Brakeman + bundler-audit green in CI; treat findings as build failures, not warnings.

Do

  • Put multi-step or cross-model logic in a service object (app/services, single call) 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 transaction and use find_each/insert_all for bulk data.

Avoid

  • Fat controllers / fat models with business logic — extract to services, queries, and form objects.
  • The old params.require(:user).permit(...) when params.expect(user: [...]) fits (Rails 8+ idiom); and never skipping strong params.
  • N+1: Post.all.each { |p| p.author.name } without includes(:author).
  • Callback-driven side effects: enqueuing jobs, sending email, or calling APIs from after_save/after_commit as 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_index without algorithm: :concurrently, no non-batched backfills, no unsafe column changes (use strong_migrations).
  • Building an SPA for CRUD, disabling CSRF, html_safe on 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 prefer bin/ci for the full gate. Fix, don't suppress, failures.
  • When adding a column/index, write the migration reversible (change or up/down), run bin/rails db:migrate + db:rollback to prove it, and update db/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.

Back to top ↑