Skip to main content

rails-patterns

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.

Aller à l'installation

Informations de source

Dépôt
affaan-m/ECC
Dernière activité de la source
12 septembre 2026 à 08:39
Langue détectée de SKILL.md
anglais
Étoiles
260 918
Forks
39 042

Options d'installation

Le prompt qui vérifie d'abord la source est sélectionné par défaut. Vous pouvez passer à une commande directe ou télécharger une copie locale.

Vérifiez les fichiers source

Lisez SKILL.md et les fichiers associés affichés par SkillsMP avant de décider de l'installer.

Affichage de SKILL.md

SKILL.md
Instructions source · Aperçu en lecture seule
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 %> <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.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
Voir sur GitHub