---
name: rails
description: "Use when building or maintaining a Ruby on Rails app — generating or editing app/ models, controllers, views, config/routes.rb, db/migrate; writing ActiveRecord associations/scopes/queries; building Hotwire (Turbo + Stimulus) UI; Active Job / Solid Queue jobs; or Rails tests. Triggers: 'rails generate', 'fix this N+1', 'add a Turbo Stream', 'belongs_to / has_many', 'is this migration safe on a live table?', 'my system test went flaky after I added a Turbo Frame', 'should this be Sidekiq or Solid Queue?', 'scaffold un CRUD en Rails', 'arreglar una consulta ActiveRecord', 'aquesta migració és segura en producció?'. NOT plain Ruby scripts/gems/CLIs with no Rails loaded (that is ruby)."
tags: [rails, activerecord, hotwire, turbo, stimulus, solid-queue, minitest]
recommends: [ruby, postgresdb, testing-web, deployment, db-migrations]
profiles: []
origin: risco
---

# Build Rails the omakase way

Write Rails the way a core-adjacent senior would: fat-enough models, skinny
controllers, zero N+1, strong parameters, migrations that are reversible and safe on a
live table, and tests that exercise the request rather than the implementation. Lean
into the Rails 8 "omakase" defaults instead of fighting them — the framework already
made the boring choices for you.

This is **Rails the framework**: the MVC request cycle, ActiveRecord, Hotwire, Active
Job, the Rails test stack. The moment code stops loading Rails — a plain script, a gem,
an `Enumerable` refactor with no `ActiveRecord` in sight — it is no longer this skill.
That is `ruby` (see `../ruby/SKILL.md`).

## Target versions

```text
Rails        8.1   (stable line, released 2025-10-22; 8.0 shipped 2024-11-07)
Ruby         3.4+  (floor for current Rails)
Jobs/queue   Solid Queue   (default Active Job backend — database-backed, no Redis)
Cache        Solid Cache   (default cache store — database-backed)
WebSockets   Solid Cable   (default Action Cable adapter — database-backed)
Assets       Propshaft     (default; Sprockets is legacy)
JS           Importmaps    (default; no Node build step required)
Deploy       Kamal 2 + Thruster
Front-end    Hotwire = Turbo 8 + Stimulus
Auth         `rails generate authentication`  (built in; no Devise for basic auth)
```

A fresh Rails 8 app needs **no Redis and no Node**. Stop reaching for Sidekiq/Redis or
a JS bundler by reflex — the Solid trifecta and Importmaps already cover the default
case. Rails 8.1 adds Active Job Continuations (resumable long jobs), Structured Event
Reporting, local CI via `config/ci.rb`, and Markdown rendering.

## When to use

- Generating/editing anything under `app/` (models, controllers, views, jobs, mailers,
  channels), `config/routes.rb`, or `db/migrate/`.
- ActiveRecord work: associations, scopes, validations, callbacks, `includes`/`preload`/
  `eager_load`, query objects, migrations.
- Hotwire UI: Turbo Frames/Streams, morphing, Stimulus controllers, broadcasts.
- Background work via Active Job / Solid Queue; fragment caching via Solid Cache.
- Rails testing: Minitest model/integration/system tests, fixtures, RSpec + Capybara,
  debugging flaky Turbo system tests.
- Upgrading a Rails app or migrating off Redis/Sidekiq onto the Solid stack.

## When NOT to use

- Plain Ruby with no Rails loaded — scripts, gems, CLIs, Bundler packaging → `ruby`.
- PHP/Laravel → `laravel`. Python/Django → `django`. (Route by the language/framework
  the file actually uses.)
- Pure engine-level schema/index/locking depth not expressed as an AR migration →
  `postgresdb` / `mysql` / `db-migrations`.
- Host/container/CI provisioning (Kamal host setup, Docker, runners) → `deployment` /
  `docker` / `github-actions`. This skill writes the Rails-side glue, then hands off.

## The request cycle — where logic lives

Route → controller → model → view. Keep each layer doing one job.

- **Controllers stay skinny.** A controller action finds/builds objects, calls one
  domain method, and renders/redirects. Why: a controller fat with business rules is
  untestable except through the full HTTP stack and impossible to reuse.
- **Domain logic lives on the model.** Validations, scopes, state transitions, and
  computed attributes belong on the AR class. Why: that is where the data and its
  invariants already are.
- **Extract a PORO/service object only when** a method spans 2+ models or does external
  IO (payment, email, third-party API). Don't pre-extract — a one-model method is just
  a model method. Why: premature service objects scatter logic that AR would hold fine.

```ruby
# Good: skinny controller, domain logic on the model.
class PostsController < ApplicationController
  def create
    @post = Current.user.posts.build(post_params)
    if @post.publish   # model method owns the rule
      redirect_to @post, notice: "Published"
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def post_params
    params.expect(post: [:title, :body])   # Rails 8 strong params
  end
end
```

## ActiveRecord

Associations, validations, and scopes are the vocabulary — use them before writing raw
SQL or manual loops.

```ruby
class Post < ApplicationRecord
  belongs_to :author, class_name: "User"
  has_many :comments, dependent: :destroy

  validates :title, presence: true, length: { maximum: 120 }
  scope :published, -> { where.not(published_at: nil) }

  def publish
    update(published_at: Time.current)
  end
end
```

**Kill N+1 with eager loading.** The classic footgun is touching an association inside
a loop, firing one query per row.

```ruby
# Bad: 1 query for posts + 1 per post for its author (N+1).
Post.published.each { |p| puts p.author.name }

# Good: 2 queries total — posts, then all authors in one IN(...).
Post.published.includes(:author).each { |p| puts p.author.name }
```

Gate it: add the `bullet` gem in development, or mark a hot association
`strict_loading` so an accidental lazy load raises instead of silently degrading.

**Migrations must be reversible and safe on a live table.** A migration that locks a
large table blocks every reader and writer for its duration.

```ruby
# Good: a Postgres index built without an exclusive table lock.
class AddIndexToPostsOnAuthor < ActiveRecord::Migration[8.1]
  disable_ddl_transaction!   # required — concurrent index can't run in a txn
  def change
    add_index :posts, :author_id, algorithm: :concurrently
  end
end
```

Rules: never `add_column ..., default:` on a huge legacy table without checking your
Rails/Postgres version handles it without a full rewrite; backfill data in batches with
`find_each` or `update_all`, not one giant `UPDATE` in the migration; add the
`strong_migrations` gem so unsafe operations fail in review, not in production.

Deeper AR — query objects, `strict_loading`, counter caches, composite primary keys,
advisory locks, per-operation safe-migration recipes (add column, rename, backfill in
batches) — is in `references/activerecord.md`.

## Hotwire

Hotwire is the default front-end: Turbo for navigation/updates, Stimulus for behavior.
Reach for the lightest tool that does the job.

| Need | Use | Why |
|------|-----|-----|
| Faster full-page navigation, no code | Turbo Drive (on by default) | Free; intercepts links/forms |
| Replace one bounded region after an action | Turbo **Frame** | Scoped DOM swap, no JS |
| Update several elements / push from server | Turbo **Stream** | Targeted append/replace/remove + broadcasts |
| Re-render a whole page and keep scroll/focus | **Morphing** (Turbo 8) | Diffs the DOM; kills most custom Stream choreography |
| Client behavior (toggle, copy, debounce) | Stimulus controller | Sprinkle, not a SPA |

Default to Turbo Drive and morphing; only hand-write a Turbo Stream when you need to
touch elements that morphing can't infer (e.g. prepend to a list from a broadcast).

```javascript
// app/javascript/controllers/clipboard_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["source"]
  static values = { successText: { type: String, default: "Copied!" } }

  copy() {
    navigator.clipboard.writeText(this.sourceTarget.value)
  }
}
```

Worked Frame/Stream/morph examples, model broadcasting (`broadcasts_to`), Stimulus
values/targets/outlets, and how to system-test Turbo are in `references/hotwire.md`.

## Background jobs & caching

Use Active Job over **Solid Queue** (the Rails 8 default). Two non-negotiable rules:

- **Jobs take serializable arguments — pass IDs, not records.** Why: a serialized AR
  object goes stale between enqueue and run; an ID is re-fetched fresh.
- **Jobs must be idempotent.** Why: retries and at-least-once delivery mean a job can
  run twice; design so the second run is a no-op.

```ruby
class CommentNotificationJob < ApplicationJob
  queue_as :default

  def perform(comment_id)
    comment = Comment.find_by(id: comment_id)
    return unless comment   # idempotent: nothing to do if it's gone
    CommentMailer.posted(comment).deliver_now
  end
end
```

For genuinely long work (large backfills, bulk exports) use **Active Job Continuations**
(Rails 8.1) so a job can checkpoint and resume instead of restarting from zero. Cache
expensive view fragments with the `cache` helper backed by Solid Cache:

```erb
<% cache @post do %>
  <%= render @post.comments %>
<% end %>
```

Do **not** add Sidekiq/Redis to a Rails 8 app unless a real, stated throughput or
latency requirement justifies the extra moving part. Solid Queue covers the default
case on the database you already run.

## Testing

Minitest + fixtures is the framework default; parallel execution is on by default.
RSpec-rails (8.x) is the popular alternative. Pick one and exercise behavior through the
request, never private methods.

| Test type | Base class | Tests |
|-----------|-----------|-------|
| Model / unit | `ActiveSupport::TestCase` | validations, scopes, domain methods |
| Integration | `ActionDispatch::IntegrationTest` | a request → response, status, redirects |
| System (browser) | `ApplicationSystemTestCase` | end-to-end UI incl. Turbo/Stimulus |
| Job | `ActiveJob::TestCase` | enqueue + `perform` behavior |

Fixtures (default) are fast and load once; reach for factories (FactoryBot) only when
per-test object variation gets unwieldy. **Flaky Turbo system test?** It's almost always
a timing race: never `sleep`. Use Capybara's auto-waiting matchers (`assert_selector`,
`assert_text`, `assert_no_selector`) which retry until the DOM settles, and swap the
default Selenium driver for **Cuprite or Playwright** to cut Turbo-timing flake.

Minitest vs RSpec layout, fixtures gotchas, driver config, parallelization, and
request-spec / broadcast-testing patterns are in `references/testing.md`.

## Anti-patterns

| Anti-pattern | Why it bites | Do instead |
|--------------|--------------|------------|
| Business logic in the controller | Untestable except through HTTP; not reusable | Push it onto the model; service object only for cross-model/IO |
| Callback soup (`after_save` chains for business rules) | Hidden order-dependent side effects, brutal to test | Explicit method called from the action; reserve callbacks for data hygiene |
| Touching an association in a loop | N+1 — one query per row, silent in dev | `includes`/`preload`/`eager_load`; gate with `bullet`/`strict_loading` |
| Blocking DDL on a big live table | Locks readers + writers for the whole migration | `disable_ddl_transaction!` + `algorithm: :concurrently`; `strong_migrations` |
| Passing AR records into jobs | Stale serialized state; fails on retry | Pass IDs, re-`find` inside `perform` |
| Adding Sidekiq/Redis to a Rails 8 app by reflex | A new daemon + dependency for nothing | Solid Queue is the default; add Sidekiq only for a stated need |
| Queries or business logic in views | N+1 + logic you can't test | Prepare in controller/model; views only render |
| Testing private methods | Locks tests to internals; refactors break green tests | Test observable behavior through the request |
| Hand-rolled `fetch`/JS for live updates | Reinvents what Turbo gives free | Turbo Stream or morphing |

## Verify

Run `scripts/verify.sh` inside a Rails app for a read-only static gate: it runs RuboCop
(if rubocop-rails is configured) and `bin/rails zeitwerk:check`, and greps for the
statically-detectable anti-patterns above (Sidekiq/Redis in a default app, `.all.each`
mass loads, a concurrent `add_index` missing `disable_ddl_transaction!`). It skips any
check whose tooling is absent and exits non-zero only on a hard violation.
