'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:
- Envolve mais de uma classe
- É uma funcionalidade por si só
- É 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:
- Coloque serviços dentro de
app
, num diretório chamadoservices
- Implemente um método
initialize
que recebe um ou mais objetos - 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:
- Service objects in Rails - The path to slimmer controllers and models
- What are Service Objects in Ruby on Rails? Should you use it?
- Beware of “service objects” in Rails
- Enough With the Service Objects Already - Questioning Trends in Web Application Architecture
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!