--- name: rails-patterns description: Ruby on Rails framework patterns for Rails 7.1+ and 8.x apps. Covers the directory contract, skinny controllers with service objects, form objects, query objects, idiomatic ActiveRecord, background jobs, ViewComponent, Hotwire, and the Rails 8 Solid stack. Use when building or reviewing Rails apps, controllers, models, services, jobs, or views. origin: community --- # 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/` or `config/` - 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 `.call` delegates 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`), never `Invoices::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 ```ruby # 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 ```ruby # 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.line_items.build(params[:line_items]) invoice.tax_total = TaxCalculator.call(invoice) 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 ```ruby # 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 ```ruby # 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 ```ruby # 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. Since Rails 6.1, `strict_loading` raises on accidental lazy loads. ### Counter cache ```ruby class Comment < ApplicationRecord belongs_to :post, counter_cache: true end ``` ```ruby 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. ```ruby 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 ``` ```ruby 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 ```ruby # 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 ``` ```erb <%= render InvoiceStatusBadgeComponent.new(invoice: @invoice) %> ``` ### Hotwire ```erb <%# Turbo Frame: clicking Edit replaces only this frame %> <%= turbo_frame_tag "invoice_#{@invoice.id}" do %>
<%= link_to "Edit", edit_invoice_path(@invoice) %>
<% end %> ``` ```erb <%# 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 } %> ``` ```javascript // 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 ```ruby # 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 ```ruby # 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 `perform` idempotent, 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 rules (`rules/ruby/patterns.md`, installed as `rules/ecc/ruby/patterns.md`) — the decisions and when-to-use guidance this skill implements