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 Community für Apps geeinigt hat, die auch jenseits der 50-Modell-Marke wartbar bleiben. Diese Skill beschreibt 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)
- Überprüfen eines PRs, der
app/oderconfig/betrifft - Generieren von Modellen, Controllern, Services oder Jobs
- Eine Controller-Aktion wächst über ~10 Zeilen
- Eine Model-Datei wächst über ~200 Zeilen
- ActiveRecord-Abfragen tauchen in Controllern oder Views auf
Kernkonzepte
Das Verzeichnisvertrag (Directory Contract)
Rails-Apps folgen einer vorhersagbaren Struktur. Füge Verzeichnisse bewusst hinzu, nicht beiläufig.
app/
models/ ActiveRecord-Modelle. Persistenz und Domänenlogik nahe an den Daten.
controllers/ HTTP-Anfrageverarbeitung. Nur dünne Orchestrierung.
views/ ERB-Vorlagen. Keine Geschäftslogik.
components/ ViewComponent-Klassen. View-Logik, die Tests benötigt.
services/ Service-Objekte. Mehrschrittige Geschäftsabläufe.
forms/ Formularobjekte. Komplexe Formularverarbeitung über mehrere Modelle hinweg.
queries/ Abfrageobjekte. Wiederverwendbare, zusammensetzbare ActiveRecord-Abfragen.
jobs/ Hintergrundjobs. Asynchrone Arbeit via Solid Queue, Sidekiq oder GoodJob.
mailers/ ActionMailer-Klassen.
helpers/ View-Helfer. Nur winzige Präsentationslogik.
policies/ Autorisierungsrichtlinien (falls Pundit verwendet wird). Optional.
channels/ ActionCable-Kanäle für WebSocket-Arbeit.
Vermeide 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/.
Schlanke Controller (Skinny Controllers)
Controller empfangen eine Anfrage, delegieren an das richtige Objekt und rendern eine Antwort. Geschäftslogik lebt woanders. (Gemäß den Ruby-Patterns-Regeln: Extrahiere in ein Service-Objekt, wenn der Controller mehrere Verantwortlichkeiten übernimmt.)
Service-Objekte
Der Standard für Geschäftsabläufe, die mehr als ein einzelnes Model-Save betreffen. Konventionen, die sie konsistent halten:
- Namespace nach Domäne (
Invoices::Create), nicht nach Suffix (InvoiceCreator). - Eine Klassenmethode
.calldelegiert an eine Instanz#call. - Gib ein Ergebnisobjekt (Result) zurück, keinen Boolean oder einen nackten Datensatz, damit der Aufrufer auf Erfolg, Fehler und den betroffenen Datensatz verzweigen kann.
- Umschließe Mehrfachdatensatz-Schreibvorgänge in einer Transaktion.
- Halte jedes Service-Objekt auf einen Zweck fokussiert (
Invoices::Create,Invoices::MarkPaid), niemalsInvoices::Manager.
Formularobjekte (Form Objects)
Wenn ein Formular mehrere Modelle umfasst oder Felder hat, die nicht auf Spalten abbilden, verwende ein Formularobjekt anstelle von verschachtelten Attributen oder virtuellen Attributen auf dem falschen Modell. Es verhält sich gegenüber der View wie ein Modell (form_with model: @form), während es Datensätze sauber zusammensetzt.
Abfrageobjekte (Query Objects)
Für ActiveRecord-Abfragen, die über Controller oder Services wiederverwendet werden oder zu komplex für einen Scope sind, extrahiere ein Abfrageobjekt, das einen Scope als Eingabe akzeptiert, sodass es sich zusammensetzen lässt. Faustregel: Ein Scope, der über drei verkettete Bedingungen hinauswächst oder Parameter annimmt, möchte ein Abfrageobjekt werden.
Hintergrundjobs (Background Jobs)
Lagere alles Langsame aus. (Gemäß den Ruby-Patterns-Regeln: Solid Queue für grüne Wiese Rails 8 mit moderatem Durchsatz; Sidekiq, wenn ausgereifte Beobachtbarkeit, hoher Durchsatz oder vorhandener Redis benötigt werden.) Unabhängig vom Adapter: Übergib IDs, nicht Datensätze, mache perform idempotent und setze retry_on/discard_on explizit.
ViewComponent statt Partials
Verwende für View-Logik mit bedingtem Rendern, mehr als zwei Argumenten oder Wiederverwendung an mehr als drei Stellen bevorzugt einen 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: Bevorzuge Hotwire für serverseitig gerenderte Apps; greife zu React/Vue nur, wenn die Interaktionskomplexität die Client-Oberfläche rechtfertigt.) Turbo Frames für teilweise Seitenaktualisierungen, Turbo Streams für serverseitig gesteuerte Aktualisierungen, Stimulus für kleine clientseitige Verhaltensweisen neben dem 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; eine gute Wahl für moderaten Durchsatz, während Redis bei hoher Skalierung weiterhin die Nase vorn hat. Kamal ist das standardmäßige Docker-basierte Bereitstellungstool.
Codebeispiele
Schlanker 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 ausführbar, das Rails 7.1 unterstützt.
# Auf Ruby 3.2+ ist `Data.define(:success?, :invoice, :errors)` eine prägnantere
# 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 sich zusammensetzen lassen: Invoices::Overdue.call(scope: current_user.invoices).
N+1-Vermeidung
# Schlecht: N+1 in der View, wenn sie post.author.name aufruft
@posts = Post.published
# Gut: Eager Loading
@posts = Post.published.includes(:author)
includes lässt Rails zwischen Preload und Eager Load wählen. Verwende preload für separate Abfragen, eager_load für einen JOIN, wenn auf die Assoziation gefiltert wird. Seit Rails 6.1 löst strict_loading bei versehentlichen Lazy Loads einen Fehler aus.
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 einem Spaltenlesevorgang statt einem COUNT(*). Dieses Beispiel geht von einer neuen Tabelle aus; das Hinzufügen eines Counter Cache zu einer Tabelle, die bereits Zeilen enthält, erfordert ein Backfill, das hier außerhalb des Rahmens liegt.
Hintergrundjob-Struktur
Übergebe Datensatz-IDs, nicht Datensätze. Wiederholungen machen die Zustellung mindestens einmalig, daher muss jeder Job, der einen externen Dienst aufruft, idempotent sein – sonst dupliziert ein transienter 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 gleichzeitig laufen, lehnt die Datenbank den zweiten Einfügevorgang ab und Active Record löst den Konflikt innerhalb des Aufrufs auf, indem es die vorhandene Zeile zurückgibt. Dies geschieht ohne jobseitige Wiederholung – retry_on oben deckt nur AccountingApi::TransientError ab. Die Absicherung deckt das Fenster 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 einzelnen Export führt.
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: Nebenwirkungen in einem Callback versteckt
# 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 dorthin 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 Invoice eigene Logik,
# die für den Anschein eines "schlanken" Modells in einem Modul versteckt wird. Setze es zurück in Invoice.
Ein Concern, das nur von einer Klasse verwendet wird, ist nur Code-Verschiebung; es gehört in diese Klasse. Ein Concern sollte wie das SoftDeletable-Beispiel sein: Es fügt eine abgeschlossene, testbare, von den einbindenden Modellen unabhängige Funktionalität hinzu. Halte Dinge nah beieinander, bis es einen Grund gibt, sie zu trennen.