| name | rails-patterns |
| description | Deep reference for Ruby on Rails 8 patterns -- Hotwire/Turbo 8 morphing, Sidekiq + Solid Queue, service objects, minitest + fixtures, ActiveRecord, security, deployment with Kamal. Focused on Rails 8-specific and non-obvious patterns. |
| origin | claude-rails (audited, rebuilt, and expanded for nunchuck-skills) |
Ruby on Rails Patterns
Production patterns for Rails 8 applications. Focused on what's non-obvious, Rails 8-specific, or commonly gotten wrong. Generic Rails advice that any model already knows is excluded.
To run an automated review, use the rails-reviewer agent or /rails-review.
Table of Contents
- Hotwire / Turbo 8
- Service Objects
- Controller Patterns
- Model Patterns
- Background Jobs (Sidekiq + Solid Queue)
- Authentication (Rails 8 Generator)
- Testing (Minitest + Fixtures)
- Database & Migrations
- Deployment (Kamal 2)
- ERB Patterns
- Rails 8 Features
- Quick Reference
Hotwire / Turbo 8
Decision Hierarchy
Before reaching for JavaScript, exhaust simpler options:
HTML (links, forms, semantic elements)
→ CSS (transitions, animations, :has(), :target)
→ Turbo Drive (SPA-like navigation for free)
→ Turbo Frames (partial page updates)
→ Turbo Streams (targeted DOM mutations)
→ Turbo 8 Morphing (full-page refresh with diff)
→ Stimulus (lightweight JS behavior)
→ Custom JS (only when nothing above works)
Turbo 8 Morphing (Page Refreshes)
The biggest Hotwire change. Instead of targeted Turbo Stream DOM operations, the server re-renders the full page and morphs only the changed elements.
Setup (in layout head):
<meta name="turbo-refresh-method" content="morph">
<meta name="turbo-refresh-scroll" content="preserve">
Model:
class Board < ApplicationRecord
broadcasts_refreshes
end
View subscription:
<%= turbo_stream_from @board %>
Controller stays vanilla (no turbo_stream format):
def create
@column = @board.columns.create!(column_params)
redirect_to @board
end
Why this is better than targeted broadcasts:
- No coupling between models and views via DOM IDs
- No separate
*.turbo_stream.erb templates to maintain
- Solves the session context problem: each client fetches its own content, so you can't accidentally expose hidden data
- Automatic debouncing of sequential refreshes
data-turbo-permanent preserves UI state (open menus, form input) during morphs
When to still use Turbo Streams: Client-specific targeted updates (flash messages for one user), or when full-page re-render is too expensive.
Turbo Frame Gotchas
<%# Page A: link inside a frame %>
<%= turbo_frame_tag "edit_form" do %>
<%= link_to "Edit", edit_post_path(@post) %>
<% end %>
<%# Page B (edit_post_path): MUST have matching frame tag %>
<%= turbo_frame_tag "edit_form" do %>
<%= form_with model: @post do |f| %>
...
<% end %>
<% end %>
<%# If the target page doesn't have a matching turbo_frame_tag,
the frame renders EMPTY with no error. This is the #1 Turbo debugging issue. %>
Status Codes Matter for Turbo
def create
@post = Post.new(post_params)
if @post.save
redirect_to @post, status: :see_other
else
render :new, status: :unprocessable_entity
end
end
Without :unprocessable_entity, Turbo treats the response as a full page navigation and your form errors don't appear. Without :see_other on redirects after POST/DELETE, Turbo replays the POST instead of following the redirect.
Service Objects
The Callable Pattern
class Users::Register
def initialize(params:, ip_address: nil)
@params = params
@ip_address = ip_address
end
def call
user = User.new(@params)
return Result.failure(user.errors) unless user.save
UserMailer.welcome(user).deliver_later
AuditLog.record(action: "register", user: user, ip: @ip_address)
Result.success(user)
end
end
Result = Data.define(:success?, :value, :errors) do
def self.success(value) = new(success?: true, value: value, errors: nil)
def self.failure(errors) = new(success?: false, value: nil, errors: errors)
end
result = Users::Register.new(params: user_params, ip_address: request.remote_ip).call
if result.success?
redirect_to dashboard_path
else
@user = User.new(user_params)
@user.errors.merge!(result.errors)
render :new, status: :unprocessable_entity
end
When NOT to Use a Service
Don't wrap single ActiveRecord calls just for architecture:
class Posts::Find
def initialize(id:) = @id = id
def call = Post.find(@id)
end
Post.find(params[:id])
Use services when: business logic beyond CRUD, multiple models coordinate, side effects (email, audit, webhooks), or the logic needs independent testing.
File Organization
app/services/
├── users/
│ ├── register.rb
│ ├── deactivate.rb
│ └── export_data.rb
├── orders/
│ ├── process.rb
│ ├── refund.rb
│ └── calculate_total.rb
└── external/
├── stripe_sync.rb
└── email_provider.rb
Namespace by domain, not by type (Users::Register not RegisterUserService).
Controller Patterns
CRUD-Only Actions (37signals Style)
Only 7 REST actions. Custom operations become new resources:
class PostsController < ApplicationController
def archive = ...
def publish = ...
def feature = ...
end
class Posts::ArchivalsController < ApplicationController
def create
@post = Post.find(params[:post_id])
@post.archive!
redirect_to @post, status: :see_other
end
end
params.expect (Rails 8)
Replaces require.permit. Returns 400 on malformed input instead of 500:
params.require(:user).permit(:name, :email)
params.expect(user: [:name, :email])
params.expect(post: [:title, categories: [[:name]]])
Model Patterns
normalizes -- Automatic Attribute Normalization
class User < ApplicationRecord
normalizes :email, with: -> (e) { e.strip.downcase }
normalizes :phone, with: -> (p) { p.delete("^0-9").delete_prefix("1") }
end
user.email = " FOO@BAR.COM "
user.email
User.find_by(email: "FOO@bar.com")
generates_token_for -- Purpose-Built Tokens
class User < ApplicationRecord
has_secure_password
generates_token_for :password_reset, expires_in: 15.minutes do
password_salt&.last(10)
end
generates_token_for :email_confirmation, expires_in: 24.hours do
email
end
end
token = user.generate_token_for(:password_reset)
user = User.find_by_token_for(:password_reset, token)
The block value is embedded in the token. If the tracked attribute changes (password_salt after password change), the token auto-invalidates.
Dual Validation (Model + DB Constraint)
class User < ApplicationRecord
validates :email, uniqueness: true
end
add_index :users, :email, unique: true
Model-level validation is not thread-safe. Two requests checking uniqueness simultaneously can both pass validation. The database constraint is the last line of defense.
Enum with Explicit Values
class Order < ApplicationRecord
enum :status, {
pending: "pending",
processing: "processing",
shipped: "shipped",
delivered: "delivered",
cancelled: "cancelled",
}
end
Callbacks: Only for Model-Intrinsic Behavior
class Post < ApplicationRecord
before_validation :generate_slug, if: -> { slug.blank? }
before_destroy :ensure_not_published
private
def generate_slug = self.slug = title.parameterize
def ensure_not_published
throw(:abort) if published?
end
end
class Post < ApplicationRecord
after_create :send_notification
after_update :sync_to_search_index
after_destroy :update_user_stats
end
Strict Loading (Catch N+1 in Dev)
config.active_record.strict_loading_by_default = true
posts = Post.all
posts.first.comments
posts = Post.includes(:comments).all
posts.first.comments
Background Jobs
Sidekiq
Idempotent Job Design
The most important pattern. Sidekiq retries failed jobs 25 times by default. If your job isn't idempotent, retries create duplicates.
class ProcessPaymentJob
include Sidekiq::Job
def perform(order_id)
order = Order.find(order_id)
PaymentGateway.charge(order.amount)
order.update!(status: :paid)
end
end
class ProcessPaymentJob
include Sidekiq::Job
def perform(order_id)
order = Order.find(order_id)
return if order.paid?
PaymentGateway.charge(order.amount, idempotency_key: order_id)
order.update!(status: :paid)
end
end
Use find_by with Early Return, Not find
def perform(user_id)
user = User.find(user_id)
user.send_welcome_email
end
def perform(user_id)
user = User.find_by(id: user_id)
return unless user
user.send_welcome_email
end
Handle Exhausted Retries
class ImportJob
include Sidekiq::Job
sidekiq_options retry: 5
sidekiq_retries_exhausted do |job, exception|
Rails.error.report(exception, context: {
job_class: job["class"],
job_id: job["jid"],
args: job["args"],
})
Import.find_by(id: job["args"].first)&.update!(status: :failed)
end
def perform(import_id)
import = Import.find_by(id: import_id)
return unless import
end
end
Testing Job Logic Directly
Test the perform method, not Sidekiq infrastructure:
test "processes payment" do
order = orders(:unpaid)
ProcessPaymentJob.new.perform(order.id)
assert_equal "paid", order.reload.status
end
test "skips already paid orders" do
order = orders(:paid)
ProcessPaymentJob.new.perform(order.id)
end
test "handles deleted orders" do
ProcessPaymentJob.new.perform(SecureRandom.uuid)
end
Scheduling
ImportJob.perform_async(import.id)
ReminderJob.perform_in(1.hour, user.id)
ReportJob.perform_at(Date.tomorrow.noon, account.id)
For recurring jobs, use sidekiq-cron or sidekiq-scheduler:
daily_summary:
cron: "0 9 * * *"
class: DailySummaryJob
queue: mailers
Solid Queue (Rails 8 Default)
If you're on Rails 8 defaults instead of Sidekiq:
Key Differences from Sidekiq
- No retry by default. You must configure
retry_on explicitly in your job classes. Sidekiq retries 25 times by default.
- Uses database tables instead of Redis for job storage
- Built-in concurrency controls (Sidekiq needs
sidekiq-unique-jobs gem for this):
class InvoiceExportJob < ApplicationJob
limits_concurrency to: 1, key: -> (account_id) { "invoice_export_#{account_id}" }
retry_on StandardError, wait: :exponentially_longer, attempts: 5
discard_on ActiveRecord::RecordNotFound
def perform(account_id)
end
end
Recurring Jobs
production:
daily_summary:
class: DailySummaryJob
schedule: every day at 9am
queue: mailers
Authentication
The Generator
rails generate authentication
What it does NOT generate: Registration. You must build signup yourself:
class RegistrationsController < ApplicationController
allow_unauthenticated_access
def create
@user = User.new(params.expect(user: [:email_address, :password, :password_confirmation]))
if @user.save
start_new_session_for @user
redirect_to root_url
else
render :new, status: :unprocessable_entity
end
end
end
Built-in Rate Limiting
class SessionsController < ApplicationController
rate_limit to: 10, within: 3.minutes, only: :create,
with: -> { redirect_to new_session_url, alert: "Try again later." }
end
Current Attributes
class Current < ActiveSupport::CurrentAttributes
attribute :session
delegate :user, to: :session, allow_nil: true
end
Use sparingly. Limit to 2-3 top-level attributes (user, session, account). If Current has 10 attributes, it's a god object.
Testing
Fixtures Over Factories
alice:
email_address: alice@example.com
password_digest: <%= BCrypt::Password.create("password") %>
bob:
email_address: bob@example.com
password_digest: <%= BCrypt::Password.create("password") %>
Why fixtures: Pre-built database state is faster than runtime construction. No factory chain explosions. Tests see the same data shape every time.
What to Test (Decision Table)
| Layer | Test Type | What to Assert |
|---|
| Model | Unit | Validations, scopes, methods |
| Service | Unit | Business logic, return values, side effects |
| Controller | Request | Status codes, redirects, response body |
| View | System (Capybara) | Critical user flows only |
| Job | Unit | Job logic directly (not Sidekiq/SQ infrastructure) |
Request Tests with Turbo Streams
test "create appends via turbo stream" do
post messages_url, params: { message: { body: "Hello" } }, as: :turbo_stream
assert_response :success
assert_turbo_stream action: "append", target: "messages" do
assert_select "template p", text: "Hello"
end
end
Broadcast Assertions
test "model broadcasts on create" do
assert_turbo_stream_broadcasts "messages" do
Message.create!(body: "Hello")
end
end
Testing Service Objects
test "registers user and sends welcome email" do
assert_enqueued_emails 1 do
result = Users::Register.new(
params: { email_address: "new@example.com", password: "secure123" }
).call
assert result.success?
assert_equal "new@example.com", result.value.email_address
end
end
test "fails with invalid params" do
result = Users::Register.new(params: { email_address: "" }).call
refute result.success?
assert_includes result.errors.full_messages, "Email address can't be blank"
end
Database & Migrations
strong_migrations (Zero-Downtime Safety)
| Dangerous | Why | Safe |
|---|
remove_column | ActiveRecord caches columns | Add self.ignored_columns, deploy, then remove |
add_index | Blocks writes | add_index :t, :col, algorithm: :concurrently with disable_ddl_transaction! |
add_foreign_key | Blocks reads/writes | Add with validate: false, validate separately |
change_column type | Rewrites table | New column, backfill, swap, drop old |
| Backfill in same migration | Table locked | Separate migration with disable_ddl_transaction! |
Safe Backfill Pattern
class BackfillStatusColumn < ActiveRecord::Migration[8.1]
disable_ddl_transaction!
def up
User.unscoped.in_batches(of: 10_000) do |relation|
relation.where(status: nil).update_all(status: "active")
sleep(0.01)
end
end
end
Strict Locals in Partials (Rails 7.1+)
<%# app/views/posts/_post.html.erb %>
<%# locals: (post:, show_author: true) -%>
<h2><%= post.title %></h2>
<% if show_author %>
<p>By <%= post.author.name %></p>
<% end %>
Calling render partial: "post", locals: { post: @post, extra: "oops" } raises. The partial explicitly declares what it accepts.
Deployment
Kamal 2 Request Flow
User -> Cloudflare (optional) -> kamal-proxy (SSL/routing) -> Thruster (compression) -> Puma -> Rails
Thruster is included in Rails 8 Dockerfile by default. It replaces Nginx for HTTP/2, gzip/brotli, and asset caching.
Key deploy.yml Patterns
service: myapp
image: myapp
servers:
web:
hosts: [203.0.113.10]
job:
hosts: [203.0.113.10]
cmd: bin/jobs start
proxy:
ssl: true
hosts: [myapp.com]
asset_path: /rails/public/assets
volumes:
- "myapp_storage:/rails/storage"
Non-Obvious Kamal Gotchas
asset_path is critical: without it, users who loaded a page before deploy get 404s for JS/CSS (old asset fingerprints disappear)
- Run Solid Queue in a separate container (
cmd: bin/jobs start) so runaway jobs can't starve web requests
- With Cloudflare, use "Full" SSL mode, not "Flexible" (causes infinite redirect loops)
- Set
config.assume_ssl = true in production.rb when behind proxy chains
SECRET_KEY_BASE_DUMMY=1 in Dockerfile lets asset precompilation run without real secrets
- Docker entrypoint runs
bin/rails db:prepare automatically on web start
ERB Patterns
Presenter Pattern
class PostPresenter < SimpleDelegator
def status_badge
case status
when "published" then tag.span("Published", class: "badge badge-green")
when "draft" then tag.span("Draft", class: "badge badge-gray")
end
end
def reading_time
words = body.split.size
minutes = (words / 200.0).ceil
"#{minutes} min read"
end
private
def tag = ActionController::Base.helpers
end
<%= PostPresenter.new(@post).status_badge %>
Rails 8 Features
delegated_type (Alternative to STI)
class Entry < ApplicationRecord
delegated_type :entryable, types: %w[Message Comment]
delegate :title, to: :entryable
end
class Message < ApplicationRecord
include Entryable
def title = subject
end
Avoids table bloat from STI (separate tables per subtype).
rate_limit
class PasswordsController < ApplicationController
rate_limit to: 10, within: 3.minutes, only: :create,
with: -> { redirect_to new_password_url, alert: "Too many attempts." }
end
Solid Cache
Database-backed cache using FIFO eviction (not LRU like Redis).
When to use: Large cached values, moderate read rates, want to eliminate Redis.
When NOT to use: Sub-millisecond latency needed, DB is already the bottleneck, cache churn is very high.
Gotcha: FIFO means frequently-accessed-but-old entries get evicted before rarely-accessed-but-new ones. The opposite of what you want for hot-path caching.
Quick Reference
| Mistake | Fix |
|---|
Missing status: :unprocessable_entity on form error | Turbo won't re-render the form without 422 |
Missing status: :see_other on POST/DELETE redirect | Turbo replays the POST instead of following |
Missing matching turbo_frame_tag on target page | Frame renders empty with no error |
after_create for business logic | Use service object |
Model.find(params[:id]) unscoped | Scope to current user: current_user.posts.find(...) |
permit! on params | Never. Whitelist every field explicitly |
| Integer enum values | Use string values: enum :status, { active: "active" } |
| Non-idempotent Sidekiq jobs | Check-before-act pattern, find_by with early return |
No retry_on in ApplicationJob (Solid Queue) | Solid Queue doesn't retry by default, unlike Sidekiq |
add_index without algorithm: :concurrently | Blocks writes on large tables |
remove_column without ignored_columns | Crashes on deploy until column is removed |
| Module-scope store in Wrapper/Layout | Cross-request data leaks during SSR |
config.assume_ssl not set behind proxy | Rails generates HTTP URLs instead of HTTPS |