Um dos assuntos que mais levantados nas primeiras semanas de um novo desenvolvedor na Plataformatec é sobre o uso de callbacks do ActiveRecord. Neste post, vou descrever um cenário prático que oriente quando o uso de callbacks é bem-vindo ou quando eles deveriam ser evitados. A ideia é ter, ao final, um modelo mental que indique quando seguir um dos caminhos possíveis. (spoiler: o modelo mental vai ser fundamentado em uma boa prática de design de software).
Imagine que, em um software fictício, exista uma funcionalidade "registrar pessoas" com os seguintes requisitos:
- Os dados de uma pessoa devem ser persistidos no banco de dados (e-mail e CPF, ambos como String).
- O CPF deve ser persistido apenas como dígitos (isto é, caso o input do usuário siga o formato
"999.999.999-99"
, esse valor deve ser persistido como"99999999999"
). - Caso a pessoa seja registrada com sucesso, um e-mail deve ser enviado para o endereço fornecido.
O pseudocódigo de um controller de registro de pessoas seria algo como:
def create
person = Person.new
person.email = params.fetch(:email) # '[email protected]'
person.cpf = params.fetch(:cpf) # '999.999.999-99'
# cpf must be saved only with numeric chars
person = normalize_person_cpf(person)
person.save!
# is expected to send an e-mail for this new person
PersonMailer.welcome_email(person.email).deliver_now
render :ok
end
private
def normalize_person_cpf(person)
person.cpf = person.cpf.gsub(/\D/, '')
person
end
Dois comportamentos desse fluxo são comumente extraídos da camada de controller para uma camada de domínio:
- a lógica de normalizar o valor do CPF; e
- a lógica de envio de e-mail.
Em aplicações Rails simples, esse tipo de lógica é geralmente implementado no ciclo de vida do model em questão:
class Person < ApplicationRecord
before_save :normalize_cpf # a `before_validation` callback could also be applied here.
after_save :send_email
private
def normalize_cpf
self.cpf = self.cpf.gsub(/\D/, '')
self
end
def send_email
PersonMailer.welcome_email(self.email).deliver_now
end
end
Após essa refatoração, o código do controller fica levemente mais simples e conciso:
def create
person = Person.new
person.email = params.fetch(:email) # '[email protected]'
person.cpf = params.fetch(:cpf) # '999.999.999-99'
person.save!
render :ok
end
Apesar da aparente sensação de melhoria, um ponto sobre design de código geralmente vem à tona:
Callbacks do ActiveRecord são nocivos e deveriam ser sempre evitados. 👮
Porém, como a vida não é feita de extremos, deve haver algum racional que faça o argumento seguir um caminho do meio. O que nos leva de volta à pergunta cerne deste texto: quando faz sentido usar callbacks do ActiveRecord?
Antes de entrar nesse mérito, vamos revisitar as possíveis vantagens de se usar callbacks:
- Eles provém DSLs que ajudam a estruturar e comunicar o ciclo de vida de uma entidade de domínio (ex: antes de persistir uma entidade de domínio, faça X, e após persisti-la, faça Y).
- Eles auxiliam no controle transacional das operações de banco de dados (i.e.: um erro em um callback gera um rollback da operação, garantindo consistência do banco de dados).
E as desvantagens?
- O seu uso descuidado pode ser foco de complexidade, principalmente quando o model é exposto a diferentes contextos de negócio (ex: "no cenário B, algumas regras devem ser evitadas, enquanto outras lógicas adicionais devem acontecer").
- Um reflexo dessa complexidade pode ser refletido na suíte de testes. Ex: "em alguns testes, eu não preciso que alguma operação dispendiosa aconteça em todos os test cases, como por exemplo, fazer alguma chamada de API ou indexação de ElasticSearch").
E quanto ao nosso exemplo, existe algo que possa ser revisitado? Vamos analisar as duas lógicas do fluxo de "registrar pessoa" com mais detalhes:
- A lógica que normaliza o CPF basicamente remove alguns caracteres do valor fornecido e atribui o novo valor normalizado ao campo CPF. Nesse caso, apenas o estado interno do objeto é alterado (é esperado, afinal, que a entidade
Person
tenha capacidade de manter sua própria consistência). Além disso, o resultado dessa operação é previsível: é esperado que um CPF"999.999.999-99"
seja sempre persistido como"99999999999"
. - Já a lógica que envia um e-mail é um pouco mais sensível. Ela interage com um serviço que depende de uma operação de rede e que pode gerar side-effects inesperados (ou, no mínimo, onde é difícil ter controle sobre). Outras operações semelhantes poderiam ser chamadas de API (RPC em geral), manipulação de arquivos, execução de processos OS, etc.
Um outro ponto que vale ressaltar na lógica de envio de e-mail é que ela depende de estado externo fora do objeto em questão. Não apenas é realizada uma operação com um colaborador externo, como a operação pode gerar side-effects¹. Aqui, a complexidade desnecessária pode ser manifestada em situações como: "todos os nossos testes unitários que persistem um model Person
devem tratar o envio de e-mail, seja aceitando a operação ou mockando-a de alguma forma".
Além disso, há uma distinção clara sobre quem possui a responsabilidade de orquestrar a lógica de envio de e-mail: essa lógica pertence a um fluxo de negócios de "registrar nova pessoa", e não diretamente do ciclo de vida de uma entidade de domínio. Essa diferença é crucial, pois o model Person
pode viver independente do fato que o fluxo de registro de pessoas envia um e-mail.
Dito isso, podemos concluir que a lógica de envio de e-mail pode ter três caminhos naturais de evolução:
- ela pode ser movida de volta para o controller.
- ela pode ser movida para um novo método do model
Person
, focado no fluxo de registrar pessoas. - ela pode ser movida para um novo objeto em nível de serviço (aka, o pattern "service object").
Todas as opções acima são válidas e a escolha de uma delas vai depender do contexto do seu projeto. Ex:
- o quanto esse fluxo é passível de mudanças?
- o quanto ele pode evoluir para um caminho mais complexo?
- esse trecho de código já foi implantado em produção ao menos uma vez?
O ponto principal é que ele não estaria mais vinculado a um objeto de domínio (i.e., em nível unitário, possivelmente reutilizável em outros contextos), e sim a um cenário em nível de integração, que expressa um comportamento de negócios do sistema (i.e., em camadas de controller ou service object). (o princípio da pirâmide de testes é oportuno aqui).
Seguindo os três caminhos sugeridos, as possíveis versões refatoradas seriam:
- Movendo a lógica de e-mail de volta para o controller:
def create
person = Person.new
person.email = params.fetch(:email) # '[email protected]'
person.cpf = params.fetch(:cpf) # '999.999.999-99'
person.save!
PersonMailer.welcome_email(person.email).deliver_now
render :ok
end
class Person < ApplicationRecord
before_save :normalize_cpf
private
def normalize_cpf
self.cpf = self.cpf.gsub(/\D/, '')
self
end
end
- Movendo a lógica para um método especializado de
Person
:
def create
Person.register_person!(params.fetch(:email), params.fetch(:cpf))
render :ok
end
class Person < ApplicationRecord
before_save :normalize_cpf
# it is expected to be refactored as things grow up.
def self.register_person!(cpf, email)
person_instance = Person.new
person_instance.cpf = cpf
person_instance.email = email
person_instance.save!
PersonMailer.welcome_email(person_instance.email).deliver_now
end
private
def normalize_cpf
self.cpf = self.cpf.gsub(/\D/, '')
self
end
end
- Movendo a lógica de e-mail para um service object:
def create
RegisterPersonOperation.new.call!(params.fetch(:email), params.fetch(:cpf))
render :ok
end
class RegisterPersonOperation
def call!(email, cpf)
person = Person.new
person.email = params.fetch(:email) # '[email protected]'
person.cpf = params.fetch(:cpf) # '999.999.999-99'
person.save!
PersonMailer.welcome_email(person.email).deliver_now
end
end
class Person < ApplicationRecord
before_save :normalize_cpf
private
def normalize_cpf
self.cpf = self.cpf.gsub(/\D/, '')
self
end
end
Após mais uma rodada de refatorações, é possível finalmente chegar a um modelo mental que indique quando callbacks AR são aceitáveis ou não. O detalhe que nos ajuda a chegar neste modelo é relacionado ao controle de estado presente na interação entre diferentes objetos. Sendo mais específico: é recomendável evitar callbacks em lógicas que exercitem um colaborador externo e que gere um possível side-effect (o raciocínio por trás de "funções puras vs funções impuras" de linguagens funcionais se aplicariam aqui).
Portanto, aplicando o mesmo racional na primeira versão do nosso exemplo, teríamos as seguintes avaliações descritas nos comentários abaixo:
class Person < ApplicationRecord
# this changes only internal state.
# callbacks are welcome here.
# live can go on.
before_save :normalize_cpf
# this changes the external state of a collaborator.
# callbacks may hurt you so bad.
# a refactoring is calling for you attention.
after_save :send_email
end
Com esse modelo, é esperado que a base de código seja mais simples de manter e manusear, por conta da separação de responsabilidades e pela distribuição de efeitos colaterais em camadas mais externas e integradas da aplicação.
Por fim, os insights principais desse post são:
- Callbacks do ActiveRecord são aplicáveis em alguns cenários e podem trazer ganhos de legibilidade.
- Fique atento a lógicas de fluxo de negócio sendo incorporadas ao ciclo de vida de uma entidade de domínio.
- Callbacks
after_[save|create|update]
são geralmente um smell.
¹um hint para identificar operações que geram side-effect: chamadas de método sem parâmetros e cujo retorno é vazio.