Skip to content

Instantly share code, notes, and snippets.

@rondy
Last active March 29, 2023 19:55
Show Gist options
  • Save rondy/5825b1f84cda2c27ab06df1fde2a5214 to your computer and use it in GitHub Desktop.
Save rondy/5825b1f84cda2c27ab06df1fde2a5214 to your computer and use it in GitHub Desktop.

Callbacks do ActiveRecord: o mal secreto ou apenas mal compreendidos?

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).

Um exemplo prático

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:

  1. a lógica de normalizar o valor do CPF; e
  2. 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:

  1. ela pode ser movida de volta para o controller.
  2. ela pode ser movida para um novo método do model Person, focado no fluxo de registrar pessoas.
  3. 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

Conclusão

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment