Rails Patterns

by affaan-mef648e01899bNo license275K starsListed Oct 8, 2026Updated Oct 8, 2026Repository updated 3 days ago

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.

Instructions onlySoftware Development
AI-generated overview

Guidance on Ruby on Rails 7.1+ and 8.x patterns: skinny controllers, service, form and query objects, jobs, ViewComponent and Hotwire.

What it does
This skill supplies framework conventions and code examples for building or reviewing modern Ruby on Rails applications. It covers the app directory contract, skinny controllers with service objects, form objects, query objects, idiomatic ActiveRecord including eager loading and counter caches, background job shapes, ViewComponent, Hotwire, and the Rails 8 Solid stack. It also lists anti-patterns and best practices, and produces reference guidance and example code rather than running anything.
When to use it
Use it when building a Rails application, whether full-stack, API-only or hybrid, or when reviewing a pull request that touches app/ or config/. It also fits generating models, controllers, services or jobs, and cases where a controller action grows past roughly ten lines, a model file grows past roughly two hundred lines, or ActiveRecord queries start appearing in controllers or views.
Requirements
No scripts or tooling are required; the skill is instructions and code examples only. It assumes a Ruby on Rails 7.1+ or 8.x project context and mentions optional ecosystem pieces such as Solid Queue, Sidekiq, GoodJob, Pundit, ViewComponent, Hotwire and Kamal.

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 controllerclass 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  endend
# Good: controller orchestrates, service does the workclass 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  endend

The service object

ruby
# app/services/invoices/create.rbmodule 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  endend

Form object

ruby
# app/forms/signup_form.rbclass 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  endend

Query object

ruby
# app/queries/invoices/overdue.rbmodule 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  endend

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: trueend
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)  endend
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.rbclass 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")  endend
erb
<%= render InvoiceStatusBadgeComponent.new(invoice: @invoice) %>

Hotwire

erb
<%# 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 %>
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.jsimport { 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 normalizationclass User < ApplicationRecord  before_validation :normalize_email
  private
  def normalize_email    self.email = email.to_s.downcase.strip  endend
# 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.rbmodule 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.rbmodule InvoiceCalculations  extend ActiveSupport::Concern
  def calculate_total    line_items.sum(&:amount) + tax_total  endend# 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

Source and attribution

Source:affaan-m/eccinskills/rails-patternsat commitef648e0

License: No license

Content belongs to its original authors. SourceWeft indexes it from a public repository.

Report or request removal