Skip to content

Latest commit

 

History

History
1460 lines (1082 loc) · 50.8 KB

README.pt-BR.md

File metadata and controls

1460 lines (1082 loc) · 50.8 KB

u-case - Represent use cases in a simple and powerful way while writing modular, expressive and sequentially logical code.

Represente casos de uso de forma simples e poderosa ao escrever código modular, expressivo e sequencialmente lógico.


Ruby Gem Build Status Maintainability Test Coverage

Principais objetivos deste projeto:

  1. Fácil de usar e aprender ( entrada >> processamento >> saída ).
  2. Promover imutabilidade (transformar dados ao invés de modificar) e integridade de dados.
  3. Nada de callbacks (ex: before, after, around) para evitar indireções no código que possam comprometer o estado e entendimento dos fluxos da aplicação.
  4. Resolver regras de negócio complexas, ao permitir uma composição de casos de uso (criação de fluxos).
  5. Ser rápido e otimizado (verifique a seção de benchmarks).

Nota: Verifique o repo https://github.com/serradura/from-fat-controllers-to-use-cases para ver uma aplicação Ruby on Rails que utiliza esta gem para resolver as regras de negócio.

Documentação

Versão Documentação
unreleased https://github.com/serradura/u-case/blob/main/README.md
4.5.1 https://github.com/serradura/u-case/blob/v4.x/README.md
3.1.0 https://github.com/serradura/u-case/blob/v3.x/README.md
2.6.0 https://github.com/serradura/u-case/blob/v2.x/README.md
1.1.0 https://github.com/serradura/u-case/blob/v1.x/README.md

Índice

Compatibilidade

u-case branch ruby activemodel u-attributes-
unreleased main >= 2.2.0 >= 3.2, < 7.0 >= 2.7, < 3.0
4.5.1 v4.x >= 2.2.0 >= 3.2, < 7.0 >= 2.7, < 3.0
3.1.0 v3.x >= 2.2.0 >= 3.2, < 6.1 ~> 1.1
2.6.0 v2.x >= 2.2.0 >= 3.2, < 6.1 ~> 1.1
1.1.0 v1.x >= 2.2.0 >= 3.2, < 6.1 ~> 1.1

Nota: O activemodel é uma dependência opcional, esse módulo que pode ser habilitado para validar os atributos dos casos de uso.

Dependências

  1. Gem kind.

    Sistema de tipos simples (em runtime) para Ruby.

    É usado para validar os inputs de alguns métodos do u-case, além de expor um validador de tipos através do activemodel validation (veja como habilitar).

  2. u-attributes gem.

    Essa gem permite definir atributos de leitura (read-only), ou seja, os seus objetos só terão getters para acessar os dados dos seus atributos. Ela é usada para definir os atributos dos casos de uso.

Instalação

Adicione essa linha ao Gemfile da sua aplicação:

gem 'u-case', '~> 4.5.1'

E então execute:

$ bundle

Ou instale manualmente:

$ gem install u-case

Uso

Micro::Case - Como definir um caso de uso?

class Multiply < Micro::Case
  # 1. Defina o input como atributos
  attributes :a, :b

  # 2. Defina o método `call!` com a regra de negócio
  def call!

    # 3. Envolva o resultado do caso de uso com os métodos `Success(result: *)` ou `Failure(result: *)`
    if a.is_a?(Numeric) && b.is_a?(Numeric)
      Success result: { number: a * b }
    else
      Failure result: { message: '`a` and `b` attributes must be numeric' }
    end
  end
end

#===========================#
# Executando um caso de uso #
#===========================#

# Resultado de sucesso

result = Multiply.call(a: 2, b: 2)

result.success? # true
result.data     # { number: 4 }

# Resultado de falha

bad_result = Multiply.call(a: 2, b: '2')

bad_result.failure? # true
bad_result.data     # { message: "`a` and `b` attributes must be numeric" }

# Nota:
# ----
# O resultado de um Micro::Case.call é uma instância de Micro::Case::Result

⬆️ Voltar para o índice

Micro::Case::Result - O que é o resultado de um caso de uso?

Um Micro::Case::Result armazena os dados de output de um caso de uso. Esses são seus métodos:

  • #success? retorna true se for um resultado de sucesso.
  • #failure? retorna true se for um resultado de falha.
  • #use_case retorna o caso de uso responsável pelo resultado. Essa funcionalidade é útil para lidar com falhas em flows (esse tópico será abordado mais a frente).
  • #type retorna um Symbol que dá significado ao resultado, isso é útil para declarar diferentes tipos de falha e sucesso.
  • #data os dados do resultado (um Hash).
  • #[] e #values_at são atalhos para acessar as propriedades do #data.
  • #key? retorna true se a chave estiver present no #data.
  • #value? retorna true se o valor estiver present no #data.
  • #slice retorna um novo Hash que inclui apenas as chaves fornecidas. Se as chaves fornecidas não existirem, um Hash vazio será retornado.
  • #on_success or #on_failure são métodos de hooks que te auxiliam a definir o fluxo da aplicação.
  • #then este método permite aplicar novos casos de uso ao resultado atual se ele for sucesso. A ideia dessa feature é a criação de fluxos dinâmicos.
  • #transitions retorna um array com todas as transformações que um resultado teve durante um flow.

Nota: por conta de retrocompatibilidade, você pode usar o método #value como um alias para o método #data.

⬆️ Voltar para o índice

O que são os tipos de resultados?

Todo resultado tem um tipo (#type), e estes são os valores padrões:

  • :ok em casos de sucesso;
  • :error ou :exception em casos de falhas.
class Divide < Micro::Case
  attributes :a, :b

  def call!
    if invalid_attributes.empty?
      Success result: { number: a / b }
    else
      Failure result: { invalid_attributes: invalid_attributes }
    end
  rescue => exception
    Failure result: exception
  end

  private def invalid_attributes
    attributes.select { |_key, value| !value.is_a?(Numeric) }
  end
end

# Resultado de sucesso

result = Divide.call(a: 2, b: 2)

result.type     # :ok
result.data     # { number: 1 }
result.success? # true
result.use_case # #<Divide:0x0000 @__attributes={"a"=>2, "b"=>2}, @a=2, @b=2, @__result=...>

# Resultado de falha (type == :error)

bad_result = Divide.call(a: 2, b: '2')

bad_result.type     # :error
bad_result.data     # { invalid_attributes: { "b"=>"2" } }
bad_result.failure? # true
bad_result.use_case # #<Divide:0x0000 @__attributes={"a"=>2, "b"=>"2"}, @a=2, @b="2", @__result=...>

# Resultado de falha (type == :exception)

err_result = Divide.call(a: 2, b: 0)

err_result.type     # :exception
err_result.data     # { exception: <ZeroDivisionError: divided by 0> }
err_result.failure? # true
err_result.use_case # #<Divide:0x0000 @__attributes={"a"=>2, "b"=>0}, @a=2, @b=0, @__result=#<Micro::Case::Result:0x0000 @use_case=#<Divide:0x0000 ...>, @type=:exception, @value=#<ZeroDivisionError: divided by 0>, @success=false>

# Nota:
# ----
# Toda instância de Exception será envolvida pelo método
# Failure(result: *) que receberá o tipo `:exception` ao invés de `:error`.

⬆️ Voltar para o índice

Como definir tipos customizados de resultados?

Resposta: Use um Symbol com argumento dos métodos Success(), Failure() e declare o result: keyword para definir os dados do resultado.

class Multiply < Micro::Case
  attributes :a, :b

  def call!
    if a.is_a?(Numeric) && b.is_a?(Numeric)
      Success result: { number: a * b }
    else
      Failure :invalid_data, result: {
        attributes: attributes.reject { |_, input| input.is_a?(Numeric) }
      }
    end
  end
end

# Resultado de sucesso

result = Multiply.call(a: 3, b: 2)

result.type     # :ok
result.data     # { number: 6 }
result.success? # true

# Resultado de falha

bad_result = Multiply.call(a: 3, b: '2')

bad_result.type     # :invalid_data
bad_result.data     # { attributes: {"b"=>"2"} }
bad_result.failure? # true

⬆️ Voltar para o índice

É possível definir um tipo sem definir os dados do resultado?

Resposta: Sim, é possível. Mas isso terá um comportamento especial por conta dos dados do resultado ser um hash com o tipo definido como chave e true como o valor.

class Multiply < Micro::Case
  attributes :a, :b

  def call!
    if a.is_a?(Numeric) && b.is_a?(Numeric)
      Success result: { number: a * b }
    else
      Failure(:invalid_data)
    end
  end
end

result = Multiply.call(a: 2, b: '2')

result.failure?            # true
result.data                # { :invalid_data => true }
result.type                # :invalid_data
result.use_case.attributes # {"a"=>2, "b"=>"2"}

# Nota:
# ----
# Essa funcionalidade será muito útil para lidar com resultados de falha de um Flow
# (este tópico será coberto em breve).

⬆️ Voltar para o índice

Como utilizar os hooks dos resultados?

Como mencionando anteriormente, o Micro::Case::Result tem dois métodos para melhorar o controle do fluxo da aplicação. São eles: #on_success, on_failure.

Os exemplos abaixo os demonstram em uso:

class Double < Micro::Case
  attribute :number

  def call!
    return Failure :invalid, result: { msg: 'number must be a numeric value' } unless number.is_a?(Numeric)
    return Failure :lte_zero, result: { msg: 'number must be greater than 0' } if number <= 0

    Success result: { number: number * 2 }
  end
end

#================================#
# Imprimindo o output se sucesso #
#================================#

Double
  .call(number: 3)
  .on_success { |result| p result[:number] }
  .on_failure(:invalid) { |result| raise TypeError, result[:msg] }
  .on_failure(:lte_zero) { |result| raise ArgumentError, result[:msg] }

# O output será:
#   6

#===================================#
# Lançando um erro em caso de falha #
#===================================#

Double
  .call(number: -1)
  .on_success { |result| p result[:number] }
  .on_failure { |_result, use_case| puts "#{use_case.class.name} was the use case responsible for the failure" }
  .on_failure(:invalid) { |result| raise TypeError, result[:msg] }
  .on_failure(:lte_zero) { |result| raise ArgumentError, result[:msg] }

# O output será:
#
# 1. Imprimirá a mensagem: Double was the use case responsible for the failure
# 2. Lançará a exception: ArgumentError (the number must be greater than 0)

# Nota:
# ----
# O caso de uso responsável estará sempre acessível como o segundo argumento do hook

Por que o hook sem um tipo definido expõe o próprio resultado?

Resposta: Para permitir que você defina o controle de fluxo da aplicação usando alguma estrutura condicional como um if ou case when.

class Double < Micro::Case
  attribute :number

  def call!
    return Failure(:invalid) unless number.is_a?(Numeric)
    return Failure :lte_zero, result: attributes(:number) if number <= 0

    Success result: { number: number * 2 }
  end
end

Double
  .call(number: -1)
  .on_failure do |result, use_case|
    case result.type
    when :invalid then raise TypeError, "number must be a numeric value"
    when :lte_zero then raise ArgumentError, "number `#{result[:number]}` must be greater than 0"
    else raise NotImplementedError
    end
  end

# O output será uma exception:
#
# ArgumentError (number `-1` must be greater than 0)

Nota: O mesmo que foi feito no exemplo anterior poderá ser feito com o hook #on_success!

Usando decomposição para acessar os dados e tipo do resultado

A sintaxe para decompor um Array pode ser usada na declaração de variáveis e nos argumentos de métodos/blocos. Se você não sabia disso, confira a documentação do Ruby.

# O objeto exposto em hook sem um tipo é um Micro::Case::Result e ele pode ser decomposto. Exemplo:

Double
  .call(number: -2)
  .on_failure do |(data, type), use_case|
    case type
    when :invalid then raise TypeError, 'number must be a numeric value'
    when :lte_zero then raise ArgumentError, "number `#{data[:number]}` must be greater than 0"
    else raise NotImplementedError
    end
  end

# O output será a exception:
#
# ArgumentError (the number `-2` must be greater than 0)

Nota: O que mesmo pode ser feito com o #on_success hook!

⬆️ Voltar para o índice

O que acontece se um hook de resultado for declarado múltiplas vezes?

Resposta: Se o tipo do resultado for identificado o hook será sempre executado.

class Double < Micro::Case
  attributes :number

  def call!
    if number.is_a?(Numeric)
      Success :computed, result: { number: number * 2 }
    else
      Failure :invalid, result: { msg: 'number must be a numeric value' }
    end
  end
end

result = Double.call(number: 3)
result.data         # { number: 6 }
result[:number] * 4 # 24

accum = 0

result
  .on_success { |result| accum += result[:number] }
  .on_success { |result| accum += result[:number] }
  .on_success(:computed) { |result| accum += result[:number] }
  .on_success(:computed) { |result| accum += result[:number] }

accum # 24

result[:number] * 4 == accum # true

Como usar o método Micro::Case::Result#then?

Este método permite você criar fluxos dinâmicos. Com ele, você pode adicionar novos casos de uso ou fluxos para continuar a transformação de um resultado. Exemplo:

class ForbidNegativeNumber < Micro::Case
  attribute :number

  def call!
    return Success result: attributes if number >= 0

    Failure result: attributes
  end
end

class Add3 < Micro::Case
  attribute :number

  def call!
    Success result: { number: number + 3 }
  end
end

result1 =
  ForbidNegativeNumber
    .call(number: -1)
    .then(Add3)

result1.data    # {'number' => -1}
result1.failure? # true

# ---

result2 =
  ForbidNegativeNumber
    .call(number: 1)
    .then(Add3)

result2.data     # {'number' => 4}
result2.success? # true

Nota: este método altera o Micro::Case::Result#transitions.

⬆️ Voltar para o índice

O que acontece quando um Micro::Case::Result#then recebe um bloco?

Ele passará o próprio resultado (uma instância do Micro::Case::Result) como argumento do bloco, e retornará o output do bloco ao invés dele mesmo. e.g:

class Add < Micro::Case
  attributes :a, :b

  def call!
    if Kind.of?(Numeric, a, b)
      Success result: { sum: a + b }
    else
      Failure(:attributes_arent_numbers)
    end
  end
end

# --

success_result =
  Add
    .call(a: 2, b: 2)
    .then { |result| result.success? ? result[:sum] : 0 }

puts success_result # 4

# --

failure_result =
  Add
    .call(a: 2, b: '2')
    .then { |result| result.success? ? result[:sum] : 0 }

puts failure_result # 0

⬆️ Voltar para o índice

Como fazer injeção de dependência usando este recurso?

Passe um Hash como segundo argumento do método Micro::Case::Result#then.

Todo::FindAllForUser
  .call(user: current_user, params: params)
  .then(Paginate)
  .then(Serialize::PaginatedRelationAsJson, serializer: Todo::Serializer)
  .on_success { |result| render_json(200, data: result[:todos]) }

⬆️ Voltar para o índice

Micro::Cases::Flow - Como compor casos de uso?

Chamamos de fluxo uma composição de casos de uso. A ideia principal desse recurso é usar/reutilizar casos de uso como etapas de um novo caso de uso. Exemplo:

module Steps
  class ConvertTextToNumbers < Micro::Case
    attribute :numbers

    def call!
      if numbers.all? { |value| String(value) =~ /\d+/ }
        Success result: { numbers: numbers.map(&:to_i) }
      else
        Failure result: { message: 'numbers must contain only numeric types' }
      end
    end
  end

  class Add2 < Micro::Case::Strict
    attribute :numbers

    def call!
      Success result: { numbers: numbers.map { |number| number + 2 } }
    end
  end

  class Double < Micro::Case::Strict
    attribute :numbers

    def call!
      Success result: { numbers: numbers.map { |number| number * 2 } }
    end
  end

  class Square < Micro::Case::Strict
    attribute :numbers

    def call!
      Success result: { numbers: numbers.map { |number| number * number } }
    end
  end
end

#-----------------------------------------#
# Criando um flow com Micro::Cases.flow() #
#-----------------------------------------#

Add2ToAllNumbers = Micro::Cases.flow([
  Steps::ConvertTextToNumbers,
  Steps::Add2
])

result = Add2ToAllNumbers.call(numbers: %w[1 1 2 2 3 4])

result.success? # true
result.data    # {:numbers => [3, 3, 4, 4, 5, 6]}

#--------------------------------#
# Criando um flow usando classes #
#--------------------------------#

class DoubleAllNumbers < Micro::Case
  flow Steps::ConvertTextToNumbers,
       Steps::Double
end

DoubleAllNumbers.
  call(numbers: %w[1 1 b 2 3 4]).
  on_failure { |result| puts result[:message] } # "numbers must contain only numeric types"

Ao ocorrer uma falha, o caso de uso responsável ficará acessível no resultado. Exemplo:

result = DoubleAllNumbers.call(numbers: %w[1 1 b 2 3 4])

result.failure?                                    # true
result.use_case.is_a?(Steps::ConvertTextToNumbers) # true

result.on_failure do |_message, use_case|
  puts "#{use_case.class.name} was the use case responsible for the failure" # Steps::ConvertTextToNumbers was the use case responsible for the failure
end

⬆️ Voltar para o índice

É possível compor um fluxo com outros fluxos?

Resposta: Sim, é possível.

module Steps
  class ConvertTextToNumbers < Micro::Case
    attribute :numbers

    def call!
      if numbers.all? { |value| String(value) =~ /\d+/ }
        Success result: { numbers: numbers.map(&:to_i) }
      else
        Failure result: { message: 'numbers must contain only numeric types' }
      end
    end
  end

  class Add2 < Micro::Case::Strict
    attribute :numbers

    def call!
      Success result: { numbers: numbers.map { |number| number + 2 } }
    end
  end

  class Double < Micro::Case::Strict
    attribute :numbers

    def call!
      Success result: { numbers: numbers.map { |number| number * 2 } }
    end
  end

  class Square < Micro::Case::Strict
    attribute :numbers

    def call!
      Success result: { numbers: numbers.map { |number| number * number } }
    end
  end
end

DoubleAllNumbers =
  Micro::Cases.flow([Steps::ConvertTextToNumbers, Steps::Double])

SquareAllNumbers =
  Micro::Cases.flow([Steps::ConvertTextToNumbers, Steps::Square])

DoubleAllNumbersAndAdd2 =
  Micro::Cases.flow([DoubleAllNumbers, Steps::Add2])

SquareAllNumbersAndAdd2 =
  Micro::Cases.flow([SquareAllNumbers, Steps::Add2])

SquareAllNumbersAndDouble =
  Micro::Cases.flow([SquareAllNumbersAndAdd2, DoubleAllNumbers])

DoubleAllNumbersAndSquareAndAdd2 =
  Micro::Cases.flow([DoubleAllNumbers, SquareAllNumbersAndAdd2])

SquareAllNumbersAndDouble
  .call(numbers: %w[1 1 2 2 3 4])
  .on_success { |result| p result[:numbers] } # [6, 6, 12, 12, 22, 36]

DoubleAllNumbersAndSquareAndAdd2
  .call(numbers: %w[1 1 2 2 3 4])
  .on_success { |result| p result[:numbers] } # [6, 6, 18, 18, 38, 66]

Nota: Você pode mesclar qualquer approach para criar flows - exemplos.

⬆️ Voltar para o índice

É possível que um fluxo acumule sua entrada e mescle cada resultado de sucesso para usar como argumento dos próximos casos de uso?

Resposta: Sim, é possível! Veja o exemplo abaixo para entender como funciona o acúmulo de dados dentro da execução de um fluxo.

module Users
  class FindByEmail < Micro::Case
    attribute :email

    def call!
      user = User.find_by(email: email)

      return Success result: { user: user } if user

      Failure(:user_not_found)
    end
  end
end

module Users
  class ValidatePassword < Micro::Case::Strict
    attributes :user, :password

    def call!
      return Failure(:user_must_be_persisted) if user.new_record?
      return Failure(:wrong_password) if user.wrong_password?(password)

      return Success result: attributes(:user)
    end
  end
end

module Users
  Authenticate = Micro::Cases.flow([
    FindByEmail,
    ValidatePassword
  ])
end

Users::Authenticate
  .call(email: '[email protected]', password: 'password')
  .on_success { |result| sign_in(result[:user]) }
  .on_failure(:wrong_password) { render status: 401 }
  .on_failure(:user_not_found) { render status: 404 }

Primeiro, vamos ver os atributos usados por cada caso de uso:

class Users::FindByEmail < Micro::Case
  attribute :email
end

class Users::ValidatePassword < Micro::Case
  attributes :user, :password
end

Como você pode ver, Users::ValidatePassword espera um usuário como sua entrada. Então, como ele recebe o usuário? R: Ele recebe o usuário do resultado de sucesso Users::FindByEmail!

E este é o poder da composição de casos de uso porque o output de uma etapa irá compor a entrada do próximo caso de uso no fluxo!

input >> processamento >> output

Nota: Verifique esses exemplos de teste Micro::Cases::Flow e Micro::Cases::Safe::Flow para ver diferentes casos de uso tendo acesso aos dados de um fluxo.

⬆️ Voltar para o índice

Como entender o que aconteceu durante a execução de um flow?

Use Micro::Case::Result#transitions!

Vamos usar os exemplos da seção anterior para ilustrar como utilizar essa feature.

user_authenticated =
  Users::Authenticate.call(email: '[email protected]', password: user_password)

user_authenticated.transitions
[
  {
    :use_case => {
      :class      => Users::FindByEmail,
      :attributes => { :email => "[email protected]" }
    },
    :success => {
      :type  => :ok,
      :result => {
        :user => #<User:0x00007fb57b1c5f88 @email="[email protected]" ...>
      }
    },
    :accessible_attributes => [ :email, :password ]
  },
  {
    :use_case => {
      :class      => Users::ValidatePassword,
      :attributes => {
        :user     => #<User:0x00007fb57b1c5f88 @email="[email protected]" ...>
        :password => "123456"
      }
    },
    :success => {
      :type  => :ok,
      :result => {
        :user => #<User:0x00007fb57b1c5f88 @email="[email protected]" ...>
      }
    },
    :accessible_attributes => [ :email, :password, :user ]
  }
]

O exemplo acima mostra a saída gerada pelas Micro::Case::Result#transitions. Com ele é possível analisar a ordem de execução dos casos de uso e quais foram os inputs fornecidos ([:attributes]) e outputs ([:success][:result]) em toda a execução.

E observe a propriedade accessible_attributes, ela mostra quais atributos são acessíveis nessa etapa do fluxo. Por exemplo, na última etapa, você pode ver que os atributos accessible_attributes aumentaram devido ao acúmulo de fluxo de dados.

Nota: O Micro::Case::Result#then incrementa o Micro::Case::Result#transitions.

Micro::Case::Result#transitions schema
[
  {
    use_case: {
      class:      <Micro::Case>,# Caso de uso que será executado
      attributes: <Hash>        # (Input) Os atributos do caso de uso
    },
    [success:, failure:] => {   # (Output)
      type:  <Symbol>,          # Tipo do resultado. Padrões:
                                # Success = :ok, Failure = :error or :exception
      result: <Hash>            # Os dados retornados pelo resultado do use case
    },
    accessible_attributes: <Array>, # Propriedades que podem ser acessadas pelos atributos do caso de uso,
                                    # começando com Hash usado para invocá-lo e que são incrementados
                                    # com os valores de resultado de cada caso de uso do fluxo.
  }
]
É possível desabilitar o Micro::Case::Result#transitions?

Resposta: Sim! Você pode usar o Micro::Case.config para fazer isso. Link para essa seção.

É possível declarar um fluxo que inclui o próprio caso de uso?

Resposta: Sim! Você pode usar a macro self ou self.call!. Exemplo:

class ConvertTextToNumber < Micro::Case
  attribute :text

  def call!
    Success result: { number: text.to_i }
  end
end

class ConvertNumberToText < Micro::Case
  attribute :number

  def call!
    Success result: { text: number.to_s }
  end
end

class Double < Micro::Case
  flow ConvertTextToNumber,
       self.call!,
       ConvertNumberToText

  attribute :number

  def call!
    Success result: { number: number * 2 }
  end
end

result = Double.call(text: '4')

result.success? # true
result[:number] # "8"

Note: Essa funcionalidade pode ser usada com Micro::Case::Safe. Verifique esse teste para ver um example: https://github.com/serradura/u-case/blob/714c6b658fc6aa02617e6833ddee09eddc760f2a/test/micro/case/safe/with_inner_flow_test.rb

⬆️ Voltar para o índice

Micro::Case::Strict - O que é um caso de uso estrito?

Resposta: é um tipo de caso de uso que exigirá todas as palavras-chave (atributos) em sua inicialização.

class Double < Micro::Case::Strict
  attribute :numbers

  def call!
    Success result: { numbers: numbers.map { |number| number * 2 } }
  end
end

Double.call({})

# O output será:
# ArgumentError (missing keyword: :numbers)

⬆️ Voltar para o índice

Micro::Case::Safe - Existe algum recurso para lidar automaticamente com exceções dentro de um caso de uso ou fluxo?

Sim, assim como Micro::Case::Strict, o Micro::Case::Safe é outro tipo de caso de uso. Ele tem a capacidade de interceptar automaticamente qualquer exceção como um resultado de falha. Exemplo:

require 'logger'

AppLogger = Logger.new(STDOUT)

class Divide < Micro::Case::Safe
  attributes :a, :b

  def call!
    if a.is_a?(Integer) && b.is_a?(Integer)
      Success result: { number: a / b}
    else
      Failure(:not_an_integer)
    end
  end
end

result = Divide.call(a: 2, b: 0)
result.type == :exception                   # true
result.data                                 # { exception: #<ZeroDivisionError...> }
result[:exception].is_a?(ZeroDivisionError) # true

result.on_failure(:exception) do |result|
  AppLogger.error(result[:exception].message) # E, [2019-08-21T00:05:44.195506 #9532] ERROR -- : divided by 0
end

Se você precisar lidar com um erro específico, recomendo o uso de uma instrução case. Exemplo:

result.on_failure(:exception) do |data, use_case|
  case exception = data[:exception]
  when ZeroDivisionError then AppLogger.error(exception.message)
  else AppLogger.debug("#{use_case.class.name} was the use case responsible for the exception")
  end
end

Note: É possível resgatar uma exceção mesmo quando é um caso de uso seguro. Exemplos:

class Divide2ByArgV2 < Micro::Case::Safe
attribute :arg
def call!
Success(result: 2 / arg)
rescue => e
Failure result: e
end
end
class Divide2ByArgV3 < Micro::Case::Safe
attribute :arg
def call!
Success(result: 2 / arg)
rescue => e
Failure :foo, result: e
end
end
class GenerateZeroDivisionError < Micro::Case::Safe
attribute :arg
def call!
Failure(result: arg / 0)
rescue => e
Success(result: e)
end
end

⬆️ Voltar para o índice

Micro::Cases::Safe::Flow

Como casos de uso seguros, os fluxos seguros podem interceptar uma exceção em qualquer uma de suas etapas. Estas são as maneiras de definir um:

module Users
  Create = Micro::Cases.safe_flow([
    ProcessParams,
    ValidateParams,
    Persist,
    SendToCRM
  ])
end

Definindo dentro das classes:

module Users
  class Create < Micro::Case::Safe
    flow ProcessParams,
         ValidateParams,
         Persist,
         SendToCRM
  end
end

⬆️ Voltar para o índice

Micro::Case::Result#on_exception

Na programação funcional os erros/exceções são tratados como dados comuns, a ideia é transformar a saída mesmo quando ocorre um comportamento inesperado. Para muitos, as exceções são muito semelhantes à instrução GOTO, pulando o fluxo do programa para caminhos que podem ser difíceis de descobrir como as coisas funcionam em um sistema.

Para resolver isso, o Micro::Case::Result tem um hook especial #on_exception para ajudá-lo a lidar com o fluxo de controle no caso de exceções.

Note: essa funcionalidade funcionará melhor se for usada com um flow ou caso de uso Micro::Case::Safe.

Como ele funciona?

class Divide < Micro::Case::Safe
  attributes :a, :b

  def call!
    Success result: { division: a / b }
  end
end

Divide
  .call(a: 2, b: 0)
  .on_success { |result| puts result[:division] }
  .on_exception(TypeError) { puts 'Please, use only numeric attributes.' }
  .on_exception(ZeroDivisionError) { |_error| puts "Can't divide a number by 0." }
  .on_exception { |_error, _use_case| puts 'Oh no, something went wrong!' }

# Output:
# -------
# Can't divide a number by 0
# Oh no, something went wrong!

Divide
  .call(a: 2, b: '2')
  .on_success { |result| puts result[:division] }
  .on_exception(TypeError) { puts 'Please, use only numeric attributes.' }
  .on_exception(ZeroDivisionError) { |_error| puts "Can't divide a number by 0." }
  .on_exception { |_error, _use_case| puts 'Oh no, something went wrong!' }

# Output:
# -------
# Please, use only numeric attributes.
# Oh no, something went wrong!

Como você pode ver, este hook tem o mesmo comportamento de result.on_failure(:exception), mas, a ideia aqui é ter uma melhor comunicação no código, fazendo uma referência explícita quando alguma falha acontecer por causa de uma exceção.

⬆️ Voltar para o índice

u-case/with_activemodel_validation - Como validar os atributos do caso de uso?

Requisitos:

Para fazer isso a sua aplicação deverá ter o activemodel >= 3.2, < 6.1.0 como dependência.

Por padrão, se a sua aplicação tiver o ActiveModel como uma dependência, qualquer tipo de caso de uso pode fazer uso dele para validar seus atributos.

class Multiply < Micro::Case
  attributes :a, :b

  validates :a, :b, presence: true, numericality: true

  def call!
    return Failure :invalid_attributes, result: { errors: self.errors } if invalid?

    Success result: { number: a * b }
  end
end

Mas se você deseja uma maneira automática de falhar seus casos de uso em erros de validação, você poderá fazer:

  1. require 'u-case/with_activemodel_validation' no Gemfile
gem 'u-case', require: 'u-case/with_activemodel_validation'
  1. Usar o Micro::Case.config para habilitar ele. Link para essa seção.

Usando essa abordagem, você pode reescrever o exemplo anterior com menos código. Exemplo:

require 'u-case/with_activemodel_validation'

class Multiply < Micro::Case
  attributes :a, :b

  validates :a, :b, presence: true, numericality: true

  def call!
    Success result: { number: a * b }
  end
end

Nota: Após habilitar o modo de validação, as classes Micro::Case::Strict e Micro::Case::Safe irão herdar este novo comportamento.

Se eu habilitei a validação automática, é possível desabilitá-la apenas em casos de uso específicos?

Resposta: Sim, é possível. Para fazer isso, você só precisará usar a macro disable_auto_validation. Exemplo:

require 'u-case/with_activemodel_validation'

class Multiply < Micro::Case
  disable_auto_validation

  attribute :a
  attribute :b
  validates :a, :b, presence: true, numericality: true

  def call!
    Success result: { number: a * b }
  end
end

Multiply.call(a: 2, b: 'a')

# O output será:
# TypeError (String can't be coerced into Integer)

⬆️ Voltar para o índice

Kind::Validator

A gem kind possui um módulo para habilitar a validação do tipo de dados através do ActiveModel validations. Então, quando você fizer o require do 'u-case/with_activemodel_validation', este módulo também irá fazer o require do Kind::Validator.

O exemplo abaixo mostra como validar os tipos de atributos.

class Todo::List::AddItem < Micro::Case
  attributes :user, :params

  validates :user, kind: User
  validates :params, kind: ActionController::Parameters

  def call!
    todo_params = params.require(:todo).permit(:title, :due_at)

    todo = user.todos.create(todo_params)

    Success result: { todo: todo }
  rescue ActionController::ParameterMissing => e
    Failure :parameter_missing, result: { message: e.message }
  end
end

⬆️ Voltar para o índice

Micro::Case.config

A ideia deste recurso é permitir a configuração de algumas funcionalidades/módulos do u-case. Eu recomendo que você use apenas uma vez em sua base de código. Exemplo: Em um inicializador do Rails.

Você pode ver abaixo todas as configurações disponíveis com seus valores padrão:

Micro::Case.config do |config|
  # Use ActiveModel para auto-validar os atributos dos seus casos de uso.
  config.enable_activemodel_validation = false

  # Use para habilitar/desabilitar o `Micro::Case::Results#transitions`.
  config.enable_transitions = true
end

⬆️ Voltar para o índice

Benchmarks

Micro::Case

Success results

Gem / Abstração Iterações por segundo Comparação
Dry::Monads 315635.1 O mais rápido
Micro::Case 75837.7 4.16x mais lento
Interactor 59745.5 5.28x mais lento
Trailblazer::Operation 28423.9 11.10x mais lento
Dry::Transaction 10130.9 31.16x mais lento
Show the full benchmark/ips results.
# Warming up --------------------------------------
#           Interactor     5.711k i/100ms
# Trailblazer::Operation
#                          2.283k i/100ms
#          Dry::Monads    31.130k i/100ms
#     Dry::Transaction   994.000  i/100ms
#          Micro::Case     7.911k i/100ms
#    Micro::Case::Safe     7.911k i/100ms
#  Micro::Case::Strict     6.248k i/100ms

# Calculating -------------------------------------
#           Interactor     59.746k (±29.9%) i/s -    274.128k in   5.049901s
# Trailblazer::Operation
#                          28.424k (±15.8%) i/s -    141.546k in   5.087882s
#          Dry::Monads    315.635k (± 6.1%) i/s -      1.588M in   5.048914s
#     Dry::Transaction     10.131k (± 6.4%) i/s -     50.694k in   5.025150s
#          Micro::Case     75.838k (± 9.7%) i/s -    379.728k in   5.052573s
#    Micro::Case::Safe     75.461k (±10.1%) i/s -    379.728k in   5.079238s
#  Micro::Case::Strict     64.235k (± 9.0%) i/s -    324.896k in   5.097028s

# Comparison:
#          Dry::Monads:   315635.1 i/s
#          Micro::Case:    75837.7 i/s - 4.16x  (± 0.00) slower
#    Micro::Case::Safe:    75461.3 i/s - 4.18x  (± 0.00) slower
#  Micro::Case::Strict:    64234.9 i/s - 4.91x  (± 0.00) slower
#           Interactor:    59745.5 i/s - 5.28x  (± 0.00) slower
# Trailblazer::Operation:    28423.9 i/s - 11.10x  (± 0.00) slower
#     Dry::Transaction:    10130.9 i/s - 31.16x  (± 0.00) slower

https://github.com/serradura/u-case/blob/main/benchmarks/perfomance/use_case/success_results.

Failure results

Gem / Abstração Iterações por segundo Comparação
Dry::Monads 135386.9 O mais rápido
Micro::Case 73489.3 1.85x mais lento
Trailblazer::Operation 29016.4 4.67x mais lento
Interactor 27037.0 5.01x mais lento
Dry::Transaction 8988.6 15.06x mais lento
Mostrar o resultado completo do benchmark/ips.
# Warming up --------------------------------------
#           Interactor     2.626k i/100ms
# Trailblazer::Operation   2.343k i/100ms
#          Dry::Monads    13.386k i/100ms
#     Dry::Transaction   868.000  i/100ms
#          Micro::Case     7.603k i/100ms
#    Micro::Case::Safe     7.598k i/100ms
#  Micro::Case::Strict     6.178k i/100ms

# Calculating -------------------------------------
#           Interactor     27.037k (±24.9%) i/s -    128.674k in   5.102133s
# Trailblazer::Operation   29.016k (±12.4%) i/s -    145.266k in   5.074991s
#          Dry::Monads    135.387k (±15.1%) i/s -    669.300k in   5.055356s
#     Dry::Transaction      8.989k (± 9.2%) i/s -     45.136k in   5.084820s
#          Micro::Case     73.247k (± 9.9%) i/s -    364.944k in   5.030449s
#    Micro::Case::Safe     73.489k (± 9.6%) i/s -    364.704k in   5.007282s
#  Micro::Case::Strict     61.980k (± 8.0%) i/s -    308.900k in   5.014821s

# Comparison:
#          Dry::Monads:   135386.9 i/s
#    Micro::Case::Safe:    73489.3 i/s - 1.84x  (± 0.00) slower
#          Micro::Case:    73246.6 i/s - 1.85x  (± 0.00) slower
#  Micro::Case::Strict:    61979.7 i/s - 2.18x  (± 0.00) slower
# Trailblazer::Operation:    29016.4 i/s - 4.67x  (± 0.00) slower
#           Interactor:    27037.0 i/s - 5.01x  (± 0.00) slower
#     Dry::Transaction:     8988.6 i/s - 15.06x  (± 0.00) slower

https://github.com/serradura/u-case/blob/main/benchmarks/perfomance/use_case/failure_results.


Micro::Cases::Flow

Gem / Abstração Resultados de sucesso Resultados de falha
Micro::Case::Result pipe method 80936.2 i/s 78280.4 i/s
Micro::Case::Result then method 0x mais lento 0x mais lento
Micro::Cases.flow 0x mais lento 0x mais lento
Micro::Case class with an inner flow 1.72x mais lento 1.68x mais lento
Micro::Case class including itself as a step 1.93x mais lento 1.87x mais lento
Interactor::Organizer 3.33x mais lento 3.22x mais lento

* As gems Dry::Monads, Dry::Transaction, Trailblazer::Operation estão fora desta análise por não terem esse tipo de funcionalidade.

Resultados de sucesso - Mostrar o resultado completo do benchmark/ips.
# Warming up --------------------------------------
# Interactor::Organizer             1.809k i/100ms
# Micro::Cases.flow([])             7.808k i/100ms
# Micro::Case flow in a class       4.816k i/100ms
# Micro::Case including the class   4.094k i/100ms
# Micro::Case::Result#|             7.656k i/100ms
# Micro::Case::Result#then          7.138k i/100ms

# Calculating -------------------------------------
# Interactor::Organizer             24.290k (±24.0%) i/s -    113.967k in   5.032825s
# Micro::Cases.flow([])             74.790k (±11.1%) i/s -    374.784k in   5.071740s
# Micro::Case flow in a class       47.043k (± 8.0%) i/s -    235.984k in   5.047477s
# Micro::Case including the class   42.030k (± 8.5%) i/s -    208.794k in   5.002138s
# Micro::Case::Result#|             80.936k (±15.9%) i/s -    398.112k in   5.052531s
# Micro::Case::Result#then          71.459k (± 8.8%) i/s -    356.900k in   5.030526s

# Comparison:
# Micro::Case::Result#|:            80936.2 i/s
# Micro::Cases.flow([]):            74790.1 i/s - same-ish: difference falls within error
# Micro::Case::Result#then:         71459.5 i/s - same-ish: difference falls within error
# Micro::Case flow in a class:      47042.6 i/s - 1.72x  (± 0.00) slower
# Micro::Case including the class:  42030.2 i/s - 1.93x  (± 0.00) slower
# Interactor::Organizer:            24290.3 i/s - 3.33x  (± 0.00) slower
Resultados de falha - Mostrar o resultado completo do benchmark/ips.
# Warming up --------------------------------------
# Interactor::Organizer            1.734k i/100ms
# Micro::Cases.flow([])            7.515k i/100ms
# Micro::Case flow in a class      4.636k i/100ms
# Micro::Case including the class  4.114k i/100ms
# Micro::Case::Result#|            7.588k i/100ms
# Micro::Case::Result#then         6.681k i/100ms

# Calculating -------------------------------------
# Interactor::Organizer            24.280k (±24.5%) i/s -    112.710k in   5.013334s
# Micro::Cases.flow([])            74.999k (± 9.8%) i/s -    375.750k in   5.055777s
# Micro::Case flow in a class      46.681k (± 9.3%) i/s -    236.436k in   5.105105s
# Micro::Case including the class  41.921k (± 8.9%) i/s -    209.814k in   5.043622s
# Micro::Case::Result#|            78.280k (±12.6%) i/s -    386.988k in   5.022146s
# Micro::Case::Result#then         68.898k (± 8.8%) i/s -    347.412k in   5.080116s

# Comparison:
# Micro::Case::Result#|:            78280.4 i/s
# Micro::Cases.flow([]):            74999.4 i/s - same-ish: difference falls within error
# Micro::Case::Result#then:         68898.4 i/s - same-ish: difference falls within error
# Micro::Case flow in a class:      46681.0 i/s - 1.68x  (± 0.00) slower
# Micro::Case including the class:  41920.8 i/s - 1.87x  (± 0.00) slower
# Interactor::Organizer:            24280.0 i/s - 3.22x  (± 0.00) slower

https://github.com/serradura/u-case/blob/main/benchmarks/perfomance/flow/

⬆️ Voltar para o índice

Execuntando os benchmarks

Performance (Benchmarks IPS)

Clone este repositório e acesse a sua pasta, então execute os comandos abaixo:

Casos de uso

ruby benchmarks/perfomance/use_case/failure_results.rb
ruby benchmarks/perfomance/use_case/success_results.rb

Flows

ruby benchmarks/perfomance/flow/failure_results.rb
ruby benchmarks/perfomance/flow/success_results.rb

Memory profiling

Casos de uso

./benchmarks/memory/use_case/success/with_transitions/analyze.sh
./benchmarks/memory/use_case/success/without_transitions/analyze.sh

Flows

./benchmarks/memory/flow/success/with_transitions/analyze.sh
./benchmarks/memory/flow/success/without_transitions/analyze.sh

⬆️ Voltar para o índice

Comparações

Confira as implementações do mesmo caso de uso com diferentes gems/abstrações.

⬆️ Voltar para o índice

Exemplos

1️⃣ Criação de usuários

Um exemplo de fluxo que define etapas para higienizar, validar e persistir seus dados de entrada. Ele tem todas as abordagens possíveis para representar casos de uso com a gem u-case.

Link: https://github.com/serradura/u-case/blob/main/examples/users_creation

2️⃣ Rails App (API)

Este projeto mostra diferentes tipos de arquitetura (uma por commit), e na última, como usar a gem Micro::Case para lidar com a lógica de negócios da aplicação.

Link: https://github.com/serradura/from-fat-controllers-to-use-cases

3️⃣ CLI calculator

Rake tasks para demonstrar como lidar com os dados do usuário e como usar diferentes tipos de falha para controlar o fluxo do programa.

Link: https://github.com/serradura/u-case/tree/main/examples/calculator

4️⃣ Interceptando exceções dentro dos casos de uso

Link: https://github.com/serradura/u-case/blob/main/examples/rescuing_exceptions.rb

⬆️ Voltar para o índice

Desenvolvimento

Após fazer o checking out do repo, execute bin/setup para instalar dependências. Então, execute ./test.sh para executar os testes. Você pode executar bin/console para ter um prompt interativo que permitirá você experimenta-lá.

Para instalar esta gem em sua máquina local, execute bundle exec rake install. Para lançar uma nova versão, atualize o número da versão em version.rb e execute bundle exec rake release, que criará uma tag git para a versão, enviará git commits e tags e enviará o arquivo .gempara rubygems.org.

Contribuindo

Reportar bugs e solicitar pull requests são bem-vindos no GitHub em https://github.com/serradura/u-case. Este projeto pretende ser um espaço seguro e acolhedor para colaboração, e espera-se que os colaboradores sigam o código de conduta do Covenant do Contribuidor.

Licença

A gem está disponível como código aberto nos termos da licença MIT.

Código de conduta

Espera-se que todos que interagem com o codebase do projeto Micro::Case, issue trackers, chat rooms and mailing lists sigam o código de conduta.