Go to English Blog

Diminuindo a complexidade dos controllers no Ruby on Rails

Leia em 3 minutos

Não é de hoje que desenvolvedores tem problemas em manter a complexidade de seus projetos sob controle. No começo, tudo é bem organizado, os testes são rápidos e você sente prazer em modificar aquele projeto. Mas com o passar do tempo, muitos projetos começam a se deteriorar e logo você sente que podia ter feito um trabalho melhor. E não importa a linguagem ou framework que você utilize; esses problemas estão ligados ao modo como você escreve o código, e não quanto às características da linguagem/framework.

Já voltado ao Rails, você provavelmente já deve ter ouvido a expressão “fat model, skinny controller”, termo cunhado por Jamis Buck em um artigo de 2006. Neste artigo, ele demonstra como você pode reduzir a complexidade de views movendo algumas lógicas utilizadas pelo template para o model, neste caso, ActiveRecord.

E isso trouxe novos problemas. Agora, os modelos de ActiveRecord continham milhares de linhas, sempre com a premissa de que ter controllers com pouca responsabilidade implicava em ter modelos do ActiveRecord extremamente inchados. Acredito que este problema foi causado, provavelmente, por mostrar um exemplo superficial que usava o ActiveRecord. O ponto é que você deve fazer isso, mas não precisa necessariamente usar modelos do ActiveRecord.

Eu nunca estou satisfeito com a complexidade dos projetos que participo e sempre tento, em novos projetos, aplicar novos conceitos, experimentando, tentando atingir o nirvana. Neste artigo vou mostrar o modo como escrevo meus controllers, buscando a simplicidade máxima sem comprometer a produtividade e legibilidade.

Nosso caso de uso

Neste exemplo, iremos criar o controller de uma página de checkout, por exemplo do HOWTO. Veja o que deve acontecer quando um pedido é finalizado:

Nossa página de checkout será algo nessa linha:

Mockup da página de checkout

O primeiro passo é criar o controller. Vou focar apenas no método CheckoutController#create, já que é ele o responsável por lidar com o envio do formulário.

class CheckoutController < ApplicationController
  def create
    checkout = Checkout.process(checkout_params)
    respond_with checkout, location: -> { order_path(checkout.order_id) }
  end

  private

  def checkout_params
    params
      .require(:checkout)
      .permit(:number_of_subscriptions, :payment_method, :name, :email, :tos)
  end
end

Como você pode perceber, nosso controller é responsável apenas por passar os dados para a classe Checkout. Simples assim.

Uma biblioteca essencial para mim é a gem responders. Com ela posso remover coisas como definição de flash messages e condicionais dependendo do estado do objeto de meus controllers.

A classe Checkout precisa ser compatível com ActiveModel. Para isso, basta usar o módulo ActiveModel::Model. Vamos aproveitar para definir as propriedades e validações.

class Checkout
  include ActiveModel::Model

  attr_accessor :number_of_subscriptions, :payment_method, :name, :email, :tos

  validates_numbericality_of :number_of_subscriptions, minimum: 1
  validates_inclusion_of :payment_method, in: %w[paypal pagseguro]
  validates_presence_of :name
  validates_email_format_of :email
  validates_acceptance_of :tos
end

Ao incluir o módulo ActiveModel::Model, temos todas as caractísticas de validação, assim como o objeto Checkout#errors de erros, além do método Checkout#valid?. Temos também a capacidade de mass assignment na instanciação do objeto.

Agora, vamos criar o método Checkout.process, responsável por efetuar o pedido caso ele seja válido. Ele irá apenas instanciar a classe Checkout, executando o método Checkout#process.

class Checkout
  # ...

  def self.process(attrs)
    new(attrs).tap(&:process)
  end
end

Vamos precisar implementar também o método Checkout#process. Neste método, temos que criar o pedido e enviar o e-mail de confirmação.

class Checkout
  # ...

  def process
    return unless valid?

    save_order
    send_confirmation_email
  end

  def order_id
    order.id
  end

  private

  def order
    @order ||= Order.new(
      number_of_subscriptions: number_of_subscriptions,
      email: email,
      name: name,
      payment_method: payment_method
    )
  end

  def save_order
    order.save!
  end

  def send_confirmation_email
    Mailer.order_confirmation(order_id).deliver_later
  end
end

Agora, precisamos entender como a gem responders funciona. Toda vez que você utiliza o método respond_with, o estado do objeto será verificado, assim como o formato da requisição (content negotiation). Caso o objeto seja válido, o redirecionamento será feito para a url especificada (ou para a URL do resource, se esta tiver sido definida no arquivo de rotas). Em caso de falha, ele irá renderizar a página dependendo da ação que você está (:edit para a ação update e new para a ação create). Se você precisar definir flash messages, pode fazer isso com o responder :flash; basta configurar o arquivo app/controllers/application_controller.rb como o exemplo abaixo:

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception

  respond_to :html
  responders :flash
end

Finalmente, você deverá definir as mensagens em seu arquivo de internacionalização, como no exemplo abaixo:

pt-BR
  flash:
    checkout:
      create:
        notice: Seu pedido foi realizado com sucesso!

Para saber mais sobre responders, leia um artigo que escrevi aqui no blog.

Finalizando

De um modo geral, é assim que crio meus controllers que precisam fazer mais de uma ação (como enviar e-mails caso o registro seja criado). Em casos mais simples, onde preciso apenas criar e redirecionar para uma página, simplesmente não preciso desta técnica. Basta fazer algo como isso:

class WorkshopsController < ApplicationController
  def create
    @workshop = Workshop.create(workshop_params)
    respond_with @workshop, location: -> { edit_workshop_path(@workshop) }
  end

  private

  def workshop_params
    # ...
  end
end

Você faz algo para simplificar os seus controllers? Compartilhe suas dicas nos comentários!

Compartilhe: