Padrões Rails
Padrões de framework para aplicações modernas Ruby on Rails (Rails 7.1+ e 8.x). Rails é opinativo por design; estes são os padrões nos quais a comunidade convergiu para aplicações que se mantêm sustentáveis 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 cresce além de ~10 linhas
- Um arquivo de modelo cresce além de ~200 linhas
- Consultas ActiveRecord começam a aparecer em controladores ou visões
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. Nenhuma lógica de negócio.
components/ Classes ViewComponent. Lógica de visão que precisa de testes.
services/ Objetos de serviço. Operações de negócio com múltiplas etapas.
forms/ Objetos de formulário. Manipulação complexa de formulários em vários 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 visão. Apenas lógica de apresentação pequena.
policies/ Políticas de autorização (se usando 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ócio vive em outro lugar. (De acordo com 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ócio que tocam mais do 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 Resultado, não um booleano ou um registro simples, para que o chamador possa ramificar em sucesso, erros e o registro afetado.
- Envolva escritas de vários 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 vários 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 visão (form_with model: @form) enquanto compõe registros de forma limpa.
Objetos de consulta
Para consultas ActiveRecord reutilizadas em controladores ou serviços, ou muito complexas para um escopo, extraia um objeto de consulta que aceita um escopo como entrada para que ele componha. Regra de ouro: um escopo que cresce além de três condições encadeadas ou começa a receber parâmetros quer ser um objeto de consulta.
Jobs em segundo plano
Terceirize qualquer coisa lenta. (De acordo com as regras de padrões Ruby, Solid Queue para Rails 8 novo com throughput modesto; Sidekiq quando você precisa de observabilidade madura, alto throughput, ou Redis existente.) Independentemente do adaptador: passe IDs não registros, torne perform idempotente e defina retry_on/discard_on explicitamente.
ViewComponent sobre parciais
Para lógica de visão com renderização condicional, mais de dois argumentos, ou reuso 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.
Hotwire: Turbo e Stimulus
A stack frontend padrão do Rails. (De acordo com as regras de padrões Ruby, prefira Hotwire para aplicações renderizadas 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 no lado do cliente junto à marcação.
A stack Solid do Rails 8
Rails 8 fornece 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 para um componente de infraestrutura a menos; uma boa escolha 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 um objeto de serviço
# Ruim: lógica de negócio 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: "Fatura criada"
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: "Fatura criada"
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 todos os Rubies que o 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 fatura #{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 N+1
# Ruim: N+1 na visão quando chama post.author.name
@posts = Post.published
# Bom: eager load
@posts = Post.published.includes(:author)
includes permite que o Rails escolha entre preload e eager_load. Force preload para consultas separadas, eager_load para um JOIN ao filtrar na associação. Desde o Rails 6.1, strict_loading levanta erro em carregamentos lentos acidentais.
Cache de contagem
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 contagem 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 competem, 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, para que uma falha entre a chamada da API e update! ainda resolva para 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 "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 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 optar por não usar, difícil de testar
# end
Concern bom vs concern ruim
# Bom: genuinamente transversal, reutilizável em 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 capturar um padrão transversal a múltiplos modelos não relacionados. Se você tem apenas um modelo (como SoftDeletable apenas em Invoice e Comment), o concern é válido. Se você tem apenas um modelo, o código deve estar diretamente nele.