← voltar aos artigos
19/07/2026Ruby on Rails · MVC · Arquitetura · Service Objects

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:

plaintext
[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

ruby
# 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
end

O Concern (módulo compartilhado)

ruby
# 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
end

3. 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).

ruby
# 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
end

4. 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.

ruby
# 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
end

5. 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)

html
<!-- 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>
html
<!-- 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)

ruby
# 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
end

Resumo: onde cada responsabilidade mora

Camada / PadrãoResponsabilidade
Model (ActiveRecord)Estado, associações, validações, escopos e cálculos puros de domínio
ConcernComportamentos transversais reutilizáveis (auditoria, soft delete)
Service ObjectOrquestração de regras de negócio, transações, APIs externas e jobs
ControllerParams, 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

EOF, Israel Santos

← voltar aos artigos