MVC escalável no Rails: Service Objects, Concerns e checkout de pedidos
Como evitar Fat Models e Fat Controllers no Ruby on Rails usando POROs, Service Objects e Concerns, com exemplo completo de checkout, estoque concorrente e múltiplos gateways de pagamento.
O padrão MVC (Model-View-Controller) é a espinha dorsal do Ruby on Rails, projetado para separar as responsabilidades da aplicação em três camadas distintas. No entanto, à medida que os sistemas crescem, a implementação puramente básica do MVC gera um fenômeno conhecido como Fat Models (Modelos Gordos) ou Fat Controllers (Controladores Gordos).
Para manter o MVC escalável, a comunidade Rails adota padrões complementares baseados em POROs (Plain Old Ruby Objects), como Service Objects (para orquestrar lógica de negócio complexa) e Concerns (para reaproveitamento de código polimórfico).
Abaixo, exploramos a fundo a arquitetura MVC por meio de um cenário avançado e de alta complexidade: um sistema de processamento de pedidos com múltiplos gateways de pagamento e regras rígidas de estoque.
1. O fluxo arquitetural avançado
Em uma aplicação corporativa, o ciclo de uma requisição segue um fluxo desacoplado para não sobrecarregar as camadas base do MVC:
[Browser/Client] ──(HTTP POST)──> [Router] ──> [OrderController]
│
┌────────────────────────────────────────┘ (Instancia)
▼
[CheckoutService] ───(Valida regras de negócio)───> [Order & LineItem Models]
│
├─(Chama API externa)──> [PaymentGateway Integration]
│
└─(Dispara assincronamente)──> [NotificationWorker] ──> [View / Mailer]O controller recebe a requisição e delega a orquestração ao service object. O model cuida do estado e das validações intrínsecas. Integrações externas e efeitos colaterais (notificações, jobs) ficam fora das camadas MVC tradicionais.
2. A camada M: Model complexo (ActiveRecord + Concerns)
O Modelo deve gerenciar apenas o estado, associações, escopos e validações intrínsecas do banco de dados. Lógicas mutáveis ou transversais (como rastreamento de auditoria ou soft delete) são extraídas para Concerns.
Objeto de domínio base
# app/models/order.rb
class Order < ApplicationRecord
include Loggable # Concern para auditoria externa
belongs_to :user
has_many :line_items, dependent: :destroy
has_many :products, through: :line_items
enum :status, { pending: 0, processing: 1, completed: 2, failed: 3 }, default: :pending
validates :total_price, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :status, presence: true
scope :recent, -> { order(created_at: :desc) }
scope :totals_above, ->(amount) { where("total_price > ?", amount) }
# Encapsula o cálculo puro, mas não executa a transação de persistência
def calculate_total!
self.total_price = line_items.sum { |item| item.quantity * item.price_at_purchase }
end
endO Concern (módulo compartilhado)
# app/models/concerns/loggable.rb
module Loggable
extend ActiveSupport::Concern
included do
after_commit :log_action, on: [:create, :update]
end
private
def log_action
Rails.logger.info("[AUDIT] #{self.class.name} ID #{id} modificado em #{updated_at}")
# Aqui poderia persistir em uma tabela isolada de Logs/Auditoria
end
end3. O ecossistema de suporte: Service Object
Para evitar que o Model fique inchado com chamadas de APIs externas (ex.: Stripe, Pagar.me) ou envio de e-mails, isolamos a ação em um Service Object (um PORO estruturado).
# app/services/application_service.rb
class ApplicationService
def self.call(*args, &block)
new(*args, &block).call
end
end
# app/services/orders/checkout_service.rb
module Orders
class CheckoutService < ApplicationService
class InventoryError < StandardError; end
class PaymentError < StandardError; end
def initialize(user:, cart_items:, payment_token:)
@user = user
@cart_items = cart_items
@payment_token = payment_token
end
def call
# Executa tudo dentro de uma transação do banco de dados
ActiveRecord::Base.transaction do
order = create_order_structure
verify_and_update_inventory!(order)
process_payment!(order)
# Dispara jobs em segundo plano (assíncrono)
OrderConfirmationJob.perform_later(order.id)
{ success: true, order: order }
end
rescue InventoryError, PaymentError => e
{ success: false, error: e.message }
rescue StandardError => e
Bugsnag.notify(e) # Exemplo de tratamento de log de erros de infraestrutura
{ success: false, error: "Ocorreu um erro interno inesperado." }
end
private
def create_order_structure
order = @user.orders.build
@cart_items.each do |item|
order.line_items.build(
product_id: item[:product_id],
quantity: item[:quantity],
price_at_purchase: item[:product].price
)
end
order.calculate_total!
order.save!
order
end
def verify_and_update_inventory!(order)
order.line_items.each do |item|
product = item.product
# Lock pessimista para evitar condições de corrida (race conditions)
product.lock!
if product.stock < item.quantity
raise InventoryError, "Estoque insuficiente para o produto #{product.name}."
end
product.decrement!(:stock, item.quantity)
end
end
def process_payment!(order)
order.processing!
# Simulação de chamada de API externa via SDK hipotético
gateway_response = MyPaymentGateway::Charge.create(
amount: order.total_price,
token: @payment_token
)
unless gateway_response.success?
order.failed!
raise PaymentError, "Falha no pagamento: #{gateway_response.error_message}"
end
order.update!(status: :completed, payment_id: gateway_response.transaction_id)
end
end
end4. Camada C: Controller magro e resiliente
O controlador deve apenas receber os parâmetros da requisição, invocar a lógica de negócios (neste caso, o Service Object) e responder à camada visual condizente.
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
before_action :authenticate_user!
def create
# Orquestra os dados crus recebidos do formulário
cart_data = prepare_cart_items(checkout_params[:items])
result = Orders::CheckoutService.call(
user: current_user,
cart_items: cart_data,
payment_token: checkout_params[:payment_token]
)
if result[:success]
@order = result[:order]
respond_to do |format|
format.html { redirect_to order_path(@order), notice: "Pedido realizado com sucesso!" }
format.json { render json: @order, status: :created }
end
else
@error_message = result[:error]
respond_to do |format|
format.html { render :new, status: :unprocessable_entity }
format.json { render json: { error: @error_message }, status: :unprocessable_entity }
end
end
end
private
def checkout_params
params.require(:checkout).permit(:payment_token, items: [:product_id, :quantity])
end
def prepare_cart_items(items_params)
items_params.map do |item|
{
product_id: item[:product_id],
quantity: item[:quantity].to_i,
product: Product.find(item[:product_id])
}
end
end
end5. Camada V: View reativa estruturada (HTML + JSON builder)
No ecossistema Rails, a View pode tomar a forma de servidores tradicionais usando ERB estruturado em parciais reutilizáveis ou geradores de payloads JSON puros (como a gem jbuilder).
Visão HTML estruturada (utilizando parciais)
<!-- app/views/orders/show.html.erb -->
<div class="order-container container mx-auto p-6 bg-white rounded-lg shadow-sm">
<h1 class="text-2xl font-bold mb-4">Pedido #<%= @order.id %></h1>
<div class="status-badge <%= @order.status %> mb-6">
Status: <span class="font-semibold"><%= t("orders.status.#{@order.status}") %></span>
</div>
<h2 class="text-xl font-semibold mb-2">Itens Comprados</h2>
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th class="text-left">Produto</th>
<th class="text-left">Quantidade</th>
<th class="text-left">Preço Unitário</th>
</tr>
</thead>
<tbody>
<%= render partial: 'line_item', collection: @order.line_items %>
</tbody>
</table>
<div class="mt-6 text-right font-bold text-lg">
Total: <%= number_to_currency(@order.total_price, unit: "R$ ", separator: ",", delimiter: ".") %>
</div>
</div><!-- app/views/orders/_line_item.html.erb -->
<tr>
<td class="py-2"><%= line_item.product.name %></td>
<td class="py-2"><%= line_item.quantity %></td>
<td class="py-2"><%= number_to_currency(line_item.price_at_purchase, unit: "R$ ") %></td>
</tr>Resposta de API nativa (Jbuilder)
# app/views/orders/show.json.jbuilder
json.order do
json.id @order.id
json.status @order.status
json.total_price_formatted number_to_currency(@order.total_price, unit: "R$ ")
json.created_at @order.created_at.iso8601
json.items @order.line_items do |item|
json.product_name item.product.name
json.quantity item.quantity
json.subtotal item.quantity * item.price_at_purchase
end
endResumo: onde cada responsabilidade mora
| Camada / Padrão | Responsabilidade |
|---|---|
| Model (ActiveRecord) | Estado, associações, validações, escopos e cálculos puros de domínio |
| Concern | Comportamentos transversais reutilizáveis (auditoria, soft delete) |
| Service Object | Orquestração de regras de negócio, transações, APIs externas e jobs |
| Controller | Params, autenticação, invocação do service e resposta HTTP |
| View (ERB / Jbuilder) | Apresentação HTML ou serialização JSON |
Conclusão
MVC no Rails não é um limite rígido, é um ponto de partida. Quando pedidos envolvem estoque concorrente, pagamentos externos e notificações assíncronas, manter cada camada enxuta exige extrair lógica para POROs bem nomeados.
- ▸Models guardam estado e validações; Concerns compartilham comportamento transversal.
- ▸Service Objects concentram a orquestração de negócio e integrações.
- ▸Controllers ficam magros: recebem, delegam e respondem.
- ▸Views apresentam dados sem reimplementar regra de negócio.
Referências
- ▸Ruby on Rails Guides, Getting Started with Rails: https://guides.rubyonrails.org/getting_started.html
- ▸ActiveSupport::Concern API Documentation: https://api.rubyonrails.org/classes/ActiveSupport/Concern.html
- ▸Martin Fowler, Patterns of Enterprise Application Architecture: https://martinfowler.com/books/eaa.html
- ▸The Rails Way (Obie Fernandez): https://leanpub.com/therailsway
- ▸Rails Guides, Active Record Transactions: https://guides.rubyonrails.org/active_record_transactions.html
- ▸Rails Guides, Action Controller Overview: https://guides.rubyonrails.org/action_controller_overview.html
✓ EOF, Israel Santos
← voltar aos artigos