Code Monkey home page Code Monkey logo

u-case's Introduction

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

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


Ruby Gem Build Status Maintainability Test Coverage

The main project goals are:

  1. Easy to use and easy to learn (input >> process >> output).
  2. Promote immutability (transforming data instead of modifying it) and data integrity.
  3. No callbacks (ex: before, after, around) to avoid code indirections that could compromise the state and understanding of application flows.
  4. Solve complex business logic, by allowing the composition of use cases (flow creation).
  5. Be fast and optimized (Check out the benchmarks section).

Note: Check out the repo https://github.com/serradura/from-fat-controllers-to-use-cases to see a Rails application that uses this gem to handle its business logic.

Documentation

Version Documentation
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

Note: Você entende português? 🇧🇷 🇵🇹 Verifique o README traduzido em pt-BR.

Table of Contents

Compatibility

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

Note: The activemodel is an optional dependency, this module can be enabled to validate the use cases' attributes.

Dependencies

  1. kind gem.

    A simple type system (at runtime) for Ruby.

    It is used to validate some internal u-case's methods input. This gem also exposes an ActiveModel validator when requiring the u-case/with_activemodel_validation module, or when the Micro::Case.config was used to enable it.

  2. u-attributes gem.

    This gem allows defining read-only attributes, that is, your objects will have only getters to access their attributes data. It is used to define the use case attributes.

Installation

Add this line to your application's Gemfile:

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

And then execute:

$ bundle

Or install it yourself as:

$ gem install u-case

Usage

Micro::Case - How to define a use case?

class Multiply < Micro::Case
  # 1. Define its input as attributes
  attributes :a, :b

  # 2. Define the method `call!` with its business logic
  def call!

    # 3. Wrap the use case output using the `Success(result: *)` or `Failure(result: *)` methods
    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

#========================#
# Performing an use case #
#========================#

# Success result

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

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

# Failure result

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

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

# Note:
# ----
# The result of a Micro::Case.call is an instance of Micro::Case::Result

⬆️ Back to Top

Micro::Case::Result - What is a use case result?

A Micro::Case::Result stores the use cases output data. These are their main methods:

  • #success? returns true if is a successful result.
  • #failure? returns true if is an unsuccessful result.
  • #use_case returns the use case responsible for it. This feature is handy to handle a flow failure (this topic will be covered ahead).
  • #type a Symbol which gives meaning for the result, this is useful to declare different types of failures or success.
  • #data the result data itself.
  • #[] and #values_at are shortcuts to access the #data values.
  • #key? returns true if the key is present in #data.
  • #value? returns true if the given value is present in #data.
  • #slice returns a new hash that includes only the given keys. If the given keys don't exist, an empty hash is returned.
  • #on_success or #on_failure are hook methods that help you to define the application flow.
  • #then this method will allow applying a new use case if the current result was a success. The idea of this feature is to allow the creation of dynamic flows.
  • #transitions returns an array with all of transformations wich a result has during a flow.

Note: for backward compatibility, you could use the #value method as an alias of #data method.

⬆️ Back to Top

What are the default result types?

Every result has a type, and these are their default values:

  • :ok when success
  • :error or :exception when failures
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

# Success result

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

# Failure result (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=...>

# Failure result (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>

# Note:
# ----
# Any Exception instance which is wrapped by
# the Failure(result: *) method will receive `:exception` instead of the `:error` type.

⬆️ Back to Top

How to define custom result types?

Answer: Use a symbol as the argument of Success(), Failure() methods and declare the result: keyword to set the result data.

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

# Success result

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

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

# Failure result

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

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

⬆️ Back to Top

Is it possible to define a custom type without a result data?

Answer: Yes, it is possible. But this will have special behavior because the result data will be a hash with the given type as the key and true as its value.

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"}

# Note:
# ----
# This feature is handy to handle failures in a flow
# (this topic will be covered ahead).

⬆️ Back to Top

How to use the result hooks?

As mentioned earlier, the Micro::Case::Result has two methods to improve the application flow control. They are: #on_success, on_failure.

The examples below show how to use them:

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

#================================#
# Printing the output if success #
#================================#

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] }

# The output will be:
#   6

#=============================#
# Raising an error if failure #
#=============================#

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] }

# The outputs will be:
#
# 1. It will print the message: Double was the use case responsible for the failure
# 2. It will raise the exception: ArgumentError (the number must be greater than 0)

# Note:
# ----
# The use case responsible for the result will always be accessible as the second hook argument

Why the hook usage without a defined type exposes the result itself?

Answer: To allow you to define how to handle the program flow using some conditional statement like an if or 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

# The output will be an exception:
#
# ArgumentError (number `-1` must be greater than 0)

Note: The same that was did in the previous examples could be done with #on_success hook!

Using decomposition to access the result data and type

The syntax to decompose an Array can be used in assignments and in method/block arguments. If you doesn't know it, check out the Ruby doc.

# The object exposed in the hook without a type is a Micro::Case::Result and it can be decomposed. e.g:

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

# The output will be the exception:
#
# ArgumentError (the number `-2` must be greater than 0)

Note: The same that was did in the previous examples could be done with #on_success hook!

⬆️ Back to Top

What happens if a result hook was declared multiple times?

Answer: The hook always will be triggered if it matches the result type.

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

How to use the Micro::Case::Result#then method?

This method allows you to create dynamic flows, so, with it, you can add new use cases or flows to continue the result transformation. e.g:

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

Note: this method changes the Micro::Case::Result#transitions.

⬆️ Back to Top

What does happens when a Micro::Case::Result#then receives a block?

It will yields self (a Micro::Case::Result instance) to the block, and will return the output of the block instead of itself. 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

⬆️ Back to Top

How to make attributes data injection using this feature?

Pass a Hash as the second argument of the Micro::Case::Result#then method.

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]) }

⬆️ Back to Top

Micro::Cases::Flow - How to compose use cases?

We call as flow a composition of use cases. The main idea of this feature is to use/reuse use cases as steps of a new use case. e.g.

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

#-------------------------------------------#
# Creating a flow using 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]}

#-------------------------------#
# Creating a flow using 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"

When happening a failure, the use case responsible will be accessible in the result.

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

⬆️ Back to Top

Is it possible to compose a flow with other flows?

Answer: Yes, it is possible.

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]

Note: You can blend any approach to create use case flows - examples.

⬆️ Back to Top

Is it possible a flow accumulates its input and merges each success result to use as the argument of the next use cases?

Answer: Yes, it is possible! Look at the example below to understand how the data accumulation works inside of a flow execution.

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 }

First, let's see the attributes used by each use case:

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

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

As you can see the Users::ValidatePassword expects a user as its input. So, how does it receives the user? Answer: It receives the user from the Users::FindByEmail success result!

And this is the power of use cases composition because the output of one step will compose the input of the next use case in the flow!

input >> process >> output

Note: Check out these test examples Micro::Cases::Flow and Micro::Cases::Safe::Flow to see different use cases having access to the data in a flow.

⬆️ Back to Top

How to understand what is happening during a flow execution?

Use Micro::Case::Result#transitions!

Let's use the previous section example to ilustrate how to use this 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 ]
  }
]

The example above shows the output generated by the Micro::Case::Result#transitions. With it is possible to analyze the use cases' execution order and what were the given inputs ([:attributes]) and outputs ([:success][:result]) in the entire execution.

And look up the accessible_attributes property, it shows whats attributes are accessible in that flow step. For example, in the last step, you can see that the accessible_attributes increased because of the data flow accumulation.

Note: The Micro::Case::Result#then increments the Micro::Case::Result#transitions.

Micro::Case::Result#transitions schema
[
  {
    use_case: {
      class:      <Micro::Case>,# Use case which was executed
      attributes: <Hash>        # (Input) The use case's attributes
    },
    [success:, failure:] => {   # (Output)
      type:  <Symbol>,          # Result type. Defaults:
                                # Success = :ok, Failure = :error/:exception
      result: <Hash>            # The data returned by the use case result
    },
    accessible_attributes: <Array>, # Properties that can be accessed by the use case's attributes,
                                    # it starts with Hash used to invoke it and that will be incremented
                                    # with the result values of each use case in the flow.
  }
]
Is it possible disable the Micro::Case::Result#transitions?

Answer: Yes, it is! You can use the Micro::Case.config to do this. Link to this section.

Is it possible to declare a flow that includes the use case itself as a step?

Answer: Yes, it is! You can use self or the self.call! macro. e.g:

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: This feature can be used with the Micro::Case::Safe. Checkout this test to see an example: https://github.com/serradura/u-case/blob/714c6b658fc6aa02617e6833ddee09eddc760f2a/test/micro/case/safe/with_inner_flow_test.rb

⬆️ Back to Top

Micro::Case::Strict - What is a strict use case?

Answer: it is a kind of use case that will require all the keywords (attributes) on its initialization.

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

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

Double.call({})

# The output will be:
# ArgumentError (missing keyword: :numbers)

⬆️ Back to Top

Micro::Case::Safe - Is there some feature to auto handle exceptions inside of a use case or flow?

Yes, there is one! Like Micro::Case::Strict the Micro::Case::Safe is another kind of use case. It has the ability to auto intercept any exception as a failure result. e.g:

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

If you need to handle a specific error, I recommend the usage of a case statement. e,g:

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: It is possible to rescue an exception even when is a safe use case. Examples:

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

⬆️ Back to Top

Micro::Cases::Safe::Flow

As the safe use cases, safe flows can intercept an exception in any of its steps. These are the ways to define one:

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

Defining within classes:

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

⬆️ Back to Top

Micro::Case::Result#on_exception

In functional programming errors/exceptions are handled as regular data, the idea is to transform the output even when it happens an unexpected behavior. For many, exceptions are very similar to the GOTO statement, jumping the application flow to paths which could be difficult to figure out how things work in a system.

To address this the Micro::Case::Result has a special hook #on_exception to helping you to handle the control flow in the case of exceptions.

Note: this feature will work better if you use it with a Micro::Case::Safe flow or use case.

How does it work?

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!

As you can see, this hook has the same behavior of result.on_failure(:exception), but, the idea here is to have a better communication in the code, making an explicit reference when some failure happened because of an exception.

⬆️ Back to Top

u-case/with_activemodel_validation - How to validate the use case attributes?

Requirement:

To do this your application must have the activemodel >= 3.2, < 6.1.0 as a dependency.

By default, if your application has ActiveModel as a dependency, any kind of use case can make use of it to validate its attributes.

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

But if do you want an automatic way to fail your use cases on validation errors, you could do:

  1. require 'u-case/with_activemodel_validation' in the Gemfile
gem 'u-case', require: 'u-case/with_activemodel_validation'
  1. Use the Micro::Case.config to enable it. Link to this section.

Using this approach, you can rewrite the previous example with less code. e.g:

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

Note: After requiring the validation mode, the Micro::Case::Strict and Micro::Case::Safe classes will inherit this new behavior.

If I enabled the auto validation, is it possible to disable it only in specific use cases?

Answer: Yes, it is possible. To do this, you will need to use the disable_auto_validation macro. e.g:

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

# The output will be:
# TypeError (String can't be coerced into Integer)

⬆️ Back to Top

Kind::Validator

The kind gem has a module to enable the validation of data type through ActiveModel validations. So, when you require the 'u-case/with_activemodel_validation', this module will also require the Kind::Validator.

The example below shows how to validate the attributes types.

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

⬆️ Back to Top

Micro::Case.config

The idea of this resource is to allow the configuration of some u-case features/modules. I recommend you use it only once in your codebase. e.g. In a Rails initializer.

You can see below, which are the available configurations with their default values:

Micro::Case.config do |config|
  # Use ActiveModel to auto-validate your use cases' attributes.
  config.enable_activemodel_validation = false

  # Use to enable/disable the `Micro::Case::Results#transitions`.
  config.enable_transitions = true
end

⬆️ Back to Top

Benchmarks

Micro::Case

Success results

Gem / Abstraction Iterations per second Comparison
Dry::Monads 315635.1 The Fastest
Micro::Case 75837.7 4.16x slower
Interactor 59745.5 5.28x slower
Trailblazer::Operation 28423.9 11.10x slower
Dry::Transaction 10130.9 31.16x slower
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.rb

Failure results

Gem / Abstraction Iterations per second Comparison
Dry::Monads 135386.9 The Fastest
Micro::Case 73489.3 1.85x slower
Trailblazer::Operation 29016.4 4.67x slower
Interactor 27037.0 5.01x slower
Dry::Transaction 8988.6 15.06x slower
Show the full benchmark/ips results.
# 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.rb


Micro::Cases::Flow

Gems / Abstraction Success results Failure results
Micro::Case::Result pipe method 80936.2 i/s 78280.4 i/s
Micro::Case::Result then method 0x slower 0x slower
Micro::Cases.flow 0x slower 0x slower
Micro::Case class with an inner flow 1.72x slower 1.68x slower
Micro::Case class including itself as a step 1.93x slower 1.87x slower
Interactor::Organizer 3.33x slower 3.22x slower

* The Dry::Monads, Dry::Transaction, Trailblazer::Operation gems are out of this analysis because all of them doesn't have this kind of feature.

Success results - Show the full benchmark/ips results.
# 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
Failure results - Show the full benchmark/ips results.
# 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/

⬆️ Back to Top

Running the benchmarks

Performance (Benchmarks IPS)

Clone this repo and access its folder, then run the commands below:

Use cases

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

Use cases

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

⬆️ Back to Top

Comparisons

Check it out implementations of the same use case with different gems/abstractions.

⬆️ Back to Top

Examples

1️⃣ Users creation

An example of a flow that defines steps to sanitize, validate, and persist its input data. It has all possible approaches to represent use cases using the u-case gem.

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

2️⃣ Rails App (API)

This project shows different kinds of architecture (one per commit), and in the last one, how to use the Micro::Case gem to handle the application business logic.

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

3️⃣ CLI calculator

Rake tasks to demonstrate how to handle user data, and how to use different failure types to control the program flow.

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

4️⃣ Rescuing exceptions inside of the use cases

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

⬆️ Back to Top

Development

After checking out the repo, run bin/setup to install dependencies. Then, run ./test.sh to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/serradura/u-case. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.

License

The gem is available as open source under the terms of the MIT License.

Code of Conduct

Everyone interacting in the Micro::Case project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.

u-case's People

Contributors

agramms avatar brunolarouche avatar linqueta avatar luong-komorebi avatar matheusrich avatar mfbmina avatar mrbongiolo avatar rodrigomanhaes avatar serradura avatar tiagofsilva avatar tomascco avatar willdowglas avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

u-case's Issues

Raise/Rescue blocks

Is it right that calling Success(value) in fact throws a standard error?

I have something like this, which hits the recsue block with an empty Standard error.

def call!
  if a.positive?
    Success(true)
  else
    Failure (false)
  end
rescue => e
  puts e # => This is unexpectedly reached
end

[pt-BR] Aprimorar documentação

https://github.com/serradura/u-case/blob/main/README.pt-BR.md

3.0.0

Atualizar README.pt-BR.md

  • Adicionar uma boa introdução (Getting started).
  • Adicionar algumas recomendações de uso (approach: make it work, right and fast).

Adicionar exemplos sobre os recursos:

  • Micro::Case.then e Micro::Cases::Flow#then e do Micro::Case::Result#then recebendo um flow. #63.
    A composição está a um passo de qualquer ponto. By @josuetex

  • Casos de uso com etapas internas usando seus métodos privados. Com lambdas #56 e methods #61
    • Adicionar exemplos que fazem uso de keywords args quando o thenou | recebem uma instância de method. #68, #69
    • Adicionar mais testes para este recurso + combinado com outros. Exemplo: flow, safe_flow, inner flow, outros tipos de casos/fluxos usando um caso com etapas internas em suas etapas.

Outros docs

3.1.0

  • Atualizar o README com as adições #77, #72
  • Atualizar o Changelog com as adições #77, #72

4.0.0

  • Atualizar o README com as adições #29
  • Atualizar o Changelog com as adições #29

4.1.0

https://github.com/serradura/u-case/pulls?q=is%3Aclosed+is%3Apr+milestone%3A4.1.0

  • Atualizar o README com as adições
  • Atualizar o Changelog com as adições

4.2.0

Recomendação para colaboradores

Usem Update pt-BR docs nas mensagens de commit. 😉

Add a feature toggle to disable all of the verifications to optimize the performance in production

There are several checkers inside of the code to ensure a better experience in development and guide the developer to avoid predictable mistakes. So, the idea is to provide a global feature toggle like Micro::Case::Result.disable_transition_tracking to improve the gem's performance (e.g: disable theses checkers in production).

Thanks, @MatheusRich for asking me about the existence of this feature.

DRAFT: Plans for the next major version (v5)

Table of contents:

[Change] Drop support for older Ruby versions

Ruby version: >= 2.5.0

[Change] Drop support for older Rails versions

Rails version: >= 5.2.0

[Change] Allow only two ways to declare flows

Until v4, there were different ways to declare flows, but in the v5, there will be only two:

Micro::Case

class SumPositiveNumbers < Micro::Case
  def call!
    call(FilterPositiveNumbers)
      .then(SumAllNumbers)
  end
end

# or

FilterPositiveNumbers
  .call(numbers: numbers)
  .then(SumAllNumbers)
Removed Alternative
SumPositiveNumbers = Micro::Cases.flow([
  FilterPositiveNumbers,
  SumAllNumbers
])
class SumPositiveNumbers < Micro::Case
  def call!
    call(FilterPositiveNumbers)
      .then(SumAllNumbers)
  end
end

# or

FilterPositiveNumbers
  .call(numbers: numbers)
  .then(SumAllNumbers)
Removed Alternative
class SumPositiveNumbers < Micro::Case
  flow([
    FilterPositiveNumbers,
    SumAllNumbers
  ])
end
class SumPositiveNumbers < Micro::Case
  def call!
    call(FilterPositiveNumbers)
      .then(SumAllNumbers)
  end
end

# or

FilterPositiveNumbers
  .call(numbers: numbers)
  .then(SumAllNumbers)
Removed Alternative
class SumPositiveNumbers < Micro::Case
  flow([
    FilterPositiveNumbers,
    self.call!
  ])

  attributes :numbers

  def call!
    Success result: { number: numbers.sum }
  end
end
class SumPositiveNumbers < Micro::Case
  def call!
    call(FilterPositiveNumbers)
      .then(:sum_all_numbers)
  end

  private

  def sum_all_numbers(numbers:, **)
    Success result: { number: numbers.sum }
  end
end

Micro::Case::Safe

class SumPositiveNumbers < Micro::Case::Safe
  def call!
    call(FilterPositiveNumbers)
      .then(SumAllNumbers)
  end
end

# or

FilterPositiveNumbers
  .call(numbers: numbers)
  .then(SumAllNumbers)
Removed Alternative
SumPositiveNumbers = Micro::Cases.safe_flow([
  FilterPositiveNumbers,
  SumAllNumbers
])
class SumPositiveNumbers < Micro::Case::Safe
  def call!
    call(FilterPositiveNumbers)
      .then(SumAllNumbers)
  end
end

# or

FilterPositiveNumbers
  .call(numbers: numbers)
  .then(SumAllNumbers)
Removed Alternative
class SumPositiveNumbers < Micro::Case::Safe
  flow([
    FilterPositiveNumbers,
    SumAllNumbers
  ])
end
class SumPositiveNumbers < Micro::Case::Safe
  def call!
    call(FilterPositiveNumbers)
      .then(SumAllNumbers)
  end
end

# or

FilterPositiveNumbers
  .call(numbers: numbers)
  .then(SumAllNumbers)
Removed Alternative
class SumPositiveNumbers < Micro::Case::Safe
  flow([
    FilterPositiveNumbers,
    self.call!
  ])

  attributes :numbers

  def call!
    Success result: { number: numbers.sum }
  end
end
class SumPositiveNumbers < Micro::Case::Safe
  def call!
    call(FilterPositiveNumbers)
      .then(:sum_all_numbers)
  end

  private

  def sum_all_numbers(numbers:, **)
    Success result: { number: numbers.sum }
  end
end

[REMOVED] Micro::Case::Strict

Use the required: true option in all of the attributes.

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

  def call!
    doubled = numbers.map { _1 * 2 }

    Success result: { numbers: doubled }
  end
end

Double.call({})
# The output will be:
# ArgumentError (missing keyword: :numbers)
class Double < Micro::Case
  attribute :numbers, required: true

  def call!
    doubled = numbers.map { _1 * 2 }

    Success result: { numbers: doubled }
  end
end

Double.call({})
# The output will be:
# ArgumentError (missing keyword: :numbers)

[NEW] Allow instantiation with dependencies

class Divide < Micro::Case
  dependency :logger, kind: { respond_to: :error }

  attribute :a, kind: Numeric
  attribute :b, kind: Numeric

  def call!
    number = a / b

    Success result: { number: number }
  rescue ZeroDivisionError => exception
    logger.error(exception.message)

    Failure(:zero_division)
  end
end

divide = Divide.new(logger: Logger.new(STDOUT))

divide.call(a: 2, b: 0)

############################################
# Dependencies will be required by default #
############################################

Divide.new
# The output will be:
# ArgumentError (missing keyword: :logger)

# The definition of an attribute default will avoid this error.

[NEW] This change will allow the mock of internal steps:

module User::Register
  class Flow < Micro::Case
    def call!
      call(Step::ValidateAttributes)
        .then(Step::CreateRecord)
    end
  end
end

# Rspec

result = Micro::Case::Result::Success.new(data: {user: User.new})

register_user = User::Register::Flow.new

expect(register_user)
  .to receive(:then).with(Step::CreateRecord)
  .and_return(result)

Mocks will also work with the u-case call method.

result = Micro::Case::Result::Failure.new(type: :invalid_attributes)

register_user = User::Register::Flow.new

expect(register_user)
  .to receive(call).with(Step::ValidateAttributes)
  .and_return(result)

[NEW] Add support for pattern matching (Ruby >= 2.7) in the Micro::Case::Result

case result
in {success: _, data: {number: number}}
  # ...
in {failure: :invalid_attributes}
  # ...
end

[DEPRECATED] Use the method method and it's alias apply in the step's declaration.

Deprecated Alternative
class SumPositiveNumbers < Micro::Case
  attribute :numbers

  def call!
    filter_positive_numbers
      .then(method(:sum_all_numbers))
  end

  private

    def filter_positive_numbers
      # ..
    end

    def sum_all_numbers(numbers: **)
      # ...
    end
end
class SumPositiveNumbers < Micro::Case
  attribute :numbers

  def call!
    filter_positive_numbers
      .then(:sum_all_numbers)
  end

  private

    def filter_positive_numbers
      # ..
    end

    def sum_all_numbers(numbers: **)
      # ...
    end
end
Deprecated Alternative
class SumPositiveNumbers < Micro::Case
  attribute :numbers

  def call!
    filter_positive_numbers
      .then(apply(:sum_all_numbers))
  end

  private

    def filter_positive_numbers
      # ..
    end

    def sum_all_numbers(numbers: **)
      # ...
    end
end
class SumPositiveNumbers < Micro::Case
  attribute :numbers

  def call!
    filter_positive_numbers
      .then(:sum_all_numbers)
  end

  private

    def filter_positive_numbers
      # ..
    end

    def sum_all_numbers(numbers: **)
      # ...
    end
end

Add new Micro::Case::Config (enable_attributes_accept = true)

Configurations:

Micro::Case.config do |config|
  config.enable_attributes_accept = true

  config.enable_activemodel_validation = true
end

Attributes changes:

attribute :first_name, accept: String, 
                      default: -> value { value.try(:strip) },
                      validates: { length: { maximum: 30 } }

# ---

attribute :first_name, accept: String, 
                      default: Kind::Try(:strip), # Kind::Try will be available as soon as possible
                      validates: { length: { maximum: 30 } }

The execution order will be:

  1. u-attributes: Fetch the default value
  2. u-attributes: Validate using the accept/reject (options serradura/u-attributes#8)
  3. u-case: Valide using ActiveModel::Validation if the u-attributes validation was ok.

Class/Case with an inner flow has a strange behavior when it receives an array of cases

This is not a real bug, because I never documented/tested this feature. However, I tried this (array with cases) and I could see this strange behavior.

class MyCaseB < Micro::Case
  def call!; Success(); end
end

class MyCaseAandB < Micro::Case
  flow [self, MyCaseB] # has a strange behavior, to fix it you need to remove the array. eg: flow(self, MyCaseB)

  def call!; Success(); end
end

Micro::Case::Result#on - Enable pattern matching of result type and data for Ruby >= 2.2.0

result.on(
  success: { 
    greet: -> (first, last) { puts "Hi #{first} #{last}!" },
    print_name: -> (last, first) { puts "#{last}, #{first}!" }
  }
)

Original POC:

https://gist.github.com/serradura/499e403e64d53dfcb8c891dfff594e20

ezgif com-video-to-gif

def on(arg, spec)
  track, result = arg.to_a[0]
  type, data = result.to_a[0]

  hook = spec.fetch(track, {})[type]

  return unless hook

  keys = hook.parameters.map(&:last)

  keys.empty? ? hook.call : hook.call(*data.values_at(*keys))
end

result = { success: { print_name: { first: 'Rodrigo', last: 'Serradura' } } }

on(
  result,
  success: { 
    greet: -> (first, last) { puts "Hi #{first} #{last}!" },
    print_name: -> (last, first) { puts "#{last}, #{first}!" }
  }
)

Micro::Case contracts

Micro::Case contracts

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

  results do |on|
    on.failure(:attributes_must_be_numbers)
    on.failure(:division_by_zero)

    on.success(result: [:division])
  end

  def call!
    return Failure(:attributes_must_be_numbers) unless Kind.of?(Numeric, a, b)

    return Failure(:division_by_zero) if b == 0

    Success result: { division: a / b }
  end
end

Add more methods to Micro::Case::Result quack like a Hash

Definition of Done:

Add these new methods into Micro::Case::Result:

Documentation

Look here to see the implementation of the methods #[] and #values_at().

Below are the places to put the tests to assert these new methods:

Because of the Hash#slice method does not exists in older Ruby versions, we will need to create an util to do this inside of Micro::Case::Result#slice. e.g:

module Micro
  class Case
    module Utils
      def self.slice_hash(hash, keys)
        if Kind::Of::Hash(hash).respond_to?(:slice)
          hash.slice(*keys)
        else
          hash.select { |key, _value| keys.include?(key) }
        end
      end
    end
  end
end

QUESTION: Navigating a flow

@serradura fantastic tool! I'm experimenting while still acclimating to all of the features offered. But it appears we have a good fit for it in our project.

Our application has a decision engine where a decision == a flow. in the diagram below we have a decision with various rules == cases. On Success we persist the decision result. On Failure we move on to the next rule or ultimately move on to the next decision.

The chart below is how we have it modeled. The Failure path goes from one rule to the next if a given rule evaluates false. However, when I check Flow.call.success?, the flow stops if the first rule is a failure and does not move on to the next rule.

Screen Shot 2020-12-07 at 11 01 53 AM

We get this to work by inverting the Failure/Success. In other words, a rule's success path navigates to the next rule and so on. While we can accommodate, it seems like we're missing something in our understanding of the DSL.

All of the examples show a chain of Success. Our use case is one that needs to stop at the rule in the flow where the Success occurs and continue through other rules if a rule returns Failure.

We feel like we're missing something obvious. We would be glad to contribute to the documentation once we're able to implement. Any clarity you can provide would be most helpful.

Allow dependency injection in static flows

The u-case allows the usage of dependency injection via the #then method, its method is used to declare dynamic flows.

Group::FindAllForUser
  .call(user: current_user, params: params)
  .then(Paginate)
  .then(Serialize::PaginatedRelationAsJson, serializer: Group::Serialize)

e.g: # https://github.com/EliezerSalvato/financial_control/blob/7c9e3d4547d58b307109c6adf0634869b76d5c89/app/controllers/api/v1/groups_controller.rb#L3-L7

The idea of this issue is to propose the creation of a mechanism to allow dependency injection in static flows. Like these examples:

FetchListAsJson = Micro::Cases.flow([
  Group::FindAllForUser,
  Paginate,
  [Serialize::PaginatedRelationAsJson, serializer: Group::Serialize]
])

FetchListAsJson = Micro::Cases.safe_flow([
  # ...
])
class FetchListAsJson < Micro::Case
  flow [
    Group::FindAllForUser,
    Paginate,
    [Serialize::PaginatedRelationAsJson, serializer: Group::Serialize]
  ])
end

class FetchListAsJson < Micro::Case::Safe
  flow [
    # ...
  ])
end

Improve Micro::Case internal steps [NEW FEATURE]

  • Support Method instances as an alternative to lambdas. Use Method#arity to define when it will receive or not the result #data values.

Micro::Case::Result#then

class ContractaTemplate::Update < Micro::Case
  attributes :params, :contract_template
  
  def call!
    find
      .then(method(:validate))
      .then(method(:persist))
      .then(method(:create_revision), some: :hash)
  end
end

Micro::Case::Result#|

class ContractaTemplate::Update < Micro::Case
  attributes :params, :contract_template
  
  def call!
    find \
      | method(:validate) \
      | method(:persist)  \
      | method(:create_revision)
  end
end
  • Make the Micro::Case::Result#then accumulates the data when receiving a lambda or a method instance.

Thanks, @josuetex, @lunks for helping me to elaborate on this idea. 👏

Improve the inspect output

Suggestions:

Micro::Case instance

<Sum (Micro::Case) attributes={a: 1, b: 1}>
<Sum (Micro::Case::Safe) attributes={a: 1, b: 1}>
<Sum (Micro::Case::Strict) attributes={a: 1, b: 1}>

Micro::Case:::Result instance

<Success (Micro::Case::Result) type=:ok data={number: 2} transitions=1>

<Failure (Micro::Case::Result) type=:invalid_number data={invalid_number: true} transitions=1>

Micro::Cases::Flow instance

<Add9 (Micro::Cases::Flow) use_cases=[Add3, Add3, Add3]>
<Add9 (Micro::Cases::Safe::Flow) use_cases=[Add3, Add3, Add3]>

Change Micro::Case::Result#transitions [Breaking change]

Replace the key value by result into success and failure hash/key.

module UserTodos
  Create = Micro::Cases.flow([
    Users::Authenticate,
    Todos::Create
  ])
end

result = UserTodos.call({
  email: '[email protected]',
  token: '12345678',
  description: 'buy milk'
})

result.transitions == [
  {
    use_case: {
      class: Users::Authenticate,
      attributes: { email: '[email protected]', token: '12345678' }
    }
    success: {
      type: :ok, result: { user: <User...> }
    },
    accessible_attributes: [:email, :token, :description]
  },
  {
    use_case: {
      class: Todos::Create,
      attributes: {
        user: <User...>, description: 'buy milk'
      }
    }
    success: {
      type: :ok, result: { todo: <Todo...> }
    },
    accessible_attributes: [:email, :token, :description, :user]
  }
]

Allow only two ways to define a flow

Keep

class Flow < Micro::Case
  flow StepA,
       StepB,
       StepC
end

class SafeFlow < Micro::Case::Safe
  flow [
    # ...
  ]
end

# ---

Flow = Micro::Cases.flow([
  StepA,
  StepB,
  StepC
])

SafeFlow = Micro::Cases.safe_flow([
 # ...
])

Deprecate

Flow = StepA >> StepB >> StepC

SafeFlow = StepA & StepB & StepC

class Flow
  include Micro::Case::Flow
end

class SafeFlow
  include Micro::Case::Safe::Flow
end

Add new rules to define a Micro::Case success or failure [Breaking change]

Deprecation

  • Forbid the block syntax to define Failure and Success outputs.

Change

  • Allow Success receives a symbol without defining an output.

Contracts

  • Success result must be a Hash.
  • Failure result must be a Hash or an Exception.

Defining a result type (Symbol) without defining its output

Failure

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

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

    Failure(:attributes_arent_numbers)
  end
end

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

result.type                                 # :attributes_arent_numbers
result.data                                 # { attributes_arent_numbers: true }
result[:attributes_arent_numbers]           # true
result.values_at(:attributes_arent_numbers) # [true]

Success

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

  def call!
    return Success(:attributes_are_numbers) if Kind.of.Numeric?(a, b)

    Failure(:attributes_arent_numbers)
  end
end

result = ValidateNumbers.call(a: 1, b: 2)

result.type                               # :attributes_are_numbers
result.data                               # { attributes_are_numbers: true }
result[:attributes_are_numbers]           # true
result.values_at(:attributes_are_numbers) # [true]

Defining the output as Hash without defining the result type

Success

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

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

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

result.type                 # :ok
result.data                 # { sum: 2 }
result[:division]           # 2
result.values_at(:division) # [true]

Failure (:error)

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

  def call!
    return Failure(result: { message: "can't divide by zero" }) if b == 0

    Success result: { division: a / b }
  end
end

result = Divide.call(a: 4, b: 0)

result.type                # :error
result.data                # { message: "can't divide by zero" }
result[:message]           # "can't divide by zero"
result.values_at(:message) # ["can't divide by zero"]

Failure (:exception)

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

  def call!
    Success result: { division: a / b }
  rescue ZeroDivisionError => exception
    Failure result: exception
  end
end

result = Divide.call(a: 4, b: 0)

result.type                  # :exception
result.data                  # { exception: #<ZeroDivisionError: divided by 0> }
result[:exception]           # #<ZeroDivisionError: divided by 0>
result.values_at(:exception) # [#<ZeroDivisionError: divided by 0>]

Invalid output definition must raise an exception

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

  def call!
    Success result: a / b
  rescue ZeroDivisionError => exception
    Failure result: exception
  end
end

result = Divide.call(a: 4, b: 0)

# Micro::Case::Error::InvalidSuccessResult ("Success(result: 2) must be a Hash or Symbol")

# ---

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

  def call!
    Success result: { division: a / b }
  rescue ZeroDivisionError => exception
    Failure result: 0
  end
end

# Micro::Case::Error::InvalidFailureResult ("Failure(result: 0) must be a Hash, Symbol or an Exception")

# ---

module Micro::Case::Error
  class InvalidResultData < TypeError
  end

  class InvalidSuccessResult < InvalidResultData
    def initialize(object)
      super("Success(result: #{object.inspect}) must be a Hash or Symbol")
    end
  end

  class InvalidFailureResult < InvalidResultData
    def initialize(object)
      super("Failure(result: #{object.inspect}) must be a Hash, Symbol or an Exception")
    end
  end
end

(BREAKING CHANGE) Allow a success result have access to its use case

image

👆 Now, you can't use kwargs to decompose the result hash data into on_success block argument.


Thanks, @lunks, @rinaldifonseca for helping me to elaborate on this idea.

Remove Micro::Case#call (Breaking Change)

Until now was possible to invoke use cases using two approaches:

  1. MyCase.call(some_hash)
  2. MyCase.new(some_hash).call

In v3 will be possible to use only the first option. e.g. MyCase.call(some_hash)

Definition of done:

  • Update README removing examples that use this older way.
  • Remove Micro::Case#call

Micro::Cases.map()

Create an abstraction to allow the usage of the chain of responsibility pattern using the u-case. That, is call a collection of use cases with the same input

results = Micro::Cases.map([
  Foo,
  Bar,
  FooOrBar,
  FooAndBar,
  [FooAndBar, bar: 'bar']
]).call(foo: 'foo')

PS: One of the ideas is to allow the usage of dependency injection like was described in this issue: #38

Next, you can see an implementation of the concept:

require 'bundler/inline'

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

  gem 'u-case', '~> 4.0.0'
end

class Foo < Micro::Case
  attribute :foo

  def call!
    return Success(:filled_foo) if foo

    Failure(:missing_foo)
  end
end

class Bar < Micro::Case
  attribute :bar

  def call!
    return Success(:filled_bar) if bar

    Failure(:missing_bar)
  end
end

class FooOrBar < Micro::Case
  attributes :foo, :bar

  def call!
    return Success(:filled_foo_or_bar) if foo || bar

    Failure(:missing_foo_and_bar)
  end
end

class FooAndBar < Micro::Case
  attributes :foo, :bar

  def call!
    return Success(:filled_foo_and_bar) if foo && bar

    Failure(:missing_foo_or_bar)
  end
end

results = [
  Foo,
  Bar,
  FooOrBar,
  FooAndBar
].map { |use_case| use_case.call(foo: 'foo') }

results.each do |result|
  p(success: result.success?, data: result.data)
end

# {:success => true , :data => { :filled_foo         => true }}
# {:success => false, :data => { :missing_bar        => true }}
# {:success => true , :data => { :filled_foo_or_bar  => true }}
# {:success => false, :data => { :missing_foo_or_bar => true }}

@MatheusRich thanks to help me to evolve the idea! 👏

Change the Micro::Case::Result#then behavior when it receives a block

  • Keep the current behavior when it receives a Micro::Case/Micro::Cases::Flow as an argument.
  • Raise an error when the method receive a Micro::Case/Micro::Cases::Flow and a block as arguments.
  • Change the behavior when it receives a block. e.g:
class Add < Micro::Case
  attributes :a, :b

  def call!
    return Success output: { sum: a + b } if Kind.of.Numeric?(a, b)

    Failure(:attributes_arent_numbers)
  end
end

# --

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

puts success_result # 4

# --

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

puts failure_result # 0

Using hierarchy with flow

When I use a class that inherit from a class that use Micro::Case with a flow defined, this class have a different behavior from expected.

First, i have to define the same flow that is defined in top level class, the flow was not called if it isn't declared in leaf class.

Top Level Class:

Pasted_Image_01_05_20_11_01

Leaf one:

Pasted_Image_01_05_20_10_50

But even with that, in the tests it some times call the leaf call! class, and others from the inherited class. I'm using rspec for the tests.

If I call only one test, the result is ok:

image

but when I run all the tests, the Success response is from the inherited calls, not from the other one:

image

I think that it can be a unexpected way of using flow.
I'm using flow in the right way?

Let me know if you need any additional information.

Thanks!

Improve Micro::Case#call! invalid result error messages

Raise a better error when the Micro::Case#call! doesn't return a Micro::Case::Result instance.

Expected exception: Micro::Case::Error::InvalidResult.

Expected messages:

Invalid success/failure result without a custom type:

The result returned from MyCase#call! must be a Hash.

Example:
  Success(result: { key: 'value' })
The result returned from MyCase#call! must be a Hash.

Example:
  Failure(result: { key: 'value' })

Invalid success/failure result with a custom type:

The result returned from MyCase#call! must be a Hash.

Example:
  Success(:my_success, result: { key: 'value' })
The result returned from MyCase#call! must be a Hash.

Example:
  Failure(:my_failure, result: { key: 'value' })

Thanks, @MatheusRich and @mrbongiolo for helping me to elaborate on this idea. 🚀

Micro::Case#then(:method_name)

Hi folks! Just finished seeing the second ada.rb meeting and had the opportunity of seeing u-case at work. I've been checking it out once in a while but now I decided to give it a try. So congrats, first of all. It looks really pretty! 👏
So I was wondering if there is a way I can avoid using Micro::Case#then(method(:method_name)). It is a bit of metaprogramming in an undesired way (even though it is a simple one). Even using apply as an alias still is not ideal, I think. Because the user needs to know about the existence of this #apply method just to pass what he wanted in the end, which is, just a symbol or string identifying the method he wants to call next.
Ideally we could have something like:

def call!
  normalize_params
    .then(:sanitize)
    .then(:validate)
    .then(Persist)
end

then would be responsible for directing to the next use case independent of what you pass. This would make the method more robust in what it accepts (https://www.justinweiss.com/articles/simplify-your-ruby-code-with-the-robustness-principle/) and would have a common way of chaining use cases.
Of course, I'd be willing to contribute to that. But first, what do you guys think about it?

Add a config to disable the u-case safe features

To avoid a fragmented codebase where some exceptions are handled with rescue and on_exception hook. The idea is to create a feature toggle which will forbid the usage of Micro::Case::Safe, Micro::Cases::safe_flow() and Micro::Case::Result#on_exception to ensure that will have only one way to handle exceptions, in that case, using a standard rescue statement.

Thanks, @lunks for helping me to elaborate on this idea.

MyCase.call(hash) { |on| on.success {} }

MyCase.call(hash) do |on|
  on.failure(:foo) {}
  on.failure(:bar) {}
  on.failure {}
  on.success {}
end
MyFlow.call(hash) do |on|
  on.failure(:foo) {}
  on.failure(:bar) {}
  on.failure {}
  on.success {}
end

Allow calling a case inside of another one

Motivation: until now, you must create a PORO to orchestrate dynamics flows. Using this feature will be possible to achieve the same result using a regular use case class.

class FetchBalanceFromBankA < Micro::Case; end
class FetchBalanceFromBankB < Micro::Case; end

class NotifyViaEmail < Micro::Case; end
class NotifyViaSMS < Micro::Case; end

module FetchAccountBalance
  BANKS = {
    a: FetchBalanceFromBankA,
    b: FetchBalanceFromBankB
  }.freeze

  NOTIFICATION_METHODS = {
    sms: NotifyViaSMS,
    email: NotifyViaEmail
  }

  def self.call(account:)
    fetch_balance = BANKS.fetch(account.bank)
    notify_account = NOTIFICATION_METHODS.fetch(account.notification_method)

    fetch_balance.call(account: account)
      .then(notify_account)
  end
end

Using Micro::Case#call (private method)

class FetchBalanceFromBankA < Micro::Case; end
class FetchBalanceFromBankB < Micro::Case; end

class NotifyViaEmail < Micro::Case; end
class NotifyViaSMS < Micro::Case; end

class FetchAccountBalance < Micro::Case
  BANKS = {
    a: FetchBalanceFromBankA,
    b: FetchBalanceFromBankB
  }.freeze

  NOTIFICATION_METHODS = {
    sms: NotifyViaSMS,
    email: NotifyViaEmail
  }

  attribute :account, validates: { kind: ::Account }

  def call!
    fetch_balance = BANKS.fetch(account.bank)
    notify_account = NOTIFICATION_METHODS.fetch(account.notification_method)

    call(fetch_balance) # the method call could receive additional attributes (allowing dependency injection)
      .then(notify_account)
  end
end

Update docs

Update README

  • Add a good introduction.
  • Add some usage recommendations (make it work, right and fast approach).
  • Add examples about the feature: Use cases with internal steps using its private methods.
    • Add examples with the usage of keywords args when internal steps receive a method instance. #68, #69
    • Add more tests about this feature + blended with others. e.g. flow, safe_flow, inner flow, other kinds of cases/flows using a case with internal steps in their steps.

Other docs

Micro::Case::Result#on_unknow

If a result can’t be handled (imagine a flow where the result type wasn’t handled or some use case where none of its Failure or Success definition was intercepted), this hook will be triggered and the user can log, measure it for learning about this unexpected behavior.

This feature was inspired by https://youtu.be/qoriifl-z3Q

No Return: Beyond Transactions in Code and Life by Avdi Grimm

Thanks @avdi!

Micro::Cases.flow(db_transaction: true)

Wraps a Micro::Cases::Flow inside of an ActiveRecord::Transactions and if an exception happening or any step returns a failure, this flow will be halted because an ActiveRecord::Rollback will be raised.

MyFlow = Micro::Cases.flow([
  NormalizeParams,
  ValidatePassword,
  Micro::Cases.flow(db_transaction: true, steps: [
    CreateUser,
    CreateUserProfile
  ]),
  EnqueueIndexingJob
])

# ---

MyFlowWrappedInATransaction = Micro::Cases.flow(db_transaction: true, steps: [
  CreateUser,
  CeateUserProfile
])

MyFlow = Micro::Cases.flow([
  NormalizeParams,
  ValidatePassword,
  MyFlowWrappedInATransaction,
  EnqueueIndexingJob
])
class MyFlow < Micro::Case
  flow(db_transaction: true, steps: [
    NormalizeParams,
    ValidatePassword,
    CreateUser,
    CreateUserProfile
  ])
])

# ---

MyFlowWrappedInATransaction =
  Micro::Cases.flow(db_transaction: true, steps: [
    CreateUser,
    CeateUserProfile
  ])

class MyFlow < Micro::Case
  flow([
    NormalizeParams,
    ValidatePassword,
    MyFlowWrappedInATransaction
  ])
])

Definition of done:

  • This mode/plugin will be disabled by default, that is, it will be enabled to be available. Except for its core dependencies kind and u-attributes, this gem never will require any external dependency by default.

Thanks, @marlosirapuan, @josuetex, @marcosgz, @MatheusRich, @mrbongiolo for helping me to elaborate on this idea. 🚀

Multiple arguments in pipeline

I have a situation, where I would like to chain multiple services together. Their airity isn't identical, so it becomes a challenge that the input of the subsequent elements in the chain only gets called with the single response from the previous service.

E.g. ServiceA takes one argument, a user. ServiceB takes two arguments: The user and some parameter value.

This is currently not possible afaik because a service can only have a single response (which I agree with), but my second service takes more than one argument.

What's the intended way that this should work in this case?

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.