Aprenda / Ruby on Rails

IntermediárioAula 16 de 2015 min0% da trilha

Service Result: aprofundando service objects

Levantar exception pra 'e-mail já cadastrado' trata um resultado esperado do negócio como se fosse um bug — um objeto de resultado resolve isso.

O FinalizarPedido da lição de service objects devolvia só true/false — o controller sabia que falhou, mas não sabia por quê. É tentador resolver isso levantando uma exception customizada pra carregar a mensagem de erro. Isso é pior do que o problema original.

O anti-padrão: exception pra um fluxo esperado

NÃO faça isso
class EmailJaCadastrado < StandardError; end

class CadastrarUsuario
  def initialize(params)
    @params = params
  end

  def call
    raise EmailJaCadastrado if Usuario.exists?(email: @params[:email])
    Usuario.create!(@params)
  end
end
controller precisando saber disso de antemão
def create
  usuario = CadastrarUsuario.new(usuario_params).call
  render json: usuario, status: :created
rescue EmailJaCadastrado
  render json: { error: "E-mail já cadastrado" }, status: :unprocessable_entity
end

"E-mail já cadastrado" acontece o tempo todo no uso normal do sistema — não é uma falha inesperada do programa, é um resultado esperado da regra de negócio. Tratar isso como exception mistura, no mesmo rescue, coisas de gravidade completamente diferente: um usuário digitando um e-mail já usado e o banco de dados caindo no meio da chamada viram, pro código, o mesmo tipo de evento. E o método call não deixa claro, só pela assinatura, que quem chama precisa envolver isso num rescue.

O padrão correto: um objeto de resultado

app/services/resultado.rb
class Resultado
  attr_reader :erros, :dado

  def initialize(sucesso:, dado: nil, erros: [])
    @sucesso = sucesso
    @dado = dado
    @erros = erros
  end

  def sucesso?
    @sucesso
  end
end
app/services/cadastrar_usuario.rb
class CadastrarUsuario
  def initialize(params)
    @params = params
  end

  def call
    return Resultado.new(sucesso: false, erros: ["E-mail já cadastrado"]) if Usuario.exists?(email: @params[:email])

    usuario = Usuario.new(@params)
    if usuario.save
      Resultado.new(sucesso: true, dado: usuario)
    else
      Resultado.new(sucesso: false, erros: usuario.errors.full_messages)
    end
  end
end
controller reagindo ao resultado, sem rescue nenhum
def create
  resultado = CadastrarUsuario.new(usuario_params).call
  if resultado.sucesso?
    render json: resultado.dado, status: :created
  else
    render json: { errors: resultado.erros }, status: :unprocessable_entity
  end
end

Repare: "e-mail já cadastrado" e "senha muito curta" (vindo de usuario.errors) agora passam pelo mesmo caminho — sucesso? falso, com a mensagem certa dentro de erros. Uma falha de verdade (o banco caindo no meio da chamada) continua subindo como exception normal e é tratada pelo rescue_from central — a diferença é que ela não é mais usada pra representar um resultado que o negócio já esperava.

Exercício

Reescreva o CancelarPedido (que reverte o status pra "cancelado" e devolve o estoque, mas só funciona se o pedido não tiver sido entregue) pra devolver um Resultado em vez de true/false, com a mensagem "Pedido já entregue não pode ser cancelado" no caso de falha.

Quiz rápido

Por que 'e-mail já cadastrado' não deveria ser implementado como uma exception levantada pelo service?
No objeto Resultado do exemplo, o que resultado.sucesso? falso, com erros preenchido, permite que o controller faça?