mirror of
https://github.com/affaan-m/ECC.git
synced 2026-11-14 20:12:09 +00:00
Independent local Codex review PASS at a6a6419402, no P0/P1. Independent 181 checks and strict skill validation pass. CI run 34667546951 passed, all 45 current checks green. Existing Rails skill mapping and documentation corrections only. Rollback: revert this squash commit.
476 lines
16 KiB
Markdown
476 lines
16 KiB
Markdown
---
|
|
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
|