Padrões Rails
Padrões de framework para aplicações Ruby on Rails modernas (Rails 7.1+ e 8.x). Rails é opinativo por design; estes são os padrões nos quais a comunidade convergiu para aplicativos que permanecem fáceis de manter após a marca de 50 modelos. Este skill é o "como". Para o "o quê" e "quando" (as decisões sobre qual padrão usar), veja as regras de padrões Ruby — rules/ruby/patterns.md neste repositório, instaladas como rules/ecc/ruby/patterns.md.
Quando Ativar
- Construindo uma aplicação Rails (full-stack, somente API ou híbrida)
- Revisando um PR que modifica
app/ouconfig/ - Gerando modelos, controladores, serviços ou jobs
- Uma ação de controlador ultrapassa ~10 linhas
- Um arquivo de modelo ultrapassa ~200 linhas
- Consultas ActiveRecord começam a aparecer em controladores ou views
Conceitos Principais
O contrato de diretórios
Aplicativos Rails seguem uma estrutura previsível. Adicione diretórios deliberadamente, não casualmente.
app/
models/ Modelos ActiveRecord. Persistência e lógica de domínio próximas aos dados.
controllers/ Manipulação de requisições HTTP. Orquestração enxuta apenas.
views/ Templates ERB. Sem lógica de negócios.
components/ Classes ViewComponent. Lógica de visualização que precisa de testes.
services/ Objetos de serviço. Operações de negócios com várias etapas.
forms/ Objetos de formulário. Manipulação complexa de formulários entre múltiplos modelos.
queries/ Objetos de consulta. Consultas ActiveRecord reutilizáveis e compostas.
jobs/ Jobs em segundo plano. Trabalho assíncrono via Solid Queue, Sidekiq ou GoodJob.
mailers/ Classes ActionMailer.
helpers/ Helpers de visualização. Apenas lógica de apresentação pequena.
policies/ Políticas de autorização (se usar Pundit). Opcional.
channels/ Canais ActionCable para trabalho WebSocket.
Evite app/lib/, app/utils/, app/managers/. Se algo não se encaixa nos diretórios acima, o design geralmente precisa ser repensado, não de um novo diretório. Código verdadeiramente genérico vai em lib/.
Controladores enxutos
Controladores recebem uma requisição, delegam ao objeto certo e renderizam uma resposta. A lógica de negócios vive em outro lugar. (Conforme as regras de padrões Ruby, extraia para um objeto de serviço quando o controlador começar a carregar múltiplas responsabilidades.)
Objetos de serviço
O padrão para operações de negócios que tocam mais que um único salvamento de modelo. Convenções que os mantêm consistentes:
- Namespace por domínio (
Invoices::Create), não por sufixo (InvoiceCreator). - Um método de classe
.calldelega para uma instância#call. - Retorna um objeto Result, não um booleano ou um registro simples, para que o chamador possa ramificar em sucesso, erros e o registro afetado.
- Envolve gravações de múltiplos registros em uma transação.
- Mantenha cada serviço com um único propósito (
Invoices::Create,Invoices::MarkPaid), nuncaInvoices::Manager.
Objetos de formulário
Quando um formulário abrange múltiplos modelos ou tem campos que não mapeiam para colunas, use um objeto de formulário em vez de atributos aninhados ou atributos virtuais no modelo errado. Ele se comporta como um modelo para a view (form_with model: @form) enquanto compõe registros de forma limpa.
Objetos de consulta
Para consultas ActiveRecord reutilizadas entre controladores ou serviços, ou muito complexas para um escopo, extraia um objeto de consulta que aceita um escopo como entrada para que possa ser composto. Regra prática: um escopo que cresce além de três condições encadeadas ou começa a aceitar parâmetros quer ser um objeto de consulta.
Jobs em segundo plano
Terceirize qualquer coisa lenta. (Conforme as regras de padrões Ruby, Solid Queue para Rails 8 novos com throughput modesto; Sidekiq quando você precisa de observabilidade madura, alto throughput ou Redis existente.) Independente do adaptador: passe IDs, não registros, torne perform idempotente e defina retry_on/discard_on explicitamente.
ViewComponent em vez de parciais
Para lógica de visualização com renderização condicional, mais de dois argumentos ou reutilização em mais de três lugares, prefira um ViewComponent. Componentes são testáveis isoladamente e expõem sua interface explicitamente; parciais com lógica condicional profunda se tornam dívida técnica.
Hotwire: Turbo e Stimulus
O stack frontend padrão do Rails. (Conforme as regras de padrões Ruby, prefira Hotwire para aplicativos renderizados no servidor; recorra a React/Vue apenas quando a complexidade da interação justificar a superfície do cliente.) Turbo Frames para atualizações parciais de página, Turbo Streams para atualizações orientadas pelo servidor, Stimulus para pequenos comportamentos do lado do cliente próximos ao markup.
O stack Solid do Rails 8
O Rails 8 traz padrões baseados em banco de dados que antes precisavam de Redis: Solid Queue (jobs), Solid Cache (cache), Solid Cable (ActionCable). A troca é mais carga no banco de dados por um componente de infraestrutura a menos; um bom ajuste para throughput modesto, com Redis ainda vencendo em alta escala. Kamal é a ferramenta de deploy padrão baseada em Docker.
Exemplos de Código
Controlador enxuto com objeto de serviço
# Ruim: lógica de negócios no 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: "Invoice created"
else
render :new
end
end
end
# Bom: controlador orquestra, serviço faz o trabalho
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
O objeto de serviço
# app/services/invoices/create.rb
module Invoices
class Create
# Struct mantém isso executável em todo Ruby que Rails 7.1 suporta.
# No Ruby 3.2+, `Data.define(:success?, :invoice, :errors)` é uma
# alternativa imutável mais 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("Falha no envio de notificação para 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
Objeto de formulário
# 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
Objetos de consulta aceitam um escopo, então eles compõem: Invoices::Overdue.call(scope: current_user.invoices).
Prevenção de N+1
# Ruim: N+1 na view quando chama post.author.name
@posts = Post.published
# Bom: carregamento ansioso
@posts = Post.published.includes(:author)
includes permite que Rails escolha entre preload e eager_load. Force preload para consultas separadas, eager_load para um JOIN ao filtrar na associação. Desde Rails 6.1, strict_loading levanta exceção em carregamentos preguiçosos acidentais.
Cache 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 torna uma leitura de coluna em vez de um COUNT(*). Este exemplo assume uma nova tabela; adicionar um cache de contador a uma tabela que já tem linhas requer um backfill, que está fora do escopo aqui.
Formato de job em segundo plano
Passe IDs de registros, não registros. As tentativas tornam a entrega pelo menos uma vez, então qualquer job que chame um serviço externo deve ser idempotente — caso contrário, uma falha transitória após a chamada remota bem-sucedida duplicará o efeito na próxima tentativa.
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
O índice único é o que torna isso seguro: quando duas tentativas concorrem, o banco de dados rejeita a segunda inserção e o Active Record resolve o conflito dentro da chamada, retornando a linha existente. Isso acontece sem qualquer nova tentativa no nível do job — retry_on acima cobre apenas AccountingApi::TransientError. A guarda cobre a janela antes da chamada remota; passar idempotency_key para a API cobre a janela depois dela, então uma falha entre a chamada da API e update! ainda resulta em uma única exportação.
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: clicar em Edit substitui apenas este 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 aceitáveis vs inaceitáveis
# Aceitável: normalização pura de dados
class User < ApplicationRecord
before_validation :normalize_email
private
def normalize_email
self.email = email.to_s.downcase.strip
end
end
# Mover para um serviço: efeitos colaterais escondidos em um callback
# class User < ApplicationRecord
# after_create :send_welcome_email # difícil de desativar, difícil de testar
# end
Concern bom vs concern ruim
# Bom: genuinamente transversal, reutilizável entre modelos não 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
# Ruim: um "concern" usado por exatamente um modelo, segurando lógica que pertence a ele
# app/models/concerns/invoice_calculations.rb
module InvoiceCalculations
extend ActiveSupport::Concern
def calculate_total
line_items.sum(&:amount) + tax_total
end
end
# Apenas Invoice inclui isso. Não é transversal; é a própria lógica de Invoice
# escondida em um módulo para a aparência de um modelo "enxuto". Coloque de volta em Invoice.
Um concern usado por apenas uma classe é apenas mover código; ele pertence àquela classe. Um concern deve representar um conceito que múltiplas classes não relacionadas compartilham — soft delete, versionamento, log de atividades. Caso contrário, mantenha a lógica no modelo ou extraia para um objeto de serviço.