dry-rb

Como Tratar Casos de Uso de Maneira Prática com dry-rb

Hoje irei tentar mostrar um pouco de como tratar os casos de uso dentro de suas aplicações Ruby On Rails qualquer aplicação Ruby usando uma “coleção de gems” rotuladas como:

a próxima geração de bibliotecas Ruby. dry-rb

“dry-rb helps you write clear, flexible, and more maintainable Ruby code. Each dry-rb gem fulfils a common task, and together they make a powerful platform for any kind of Ruby application.”

Quando a gente começa um projeto do zero, é natural sentir uma mistura de excitação e cautela, especialmente quando se trata de definir a arquitetura e as bases do código. É um momento crucial, porque é neste ponto que as escolhas não devem apenas facilita o trabalho inicial, mas também prepara o terreno para a escalabilidade e a manutenção a longo prazo.

Frequentemente, projetos tendem a se expandir à medida que avançam, o que, por sua vez, pode aumentar o número de desenvolvedores envolvidos. Não é raro ver equipes pequenas crescerem rapidamente, especialmente quando o projeto começa a ganhar tração. Essa expansão traz uma série de desafios, como a necessidade de manter a qualidade do código, garantir que todos os membros da equipe estejam alinhados e evitar acúmulo de dívidas técnicas.

Começando com Dry-rb

O dry-rb, como explicado anteriormente, é um grupo de gems que juntas te ajudam a resolver vários problemas do dia-a-dia, ou seja, pra você não perder tempo tentando reinventar a roda. 😜 Atualmente, o dry-rb é composto por mais de 20 gemas. No entanto, é importante destacar que você tem a liberdade de utilizar apenas as que são relevantes de acordo com seus objetivos e necessidades.

Dry Transaction

A dry-transaction é focada em te ajudar a definir um “conjunto de passos” que compõem uma regra de negócio. Mais a diante eu vou te mostrar como isso pode ser útil para organizar e estruturar os casos de uso da sua aplicação.

O caso de uso

💡 Em todos os exemplos vou estar usando o bundle inline pra ter realmente um exemplo que funcione. Em alguns momentos eu irei ocultar esse trecho para evitar que os exemplos fiquem muito grandes.

require 'bundler/inline'

gemfile do
 source 'https://rubygems.org'

 gem 'dry-monads'
 gem 'dry-transaction'
end


require 'dry/transaction'
# ...

A ideia é que o usuário “preencha um formulário”(ou seja, receberemos um input) com seu nome e e-mail. O fluxo hipotético que iremos implementar é o seguinte:

  1. O primeiro passo é validar o input
    • Verificar se o nome e o e-mail foram preenchidos.
    • Se não, retornar uma mensagem de erro.
    • Se sim, retornar os dados validados.
  2. O segundo passo é inserir o registro no banco de dados.
    • Se o registro for inserido com sucesso, retornar o e-mail do usuário.
    • Se não, retornar uma mensagem de erro.
  3. O terceiro passo é enviar um e-mail de boas vindas.
    • Se o e-mail for enviado com sucesso, retornar uma mensagem de confirmação.
    • Se não, retornar uma mensagem de erro.

Agora, observe como a representação do caso de uso mencionado acima pode ser tornada mais intuitiva através do uso do dry-transaction. E para tal, veremos duas formas: A primeira, mais convencional, é encotrada até como exemplo de uso na própria documentação da dry-transaction

# example.rb
# ... bundle inline acima.👆🏻

class CreateUser
 include Dry::Transaction

 step :validate
 step :create
 step :welcome_email

 # Database representation
 DB = []

 private

 def validate(params)
   # Verify bussiness rules of the input ...
   if params[:name] && params[:email]
     Success(params)
   else
     Failure("ops... Name and email must present!")
   end
 end

 def create(params)
   if DB.push(params)
     Success(params[:email])
   else
     Failure("Panic!!! 🐞")
   end
 end

 def welcome_email(email)
   # UserMailer.welcome(email).deliver_later

   puts "✉️ Sending welcome email to #{email}..."
   Success('Please, confirm your email. 😉')
 end
end

# New instance of CreateUser class
obj = CreateUser.new

# The call method will receive the arguments and pass them as parameters to the first step.
obj.call(name: 'Aristóteles', email: 'ari@example.org')

# $ ruby "tmp/example.rb"

# ✉️  Sending welcome email to ari@example.org...
# Success("Please, confirm your email. 😉")

Perceba que não precisamos declarar explicitamente um método construtor na classe CreateUser. Após iniciarmos um objeto, este, por sua vez, responderá ao método .call que irá receber os argumentos e passará estes argumentos como parâmetros para o primeiro passo do processo.

Todo o fluxo acontece na ordem em que especificamos acima, o que traz clareza e previsibilidade sobre o processo. Ex.

 step :validate      # 1
 step :create        # 2
 step :welcome_email # 3

Agora, apenas olhando para o trecho de código é possível entender quais os passos que irão acontecer no processo de cadastro de uma nova conta.

Uma outra abordagem muito comum, é o usar classes para isolar cada passo do nosso processo, para isso, podemos “empacotar operações” com a ajuda do Container, neste segundo exemplo, eu vou mostrar uma forma que gosto de usar e que não é apresentada na documentação oficial, um pequeno arranjo que aprendi numa talk da Camila Campos, na Ruby Summit em Dezembro de 2020.

A ideia é criar uma classe para cada “step” que representa o caso de uso. Com as classes com um único método público “call”, ou seja, ao invés de referenciarmos métodos iremos referenciar classes. Vamos refazer o exemplo anterior e ver como funciona na prática. Uma diferença nos exemplos a seguir é que vamos fazer um include explicito em cada classe separada do dry-monads para tratar os casos de sucesso e falha com uso da mônade Either

# create_user.rb
require 'bundler/inline'

gemfile do
 source 'https://rubygems.org'

 gem 'dry-monads'
 gem 'dry-transaction'
end

require 'dry/monads'
require 'dry/transaction'

# ..
# ..

class CreateUser
 include Dry::Transaction

 def initialize
   steps = {
     validate: NormalizeParams.new,
     create_record: CreateRecord.new,
     welcome_email: WelcomeEmail.new
   }

   super(**steps)
 end

 step :validate
 step :create
 step :welcome_email

 # Database representation
 DB = []
end
# normalize_params.rb

class NormalizeParams
 include Dry::Monads[:result]

 def call(**params)
   return Failure(message: 'ops... Name and email must present!') if params[:name].nil? && params[:email].nil?

   Success(params)
 end
end
# create_record.rb

class CreateRecord
 include Dry::Monads[:result]

 def call(params)
   return Success(params) if CreateUser::DB.push(params)

   Failure(message: "Panic!!! 🐞")
 end
end
# welcome_email.rb

class WelcomeEmail
 include Dry::Monads[:result]

 def call(params)
   # UserMailer.welcome(email).deliver_later

   puts "✉️ Sending welcome email to #{email}..."
   Success('Please, confirm your email. 😉')
 end
end

Testando tudo!

# Instância um novo objeto da class CreateUser
obj = CreateUser.new

# Objeto instanciado agora responde ao método call
obj.call(name: 'Aristóteles', email: 'ari@example.org')

# $ ruby "tmp/example.rb"

# ✉️ Sending welcome email to ari@example.org...
# Success("Please, confirm your email. 😉")

Teremos o mesmo resultado e o resultado será o mesmo, mas a diferença é que agora temos uma classe para cada passo do nosso processo. Isso pode ser útil para testes, por exemplo, ou para reutilizar esses passos em outros casos de uso.

Conclusão

O ecossistema dry-rb é incrível e muito bem mantido por dezenas de pessoas ao redor do mundo. Mesmo que você não o use diretamente em seu projeto, acredito que vale a pena conferir a documentação, isso até antes mesmo de tentar criar algo do zero. Lembre-se, você não precisa usar todas as gemas, procure usar apenas as que vão atender as suas necessidades Se você chegou neste assunto então deve saber que “não existe bala de prata”. Você usa o que for melhor para a sua empresa/projeto ou o que fizer mais sentido para sua equipe.