'Cause I ain't no Callback Class

Artigos - 22/Ago/2021 - por Vivi Poit

'Cause I ain't no Callback Class

Porquê e como eu uso serviços no Rails

Quando eu conheci o Rails, não demorou muito para eu descobrir os callbacks do Active Record e me apaixonar pelas automatizações e decisões dentro dos models. Naquele momento, me parecia que esta seria a melhor forma de deixar cada model responsável pelas suas próprias lógicas.

Passado mais um tempo, conforme os projetos cresceram, as dores aumentaram de forma insustentável. Neste artigo quero mostrar um exemplo desta trajetória e trazer as dicas para que outras pessoas não precisem sofrer da mesma forma.

O exemplo:

# app/models/purchase.rb

class Purchase < ApplicationRecord
  has_many :items

  before_save :calculate_totals

  private

  def calculate_totals
    self.subtotal = items.sum(:price)
    self.total = subtotal - discount
  end
end

No model Purchase acima, eu usei o callback before_save para calcular o subtotal e o total da compra toda vez que ela for salva. Minha intenção era ter certeza de que os valores da compra estariam sempre atualizadíssimos.

Parece inofensivo, mas empolgada com a descoberta, eu acabei exagerando. Minhas classes logo ficaram assim:

# app/models/purchase.rb

class Purchase < ApplicationRecord
  has_many :items

  before_save :calculate_totals
  after_save :notify_customer, if: :just_confirmed?

  private

  def calculate_totals
    self.subtotal = items.sum(:price)
    self.total = subtotal - discount
  end

  def notify_customer
    CustomerMailer.purchase_confirmed(self).deliver_now
  end

  def just_confirmed?
    status_changed? && status == 'confirmed'
  end
end

Que coisa longa e confusa!

O que acontece aqui? Quando acontece? Em que ordem acontece? Porque acontece?

Tudo isso ficava difícil de responder rapidamente e por isso manter os projetos foi ficando cada vez mais complicado. Além do desafio de entender a ordem das coisas, os métodos da classe acima quebram algumas regras de ouro ao chamar outras classes para dentro das lógicas de Purchase.

Sem falar ainda nos testes, que ficavam cheios de coisas desnecessárias...

Se eu quisesse testar o pagamento de uma compra de R$ 25,00, por exemplo, eu não conseguia criar a compra e mandar ter este valor total. Na hora que a compra era criada, o valor atualizava "sozinho" para zero, porque a compra não tinha itens. Então, para ter minha compra de R$ 25,00, eu tinha que criar a compra, depois criar itens na compra, para só então poder testar algo que não tinha nada a ver com isso!

Se eu não fizesse um mock muito bom, cada vez que eu criava uma instância de Purchase num teste, eu corria o risco da chamada para o método purchase_confirmed da CustomerMailer acontecer. Para esta chamada não causar erros, eu precisava criar as informações que o e-mail demanda. Grande parte das vezes, estas informações eram irrelevantes para o teste em questão.

Enfim, confusões no fluxo, testes complexos, lançamento de novas ferramentas dificultados e times frustrados.

O que fazer? O que mudar? Como melhorar?

É hora de usar serviços! :-)

A gente provavelmente deve usar um serviço no Rails quando a lógica:

  1. Envolve mais de uma classe
  2. É uma funcionalidade por si só
  3. É regra de negócio importante que deve ser testada em vários contextos

No nosso model acima, encontramos exemplos das 3 situações. Vamos olhar mais de perto?

calculate_totals envolve a classe Item e idealmente seria testado em diversos contextos. Precisamos testar situações com e sem desconto, assim como - tenho vergonha de dizer que aprendi isso num caso real - testar que um desconto não deixa a compra com o valor total negativo, por exemplo.

notify_customer chama a classe CustomerMailer. Se olharmos com atenção, percebemos também que este método, junto com o just_confirmed?, na verdade, formam a funcionalidade de confirmar uma compra. Temos aqui algo que pode e provavelmente deve viver muito bem num espaço próprio.

Estes são os principais porquês de eu sugerir o uso de serviços ao invés de callbacks em aplicações Rails.

Agora vamos falar do como!

Sugestões iniciais:

  1. Coloque serviços dentro de app, num diretório chamado services
  2. Implemente um método initialize que recebe um ou mais objetos
  3. Implemente um método execute que, como o nome diz, simplesmente executa a ação desejada

Bora tentar?

Cálculo dos valores:

Pensando no calculate_totals de forma crítica, percebemos que ele é desnecessário na hora do create, porque neste momento tudo é zero. Também podemos considerar que nem toda vez que mexemos numa compra, devemos mexer nos seus valores. Deve ser possível atualizar uma compra só para mudar a data de entrega, por exemplo. Porque isso deveria puxar os itens, calcular os totais, etc?

Com tudo isso em mente, criamos um serviço específico para atualizar valores. Algo mais ou menos assim:

# app/services/update_purchase_amounts.rb

class UpdatePurchaseAmounts
  attr_reader :purchase

  def initialize(purchase)
    @purchase = purchase
  end

  def execute
    subtotal = purchase.items.sum(:price)
    total = subtotal - purchase.discount

    purchase.update(subtotal: subtotal, total: total)
  end
end

Agora, em qualquer lugar da aplicação onde a gente precise atualizar os valores da compra, a gente chama UpdatePurchaseAmounts.new(purchase).execute e tem certeza que é só isso que está acontecendo. Além disso, com esta lógica isolada na sua própria classe, temos espaço para criar regras e tratar casos esquisitos - como aquele possível desconto que pode deixar o total negativo - sem "tumultuar" o model.

Confirmação da compra:

Já falamos que os métodos notify_customer e just_confirmed? na verdade tratam do fluxo de confirmação de uma compra. Vamos usar este olhar para criar um serviço que trata do que deve acontecer ao confirmar uma compra, que simplesmente confirma a compra. Desta forma:

# app/services/confirm_purchase.rb

class ConfirmPurchase
  attr_reader :purchase

  def initialize(purchase)
    @purchase = purchase
  end

  def execute
    purchase.update(status: 'confirmed')
    CustomerMailer.purchase_confirmed(purchase).deliver_now
  end
end

Percebam que nem precisamos mais daquele if just_confirmed?, porque tendo um serviço específico para cuidar da confirmação, temos certeza do momento em que a lógica vai rodar. Tudo fica mais claro e mais simples. Quando quisermos confirmar uma compra, chamamos ConfirmPurchase.new(purchase).execute e o resto está sob controle.

Resultado Final

Com estas mudanças e implementações, nosso model fica simplificado e as lógicas ficam isoladas, fáceis de encontrar, testar, alterar, e até reutilizar:

# app/models/purchase.rb

class Purchase < ApplicationRecord
  has_many :items
end


# app/services/update_purchase_amounts.rb

class UpdatePurchaseAmounts
  attr_reader :purchase

  def initialize(purchase)
    @purchase = purchase
  end

  def execute
    subtotal = purchase.items.sum(:price)
    total = subtotal - purchase.discount

    purchase.update(subtotal: subtotal, total: total)
  end
end


# app/services/confirm_purchase.rb

class ConfirmPurchase
  attr_reader :purchase

  def initialize(purchase)
    @purchase = purchase
  end

  def execute
    purchase.update(status: 'confirmed')
    CustomerMailer.purchase_confirmed(purchase).deliver_now
  end
end

Qualquer pessoa do time consegue facilmente entender o que acontece, onde acontece, quando acontece, etc. O resultado é um projeto mais prático de manter e fácil de ampliar! :-)

Bônus Round

No formato acima, que é comum vermos no mundo real, a árvore de arquivos ficaria mais ou menos assim:

app
|-- models
|   |-- purchase.rb
|-- services
|   |-- confirm_purchase.rb
|   |-- update_purchase_amounts.rb

Se pensarmos que uma aplicação pode crescer e ter diversos serviços, em algum momento podemos perder noção de quais serviços estão relacionados às mesmas coisas. No exemplo deste artigo, ambos os nossos serviços estão relacionados a Purchase. Quando vejo algo assim, minha preferência é agrupar estes serviços com um módulo em seu próprio diretório:

app
|-- models
|   |-- purchase.rb
|-- services
|   |-- purchase_service
|   |   |-- confirm.rb
|   |   |-- update_amounts.rb
# app/services/purchase_service/update_amounts.rb

module PurchaseService
  class UpdateAmounts
    attr_reader :purchase

    def initialize(purchase)
      @purchase = purchase
    end

    def execute
      subtotal = purchase.items.sum(:price)
      total = subtotal - purchase.discount

      purchase.update(subtotal: subtotal, total: total)
    end
  end
end


# app/services/purchase_service/confirm.rb

module PurchaseService
  class Confirm
    attr_reader :purchase

    def initialize(purchase)
      @purchase = purchase
    end

    def execute
      purchase.update(status: 'confirmed')
      CustomerMailer.purchase_confirmed(purchase).deliver_now
    end
  end
end

As chamadas mudam:

# antes
UpdatePurchaseAmounts.new(purchase).execute
ConfirmPurchase.new(purchase).execute

# depois:
PurchaseService::UpdateAmounts.new(purchase).execute
PurchaseService::Confirm.new(purchase).execute

É isso aí!

Estas são as razões e formas que eu troco callbacks por serviços no Rails!

Debates Filosóficos

Usar ou não callbacks é um debate que já deu uma esfriada. Em geral, a comunidade Rails tem fugido desta forma de codar. Os serviços ainda são motivo de trocas interessantes. Seguem alguns artigos para quem quiser se aprofundar mais nos prós e contras desta forma de organizar projetos:

Tem time que coda serviços com execute - como fizemos acima - e tem time que prefere chamar esses métodos de call.

Tem times que permitem que um serviço tenha mais de um método público, enquanto outros fortemente trabalham apenas com um público e colocam qualquer outra lógica no private da classe.

Existem situações em que lógicas comuns são extraídas para um base.rb no módulo e cada classe herda dele. Já vi isso dar bem certo - como talvez poderia ser o nosso caso no initialize e attr_reader de cada serviço deste artigo, mas também já vi times extrapolarem isso ao ponto de tirar toda a praticidade do uso de serviços.

Para navegar estes debates e conversas com o seu time, pense no que a nossa amiga Sandy Metz disse:

"duplication is far cheaper than the wrong abstraction"

"prefer duplication over the wrong abstraction"

Traduzindo:

"duplicidade é bem mais barata que a abstração errada"

"prefira duplicidades sobre a abstração errada"

Confira um artigo dela mesma sobre isso e sucesso nas suas decisões!

Foto de perfil do autor
Vivi Poit

Dev