Rails Patterns
Framework-Patterns für moderne Ruby on Rails-Anwendungen (Rails 7.1+ und 8.x). Rails ist von Natur aus meinungsstark; dies sind die Patterns, auf die sich die Gemeinschaft für Apps geeinigt hat, die auch nach 50 Modellen noch wartbar bleiben. Diese Skill ist das „Wie“. Für das „Was“ und „Wann“ (die Entscheidungen, welches Pattern verwendet werden soll) siehe die Ruby Patterns Regeln – rules/ruby/patterns.md in diesem Repository, installiert als rules/ecc/ruby/patterns.md.
Wann aktivieren
- Erstellen einer Rails-Anwendung (Full-Stack, API-only oder hybrid)
- Prüfen eines Pull Requests, der
app/oderconfig/betrifft - Generieren von Modellen, Controllern, Services oder Jobs
- Eine Controller-Aktion wächst über ~10 Zeilen hinaus
- Eine Model-Datei wächst über ~200 Zeilen hinaus
- ActiveRecord-Abfragen tauchen in Controllern oder Views auf
Kernkonzepte
Das Directory Contract
Rails-Apps folgen einer vorhersagbaren Struktur. Fügen Sie Verzeichnisse bewusst hinzu, nicht beiläufig.
app/
models/ ActiveRecord-Modelle. Persistenz und Domänenlogik nahe an den Daten.
controllers/ HTTP-Anfragebehandlung. Nur dünne Orchestrierung.
views/ ERB-Templates. Keine Geschäftslogik.
components/ ViewComponent-Klassen. View-Logik, die Tests benötigt.
services/ Service-Objekte. Mehrschrittige Geschäftsoperationen.
forms/ Formularobjekte. Komplexe Formularverarbeitung über mehrere Modelle hinweg.
queries/ Abfrageobjekte. Wiederverwendbare, zusammensetzbare ActiveRecord-Abfragen.
jobs/ Hintergrund-Jobs. Asynchrone Arbeit via Solid Queue, Sidekiq oder GoodJob.
mailers/ ActionMailer-Klassen.
helpers/ View-Helper. Nur kleine Darstellungslogik.
policies/ Autorisierungsrichtlinien (bei Verwendung von Pundit). Optional.
channels/ ActionCable-Kanäle für WebSocket-Arbeit.
Vermeiden Sie app/lib/, app/utils/, app/managers/. Wenn etwas nicht in die obigen Verzeichnisse passt, muss das Design meist überdacht werden, nicht ein neues Verzeichnis erstellt. Wirklich generischer Code kommt in lib/.
Skinny Controller
Controller empfangen eine Anfrage, delegieren an das richtige Objekt und rendern eine Antwort. Geschäftslogik lebt woanders. (Gemäß der Ruby Patterns Regeln: In ein Service-Object extrahieren, wenn der Controller beginnt, mehrere Verantwortlichkeiten zu tragen.)
Service-Objekte
Der Standard für Geschäftsoperationen, die mehr als eine einzelne Modellspeicherung betreffen. Konventionen, die sie konsistent halten:
- Namespace nach Domäne (
Invoices::Create), nicht nach Suffix (InvoiceCreator). - Eine Klassenmethode
.calldelegiert an eine Instanzmethode#call. - Rückgabe eines Result-Objekts, nicht eines Booleans oder eines nackten Datensatzes, damit der Aufrufer nach Erfolg, Fehlern und dem betroffenen Datensatz verzweigen kann.
- Mehrfach-Datensatz-Schreibvorgänge in eine Transaktion packen.
- Jedes Service sollte einen einzigen Zweck haben (
Invoices::Create,Invoices::MarkPaid), niemalsInvoices::Manager.
Formularobjekte
Wenn ein Formular mehrere Modelle umfasst oder Felder hat, die nicht auf Spalten abbilden, verwenden Sie ein Formularobjekt anstelle von verschachtelten Attributen oder virtuellen Attributen auf dem falschen Modell. Es verhält sich für die View wie ein Modell (form_with model: @form), während es Datensätze sauber zusammensetzt.
Abfrageobjekte
Für ActiveRecord-Abfragen, die über mehrere Controller oder Services hinweg wiederverwendet werden, oder zu komplex für einen Scope sind, extrahieren Sie ein Abfrageobjekt, das einen Scope als Eingabe akzeptiert, damit es zusammensetzbar ist. Faustregel: Ein Scope, der über drei verkettete Bedingungen hinauswächst oder Parameter annimmt, möchte ein Abfrageobjekt sein.
Hintergrund-Jobs
Alles Langsame auslagern. (Gemäß den Ruby Patterns Regeln: Solid Queue für neue Rails 8-Projekte mit moderatem Durchsatz; Sidekiq, wenn Sie ausgereifte Beobachtbarkeit, hohen Durchsatz oder vorhandenes Redis benötigen.) Unabhängig vom Adapter: IDs übergeben, nicht Datensätze; perform idempotent machen; und retry_on/discard_on explizit setzen.
ViewComponent vor Partials
Für View-Logik mit bedingtem Rendering, mehr als zwei Argumenten oder Wiederverwendung an mehr als drei Stellen bevorzugen Sie ein ViewComponent. Komponenten sind isoliert testbar und machen ihr Interface explizit; Partials mit tiefer bedingter Logik werden zu technischen Schulden.
Hotwire: Turbo und Stimulus
Der Standard-Rails-Frontend-Stack. (Gemäß den Ruby Patterns Regeln: Hotwire für serverseitig gerenderte Apps bevorzugen; zu React/Vue greifen, nur wenn die Interaktionskomplexität die Client-Oberfläche rechtfertigt.) Turbo Frames für partielle Seitenaktualisierungen, Turbo Streams für servergesteuerte Aktualisierungen, Stimulus für kleine clientseitige Verhaltensweisen direkt im Markup.
Der Rails 8 Solid Stack
Rails 8 bringt datenbankgestützte Standardeinstellungen mit, die zuvor Redis benötigten: Solid Queue (Jobs), Solid Cache (Cache), Solid Cable (ActionCable). Der Kompromiss ist eine höhere Datenbanklast für eine weniger benötigte Infrastrukturkomponente; gut geeignet für moderaten Durchsatz, während Redis bei hohem Maßstab weiterhin gewinnt. Kamal ist das standardmäßige Docker-basierte Deployment-Tool.
Codebeispiele
Skinny Controller mit einem Service-Objekt
# Schlecht: Geschäftslogik im 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: "Rechnung erstellt"
else
render :new
end
end
end
# Gut: Controller orchestriert, Service erledigt die Arbeit
class InvoicesController < ApplicationController
def create
result = Invoices::Create.call(params: invoice_params, user: current_user)
if result.success?
redirect_to result.invoice, notice: "Rechnung erstellt"
else
@invoice = result.invoice
render :new, status: :unprocessable_entity
end
end
end
Das Service-Objekt
# app/services/invoices/create.rb
module Invoices
class Create
# Struct hält dies auf jedem Ruby, das Rails 7.1 unterstützt, ausführbar.
# Auf Ruby 3.2+ ist `Data.define(:success?, :invoice, :errors)` eine knappere
# unveränderliche 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("Benachrichtigungsversand fehlgeschlagen für Rechnung #{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
Formularobjekt
# 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
Abfrageobjekt
# 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
Abfrageobjekte akzeptieren einen Scope, sodass sie zusammensetzbar sind: Invoices::Overdue.call(scope: current_user.invoices).
N+1-Prävention
# Schlecht: N+1 in der View, wenn post.author.name aufgerufen wird
@posts = Post.published
# Gut: Eager Loading
@posts = Post.published.includes(:author)
includes lässt Rails zwischen Preload und Eager Load wählen. Verwenden Sie preload für separate Abfragen, eager_load für einen JOIN, wenn Sie auf der Assoziation filtern. Seit Rails 6.1 wirft strict_loading bei versehentlichem Lazy Loading einen Fehler.
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 wird zu einer Spaltenauslese statt einem COUNT(*). Dieses Beispiel geht von einer neuen Tabelle aus; das Hinzufügen eines Counter Caches zu einer Tabelle, die bereits Zeilen enthält, erfordert ein Backfill, das hier nicht behandelt wird.
Aufbau von Hintergrund-Jobs
Übergeben Sie Datensatz-IDs, nicht Datensätze. Wiederholungen machen die Auslieferung „at-least-once“, daher muss jeder Job, der einen externen Dienst aufruft, idempotent sein – sonst dupliziert ein vorübergehender Fehler nach erfolgreichem Remote-Aufruf den Effekt beim nächsten Versuch.
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
Der eindeutige Index macht dies sicher: Wenn zwei Versuche konkurrieren, lehnt die Datenbank den zweiten Einfügevorgang ab, und Active Record löst den Konflikt innerhalb des Aufrufs, indem es die vorhandene Zeile zurückgibt. Dies geschieht ohne job-level Wiederholung – retry_on oben deckt nur AccountingApi::TransientError ab. Der Guard deckt das Zeitfenster vor dem Remote-Aufruf ab; die Übergabe von idempotency_key an die API deckt das Fenster danach ab, sodass ein Absturz zwischen dem API-Aufruf und update! immer noch zu einem einzigen Export aufgelöst wird.
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: Klicken auf „Bearbeiten“ ersetzt nur diesen Frame %>
<%= turbo_frame_tag "invoice_#{@invoice.id}" do %>
<div class="invoice">
<%= link_to "Bearbeiten", 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)
}
}
Akzeptable vs. inakzeptable Callbacks
# Akzeptabel: reine Datennormalisierung
class User < ApplicationRecord
before_validation :normalize_email
private
def normalize_email
self.email = email.to_s.downcase.strip
end
end
# In ein Service verschieben: Seiteneffekte, die in einem Callback versteckt sind
# class User < ApplicationRecord
# after_create :send_welcome_email # schwer zu deaktivieren, schwer zu testen
# end
Gutes vs. schlechtes Concern
# Gut: wirklich querschnittlich, über mehrere nicht verwandte Modelle wiederverwendbar
# 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
# Schlecht: ein „Concern“, das von genau einem Modell verwendet wird und Logik enthält, die dazu gehört
# app/models/concerns/invoice_calculations.rb
module InvoiceCalculations
extend ActiveSupport::Concern
def calculate_total
line_items.sum(&:amount) + tax_total
end
end
# Nur Invoice bindet dies ein. Es ist nicht querschnittlich; es ist Invoices eigene Logik,
# die für den Anschein eines „schlanken“ Modells in einem Modul versteckt ist. Setzen Sie es zurück in Invoice.
Ein Concern, das von nur einer Klasse verwendet wird, ist nur Code-Verschiebung; es gehört in diese Klasse. Ein Concern sollte eine klare, domänenübergreifende Abstraktion bieten, die das Modell sonst nicht hätte, wie z.B. Weichlöschung, Versionierung oder einen eindeutigen Identifikator.
Verwandte Regeln
Siehe rules/ecc/ruby/patterns.md für Entscheidungsbäume: Wann ein Service vs. ein Formularobjekt vs. ein Query-Objekt verwendet werden soll, sowie den Hotwire- vs. React-Entscheidungsbaum. Diese Skill enthält die Umsetzung; die Patterns-Regeldatei enthält die Begründung.