Patterns Rails
Patterns framework pour les applications Ruby on Rails modernes (Rails 7.1+ et 8.x). Rails est conçu avec des opinions fortes ; voici les patterns que la communauté a adoptés pour les applications qui restent maintenables après la barre des 50 modèles. Cette compétence est le « comment ». Pour le « quoi » et le « quand » (les décisions sur quel pattern utiliser), voir les règles Ruby — rules/ruby/patterns.md dans ce dépôt, installé comme rules/ecc/ruby/patterns.md.
Quand activer
- Création d'une application Rails (full-stack, API-only ou hybride)
- Révision d'une PR qui modifie
app/ouconfig/ - Génération de modèles, contrôleurs, services ou jobs
- Une action de contrôleur dépasse ~10 lignes
- Un fichier de modèle dépasse ~200 lignes
- Des requêtes ActiveRecord apparaissent dans les contrôleurs ou les vues
Concepts fondamentaux
Le contrat de répertoire
Les applications Rails suivent une structure prévisible. Ajoutez des répertoires délibérément, pas par habitude.
app/
models/ Modèles ActiveRecord. Persistance et logique métier proches des données.
controllers/ Gestion des requêtes HTTP. Orchestration légère uniquement.
views/ Templates ERB. Aucune logique métier.
components/ Classes ViewComponent. Logique de vue nécessitant des tests.
services/ Objets service. Opérations métier multi-étapes.
forms/ Objets formulaire. Gestion de formulaires complexes sur plusieurs modèles.
queries/ Objets requête. Requêtes ActiveRecord réutilisables et composables.
jobs/ Jobs en arrière-plan. Travail asynchrone via Solid Queue, Sidekiq ou GoodJob.
mailers/ Classes ActionMailer.
helpers/ Helpers de vue. Logique de présentation minimale uniquement.
policies/ Politiques d'autorisation (si utilisation de Pundit). Optionnel.
channels/ Canaux ActionCable pour le travail WebSocket.
Évitez app/lib/, app/utils/, app/managers/. Si quelque chose ne correspond pas aux répertoires ci-dessus, la conception nécessite généralement une refonte, pas un nouveau répertoire. Le code véritablement générique va dans lib/.
Contrôleurs légers
Les contrôleurs reçoivent une requête, délèguent à l'objet approprié et renvoient une réponse. La logique métier réside ailleurs. (Selon les règles Ruby, extrayez dans un objet service lorsque le contrôleur commence à porter plusieurs responsabilités.)
Objets service
La valeur par défaut pour les opérations métier qui touchent plus qu'une simple sauvegarde de modèle unique. Conventions pour les garder cohérents :
- Namespace par domaine (
Invoices::Create), pas par suffixe (InvoiceCreator). - Une méthode de classe
.calldélègue à une instance#call. - Renvoie un objet Result, pas un booléen ou un enregistrement brut, afin que l'appelant puisse bifurquer sur le succès, les erreurs et l'enregistrement concerné.
- Encapsule les écritures multi-enregistrements dans une transaction.
- Gardez chaque service à usage unique (
Invoices::Create,Invoices::MarkPaid), jamaisInvoices::Manager.
Objets formulaire
Lorsqu'un formulaire couvre plusieurs modèles ou a des champs qui ne correspondent pas aux colonnes, utilisez un objet formulaire plutôt que des attributs imbriqués ou des attributs virtuels sur le mauvais modèle. Il se comporte comme un modèle pour la vue (form_with model: @form) tout en composant proprement les enregistrements.
Objets requête
Pour les requêtes ActiveRecord réutilisées dans les contrôleurs ou les services, ou trop complexes pour un scope, extrayez un objet requête qui accepte un scope en entrée afin qu'il se compose. Règle empirique : un scope qui dépasse trois conditions chaînées ou commence à prendre des paramètres doit devenir un objet requête.
Jobs en arrière-plan
Déléguez tout ce qui est lent. (Selon les règles Ruby, Solid Queue pour un nouveau projet Rails 8 avec un débit modeste ; Sidekiq lorsque vous avez besoin d'observabilité mature, d'un débit élevé ou d'un Redis existant.) Quel que soit l'adaptateur : passez des ID, pas des enregistrements, rendez perform idempotent et définissez retry_on/discard_on explicitement.
ViewComponent plutôt que des vues partielles
Pour une logique de vue avec rendu conditionnel, plus de deux arguments ou une réutilisation dans plus de trois endroits, préférez un ViewComponent. Les composants sont testables de manière isolée et exposent leur interface explicitement ; les vues partielles avec une logique conditionnelle profonde deviennent une dette technique.
Hotwire : Turbo et Stimulus
La pile frontend par défaut de Rails. (Selon les règles Ruby, préférez Hotwire pour les applications rendues côté serveur ; optez pour React/Vue uniquement lorsque la complexité des interactions justifie la surface client.) Turbo Frames pour les mises à jour partielles de page, Turbo Streams pour les mises à jour pilotées par le serveur, Stimulus pour les petits comportements côté client à côté du balisage.
La pile Solid de Rails 8
Rails 8 intègre des valeurs par défaut basées sur la base de données qui nécessitaient auparavant Redis : Solid Queue (jobs), Solid Cache (cache), Solid Cable (ActionCable). Le compromis est une charge de base de données plus élevée pour un composant d'infrastructure en moins ; un bon choix pour un débit modeste, Redis restant gagnant à grande échelle. Kamal est l'outil de déploiement Docker par défaut.
Exemples de code
Contrôleur léger avec un objet service
# Mauvais : logique métier dans le contrôleur
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
# Bon : le contrôleur orchestre, le service fait le travail
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
L'objet service
# app/services/invoices/create.rb
module Invoices
class Create
# Struct permet de l'exécuter sur toutes les versions de Ruby que Rails 7.1 supporte.
# Sur Ruby 3.2+, `Data.define(:success?, :invoice, :errors)` est une
# alternative immuable plus concise.
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
Objet formulaire
# 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
Objet requête
# 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
Les objets requête acceptent un scope, donc ils se composent : Invoices::Overdue.call(scope: current_user.invoices).
Prévention des requêtes N+1
# Mauvais : N+1 dans la vue lorsqu'elle appelle post.author.name
@posts = Post.published
# Bon : chargement eager
@posts = Post.published.includes(:author)
includes laisse Rails choisir entre preload et eager_load. Forcez preload pour des requêtes séparées, eager_load pour un JOIN lors du filtrage sur l'association. Depuis Rails 6.1, strict_loading lève une exception en cas de chargement paresseux accidentel.
Cache de compteur
class Comment < ApplicationRecord
belongs_to :post, counter_cache: true
end
add_column :posts, :comments_count, :integer, default: 0, null: false
post.comments_count devient une lecture de colonne au lieu d'un COUNT(*). Cet exemple suppose une nouvelle table ; l'ajout d'un cache de compteur à une table qui contient déjà des lignes nécessite un backfill, qui sort du cadre de ce document.
Format du job en arrière-plan
Passez les ID des enregistrements, pas les enregistrements eux-mêmes. Les tentatives de rejeu rendent la livraison au moins une fois, donc tout job qui appelle un service externe doit être idempotent — sinon un échec transitoire après que l'appel distant a réussi dupliquera l'effet lors de la tentative suivante.
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
L'index unique est ce qui rend cela sûr : lorsque deux tentatives sont en concurrence, la base de données rejette la deuxième insertion et Active Record résout le conflit à l'intérieur de l'appel, renvoyant la ligne existante. Cela se produit sans aucune tentative de rejeu au niveau du job — retry_on ci-dessus ne couvre que AccountingApi::TransientError. La garde couvre la fenêtre avant l'appel distant ; le passage de idempotency_key à l'API couvre la fenêtre après celui-ci, de sorte qu'un crash entre l'appel API et update! aboutit toujours à un seul export.
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 : cliquer sur Edit remplace uniquement ce frame %>
<%= turbo_frame_tag "invoice_#{@invoice.id}" do %>
<div class="invoice">
<%= link_to "Edit", 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)
}
}
Callbacks acceptables vs inacceptables
# Acceptable : normalisation pure des données
class User < ApplicationRecord
before_validation :normalize_email
private
def normalize_email
self.email = email.to_s.downcase.strip
end
end
# À déplacer dans un service à la place : effets de bord cachés dans un callback
# class User < ApplicationRecord
# after_create :send_welcome_email # difficile à désactiver, difficile à tester
# end
Bon concern vs mauvais concern
# Bon : véritablement transversal, réutilisable dans des modèles sans lien
# 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
# Mauvais : un "concern" utilisé par un seul modèle, contenant une logique qui lui appartient
# app/models/concerns/invoice_calculations.rb
module InvoiceCalculations
extend ActiveSupport::Concern
def calculate_total
line_items.sum(&:amount) + tax_total
end
end
# Seul Invoice inclut cela. Ce n'est pas transversal ; c'est la propre logique d'Invoice
# cachée dans un module pour l'apparence d'un modèle "léger". Remettez-le dans Invoice.
Un concern utilisé par une seule classe n'est qu'un déplacement de code ; il appartient à cette classe. Un concern devrait rassembler un comportement qui est partagé par au moins deux modèles sans lien de parenté. Le critère décisif est la réutilisation, pas la taille du fichier.
Gestion des dépendances
Règles pour le Gemfile
- Utilisez
gem "pundit"plutôt qu'un contrôle d'accès maison — couvre les tests d'autorisation
Configuration de l'outillage
RuboCop
# .rubocop.yml
require:
- rubocop-rails
- rubocop-rspec
- rubocop-performance
AllCops:
TargetRubyVersion: 3.2
NewCops: enable
Rails:
Enabled: true
L'essentiel est rubocop-rails pour les règles spécifiques à Rails, rubocop-rspec pour la cohérence des tests et rubocop-performance pour les optimisations de performances. Ajustez TargetRubyVersion à la version de Ruby du projet (3.2 pour les nouveaux projets Rails 8).
Variables d'environnement
DATABASE_URL— chaîne de connexion à la base de donnéesRAILS_MASTER_KEY— clé de déchiffrement des credentials (ne jamais commiter)REDIS_URL— uniquement lorsque Redis est encore utiliséSOLID_QUEUE_CONCURRENCY— (Rails 8, Solid Queue) nombre de workers parallèles
Variables d'environnement clés
| Variable | But | Requise |
|---|---|---|
DATABASE_URL | Chaîne de connexion à la base de données | Oui |
RAILS_MASTER_KEY | Déchiffrement des credentials Rails | Oui, en production |
REDIS_URL | Connexion Redis (si utilisé) | Conditionnelle |
SOLID_QUEUE_CONCURRENCY | Parallelisme des jobs Solid Queue | Non, défaut à 5 |