Patrones Rails
Patrones de 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 los 50 modelos. Este skill es el "cómo". Para el "qué" y el "cuándo" (las decisiones sobre qué patrón usar), consulta las reglas de patrones Ruby — rules/ruby/patterns.md en este repositorio, instalado como rules/ecc/ruby/patterns.md.
Cuándo activarlo
- Construyendo una aplicación Rails (full-stack, solo API, o híbrida)
- Revisando un PR que toque
app/oconfig/ - Generando modelos, controladores, servicios o trabajos
- Una acción de controlador crece más allá de ~10 líneas
- Un archivo de modelo crece más allá de ~200 líneas
- Las consultas ActiveRecord comienzan a aparecer en controladores o vistas
Conceptos centrales
El contrato de directorios
Las aplicaciones Rails siguen una estructura predecible. Agrega directorios deliberadamente, no casualmente.
app/
models/ Modelos ActiveRecord. Persistencia y lógica de dominio cercana 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 multi-paso.
forms/ Objetos de formulario. Manejo de formularios complejos a través de 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 pequeña.
policies/ Políticas de autorización (si se usa Pundit). Opcional.
channels/ Canales ActionCable para trabajo con WebSocket.
Evita app/lib/, app/utils/, app/managers/. Si algo no encaja en los directorios anteriores, generalmente el diseño necesita replanteamiento, no un nuevo directorio. El código verdaderamente genérico va en lib/.
Controladores ligeros
Los controladores reciben una solicitud, delegan al objeto correcto y renderizan una respuesta. La lógica de negocio vive en otro lugar. (Según las reglas de patrones Ruby, extrae a un objeto de servicio cuando el controlador comienza a cargar con múltiples responsabilidades).
Objetos de servicio
El valor predeterminado para operaciones de negocio que tocan más de un solo 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 éxito, errores y el registro afectado.
- Envuelve escrituras multi-registro en una transacción.
- Mantén 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, usa 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 limpiamente.
Objetos de consulta
Para consultas ActiveRecord reutilizadas entre controladores o servicios, o demasiado complejas para un scope, extrae un objeto de consulta que acepte un scope como entrada para que se pueda componer. 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
Descarga cualquier cosa lenta. (Según las reglas de patrones Ruby, Solid Queue para Rails 8 nuevo con rendimiento modesto; Sidekiq cuando necesites observabilidad madura, alto rendimiento o Redis existente). Independientemente del adaptador: pasa IDs no registros, haz que perform sea idempotente y establece 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, prefiere un ViewComponent. Los componentes se pueden probar de forma aislada y muestran su interfaz explícitamente; los parciales con lógica condicional profunda se convierten en deuda.
Hotwire: Turbo y Stimulus
La pila frontend predeterminada de Rails. (Según las reglas de patrones Ruby, prefiere Hotwire para aplicaciones renderizadas por servidor; recurre a React/Vue solo cuando la complejidad de interacción justifique la superficie del cliente). Turbo Frames para actualizaciones parciales de página, Turbo Streams para actualizaciones impulsadas por servidor, Stimulus para comportamientos pequeños del lado del cliente junto al marcado.
La pila 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 equilibrio es más carga de base de datos a cambio de un componente de infraestructura menos; adecuado 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 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("La entrega de notificaciones falló 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, para que se compongan: 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 a Rails elegir entre precarga y carga ansiosa. Usa preload para consultas separadas, eager_load para un JOIN al filtrar sobre la asociación. Desde Rails 6.1, strict_loading lanza una excepción en cargas perezosas 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 relleno, que está fuera del alcance aquí.
Forma del trabajo en segundo plano
Pasa 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, un fallo transitorio 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 esto 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, por lo que un bloqueo entre la llamada 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: al 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 pura de datos
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 desactivar, difícil de probar
# end
Concern bueno vs concern malo
# Bueno: genuinamente transversal, reutilizable entre 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
# Mal: 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 lógica propia de Invoice
# escondida en un módulo por la apariencia de un modelo "ligero". Colócalo de nuevo en Invoice.
Un concern usado por solo una clase es solo mover código; pertenece a esa clase. Un concern debería representar un comportamiento transversal genuino que múltiples modelos no relacionados comparten.
Prácticas de ActiveRecord
Reglas generales
- Prefiere lazy loading (
find,where,find_by) sobre SQL sin procesar. - Usa índices de base de datos compuestos para consultas con múltiples condiciones.
- Agregados de base de datos (
.count,.sum,.maximum,...) deberían reemplazar el conteo en memoria (records.length,records.sum(&:amount)) a menos que los registros ya estén cargados. - Los callbacks de modelo deberían manipular datos, no desencadenar efectos secundarios. Los efectos secundarios pertenecen a objetos de servicio (o trabajos).
Migraciones
- Usa el DSL de migración de Rails, no SQL sin procesar.
- Siempre agrega un índice para cada
belongs_to. - Usa
changeen lugar deup/downa menos que la migración no sea reversible. - Usa
null: falsecon un valor predeterminado cuando sea relevante para restringir nulos no intencionados.
# Bien: migración explícita y reversible
class CreateInvoices < ActiveRecord::Migration[7.1]
def change
create_table :invoices do |t|
t.references :user, null: false, foreign_key: true
t.integer :status, default: 0, null: false
t.decimal :total, precision: 10, scale: 2, default: 0.0, null: false
t.datetime :due_date
t.timestamps
end
add_index :invoices, [:user_id, :status]
end
end
Políticas de autorización (Pundit)
Cuando se use Pundit, las políticas viven bajo app/policies/.
# app/policies/invoice_policy.rb
class InvoicePolicy < ApplicationPolicy
def show?
user == record.user || user.admin?
end
def create?
true
end
def update?
user == record.user
end
end
class InvoicesController < ApplicationController
def show
@invoice = Invoice.find(params[:id])
authorize @invoice
end
end
Comenzando
Para inicializar un nuevo proyecto Rails 8 con la pila Solid y Kamal:
rails new myapp --database=sqlite3 --skip-jbuilder
Para agregar Solid Queue a un proyecto Rails existente (7.1+):
bin/rails generate solid_queue:install
Para cambiar a ViewComponent:
bundle add view_component