Create simple and powerful use cases as objects (aka: service objects).
The main goals of this project are:
- Be simple to use and easy to learn (input >> process/transform >> output).
- Referential transparency and data integrity.
- No callbacks (before, after, around...).
- Represent complex business logic using a composition of use cases.
- μ-case (Micro::Case)
- Required Ruby version
- Installation
- Usage
- Comparisons
- Benchmarks
- Development
- Contributing
- License
- Code of Conduct
>= 2.2.0
Add this line to your application's Gemfile:
gem 'u-case'
And then execute:
$ bundle
Or install it yourself as:
$ gem install u-case
class Multiply < Micro::Case::Base
# 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 result/output using the `Success()` and `Failure()` methods
if a.is_a?(Numeric) && b.is_a?(Numeric)
Success(a * b)
else
Failure { '`a` and `b` attributes must be numeric' }
end
end
end
#==========================#
# Calling a use case class #
#==========================#
# Success result
result = Multiply.call(a: 2, b: 2)
result.success? # true
result.value # 4
# Failure result
bad_result = Multiply.call(a: 2, b: '2')
bad_result.failure? # true
bad_result.value # "`a` and `b` attributes must be numeric"
#-----------------------------#
# Calling a use case instance #
#-----------------------------#
result = Multiply.new(a: 2, b: 3).call
result.value # 6
# Note:
# ----
# The result of a Micro::Case::Base.call
# is an instance of Micro::Case::Result
A Micro::Case::Result
stores 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.#value
the result value itself.#type
a Symbol which gives meaning for the result, this is useful to declare different types of failures or success.#on_success
or#on_failure
are hook methods which help you define the application flow.#use_case
if is a failure result, the use case responsible for it will be accessible through this method. This feature is handy to handle a flow failure (this topic will be covered ahead).
Every result has a type and these are the defaults:
:ok
when success:error
/:exception
when failures
class Divide < Micro::Case::Base
attributes :a, :b
def call!
invalid_attributes.empty? ? Success(a / b) : Failure(invalid_attributes)
rescue => e
Failure(e)
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.value # 1
result.success? # true
result.use_case # raises `Micro::Case::Error::InvalidAccessToTheUseCaseObject: only a failure result can access its own use case`
# Failure result (type == :error)
bad_result = Divide.call(a: 2, b: '2')
bad_result.type # :error
bad_result.value # {"b"=>"2"}
bad_result.failure? # true
bad_result.use_case # #<Divide:0x0000 @__attributes={"a"=>2, "b"=>"2"}, @a=2, @b="2", @__result=#<Micro::Case::Result:0x0000 @use_case=#<Divide:0x0000 ...>, @type=:error, @value={"b"=>"2"}, @success=false>>
# Failure result (type == :exception)
err_result = Divide.call(a: 2, b: 0)
err_result.type # :exception
err_result.value # <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() method will receive `:exception` instead of the `:error` type.
Answer: Use a symbol as the argument of Success()
, Failure()
methods and declare a block to set their values.
class Multiply < Micro::Case::Base
attributes :a, :b
def call!
return Success(a * b) if a.is_a?(Numeric) && b.is_a?(Numeric)
Failure(:invalid_data) do
attributes.reject { |_, input| input.is_a?(Numeric) }
end
end
end
# Success result
result = Multiply.call(a: 3, b: 2)
result.type # :ok
result.value # 6
result.success? # true
# Failure result
bad_result = Multiply.call(a: 3, b: '2')
bad_result.type # :invalid_data
bad_result.value # {"b"=>"2"}
bad_result.failure? # true
Answer: Yes, it is. But only for failure results!
class Multiply < Micro::Case::Base
attributes :a, :b
def call!
return Failure(:invalid_data) unless a.is_a?(Numeric) && b.is_a?(Numeric)
Success(a * b)
end
end
result = Multiply.call(a: 2, b: '2')
result.failure? # true
result.value # :invalid_data
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).
As mentioned earlier, the Micro::Case::Result
has two methods to improve the flow control. They are: #on_success
, on_failure
.
The examples below show how to use them:
class Double < Micro::Case::Base
attributes :number
def call!
return Failure(:invalid) { 'the number must be a numeric value' } unless number.is_a?(Numeric)
return Failure(:lte_zero) { 'the number must be greater than 0' } if number <= 0
Success(number * 2)
end
end
#================================#
# Printing the output if success #
#================================#
Double
.call(number: 3)
.on_success { |number| p number }
.on_failure(:invalid) { |msg| raise TypeError, msg }
.on_failure(:lte_zero) { |msg| raise ArgumentError, msg }
# The output because it is a success:
# 6
#=============================#
# Raising an error if failure #
#=============================#
Double
.call(number: -1)
.on_success { |number| p number }
.on_failure { |_msg, use_case| puts "#{use_case.class.name} was the use case responsible for the failure" }
.on_failure(:invalid) { |msg| raise TypeError, msg }
.on_failure(:lte_zero) { |msg| raise ArgumentError, msg }
# The outputs because it is a failure:
# Double was the use case responsible for the failure
# (throws the error)
# ArgumentError (the number must be greater than 0)
# Note:
# ----
# The use case responsible for the failure will be accessible as the second hook argument
Answer: The hook will be triggered if it matches the result type.
class Double < Micro::Case::Base
attributes :number
def call!
return Failure(:invalid) { 'the number must be a numeric value' } unless number.is_a?(Numeric)
Success(:computed) { number * 2 }
end
end
result = Double.call(number: 3)
result.value # 6
result.value * 4 # 24
accum = 0
result.on_success { |number| accum += number }
.on_success { |number| accum += number }
.on_success(:computed) { |number| accum += number }
.on_success(:computed) { |number| accum += number }
accum # 24
result.value * 4 == accum # true
In this case, this will be is a flow, because the idea is to use/reuse use cases as steps which will define a more complex one.
module Steps
class ConvertToNumbers < Micro::Case::Base
attribute :numbers
def call!
if numbers.all? { |value| String(value) =~ /\d+/ }
Success(numbers: numbers.map(&:to_i))
else
Failure('numbers must contain only numeric types')
end
end
end
class Add2 < Micro::Case::Strict
attribute :numbers
def call!
Success(numbers: numbers.map { |number| number + 2 })
end
end
class Double < Micro::Case::Strict
attribute :numbers
def call!
Success(numbers: numbers.map { |number| number * 2 })
end
end
class Square < Micro::Case::Strict
attribute :numbers
def call!
Success(numbers: numbers.map { |number| number * number })
end
end
end
#---------------------------------------------#
# Creating a flow using the collection syntax #
#---------------------------------------------#
Add2ToAllNumbers = Micro::Case::Flow[
Steps::ConvertToNumbers,
Steps::Add2
]
result = Add2ToAllNumbers.call(numbers: %w[1 1 2 2 3 4])
p result.success? # true
p result.value # {:numbers => [3, 3, 4, 4, 5, 6]}
#---------------------------------------------------#
# An alternative way to create a flow using classes #
#---------------------------------------------------#
class DoubleAllNumbers
include Micro::Case::Flow
flow Steps::ConvertToNumbers, Steps::Double
end
DoubleAllNumbers
.call(numbers: %w[1 1 b 2 3 4])
.on_failure { |message| p message } # "numbers must contain only numeric types"
#-------------------------------------------------------------#
# Another way to create a flow using the composition operator #
#-------------------------------------------------------------#
SquareAllNumbers =
Steps::ConvertToNumbers >> Steps::Square
SquareAllNumbers
.call(numbers: %w[1 1 2 2 3 4])
.on_success { |value| p value[:numbers] } # [1, 1, 4, 4, 9, 16]
# Note:
# ----
# When happening a failure, the use case responsible
# will be accessible in the result
result = SquareAllNumbers.call(numbers: %w[1 1 b 2 3 4])
result.failure? # true
result.use_case.is_a?(Steps::ConvertToNumbers) # true
result.on_failure do |_message, use_case|
puts "#{use_case.class.name} was the use case responsible for the failure" # Steps::ConvertToNumbers was the use case responsible for the failure
end
Answer: Yes, it is.
module Steps
class ConvertToNumbers < Micro::Case::Base
attribute :numbers
def call!
if numbers.all? { |value| String(value) =~ /\d+/ }
Success(numbers: numbers.map(&:to_i))
else
Failure('numbers must contain only numeric types')
end
end
end
class Add2 < Micro::Case::Strict
attribute :numbers
def call!
Success(numbers: numbers.map { |number| number + 2 })
end
end
class Double < Micro::Case::Strict
attribute :numbers
def call!
Success(numbers: numbers.map { |number| number * 2 })
end
end
class Square < Micro::Case::Strict
attribute :numbers
def call!
Success(numbers: numbers.map { |number| number * number })
end
end
end
Add2ToAllNumbers = Steps::ConvertToNumbers >> Steps::Add2
DoubleAllNumbers = Steps::ConvertToNumbers >> Steps::Double
SquareAllNumbers = Steps::ConvertToNumbers >> Steps::Square
DoubleAllNumbersAndAdd2 = DoubleAllNumbers >> Steps::Add2
SquareAllNumbersAndAdd2 = SquareAllNumbers >> Steps::Add2
SquareAllNumbersAndDouble = SquareAllNumbersAndAdd2 >> DoubleAllNumbers
DoubleAllNumbersAndSquareAndAdd2 = DoubleAllNumbers >> SquareAllNumbersAndAdd2
SquareAllNumbersAndDouble
.call(numbers: %w[1 1 2 2 3 4])
.on_success { |value| p value[:numbers] } # [6, 6, 12, 12, 22, 36]
DoubleAllNumbersAndSquareAndAdd2
.call(numbers: %w[1 1 2 2 3 4])
.on_success { |value| p value[:numbers] } # [6, 6, 18, 18, 38, 66]
Note: You can blend any of the available syntaxes/approaches to create use case flows - examples.
Answer: Is a use case which will require all the keywords (attributes) on its initialization.
class Double < Micro::Case::Strict
attribute :numbers
def call!
Success(numbers.map { |number| number * 2 })
end
end
Double.call({})
# The output (raised an error):
# ArgumentError (missing keyword: :numbers)
Answer: Yes, there is!
Use cases:
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!
return Success(a / b) if a.is_a?(Integer) && b.is_a?(Integer)
Failure(:not_an_integer)
end
end
result = Divide.call(a: 2, b: 0)
result.type == :exception # true
result.value.is_a?(ZeroDivisionError) # true
result.on_failure(:exception) do |exception|
AppLogger.error(exception.message) # E, [2019-08-21T00:05:44.195506 #9532] ERROR -- : divided by 0
end
# Note:
# ----
# If you need to handle a specific error,
# I recommend the usage of a case statement. e,g:
result.on_failure(:exception) do |exception, use_case|
case 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
# Another note:
# ------------
# It is possible to rescue an exception even when is a safe use case.
# Examples: https://github.com/serradura/u-case/blob/5a85fc238b63811a32737493dc6c59965f92491d/test/micro/case/safe_test.rb#L95-L123
Flows:
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 = ProcessParams & ValidateParams & Persist & SendToCRM
end
# Note:
# The ampersand is based on the safe navigation operator. https://ruby-doc.org/core-2.6/doc/syntax/calling_methods_rdoc.html#label-Safe+navigation+operator
# The alternatives are:
module Users
class Create
include Micro::Case::Flow::Safe
flow ProcessParams, ValidateParams, Persist, SendToCRM
end
end
# or
module Users
Create = Micro::Case::Flow::Safe[
ProcessParams,
ValidateParams,
Persist,
SendToCRM
]
end
Requirement:
To do this your application must have the activemodel >= 3.2 as a dependency.
#
# By default, if your application has the activemodel as a dependency,
# any kind of use case can use it to validate their attributes.
#
class Multiply < Micro::Case::Base
attributes :a, :b
validates :a, :b, presence: true, numericality: true
def call!
return Failure(:validation_error) { {errors: self.errors} } unless valid?
Success(number: a * b)
end
end
#
# But if do you want an automatic way to fail
# your use cases on validation errors, you can use:
# In some file. e.g: A Rails initializer
require 'u-case/with_validation' # or require 'micro/case/with_validation'
# In the Gemfile
gem 'u-case', require: 'u-case/with_validation'
# Using this approach, you can rewrite the previous example with less code. e.g:
class Multiply < Micro::Case::Base
attributes :a, :b
validates :a, :b, presence: true, numericality: true
def call!
Success(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.
-
An example of flow in how to define steps to sanitize, validate, and persist some input data.
-
A more complex example which use rake tasks to demonstrate how to handle user data, and how to use different failures type to control the program flow.
Check it out implementations of the same use case with different gems/abstractions.
interactor VS u-case
https://github.com/serradura/u-case/tree/master/benchmarks/interactor
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.
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.
The gem is available as open source under the terms of the MIT License.
Everyone interacting in the Micro::Case project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.