Rails Patterns
Framework patterns for modern Ruby on Rails applications (Rails 7.1+ and 8.x). Rails is opinionated by design; these are the patterns the community has converged on for apps that stay maintainable past the 50-model mark. This skill is the "how." For the "what" and "when" (the decisions about which pattern to reach for), see the Ruby patterns rules — rules/ruby/patterns.md in this repository, installed as rules/ecc/ruby/patterns.md.
When to Activate
- Building a Rails application (full-stack, API-only, or hybrid)
- Reviewing a PR that touches
app/orconfig/ - Generating models, controllers, services, or jobs
- A controller action grows past ~10 lines
- A model file grows past ~200 lines
- ActiveRecord queries start appearing in controllers or views
Core Concepts
The directory contract
Rails apps follow a predictable structure. Add directories deliberately, not casually.
app/
models/ ActiveRecord models. Persistence and domain logic close to the data.
controllers/ HTTP request handling. Thin orchestration only.
views/ ERB templates. No business logic.
components/ ViewComponent classes. View logic that needs tests.
services/ Service objects. Multi-step business operations.
forms/ Form objects. Complex form handling across multiple models.
queries/ Query objects. Reusable, composable ActiveRecord queries.
jobs/ Background jobs. Async work via Solid Queue, Sidekiq, or GoodJob.
mailers/ ActionMailer classes.
helpers/ View helpers. Tiny presentational logic only.
policies/ Authorization policies (if using Pundit). Optional.
channels/ ActionCable channels for WebSocket work.
Avoid app/lib/, app/utils/, app/managers/. If something does not fit the directories above, the design usually needs rethinking, not a new directory. Truly generic code goes in lib/.
Skinny controllers
Controllers receive a request, delegate to the right object, and render a response. Business logic lives elsewhere. (Per the Ruby patterns rules, extract to a service object when the controller starts carrying multiple responsibilities.)
Service objects
The default for business operations that touch more than a single model save. Conventions that keep them consistent:
- Namespace by domain (
Invoices::Create), not by suffix (InvoiceCreator). - A class method
.calldelegates to an instance#call. - Return a Result object, not a boolean or a bare record, so the caller can branch on success, errors, and the affected record.
- Wrap multi-record writes in a transaction.
- Keep each service single-purpose (
Invoices::Create,Invoices::MarkPaid), neverInvoices::Manager.
Form objects
When a form spans multiple models or has fields that do not map to columns, use a form object rather than nested attributes or virtual attributes on the wrong model. It quacks like a model to the view (form_with model: @form) while composing records cleanly.
Query objects
For ActiveRecord queries reused across controllers or services, or too complex for a scope, extract a query object that accepts a scope as input so it composes. Rule of thumb: a scope that grows past three chained conditions or starts taking parameters wants to be a query object.
Background jobs
Offload anything slow. (Per the Ruby patterns rules, Solid Queue for greenfield Rails 8 with modest throughput; Sidekiq when you need mature observability, high throughput, or existing Redis.) Regardless of adapter: pass IDs not records, make perform idempotent, and set retry_on/discard_on explicitly.
ViewComponent over partials
For view logic with conditional rendering, more than two arguments, or reuse across more than three places, prefer a ViewComponent. Components are testable in isolation and surface their interface explicitly; partials with deep conditional logic become debt.
Hotwire: Turbo and Stimulus
The default Rails frontend stack. (Per the Ruby patterns rules, prefer Hotwire for server-rendered apps; reach for React/Vue only when interaction complexity justifies the client surface.) Turbo Frames for partial page updates, Turbo Streams for server-driven updates, Stimulus for small client-side behaviors next to the markup.
The Rails 8 Solid stack
Rails 8 ships database-backed defaults that previously needed Redis: Solid Queue (jobs), Solid Cache (cache), Solid Cable (ActionCable). The tradeoff is more database load for one fewer infrastructure component; a good fit for modest throughput, with Redis still winning at high scale. Kamal is the default Docker-based deploy tool.
Code Examples
Skinny controller with a service object
# Bad: business logic in the controller
class InvoicesController < ApplicationController
def create
@invoice = Invoice.new(invoice_params)
@invoice.user = current_user
@invoice.line_items.build(invoice_params[:line_items])
@invoice.tax_total = TaxCalculator.new(@invoice).calculate
@invoice.total = @invoice.line_items.sum(&:amount) + @invoice.tax_total
if @invoice.save
InvoiceMailer.created(@invoice).deliver_later
AccountingExportJob.perform_later(@invoice.id)
redirect_to @invoice, notice: "Invoice created"
else
render :new
end
end
end
# Good: controller orchestrates, service does the work
class InvoicesController < ApplicationController
def create
result = Invoices::Create.call(params: invoice_params, user: current_user)
if result.success?
redirect_to result.invoice, notice: "Invoice created"
else
@invoice = result.invoice
render :new, status: :unprocessable_entity
end
end
end
The service object
# app/services/invoices/create.rb
module Invoices
class Create
# Struct keeps this runnable on every Ruby that Rails 7.1 supports.
# On Ruby 3.2+, `Data.define(:success?, :invoice, :errors)` is a more
# concise immutable alternative.
Result = Struct.new(:success, :invoice, :errors, keyword_init: true) do
def success?
success
end
end
def self.call(params:, user:)
new(params: params, user: user).call
end
def initialize(params:, user:)
@params = params
@user = user
end
def call
invoice = build_invoice
ApplicationRecord.transaction do
invoice.save!
end
begin
send_notifications(invoice)
rescue StandardError => e
Rails.logger.error("Notification dispatch failed for invoice #{invoice.id}: #{e.message}")
end
Result.new(success: true, invoice: invoice, errors: nil)
rescue ActiveRecord::RecordInvalid => e
Result.new(success: false, invoice: e.record, errors: e.record.errors)
end
private
attr_reader :params, :user
def build_invoice
invoice = user.invoices.new(params.except(:line_items))
invoice.tax_total = TaxCalculator.call(invoice)
invoice.line_items.build(params[:line_items])
invoice.total = invoice.line_items.sum(&:amount) + invoice.tax_total
invoice
end
def send_notifications(invoice)
InvoiceMailer.created(invoice).deliver_later
AccountingExportJob.perform_later(invoice.id)
end
end
end
Form object
# app/forms/signup_form.rb
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :email, :string
attribute :password, :string
attribute :company_name, :string
attribute :terms_accepted, :boolean
validates :email, presence: true, format: URI::MailTo::EMAIL_REGEXP
validates :password, presence: true, length: { minimum: 12 }
validates :company_name, presence: true
validates :terms_accepted, acceptance: true
attr_reader :user, :company
def save
return false unless valid?
ApplicationRecord.transaction do
@company = Company.create!(name: company_name)
@user = @company.users.create!(email: email, password: password, role: :owner)
end
true
rescue ActiveRecord::RecordInvalid => e
errors.merge!(e.record.errors)
false
end
end
Query object
# app/queries/invoices/overdue.rb
module Invoices
class Overdue
def self.call(scope: Invoice.all, as_of: Time.current)
new(scope: scope, as_of: as_of).call
end
def initialize(scope:, as_of:)
@scope = scope
@as_of = as_of
end
def call
scope
.where(status: :sent)
.where(due_date: ..as_of)
.where.not(id: paid_invoice_ids)
.includes(:customer, :line_items)
end
private
attr_reader :scope, :as_of
def paid_invoice_ids
Payment.where(created_at: ..as_of).pluck(:invoice_id)
end
end
end
Query objects accept a scope, so they compose: Invoices::Overdue.call(scope: current_user.invoices).
N+1 prevention
# Bad: N+1 in the view when it calls post.author.name
@posts = Post.published
# Good: eager load
@posts = Post.published.includes(:author)
includes lets Rails choose preload vs eager_load. Force preload for separate queries, eager_load for a JOIN when filtering on the association. In Rails 7.1+, strict_loading raises on accidental lazy loads.
Counter cache
class Comment < ApplicationRecord
belongs_to :post, counter_cache: true
end
add_column :posts, :comments_count, :integer, default: 0, null: false
post.comments_count becomes a column read instead of a COUNT(*). This example
assumes a new table; adding a counter cache to a table that already has rows requires a
backfill, which is out of scope here.
Background job shape
Pass record IDs, not records. Retries make delivery at-least-once, so any job that calls an external service must be idempotent — otherwise a transient failure after the remote call succeeds will duplicate the effect on the next attempt.
class AccountingExportJob < ApplicationJob
queue_as :exports
retry_on AccountingApi::TransientError, wait: :polynomially_longer, attempts: 5
discard_on AccountingApi::PermanentError
def perform(invoice_id)
invoice = Invoice.find(invoice_id)
export = AccountingExport.create_or_find_by!(
invoice: invoice,
idempotency_key: "invoice-export-#{invoice.id}-#{invoice.updated_at.to_i}"
)
return if export.completed_at?
receipt = AccountingApi.export(invoice, idempotency_key: export.idempotency_key)
export.update!(completed_at: Time.current, external_id: receipt.id)
end
end
add_index :accounting_exports, :idempotency_key, unique: true
The unique index is what makes this safe: when two attempts race, the database rejects
the second insert and Active Record resolves the conflict inside the call, returning the
existing row. That happens without any job-level retry — retry_on above covers only
AccountingApi::TransientError. The guard
covers the window before the remote call; passing idempotency_key through to the API
covers the window after it, so a crash between the API call and update! still resolves
to a single export.
ViewComponent
# app/components/invoice_status_badge_component.rb
class InvoiceStatusBadgeComponent < ViewComponent::Base
STATUS_CLASSES = {
draft: "bg-gray-100 text-gray-800",
sent: "bg-blue-100 text-blue-800",
paid: "bg-green-100 text-green-800",
overdue: "bg-red-100 text-red-800"
}.freeze
def initialize(invoice:)
@invoice = invoice
end
def call
tag.span(@invoice.status.humanize, class: "rounded-full px-2 py-1 text-sm #{status_class}")
end
private
def status_class
STATUS_CLASSES.fetch(@invoice.status.to_sym, "bg-gray-100")
end
end
<%= render InvoiceStatusBadgeComponent.new(invoice: @invoice) %>
Hotwire
<%# Turbo Frame: clicking Edit replaces only this frame %>
<%= turbo_frame_tag "invoice_#{@invoice.id}" do %>
<div class="invoice">
<%= link_to "Edit", edit_invoice_path(@invoice) %>
</div>
<% end %>
<%# Turbo Stream: app/views/comments/create.turbo_stream.erb %>
<%= turbo_stream.append "comments", @comment %>
<%= turbo_stream.update "comment_form", partial: "form", locals: { comment: Comment.new } %>
// app/javascript/controllers/copy_to_clipboard_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["source"]
copy() {
navigator.clipboard.writeText(this.sourceTarget.value)
}
}
Acceptable vs unacceptable callbacks
# Acceptable: pure data normalization
class User < ApplicationRecord
before_validation :normalize_email
private
def normalize_email
self.email = email.to_s.downcase.strip
end
end
# Move to a service instead: side effects hidden in a callback
# class User < ApplicationRecord
# after_create :send_welcome_email # hard to opt out of, hard to test
# end
Good concern vs bad concern
# Good: genuinely cross-cutting, reusable across unrelated models
# app/models/concerns/soft_deletable.rb
module SoftDeletable
extend ActiveSupport::Concern
included do
scope :active, -> { where(deleted_at: nil) }
scope :deleted, -> { where.not(deleted_at: nil) }
end
def soft_delete! = update!(deleted_at: Time.current)
def restore! = update!(deleted_at: nil)
end
# Bad: a "concern" used by exactly one model, holding logic that belongs on it
# app/models/concerns/invoice_calculations.rb
module InvoiceCalculations
extend ActiveSupport::Concern
def calculate_total
line_items.sum(&:amount) + tax_total
end
end
# Only Invoice includes this. It isn't cross-cutting; it's Invoice's own logic
# hidden in a module for the appearance of a "skinny" model. Put it back on Invoice.
A concern used by only one class is just moving code; it belongs in that class. A concern should be reusable across at least two unrelated models.
Anti-Patterns
God controllers
Any controller past ~80 lines is doing too much. Split actions across controllers or extract to services.
Fat models with 30+ methods
Models should know about their own data. Methods that orchestrate other models, send notifications, or coordinate workflows belong in services.
Callback chains
after_save :update_cache, :send_notifications, :enqueue_export is the start of a debugging nightmare. Move them into a service that runs them explicitly.
Nested attributes for complex forms
accepts_nested_attributes_for is fine for simple cases. For conditional validation or cross-model logic, use a form object.
Default scopes on critical models
default_scope { where(deleted: false) } silently excludes records from every query in the app, including the ones you need for support and debugging. Prefer an explicit named scope.
Models named after database concepts
UserRole, OrderStatus, InvoiceState are usually enum candidates, not models.
Reaching for a JS framework before Hotwire
If the page is server-rendered with occasional interactivity, Hotwire ships faster. Reserve React/Vue for genuinely SPA-shaped apps.
Best Practices
- Keep controllers thin; push business logic into services.
- Return Result objects from services so callers branch on outcome, not exceptions.
- Wrap multi-record writes in a transaction; let notification/side-effect failures log without breaking the primary write.
- Pass IDs to jobs, keep
performidempotent, set retry/discard explicitly. - Default to eager loading; treat an accidental N+1 as a bug, not a nuisance.
- Reserve concerns for behavior shared across at least two unrelated models.
- Reach for Hotwire before a client-side framework on server-rendered apps.
Related Skills
backend-patterns— service boundaries and adapter patterns (referenced by the Ruby patterns rules)ruby-patterns— language-level Ruby idioms (if present)- Ruby patterns rules (
rules/ruby/patterns.md, installed asrules/ecc/ruby/patterns.md) — the decisions and when-to-use guidance this skill implements