Patrones de Rails
Patrones del framework para aplicaciones Ruby on Rails modernas (Rails 7.1+ y 8.x). Rails es opinado por diseño; estos son los patrones en los que la comunidad ha convergido para aplicaciones que se mantienen mantenibles más allá de la marca de los 50 modelos. Este skill es el "cómo". Para el "qué" y el "cuándo" (las decisiones sobre qué patrón utilizar), consulte las reglas de patrones Ruby — rules/ruby/patterns.md en este repositorio, instaladas como rules/ecc/ruby/patterns.md.
Cuándo activarlo
- Construir una aplicación Rails (full-stack, solo API o híbrida)
- Revisar un PR que toque
app/oconfig/ - Generar modelos, controladores, servicios o trabajos
- Una acción de controlador supera aproximadamente las 10 líneas
- Un archivo de modelo supera aproximadamente las 200 líneas
- Las consultas ActiveRecord comienzan a aparecer en controladores o vistas
Conceptos básicos
El contrato de directorios
Las aplicaciones Rails siguen una estructura predecible. Agregue directorios de manera deliberada, no casual.
app/
models/ Modelos ActiveRecord. Persistencia y lógica de dominio cercanas a los datos.
controllers/ Manejo de solicitudes HTTP. Solo orquestación ligera.
views/ Plantillas ERB. Sin lógica de negocio.
components/ Clases ViewComponent. Lógica de vista que necesita pruebas.
services/ Objetos de servicio. Operaciones de negocio de múltiples pasos.
forms/ Objetos de formulario. Manejo complejo de formularios en múltiples modelos.
queries/ Objetos de consulta. Consultas ActiveRecord reutilizables y componibles.
jobs/ Trabajos en segundo plano. Trabajo asíncrono mediante Solid Queue, Sidekiq o GoodJob.
mailers/ Clases ActionMailer.
helpers/ Helpers de vista. Solo lógica de presentación mínima.
policies/ Políticas de autorización (si usa Pundit). Opcional.
channels/ Canales ActionCable para trabajo WebSocket.
Evite app/lib/, app/utils/, app/managers/. Si algo no encaja en los directorios anteriores, el diseño generalmente necesita repensarse, no un nuevo directorio. El código verdaderamente genérico va en lib/.
Controladores ligeros
Los controladores reciben una solicitud, delegan en el objeto correcto y renderizan una respuesta. La lógica de negocio vive en otro lugar. (Según las reglas de patrones Ruby, extraiga a un objeto de servicio cuando el controlador comience a tener múltiples responsabilidades).
Objetos de servicio
El valor predeterminado para operaciones de negocio que tocan más de una sola operación de guardado de modelo. Convenciones que los mantienen consistentes:
- Espacio de nombres por dominio (
Invoices::Create), no por sufijo (InvoiceCreator). - Un método de clase
.calldelega a una instancia#call. - Devuelve un objeto Resultado, no un booleano o un registro simple, para que el llamador pueda bifurcar según el éxito, errores y el registro afectado.
- Envuelve escrituras de múltiples registros en una transacción.
- Mantenga cada servicio de un solo propósito (
Invoices::Create,Invoices::MarkPaid), nuncaInvoices::Manager.
Objetos de formulario
Cuando un formulario abarca múltiples modelos o tiene campos que no se asignan a columnas, use un objeto de formulario en lugar de atributos anidados o atributos virtuales en el modelo incorrecto. Se comporta como un modelo para la vista (form_with model: @form) mientras compone registros de manera limpia.
Objetos de consulta
Para consultas ActiveRecord reutilizadas en controladores o servicios, o demasiado complejas para un scope, extraiga un objeto de consulta que acepte un scope como entrada para que pueda componerse. Regla general: un scope que crece más allá de tres condiciones encadenadas o comienza a tomar parámetros quiere ser un objeto de consulta.
Trabajos en segundo plano
Descargue cualquier cosa lenta. (Según las reglas de patrones Ruby, Solid Queue para Rails 8 nuevo con rendimiento modesto; Sidekiq cuando necesite observabilidad madura, alto rendimiento o Redis existente). Independientemente del adaptador: pase IDs no registros, haga perform idempotente, y establezca retry_on/discard_on explícitamente.
ViewComponent sobre parciales
Para lógica de vista con renderizado condicional, más de dos argumentos, o reutilización en más de tres lugares, prefiera un ViewComponent. Los componentes son probables de forma aislada y muestran su interfaz explícitamente; los parciales con lógica condicional profunda se convierten en deuda técnica.
Hotwire: Turbo y Stimulus
El stack frontend predeterminado de Rails. (Según las reglas de patrones Ruby, prefiera Hotwire para aplicaciones renderizadas por servidor; recurra a React/Vue solo cuando la complejidad de la interacción justifique la superficie del cliente). Turbo Frames para actualizaciones parciales de página, Turbo Streams para actualizaciones impulsadas por el servidor, Stimulus para comportamientos pequeños del lado del cliente junto al marcado.
El stack Solid de Rails 8
Rails 8 incluye valores predeterminados basados en base de datos que antes necesitaban Redis: Solid Queue (trabajos), Solid Cache (caché), Solid Cable (ActionCable). El compromiso es más carga en la base de datos a cambio de un componente de infraestructura menos; una buena opción para rendimiento modesto, con Redis aún ganando a gran escala. Kamal es la herramienta de despliegue predeterminada basada en Docker.
Ejemplos de código
Controlador ligero con un objeto de servicio
# Mal: lógica de negocio en el controlador
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: "Factura creada"
else
render :new
end
end
end
# Bien: el controlador orquesta, el servicio hace el trabajo
class InvoicesController < ApplicationController
def create
result = Invoices::Create.call(params: invoice_params, user: current_user)
if result.success?
redirect_to result.invoice, notice: "Factura creada"
else
@invoice = result.invoice
render :new, status: :unprocessable_entity
end
end
end
El objeto de servicio
# app/services/invoices/create.rb
module Invoices
class Create
# Struct mantiene esto ejecutable en cada Ruby que Rails 7.1 soporta.
# En Ruby 3.2+, `Data.define(:success?, :invoice, :errors)` es una
# alternativa inmutable más concisa.
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("Falló el envío de notificación para la factura #{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
Objeto de formulario
# 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
Objeto de consulta
# 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
Los objetos de consulta aceptan un scope, por lo que se componen: Invoices::Overdue.call(scope: current_user.invoices).
Prevención de N+1
# Mal: N+1 en la vista cuando llama a post.author.name
@posts = Post.published
# Bien: carga ansiosa
@posts = Post.published.includes(:author)
includes permite que Rails elija entre preload y eager_load. Fuerce preload para consultas separadas, eager_load para un JOIN al filtrar en la asociación. Desde Rails 6.1, strict_loading genera una excepción en cargas diferidas accidentales.
Caché de contador
class Comment < ApplicationRecord
belongs_to :post, counter_cache: true
end
add_column :posts, :comments_count, :integer, default: 0, null: false
post.comments_count se convierte en una lectura de columna en lugar de un COUNT(*). Este ejemplo asume una tabla nueva; agregar un caché de contador a una tabla que ya tiene filas requiere un backfill, que está fuera del alcance de este documento.
Forma del trabajo en segundo plano
Pase IDs de registros, no registros. Los reintentos hacen que la entrega sea al menos una vez, por lo que cualquier trabajo que llame a un servicio externo debe ser idempotente; de lo contrario, una falla transitoria después de que la llamada remota tenga éxito duplicará el efecto en el siguiente intento.
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
El índice único es lo que hace que esto sea seguro: cuando dos intentos compiten, la base de datos rechaza la segunda inserción y Active Record resuelve el conflicto dentro de la llamada, devolviendo la fila existente. Eso sucede sin ningún reintento a nivel de trabajo — retry_on arriba cubre solo AccountingApi::TransientError. La guardia cubre la ventana antes de la llamada remota; pasar idempotency_key a la API cubre la ventana después de ella, por lo que un bloqueo entre la llamada a la API y update! aún se resuelve en una sola exportación.
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: hacer clic en Editar reemplaza solo este frame %>
<%= turbo_frame_tag "invoice_#{@invoice.id}" do %>
<div class="invoice">
<%= link_to "Editar", 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 aceptables vs inaceptables
# Aceptable: normalización de datos pura
class User < ApplicationRecord
before_validation :normalize_email
private
def normalize_email
self.email = email.to_s.downcase.strip
end
end
# Mover a un servicio en su lugar: efectos secundarios ocultos en un callback
# class User < ApplicationRecord
# after_create :send_welcome_email # difícil de optar por no participar, difícil de probar
# end
Buen concern vs mal concern
# Bueno: genuinamente transversal, reutilizable en modelos no relacionados
# 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
# Malo: un "concern" usado por exactamente un modelo, que contiene lógica que le pertenece
# app/models/concerns/invoice_calculations.rb
module InvoiceCalculations
extend ActiveSupport::Concern
def calculate_total
line_items.sum(&:amount) + tax_total
end
end
# Solo Invoice incluye esto. No es transversal; es la propia lógica de Invoice
# oculta en un módulo para aparentar un modelo "ligero". Vuelva a ponerlo en Invoice.
Un concern usado por solo una clase es solo mover código; pertenece a esa clase. Un concern debe ser transversal o no debe existir. Use concerns para comportamiento compartido por múltiples modelos no relacionados (como SoftDeletable, Sluggable, Taggable), no para agrupar métodos de un solo modelo.