| name | rails-patterns |
| description | Deep reference for Ruby on Rails patterns — Hotwire/Turbo, Stimulus, service objects, minitest, fixtures, Sidekiq, ActiveRecord, PostgreSQL, ERB, security, and Rails 8 conventions. |
| origin | claude-rails |
Rails Patterns
Production patterns for Ruby on Rails applications. Covers Hotwire (Turbo Frames, Turbo Streams, Stimulus), service objects with good OO design, minitest + fixtures, Sidekiq, ActiveRecord, PostgreSQL, ERB, and security. Targets vanilla Rails with minimal gem bloat.
To run an automated review using these patterns, use the rails-reviewer agent or the /rails-review command.
When to Activate
- Building or reviewing Rails controllers, models, or views
- Writing Hotwire interactions (Turbo Frames, Turbo Streams, Stimulus)
- Writing minitest tests with fixtures
- Designing service objects or domain logic
- Writing Sidekiq background jobs
- Reviewing database migrations or queries
- Checking for N+1 queries, missing indexes, or security issues
- Working with ERB templates and partials
Hotwire / Turbo Patterns
The Decision Hierarchy
Before reaching for JavaScript, follow this order:
- HTML — Can you solve it with a link or form?
- CSS — Can you solve it with a CSS transition,
:target, or :checked?
- Turbo Frames — Can you isolate a region of the page?
- Turbo Streams — Do you need to update multiple parts of the page?
- Stimulus — Do you need client-side behavior (toggle, validate, debounce)?
- Custom JS — Last resort
Turbo Frames
Turbo Frames isolate a section of the page for independent navigation:
<%# app/views/posts/show.html.erb %>
<%= turbo_frame_tag @post do %>
<h2><%= @post.title %></h2>
<p><%= @post.body %></p>
<%= link_to "Edit", edit_post_path(@post) %>
<% end %>
<%# app/views/posts/edit.html.erb %>
<%= turbo_frame_tag @post do %>
<%= render "form", post: @post %>
<% end %>
Clicking "Edit" replaces only the frame content — no full page reload.
Lazy-loaded frames — load content after the page renders:
<%= turbo_frame_tag "notifications", src: notifications_path, loading: :lazy do %>
<p>Loading notifications...</p>
<% end %>
Common mistakes:
- Forgetting to wrap the target page in a matching
turbo_frame_tag — Turbo shows nothing
- Nesting frames too deeply — makes debugging hard
- Using frames when a simple link with
data-turbo-frame="_top" would suffice
Turbo Streams
Turbo Streams update multiple parts of the page from a single response:
def create
@comment = @post.comments.build(comment_params)
if @comment.save
respond_to do |format|
format.turbo_stream
format.html { redirect_to @post }
end
else
render :new, status: :unprocessable_entity
end
end
<%# app/views/comments/create.turbo_stream.erb %>
<%= turbo_stream.prepend "comments", @comment %>
<%= turbo_stream.update "comment_count", html: @post.comments.count.to_s %>
<%= turbo_stream.replace "comment_form", partial: "comments/form", locals: { comment: Comment.new } %>
Broadcasting (real-time updates via ActionCable):
class Message < ApplicationRecord
broadcasts_to :room
end
<%# app/views/rooms/show.html.erb %>
<%= turbo_stream_from @room %>
<div id="messages">
<%= render @room.messages %>
</div>
Turbo 8 Morphing (Page Refreshes)
Rails 8 + Turbo 8 introduces page refreshes with morphing — the DOM is updated in place instead of replaced:
class Post < ApplicationRecord
broadcasts_refreshes
end
<%# app/views/layouts/application.html.erb %>
<head>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
</head>
Use data-turbo-permanent to preserve elements across morphs (e.g., audio players, form state):
<div id="audio-player" data-turbo-permanent>
<%= render "shared/audio_player" %>
</div>
Stimulus Controllers
Stimulus controllers add behavior to HTML. Keep them small and focused:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["content"]
static classes = ["hidden"]
toggle() {
this.contentTarget.classList.toggle(this.hiddenClass)
}
}
<div data-controller="toggle" data-toggle-hidden-class="hidden">
<button data-action="click->toggle#toggle">Show details</button>
<div data-toggle-target="content" class="hidden">
<p>Details here...</p>
</div>
</div>
Stimulus design principles:
- One controller = one behavior (toggle, debounce, clipboard, auto-submit)
- Use targets, not
querySelector
- Use values and classes, not hardcoded strings
- Connect to the DOM with
data-controller, not global event listeners
Auto-submit example (search with debounce):
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { delay: { type: Number, default: 300 } }
search() {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
this.element.requestSubmit()
}, this.delayValue)
}
}
<%= form_with url: search_path, method: :get, data: { controller: "auto-submit", turbo_frame: "results" } do |f| %>
<%= f.search_field :q, data: { action: "input->auto-submit#search" } %>
<% end %>
<%= turbo_frame_tag "results" do %>
<%= render @results %>
<% end %>
Service Objects
When to Use a Service Object
Use a service object when the operation:
- Involves multiple models or external services
- Represents a business process (not just CRUD)
- Is too complex for a model method but isn't a model's responsibility
- Needs to be tested in isolation
Don't use a service object for:
- Simple model validations or callbacks
- Wrapping a single ActiveRecord call
- Anything that's naturally a model method
The Callable Pattern
module Users
class Register
def initialize(params:, invited_by: nil)
@params = params
@invited_by = invited_by
end
def call
user = User.new(@params)
ActiveRecord::Base.transaction do
user.save!
create_default_workspace(user)
send_welcome_email(user)
credit_referral(@invited_by, user) if @invited_by
end
Result.new(success: true, user: user)
rescue ActiveRecord::RecordInvalid => e
Result.new(success: false, errors: e.record.errors)
end
private
def create_default_workspace(user)
user.workspaces.create!(name: "#{user.name}'s Workspace")
end
def send_welcome_email(user)
UserMailer.welcome(user).deliver_later
end
def credit_referral(referrer, user)
Referrals::Credit.new(referrer: referrer, referred: user).call
end
end
end
Result Objects
Keep result handling consistent:
class Result
attr_reader :errors, :value
def initialize(success:, value: nil, errors: nil)
@success = success
@value = value
@errors = errors
end
def success? = @success
def failure? = !@success
end
result = Users::Register.new(params: user_params).call
if result.success?
redirect_to dashboard_path
else
@user = User.new(user_params)
@user.errors.merge!(result.errors) if result.errors
render :new, status: :unprocessable_entity
end
Good OO Design in Services
Services should have one public method (call) and use private methods and collaborators for subtasks. Avoid procedural code:
class ProcessOrder
def call(order)
validate_inventory(order)
charge_payment(order)
update_inventory(order)
send_confirmation(order)
notify_warehouse(order)
end
end
class Orders::Process
def initialize(order:)
@order = order
end
def call
ActiveRecord::Base.transaction do
Inventory::Reserve.new(order: @order).call
payment = Payments::Charge.new(order: @order).call
@order.update!(payment_id: payment.id, status: :confirmed)
end
Orders::ConfirmationJob.perform_later(@order.id)
Result.new(success: true, value: @order)
rescue Payments::ChargeError => e
Result.new(success: false, errors: e.message)
end
end
File Organization
app/services/
├── result.rb
├── users/
│ ├── register.rb
│ ├── deactivate.rb
│ └── update_profile.rb
├── orders/
│ ├── process.rb
│ ├── cancel.rb
│ └── refund.rb
└── payments/
├── charge.rb
└── refund.rb
Namespace by domain, name with verbs: Users::Register, Orders::Process, Payments::Charge.
Minitest + Fixtures
Fixture Philosophy
Fixtures are the Rails way. Use 1-2 generic fixtures per model, customize within tests:
alice:
name: Alice Johnson
email: alice@example.com
admin: false
bob:
name: Bob Smith
email: bob@example.com
admin: true
published_post:
title: Published Post
body: This is a published post.
user: alice
published_at: <%= 1.day.ago.to_fs(:db) %>
draft_post:
title: Draft Post
body: This is a draft.
user: alice
published_at: null
Key principles:
- Reference associations by fixture name, not IDs (
user: alice)
- Use ERB for dynamic values (
<%= Time.current.to_fs(:db) %>)
- Keep fixtures minimal — customize in tests when needed
- Don't create fixtures for every edge case — build records in the test
Test Organization
class PostTest < ActiveSupport::TestCase
test "published scope returns only published posts" do
assert_includes Post.published, posts(:published_post)
assert_not_includes Post.published, posts(:draft_post)
end
test "validates title presence" do
post = Post.new(body: "content", user: users(:alice))
assert_not post.valid?
assert_includes post.errors[:title], "can't be blank"
end
test "slug is generated from title on create" do
post = Post.create!(title: "My Great Post", body: "content", user: users(:alice))
assert_equal "my-great-post", post.slug
end
end
Request Tests (Preferred Over Controller Tests)
class PostsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:alice)
@post = posts(:published_post)
sign_in @user
end
test "creates a post and redirects" do
assert_difference "Post.count", 1 do
post posts_url, params: { post: { title: "New Post", body: "Content" } }
end
assert_redirected_to post_url(Post.last)
assert_equal "Post created.", flash[:notice]
end
test "renders form with errors for invalid post" do
post posts_url, params: { post: { title: "", body: "" } }
assert_response :unprocessable_entity
assert_select "form .field_with_errors"
end
test "create responds with turbo stream" do
post posts_url, params: { post: { title: "New", body: "Content" } },
headers: { "Accept" => "text/vnd.turbo-stream.html" }
assert_response :success
assert_match "turbo-stream", response.body
end
end
Testing Service Objects
class Users::RegisterTest < ActiveSupport::TestCase
test "creates user with default workspace" do
result = Users::Register.new(
params: { name: "Charlie", email: "charlie@example.com", password: "secret123" }
).call
assert result.success?
assert_equal "Charlie's Workspace", result.value.workspaces.first.name
end
test "returns failure for invalid params" do
result = Users::Register.new(params: { name: "", email: "" }).call
assert result.failure?
assert result.errors.any?
end
test "sends welcome email" do
assert_enqueued_email_with UserMailer, :welcome do
Users::Register.new(
params: { name: "Charlie", email: "charlie@example.com", password: "secret123" }
).call
end
end
end
What to Test, What to Skip
| Test | Skip |
|---|
| Model validations and scopes | Default Rails behavior (has_many works) |
| Service object happy path + errors | Private methods directly |
| Request tests for each action | View rendering details (unless critical) |
| Sidekiq job logic | That Sidekiq itself works |
| Critical user flows (system tests) | Every UI permutation |
| Authorization/guard logic | Simple redirects |
| Edge cases in business logic | Trivial getters/setters |
Sidekiq Patterns
Job Design Principles
Jobs must be small, idempotent, and retriable:
class Orders::SendConfirmationJob
include Sidekiq::Job
sidekiq_options queue: :default, retry: 5
def perform(order_id)
order = Order.find_by(id: order_id)
return unless order
return if order.confirmation_sent?
OrderMailer.confirmation(order).deliver_now
order.update!(confirmation_sent_at: Time.current)
end
end
Rules:
- Pass IDs, not ActiveRecord objects (objects don't serialize to JSON)
- Always handle the record being gone (
find_by + early return)
- Add idempotency guards — the job may run more than once
- Keep jobs focused — one job, one task
Error Handling
class Payments::ChargeJob
include Sidekiq::Job
sidekiq_options queue: :critical, retry: 3
sidekiq_retries_exhausted do |job, exception|
order_id = job["args"].first
order = Order.find(order_id)
order.update!(status: :payment_failed)
AdminMailer.payment_failure(order, exception.message).deliver_later
end
def perform(order_id)
order = Order.find(order_id)
Payments::Charge.new(order: order).call
end
end
Testing Sidekiq Jobs
Test the job logic directly — don't test Sidekiq's infrastructure:
class Orders::SendConfirmationJobTest < ActiveSupport::TestCase
test "sends confirmation email" do
order = orders(:pending)
assert_emails 1 do
Orders::SendConfirmationJob.new.perform(order.id)
end
assert order.reload.confirmation_sent?
end
test "skips already-confirmed orders" do
order = orders(:confirmed)
assert_no_emails do
Orders::SendConfirmationJob.new.perform(order.id)
end
end
test "handles deleted order gracefully" do
assert_nothing_raised do
Orders::SendConfirmationJob.new.perform(-1)
end
end
end
Testing Job Enqueuing
test "enqueues confirmation job after order is placed" do
assert_enqueued_with(job: Orders::SendConfirmationJob) do
Orders::Process.new(order: orders(:pending)).call
end
end
Controller Patterns
Skinny Controllers
Controllers should only: authenticate, parse params, delegate to models/services, and render:
class PostsController < ApplicationController
before_action :set_post, only: %i[show edit update destroy]
def index
@posts = Post.published.includes(:user).order(published_at: :desc)
end
def create
@post = current_user.posts.build(post_params)
if @post.save
redirect_to @post, notice: "Post created."
else
render :new, status: :unprocessable_entity
end
end
def update
if @post.update(post_params)
redirect_to @post, notice: "Post updated."
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@post.destroy!
redirect_to posts_path, notice: "Post deleted.", status: :see_other
end
private
def set_post
@post = current_user.posts.find(params[:id])
end
def post_params
params.require(:post).permit(:title, :body, :published_at)
end
end
Key points:
status: :unprocessable_entity is required for Turbo to re-render the form
status: :see_other on destroy redirects (Turbo requires this for DELETE → redirect)
- Scope queries to
current_user for authorization
- Use
includes to prevent N+1 in index actions
Respond to Turbo Streams
def create
@comment = @post.comments.build(comment_params)
respond_to do |format|
if @comment.save
format.turbo_stream
format.html { redirect_to @post, notice: "Comment added." }
else
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
"comment_form",
partial: "comments/form",
locals: { comment: @comment }
)
end
format.html { render :new, status: :unprocessable_entity }
end
end
end
Model Patterns
Scopes
Keep scopes composable and use them instead of class methods:
class Post < ApplicationRecord
scope :published, -> { where.not(published_at: nil).where(published_at: ..Time.current) }
scope :draft, -> { where(published_at: nil) }
scope :by_author, ->(user) { where(user: user) }
scope :recent, -> { order(published_at: :desc) }
scope :search, ->(query) { where("title ILIKE ?", "%#{sanitize_sql_like(query)}%") }
end
Dual Validation (Model + Database)
Always validate at both levels:
class CreateUsers < ActiveRecord::Migration[8.0]
def change
create_table :users do |t|
t.string :email, null: false
t.string :name, null: false
t.timestamps
end
add_index :users, :email, unique: true
end
end
class User < ApplicationRecord
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :name, presence: true
end
The database constraint catches race conditions that model validations miss.
Callbacks (Use Sparingly)
Callbacks are appropriate for model-intrinsic side effects only:
class Post < ApplicationRecord
before_validation :generate_slug, on: :create
after_destroy :purge_attached_images
normalizes :email, with: ->(email) { email.strip.downcase }
private
def generate_slug
self.slug = title&.parameterize
end
def purge_attached_images
images.purge_later
end
end
class Order < ApplicationRecord
after_create :charge_payment
after_create :send_confirmation
after_create :notify_warehouse
end
Rails 8: normalizes and generates_token_for
class User < ApplicationRecord
normalizes :email, with: ->(email) { email.strip.downcase }
normalizes :phone, with: ->(phone) { phone.gsub(/\D/, "") }
generates_token_for :password_reset, expires_in: 15.minutes do
password_salt&.last(10)
end
generates_token_for :email_verification, expires_in: 24.hours do
email
end
end
token = user.generate_token_for(:password_reset)
user = User.find_by_token_for(:password_reset, token)
Concerns (Shared Behavior)
module Sluggable
extend ActiveSupport::Concern
included do
before_validation :generate_slug, on: :create
validates :slug, presence: true, uniqueness: true
end
private
def generate_slug
self.slug = title&.parameterize if slug.blank?
end
end
Use concerns for behavior shared across 2+ models. Don't use them to break up a god model — that just hides the problem.
PostgreSQL with Rails
Preventing N+1 Queries
config.active_record.strict_loading_by_default = true
class Post < ApplicationRecord
self.strict_loading_by_default = true
end
posts = Post.strict_loading.all
@posts = Post.includes(:user, :comments).published.recent
includes vs preload vs eager_load:
includes — Rails picks the strategy (usually 2 queries)
preload — Always 2 separate queries (one for posts, one for users)
eager_load — Always LEFT OUTER JOIN (when you need to filter/sort by association)
Database Constraints
class AddConstraintsToOrders < ActiveRecord::Migration[8.0]
def change
add_check_constraint :orders, "total_cents >= 0", name: "orders_total_non_negative"
add_foreign_key :orders, :users, on_delete: :cascade
change_column_null :orders, :status, false
change_column_default :orders, :status, from: nil, to: "pending"
end
end
Indexing Strategy
add_index :comments, :post_id
add_index :posts, [:user_id, :published_at]
add_index :posts, :published_at, where: "published_at IS NOT NULL", name: "index_posts_published"
add_index :users, :email, unique: true
PostgreSQL-Specific Features
add_column :users, :preferences, :jsonb, default: {}, null: false
add_index :users, :preferences, using: :gin
add_column :posts, :tags, :string, array: true, default: []
add_index :posts, :tags, using: :gin
create_enum :order_status, %w[pending confirmed shipped delivered cancelled]
add_column :orders, :status, :enum, enum_type: :order_status, default: "pending", null: false
Batch Processing
User.where(active: true).find_each(batch_size: 500) do |user|
UserMailer.weekly_digest(user).deliver_later
end
User.where(active: true).each do |user|
UserMailer.weekly_digest(user).deliver_later
end
ERB Best Practices
Strict Locals (Rails 7.1+)
Declare expected locals at the top of partials:
<%# app/views/posts/_post.html.erb %>
<%# locals: (post:, show_author: true) %>
<article>
<h2><%= link_to post.title, post %></h2>
<% if show_author %>
<p>by <%= post.user.name %></p>
<% end %>
<p><%= truncate(post.body, length: 200) %></p>
</article>
If a caller passes an unknown local or forgets a required one, Rails raises an error. This catches bugs early.
Collection Rendering
<%# Instead of: %>
<% @posts.each do |post| %>
<%= render "posts/post", post: post %>
<% end %>
<%# Use collection rendering (faster, cleaner): %>
<%= render partial: "posts/post", collection: @posts %>
<%# Or the shorthand: %>
<%= render @posts %>
Keep Logic Out of Views
<%# BAD: Logic in view %>
<% if @user.subscriptions.active.where(plan: "pro").any? && @user.created_at < 1.year.ago %>
<p>Loyal pro user</p>
<% end %>
<%# GOOD: Move to model %>
<% if @user.loyal_pro? %>
<p>Loyal pro user</p>
<% end %>
Helpers vs Presenters
Use helpers for simple formatting. Use a presenter/decorator for complex view logic on a single object:
module PostsHelper
def post_status_badge(post)
if post.published?
tag.span("Published", class: "badge badge-success")
else
tag.span("Draft", class: "badge badge-secondary")
end
end
end
class PostPresenter < SimpleDelegator
def reading_time
words = body.split.size
minutes = (words / 200.0).ceil
"#{minutes} min read"
end
def byline
"by #{user.name} on #{published_at.strftime('%B %d, %Y')}"
end
end
@post = PostPresenter.new(Post.find(params[:id]))
Rails 8 Conventions
Authentication Generator
bin/rails generate authentication
Generates User model with has_secure_password, Session model, SessionsController, and Authentication concern. Use this instead of Devise for vanilla Rails.
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
Delegated Types (STI Alternative)
class Entry < ApplicationRecord
delegated_type :entryable, types: %w[Message Comment]
end
class Message < ApplicationRecord
has_one :entry, as: :entryable, touch: true
end
class Comment < ApplicationRecord
has_one :entry, as: :entryable, touch: true
end
Entry.messages
Entry.comments
Security
CSRF with Turbo
Turbo automatically includes the CSRF token. No extra work needed if you use form_with and standard Rails patterns. For custom fetch requests:
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content
fetch("/api/endpoint", {
method: "POST",
headers: {
"X-CSRF-Token": csrfToken,
"Content-Type": "application/json"
},
body: JSON.stringify(data)
})
Strong Parameters
def post_params
params.require(:post).permit(:title, :body, :published_at, tags: [])
end
SQL Injection Prevention
User.where("email = ?", params[:email])
User.where(email: params[:email])
User.where("email = '#{params[:email]}'")
Brakeman + Bundler Audit
Run in CI:
bundle exec brakeman --no-pager
bundle exec bundler-audit check --update
Content Security Policy
Rails.application.configure do
config.content_security_policy do |policy|
policy.default_src :self
policy.script_src :self
policy.style_src :self, :unsafe_inline
policy.connect_src :self
policy.img_src :self, :data
end
end
RuboCop (Rails Omakase)
Rails 8 ships rubocop-rails-omakase — a lightweight, opinionated config:
inherit_gem:
rubocop-rails-omakase: rubocop.yml
Key conventions enforced:
- Double quotes for strings
assert_not over refute in tests
- Frozen string literal comments
- Standard Rails naming and structure
Don't fight the defaults. If you disagree with a specific rule, disable it explicitly with a comment explaining why.
Quick Reference: Common Mistakes
| Mistake | Fix |
|---|
| N+1 queries | Use includes, preload, or eager_load |
| Missing database index on foreign key | Add add_index in migration |
| Model validation without DB constraint | Add null: false, unique index, check constraint |
| Fat controller (business logic in actions) | Extract to service object or model method |
| Callback for external side effects | Use service object; callbacks are for model-intrinsic behavior |
| Passing ActiveRecord objects to Sidekiq | Pass IDs only; look up the record in the job |
| Non-idempotent Sidekiq job | Add idempotency guard (check before acting) |
Missing status: :unprocessable_entity | Required for Turbo to re-render forms on validation failure |
Missing status: :see_other on delete redirect | Required for Turbo DELETE → redirect flow |
| Logic in ERB views | Move to model method, helper, or presenter |
| God service object (200+ lines) | Compose from smaller, focused collaborators |
| Testing implementation details | Test behavior (inputs → outputs), not private methods |
| Missing Turbo Frame wrapper on target page | Target page must have matching turbo_frame_tag |
permit! on params | Always whitelist specific attributes |
| String interpolation in SQL | Use parameterized queries or ActiveRecord methods |