Introdução a gem u-case
Seja bem vindo(a) ao meu primeiro post. Já aviso que estou começando e conto com teu feedback para me auxiliar a melhorar a maneira como compartilho conhecimento aqui no blog. 😉
A gem u-case é um projeto que tenho me dedicado há mais de 1 ano. O projeto tem como objetivo facilitar o desenvolvimento e modelagem da camada de regras de negócio de aplicações Ruby. Embora a gem possa ser utilizada com qualquer codebase, usarei o contexto de uma aplicação Ruby on Rails para apresentar o seu uso.
Mas antes de falar dela, gostaria de destacar a abordagem mais praticada pela comunidade nos dias de hoje, no caso, a criação de service objects. Em geral, os services objects ficam localizados na pasta app/services
de uma aplicação Rails e tem como responsabilidade concentrar as regras de negócio da aplicação, permitindo assim que outras camadas fiquem mais coesas (Ex: controllers e models). Porém, essa abordagem tem sido alvo de muitas críticas [1][2]. A razão disso é que o resultado mais comum é a criação de classes enormes e com excesso de responsabilidades. Dificultando assim a manutenção e evolução do código mediante o aumento de complexidade por conta de requisitos de negócios cada vez mais sofisticados.
Clique aqui para visualizar a pasta de app/services
do Discourse
. E clique aqui para ver um exemplo de uma operação complexa (119 linhas) de um dos métodos do service object dessa aplicação.
Por muito tempo fui adepto a essa prática, mas volta e meia me via sendo penalizado por fazer uso dela. Por conta disso passei a procurar por soluções alternativas e que fossem utilizadas pela comunidade, afim de atingir o seguinte objetivo:
ter uma camada saúdavel (fácil de entender, manter/modificar e testar) de regras de negócio nas minhas aplicações Ruby/Rails.
Durante essa jornada fiz uso de gems como: interactor
(+3 milhões de downloads), trailblazer-operation
(+1 milhão de downloads), dry-transaction
(quase 1 milhão de downloads), dry-monads (do notation)
(+2 milhões de downloads).
Mas ao longo desse processo tive diversas alegrias e tristezas no uso de cada uma delas (talvez faça sentido escrever um post somente sobre isso). Sendo que a melhor experiência que tive foram com as gems do dry-rb.
E, embora o ecossistema dry-rb
fosse promissor por favorecer um desenvolvimento bem SOLID, era muito comum constar uma grande aversão a essas gems por conta do estilo funcional que elas possuem.
Enfim, por conta dessas experiência (ao longo de anos) e a dificuldade em capacitar desenvolvedores Jr, Pl e Sr. Resolvi criar uma gem que tivesse uma aproximação com o Rails (para evitar essa aversão) e que promovesse boas práticas de desenvolvimento (leia-se SOLID). Pois bem, bora ver código para entender o que a u-case tem a oferecer.
Obs: Recomendo a leitura desse post caso não saiba como executar os exemplos de código a seguir.
Nota: A fim de facilitar a experimentação, farei uso do bundler inline em alguns exemplos. Assim será possível copiar e colar os trechos de código em um arquivo
.rb
e ao executá-los (ruby exemplo_da_gem_u-case.rb
) o bundler resolverá todas as dependências.
require 'bundler/inline'
gemfile do
source 'https://rubygems.org'
gem 'u-case', '~> 4.2.1'
end
class Sum < Micro::Case
attributes :a, :b
def call!
if a.is_a?(Numeric) && b.is_a?(Numeric)
Success result: { number: a + b }
else
Failure result: { message: '`a` e `b` devem ser numéricos' }
end
end
end
# == Resultado de sucesso ==
result = Sum.call(a: 2, b: 2)
p result # #<Success (Micro::Case::Result) type=:ok data={:number=>4} transitions=1>
puts result.success? # true
puts result[:number] # 4
puts result.data # { :number => 4 }
# == Resultado de falha ==
result = Sum.call(a: '2', b: 2)
p result # #<Failure (Micro::Case::Result) type=:error data={:message=>"`a` e `b` devem ser numéricos"} transitions=1>
puts result.failure? # true
puts result[:message] # `a` e `b` devem ser numéricos
puts result.data # { :message => "`a` e `b` devem ser numéricos" }
Todo caso de uso possuí a seguinte estrutura:
- Um conjunto de atributos (método
attributes
), ou seja, serão os dados de input. - A regra de negócio, definido pelo método
call!
. - Um resultado de sucesso ou de falha, definido pelos métodos
Success(result: {})
ouFailure(result: {})
.
Se analisarmos bem, um caso de uso nada mais é do que um input (os atributos), uma ou mais ações (processamento), que retornará um output (o resultado).
Sendo que esse resultado é um Micro::Case::Result
, que como podemos ver no exemplo acima possuí os seguintes métodos:
#success?
retornarátrue
caso o retorno docall!
tenha sido com o métodoSuccess()
.#failure?
retornarátrue
caso o retorno docall!
tenha sido com o métodoFailure()
.#data
retorna o hash usado na propriedade:result
dos métodosSuccess()
ouFailure()
.#[]
permite acessar os valores contidos noresult.data
.
Ponto importante: o valor retornado na propriedade
:result
dos métodosSuccess()
eFailure()
tem sempre de ser umHash
.
O que você achou até aqui, tranquilo?
Pois bem, agora peço que me acompanhe para entender o real poder do u-case
. Que nada mais é do que favorecer o uso de composição.
E para exemplificar isso, criaremos um novo caso de uso que permitirá somar três há um determinado número.
class Add3 < Micro::Case
attribute :number
def call!
return Success result: { number: number + 3 } if number.is_a?(Numeric)
Failure result: { message: '`number` deve ser numérico' }
end
end
result = Add3.call(number: 1)
puts result.data # { :number => 4 }
Agora, que tal criarmos um caso de uso para somar nove?
Como disse anteriormente, o real poder do u-case
está na capacidade que o mesmo tem em promover o uso de composição.
Para isso, faremos uso de um novo recurso, no caso, usaremos o Micro::Cases.flow()
.
class Add3 < Micro::Case
attribute :number
def call!
return Success result: { number: number + 3 } if number.is_a?(Numeric)
Failure result: { message: '`number` deve ser numérico' }
end
end
Add9 = Micro::Cases.flow([Add3, Add3, Add3])
result = Add9.call(number: 1)
puts result.data # { :number => 10 }
E aí, o que achou? Facinho né?
Agora, bora fazermos um caso de uso um pouquinho mais rebuscado que somará dois números e então adicionará três ao resultado da soma.
class Sum < Micro::Case
attributes :a, :b
def call!
if a.is_a?(Numeric) && b.is_a?(Numeric)
Success result: { number: a + b }
else
Failure result: { message: '`a` e `b` devem ser numéricos' }
end
end
end
class Add3 < Micro::Case
attribute :number
def call!
return Success result: { number: number + 3 } if number.is_a?(Numeric)
Failure result: { message: '`number` deve ser numérico' }
end
end
SumAndAdd3 = Micro::Cases.flow([Sum, Add3])
result = SumAndAdd3.call(a: 4, b: 5)
puts result.data # { :number => 12 }
Também foi fácil né? Bacana!
Como isso foi possível?
Veja que o resultado de Sum
é Success result: { number: a + b }
, e o atributo de Add3
é :number
. Ou seja, o output de Sum
tornou-se o input do Add3
.
Hummmm… O que aconteceria se fizéssemos uma composição com outra composição?
Para testarmos isso, sugiro criarmos algo que somará dois números e então adicionará nove, sendo que essa última operação será uma composição.
class Sum < Micro::Case
attributes :a, :b
def call!
if a.is_a?(Numeric) && b.is_a?(Numeric)
Success result: { number: a + b }
else
Failure result: { message: '`a` e `b` devem ser numéricos' }
end
end
end
class Add3 < Micro::Case
attribute :number
def call!
return Success result: { number: number + 3 } if number.is_a?(Numeric)
Failure result: { message: '`number` deve ser numérico' }
end
end
Add9 = Micro::Cases.flow([Add3, Add3, Add3])
SumAndAdd9 = Micro::Cases.flow([Sum, Add9])
result = SumAndAdd9.call(a: 4, b: 5)
puts result.data # { :number => 18 }
E aí, achou fácil? Perceba que é possível fazer composição tanto com casos de usos isolados, ou com outros flows
(esse é o termo usado para indicar a composição de dois ou mais casos de uso).
Atenção: Perceba que os atributos ficam acessíveis no escopo do caso de uso, ou seja, eles são a porta de entrada para o processamento que ocorrerá no método
call!
. Sem eles essa relação de input/output ficará comprometida.
Implementando algo do nosso dia dia
Como aplicação prática, irei implementar um caso de uso que criará um usuário, o mesmo será composto dos seguintes passos:
- Normalizará os parâmetros
- Validará os parâmetros
- Persistirá o usuário no banco de dados (para simplificar ele irá instanciar um objeto)
require 'uri'
require 'ostruct'
require 'securerandom'
User = Struct.new(:id, :name, :email)
module Users
class Create < Micro::Case
attributes :name, :email
def call!
normalized_name = String(name).strip.gsub(/\s+/, ' ')
normalized_email = String(email).downcase.strip
validation_errors = []
validation_errors << "Name can't be blank" if normalized_name.empty?
validation_errors << "Email is invalid" if normalized_email !~ URI::MailTo::EMAIL_REGEXP
return Failure result: { errors: validation_errors } if !validation_errors.empty?
user = User.new(
SecureRandom.uuid,
normalized_name,
normalized_email
)
Success result: { user: user }
end
end
end
# == Resultado de Sucesso ==
result = Users::Create.call(name: 'Rodrigo', email: 'rodrigo.serradura@gmail.com')
puts result.success? # true
p result[:user] # #<struct User id="4cc0b77b-b824-4f16-a8a7-8ee30b7017dc", name="Rodrigo", email="rodrigo.serradura@gmail.com">
# == Resultado de Falha ==
result = Users::Create.call(name: 'Rodrigo', email: 'rodrigo.serradura')
puts result.failure? # true
p result.data # { :errors => ["Email is invalid"] }
Viu como foi simples definir um processo que cria um usuário?
Agora, irei implementar cada etapa em um caso de uso separado e então compor um flow
.
require 'uri'
require 'ostruct'
require 'securerandom'
User = Struct.new(:id, :name, :email)
module Users
class NormalizeParams < Micro::Case
attributes :name, :email
def call!
Success result: {
name: String(name).strip.gsub(/\s+/, ' '),
email: String(email).downcase.strip
}
end
end
class ValidateParams < Micro::Case
attributes :name, :email
def call!
validation_errors = []
validation_errors << "Name can't be blank" if name.empty?
validation_errors << "Email is invalid" if email !~ URI::MailTo::EMAIL_REGEXP
return Success() if validation_errors.empty?
Failure result: { errors: validation_errors }
end
end
class CreateRecord < Micro::Case
attributes :name, :email
def call!
user = User.new(SecureRandom.uuid, name, email)
Success result: { user: user }
end
end
CreationProccess = Micro::Cases.flow([
NormalizeParams,
ValidateParams,
CreateRecord
])
end
# == Resultado de Sucesso ==
result = Users::CreationProccess.call(name: 'Rodrigo', email: 'rodrigo.serradura@gmail.com')
puts result.success? # true
p result[:user] # #<struct User id="4cc0b77b-b824-4f16-a8a7-8ee30b7017dc", name="Rodrigo", email="rodrigo.serradura@gmail.com">
# == Resultado de Falha ==
result = Users::CreationProccess.call(name: 'Rodrigo', email: 'rodrigo.serradura')
puts result.failure? # true
p result.data # { :errors => ["Email is invalid"] }
Atenção: as validações não precisam ser conforme o exemplo acima (elas estão bem feinhas 😅).
É possível fazer uso das validações do ActiveModel nos atributos dos seus caso de uso. Clique aqui para conferir na documentação. (Abordarei isso em outro post)
💡 Insight: Testes unitários
Escreverei sobre isso em breve, mas acredito que seja perceptível o quão prático passa ser implementar testes unitários com essa abordagem. Uma vez que é possível testar o flow
como um todo e/ou cada etapa que o compõe. Usando os exemplos anteriores da criação de usuário, é mais prático testar:
# Etapas que compõe o flow Users::CreationProccess
Users::NormalizeParams
Users::ValidateParams
Users::CreateRecord
# Fluxo completo
Users::CreationProccess # Micro::Cases.flow([NormalizeParams, ValidateParams, CreateRecord])
Do que testar um único caso de uso com toda a regra de negócio, que é o caso do primeiro exemplo:
Users::Create
Concluindo
Ainda há muito a ser dito, pois mal tocamos a ponta do iceberg. Mas com os recursos que foram abordados já será possível criar casos de usos bem interessantes.
Minha intenção foi em te apresentar de maneira rápida e objetiva o que é a gem u-case
e como começar a fazer uso dela.
E destacar o seu poder de composição, que facilita a criação de um código mais expressivo e prático de se manter/testar. Algo bem diferente do que ocorre com o uso tradicional de service objects (como destaquei no início deste post).
Nos próximos posts irei abordar diversas outras funcionalidades (validação, normalização de atributos, diferentes formas de compor um flow) e conceitos relacionados.
Se tu curtiu esse conteúdo, sugiro acessar a documentação em pt-BR e a assistir uma palestra na qual apresento a gem u-case
após explicar de forma prática como aplicações Ruby on Rails vem sendo organizadas nos últimos 15 anos.
* PS: Tem um desafio após o vídeo.
Desafio
Descubra o que o método result.transitions
é capaz de fazer. Segue um exemplo prontinho para você executar e analisar.
Dica: confira a documentação dessa funcionalidade.
require 'bundler/inline'
gemfile do
source 'https://rubygems.org'
gem 'u-case', '~> 4.2.1'
gem 'amazing_print', '~> 1.2.2'
end
class Add3 < Micro::Case
attribute :number
def call!
return Success result: { number: number + 3 } if number.is_a?(Numeric)
Failure result: { message: '`number` deve ser numérico' }
end
end
Add9 = Micro::Cases.flow([Add3, Add3, Add3])
result = Add9.call(number: 1)
puts result.data # { :number => 10 }
ap result.transitions
Gostou do conteúdo? Deixe seu comentário aqui embaixo contando o que achou. Valeu! 😉
Já ouviu falar do ada.rb - Arquitetura e Design de Aplicações em Ruby? É um grupo focado em práticas de engenharia de software com Ruby. Acesse o canal no telegram e junte-se a nós em nosso meetup mensal (100% on-line).
Comments