🔀 Solid::Result
Unleash a pragmatic and observable use of Result Pattern and Railway-Oriented Programming in Ruby.
It's a general-purpose result monad that allows you to create objects representing a success (Solid::Result::Success
) or failure (Solid::Result::Failure
).
What problem does it solve?
It allows you to consistently represent the concept of success and failure throughout your codebase.
Furthermore, this abstraction exposes several features that will be useful to make the application flow react cleanly and securely to the result represented by these objects.
Use it to enable the Railway Oriented Programming pattern (superpower) in your code.
Supported Ruby
This library is tested against:
Version | 2.7 | 3.0 | 3.1 | 3.2 | 3.3 | Head |
---|
100% Coverage | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Installation
Add this line to your application's Gemfile:
gem 'solid-result'
And then execute:
$ bundle install
If bundler is not being used to manage dependencies, install the gem by executing:
$ gem install solid-result
And require it in your code:
require 'solid/result'
⬆️ back to top
Usage
To create a result, you must define a type (symbol) and its value (any kind of object). e.g.,
Solid::Result::Success(:ok, :1)
Solid::Result::Failure(:err, 'the value')
The reason for defining a type
is that it is very common for a method/operation to return different types of successes or failures. Because of this, the type
will always be required. e,g.,
Solid::Result::Success(:ok)
Solid::Result::Failure(:err)
⬆️ back to top
Solid::Result
versus Result
This gem provides a way to create constant aliases for Solid::Result
and other classes/modules.
To enable it, you must call the Solid::Result.configuration
method and pass a block to it. You can turn the aliases you want on/off in this block.
Solid::Result.configuration do |config|
config.constant_alias.enable!('Result')
end
So, instead of using Solid::Result
everywhere, you can use Result
as an alias/shortcut.
Result::Success(:ok)
Result::Failure(:err)
If you have enabled constant aliasing, all examples in this README that use Solid::Result
can be implemented using Result
.
There are other aliases and configurations available. Check the Solid::Result.configuration section for more information.
⬆️ back to top
Reference
Basic methods
Both Solid::Result::Success
and Solid::Result::Failure
are composed of the same methods. Look at the basic ones:
Solid::Result::Success
result = Solid::Result::Success(:ok, my: 'value')
result.success?
result.failure?
result.type?(:ok)
result.type
result.value
result = Solid::Result::Success(:yes)
result.success?
result.failure?
result.type?(:yes)
result.type
result.value
Solid::Result::Failure
result = Solid::Result::Failure(:err, 'my_value')
result.success?
result.failure?
result.type?(:err)
result.type
result.value
result = Solid::Result::Failure(:no)
result.success?
result.failure?
result.type?(:no)
result.type
result.value
In both cases, the type
must be a symbol, and the value
can be any kind of object.
⬆️ back to top
Checking types with result.is?
or method missing
Beyond the type?
method, you can also use the is?
method to check the result type. If you want to check the type directly, you can write the type using a method that ends with a question mark.
result = Solid::Result::Success(:ok)
result.is?(:ok)
result.ok?
result = Solid::Result::Failure(:err)
result.is?(:err)
result.err?
⬆️ back to top
Checking types with result.success?
or result.failure?
Solid::Result#success?
and Solid::Result#failure?
are methods that allow you to check if the result is a success or a failure.
You can also check the result type by passing an argument to it. For example, result.success?(:ok)
will check if the result is a success and if the type is :ok
.
result = Solid::Result::Success(:ok)
result.success?(:ok)
result.success? && result.type == :ok
The same is valid for Solid::Result#failure?
.
result = Solid::Result::Failure(:err)
result.failure?(:err)
result.failure? && result.type == :err
⬆️ back to top
Result Hooks
Result hooks are methods that allow you to execute a block of code based on the type of result obtained.
To demonstrate their use, I will implement a method that can divide two numbers.
def divide(arg1, arg2)
arg1.is_a?(::Numeric) or return Solid::Result::Failure(:invalid_arg, 'arg1 must be numeric')
arg2.is_a?(::Numeric) or return Solid::Result::Failure(:invalid_arg, 'arg2 must be numeric')
return Solid::Result::Failure(:division_by_zero, 'arg2 must not be zero') if arg2.zero?
Solid::Result::Success(:division_completed, arg1 / arg2)
end
⬆️ back to top
result.on
When you use Solid::Result#on
, the block will be executed only when the type matches the result type.
However, even if the block is executed, the method will always return the result itself.
The value of the result will be available as the first argument of the block.
result = divide(nil, 2)
#<Solid::Result::Failure type=:invalid_arg data='arg1 must be numeric'>
output =
result
.on(:invalid_arg) { |msg| puts msg }
.on(:division_by_zero) { |msg| puts msg }
.on(:division_completed) { |number| puts number }
result.object_id == output.object_id
You can define multiple types to be handled by the same hook/block
result = divide(4, 0)
output =
result.on(:invalid_arg, :division_by_zero, :division_completed) { |value| puts value }
result.object_id == output.object_id
PS: The divide()
implementation is here.
⬆️ back to top
result.on_type
Solid::Result#on_type
is an alias of Solid::Result#on
.
result = divide(nil, 2)
#<Solid::Result::Failure type=:invalid_arg data='arg1 must be numeric'>
output =
result
.on_type(:invalid_arg, :division_by_zero) { |msg| puts msg }
.on_type(:division_completed) { |number| puts number }
result.object_id == output.object_id
PS: The divide()
implementation is here.
⬆️ back to top
result.on_success
The Solid::Result#on_success
method is quite similar to the Solid::Result#on
hook, but with a few key differences:
- It will only execute the block of code if the result is a success.
- If the type declaration is not included, the method will execute the block for any successful result, regardless of its type.
divide(4, 2).on_success { |number| puts number }
divide(4, 2).on_success(:division_completed) { |number| puts number }
divide(4, 4).on_success(:ok) { |value| puts value }
divide(4, 4).on_failure { |error| puts error }
PS: The divide()
implementation is here.
⬆️ back to top
result.on_failure
It is the opposite of Result#on_success
:
- It will only execute the block of code if the result is a failure.
- If the type declaration is not included, the method will execute the block for any failed result, regardless of its type.
divide(nil, 2).on_failure { |error| puts error }
divide(4, 0).on_failure(:division_by_zero) { |error| puts error }
divide(4, 0).on_failure(:invalid_arg) { |error| puts error }
divide(4, 0).on_success { |number| puts number }
PS: The divide()
implementation is here.
⬆️ back to top
result.on_unknown
Solid::Result#on_unknown
will execute the block when no other hook (#on
, #on_type
, #on_failure
, #on_success
) has been executed.
Regardless of the block being executed, the method will always return the result itself.
The value of the result will be available as the first argument of the block.
divide(4, 2)
.on(:invalid_arg) { |msg| puts msg }
.on(:division_by_zero) { |msg| puts msg }
.on_unknown { |value, type| puts [type, value].inspect }
PS: The divide()
implementation is here.
⬆️ back to top
result.handle
This method lets you define blocks for each hook (type, failure, or success), but instead of returning itself, it will return the output of the first match/block execution.
divide(4, 2).handle do |result|
result.success { |number| number }
result.failure(:invalid_arg) { |err| puts err }
result.type(:division_by_zero) { raise ZeroDivisionError }
result.unknown { raise NotImplementedError }
end
divide(4, 2).handle do |on|
on.success { |number| number }
on.failure { |err| puts err }
on.unknown { raise NotImplementedError }
end
divide(4, 2).handle do |on|
on.type(:invalid_arg) { |err| puts err }
on.type(:division_by_zero) { raise ZeroDivisionError }
on.type(:division_completed) { |number| number }
on.unknown { raise NotImplementedError }
end
divide(4, 2).handle do |on|
on[:invalid_arg] { |err| puts err }
on[:division_by_zero] { raise ZeroDivisionError }
on[:division_completed] { |number| number }
on.unknown { raise NotImplementedError }
end
Notes:
- You can define multiple types to be handled by the same hook/block
- If the type is missing, it will execute the block for any success or failure handler.
- The
#type
and #[]
handlers require at least one type/argument.
PS: The divide()
implementation is here.
⬆️ back to top
Result Value
To access the result value, you can simply call Solid::Result#value
.
However, there may be instances where you need to retrieve the value of a successful result or a default value if the result is a failure. In such cases, you can make use of Solid::Result#value_or
.
result.value_or
BCCD::Result#value_or
returns the value when the result is a success. However, if it is a failure, the given block will be executed, and its outcome will be returned.
def divide(arg1, arg2)
arg1.is_a?(::Numeric) or return Solid::Result::Failure(:invalid_arg)
arg2.is_a?(::Numeric) or return Solid::Result::Failure(:invalid_arg)
return Solid::Result::Failure(:division_by_zero) if arg2.zero?
Solid::Result::Success(:division_completed, arg1 / arg2)
end
divide(4, 2).value_or { 0 }
divide('4', 2).value_or { 0 }
divide(4, '2').value_or { 0 }
divide(100, 0).value_or { 0 }
PS: The divide()
implementation is here.
⬆️ back to top
Result Data
result.data
The Solid::Result#data
exposes the result attributes (kind, type, value) directly and as a hash (to_h
/to_hash
) and array (to_a
/to_ary
).
This is helpful if you need to access the result attributes generically or want to use Ruby features like splat (*
) and double splat (**
) operators.
See the examples below to understand how to use it.
result = Solid::Result::Success(:ok, 1)
success_data = result.data
success_data.kind
success_data.type
success_data.value
success_data.to_h
success_data.to_a
kind, type, value = success_data
[kind, type, value]
def print_to_ary(kind, type, value)
puts [kind, type, value].inspect
end
def print_to_hash(kind:, type:, value:)
puts [kind, type, value].inspect
end
print_to_ary(*success_data)
print_to_hash(**success_data)
NOTE: The example above uses a success result, but the same is valid for a failure result.
⬆️ back to top
Railway Oriented Programming
"Railway Oriented Programming (ROP)" is a programming technique that involves linking blocks together to form a sequence of operations, also known as a pipeline.
If a failure occurs in any of the blocks, the pipeline is interrupted and subsequent blocks are skipped.
The ROP technique allows you to structure your code in a way that expresses your logic as a series of operations, with the added benefit of stopping the process at the first detection of failure.
If all blocks successfully execute, the final result of the pipeline will be a success.
result.and_then
module Divide
extend self
def call(arg1, arg2)
validate_numbers(arg1, arg2)
.and_then { |numbers| validate_nonzero(numbers) }
.and_then { |numbers| divide(numbers) }
end
private
def validate_numbers(arg1, arg2)
arg1.is_a?(::Numeric) or return Solid::Result::Failure(:invalid_arg, 'arg1 must be numeric')
arg2.is_a?(::Numeric) or return Solid::Result::Failure(:invalid_arg, 'arg2 must be numeric')
Solid::Result::Success(:ok, [arg1, arg2])
end
def validate_nonzero(numbers)
return Solid::Result::Success(:ok, numbers) if numbers.last.nonzero?
Solid::Result::Failure(:division_by_zero, 'arg2 must not be zero')
end
def divide((number1, number2))
Solid::Result::Success(:division_completed, number1 / number2)
end
end
Example of outputs:
Divide.call('4', 2)
#<Solid::Result::Failure type=:invalid_arg data="arg1 must be numeric">
Divide.call(2, '2')
#<Solid::Result::Failure type=:invalid_arg data="arg2 must be numeric">
Divide.call(2, 0)
#<Solid::Result::Failure type=:division_by_zero data="arg2 must not be zero">
Divide.call(2, 2)
#<Solid::Result::Success type=:division_completed data=1>
⬆️ back to top
Solid::Result.mixin
This method generates a module that any object can include or extend. It adds two methods to the target object: Success()
and Failure()
.
The main difference between these methods and Solid::Result::Success()
/Solid::Result::Failure()
is that the former will utilize the target object (which has received the include/extend) as the result's source.
Because the result has a source, the #and_then
method can call methods from it.
Class example (Instance Methods)
class Divide
include Solid::Result.mixin
attr_reader :arg1, :arg2
def initialize(arg1, arg2)
@arg1 = arg1
@arg2 = arg2
end
def call
validate_numbers
.and_then(:validate_nonzero)
.and_then(:divide)
end
private
def validate_numbers
arg1.is_a?(::Numeric) or return Failure(:invalid_arg, 'arg1 must be numeric')
arg2.is_a?(::Numeric) or return Failure(:invalid_arg, 'arg2 must be numeric')
Success(:ok, [arg1, arg2])
end
def validate_nonzero(numbers)
return Success(:ok, numbers) unless numbers.last.zero?
Failure(:division_by_zero, 'arg2 must not be zero')
end
def divide((number1, number2))
Success(:division_completed, number1 / number2)
end
end
Divide.new(4, 2).call #<Solid::Result::Success type=:division_completed value=2>
Divide.new(4, 0).call #<Solid::Result::Failure type=:division_by_zero value="arg2 must not be zero">
Divide.new('4', 2).call #<Solid::Result::Failure type=:invalid_arg value="arg1 must be numeric">
Divide.new(4, '2').call #<Solid::Result::Failure type=:invalid_arg value="arg2 must be numeric">
Module example (Singleton Methods)
module Divide
extend self, Solid::Result.mixin
def call(arg1, arg2)
validate_numbers(arg1, arg2)
.and_then(:validate_nonzero)
.and_then(:divide)
end
private
def validate_numbers(arg1, arg2)
arg1.is_a?(::Numeric) or return Failure(:invalid_arg, 'arg1 must be numeric')
arg2.is_a?(::Numeric) or return Failure(:invalid_arg, 'arg2 must be numeric')
Success(:ok, [arg1, arg2])
end
def validate_nonzero(numbers)
return Success(:ok, numbers) unless numbers.last.zero?
Failure(:division_by_zero, 'arg2 must not be zero')
end
def divide((number1, number2))
Success(:division_completed, number1 / number2)
end
end
Divide.call(4, 2) #<Solid::Result::Success type=:division_completed value=2>
Divide.call(4, 0) #<Solid::Result::Failure type=:division_by_zero value="arg2 must not be zero">
Divide.call('4', 2) #<Solid::Result::Failure type=:invalid_arg value="arg1 must be numeric">
Divide.call(4, '2') #<Solid::Result::Failure type=:invalid_arg value="arg2 must be numeric">
⬆️ back to top
Important Requirement
To use the #and_then
method to call methods, they must use Success()
and Failure()
to produce the results.
If you try to use Solid::Result::Success()
/Solid::Result::Failure()
, or results from another Solid::Result.mixin
instance with #and_then
, it will raise an error because the sources are different.
Note: You can still use the block syntax, but all the results must be produced by the source's Success()
and Failure()
methods.
module ValidateNonzero
extend self, Solid::Result.mixin
def call(numbers)
return Success(:ok, numbers) unless numbers.last.zero?
Failure(:division_by_zero, 'arg2 must not be zero')
end
end
module Divide
extend self, Solid::Result.mixin
def call(arg1, arg2)
validate_numbers(arg1, arg2)
.and_then(:validate_nonzero)
.and_then(:divide)
end
private
def validate_numbers(arg1, arg2)
arg1.is_a?(::Numeric) or return Failure(:invalid_arg, 'arg1 must be numeric')
arg2.is_a?(::Numeric) or return Failure(:invalid_arg, 'arg2 must be numeric')
Success(:ok, [arg1, arg2])
end
def validate_nonzero(numbers)
ValidateNonzero.call(numbers)
end
def divide((number1, number2))
Success(:division_completed, number1 / number2)
end
end
Look at the error produced by the code above:
Divide.call(2, 0)
In order to fix this, you must handle the result produced by ValidateNonzero.call()
and return a result that belongs to the same source.
module ValidateNonzero
extend self, Solid::Result.mixin
def call(numbers)
return Success(:ok, numbers) unless numbers.last.zero?
Failure(:division_by_zero, 'arg2 must not be zero')
end
end
module Divide
extend self, Solid::Result.mixin
def call(arg1, arg2)
validate_numbers(arg1, arg2)
.and_then(:validate_nonzero)
.and_then(:divide)
end
private
def validate_numbers(arg1, arg2)
arg1.is_a?(::Numeric) or return Failure(:invalid_arg, 'arg1 must be numeric')
arg2.is_a?(::Numeric) or return Failure(:invalid_arg, 'arg2 must be numeric')
Success(:ok, [arg1, arg2])
end
def validate_nonzero(numbers)
ValidateNonzero.call(numbers).handle do |on|
on.success { |numbers| Success(:ok, numbers) }
on.failure { |err| Failure(:division_by_zero, err) }
end
end
def divide((number1, number2))
Success(:division_completed, number1 / number2)
end
end
Look at the output of the code above:
Divide.call(2, 0)
#<Solid::Result::Failure type=:division_by_zero value="arg2 must not be zero">
⬆️ back to top
Dependency Injection
The Solid::Result#and_then
accepts a second argument that will be used to share a value with the source's method.
To receive this argument, the source's method must have an arity of two, where the first argument will be the result value and the second will be the injected value.
require 'logger'
module Divide
extend self, Solid::Result.mixin
def call(arg1, arg2, logger: ::Logger.new(STDOUT))
validate_numbers(arg1, arg2)
.and_then(:validate_nonzero, logger)
.and_then(:divide, logger)
end
private
def validate_numbers(arg1, arg2)
arg1.is_a?(::Numeric) or return Failure(:invalid_arg, 'arg1 must be numeric')
arg2.is_a?(::Numeric) or return Failure(:invalid_arg, 'arg2 must be numeric')
Success(:ok, [arg1, arg2])
end
def validate_nonzero(numbers, logger)
if numbers.last.zero?
logger.error('arg2 must not be zero')
Failure(:division_by_zero, 'arg2 must not be zero')
else
logger.info('The numbers are valid')
Success(:ok, numbers)
end
end
def divide((number1, number2), logger)
division = number1 / number2
logger.info("The division result is #{division}")
Success(:division_completed, division)
end
end
Divide.call(4, 2)
Divide.call(4, 2, logger: Logger.new(IO::NULL))
⬆️ back to top
Add-ons
The Solid::Result.mixin
also accepts the config:
argument. It is a hash that will be used to define custom behaviors for the mixin.
given
This addon is enabled by default. It will create the Given(value)
method. Use it to add a value to the result chain and invoke the next step (through and_then
).
You can turn it off by passing given: false
to the config:
argument or using the Solid::Result.configuration
.
continue
This addon will create the Continue(value)
method and change the Success()
behavior to terminate the step chain.
So, if you want to advance to the next step, you must use Continue(value)
instead of Success(type, value)
. Otherwise, the step chain will be terminated.
In this example below, the validate_nonzero
will return a Success(:division_completed, 0)
and terminate the chain if the first number is zero.
module Divide
extend self, Solid::Result.mixin(config: { addon: { continue: true } })
def call(arg1, arg2)
Given([arg1, arg2])
.and_then(:validate_numbers)
.and_then(:validate_nonzero)
.and_then(:divide)
end
private
def validate_numbers(numbers)
number1, number2 = numbers
number1.is_a?(::Numeric) or return Failure(:invalid_arg, 'arg1 must be numeric')
number2.is_a?(::Numeric) or return Failure(:invalid_arg, 'arg2 must be numeric')
Continue(numbers)
end
def validate_nonzero(numbers)
return Failure(:division_by_zero, 'arg2 must not be zero') if numbers.last.zero?
return Success(:division_completed, 0) if numbers.first.zero?
Continue(numbers)
end
def divide((number1, number2))
Success(:division_completed, number1 / number2)
end
end
⬆️ back to top
Solid::Result::Expectations
This feature lets you define contracts for your results' types and values. There are two ways to use it: the standalone (Solid::Result::Expectations.new
) and the mixin (Solid::Result::Expectations.mixin
) mode.
It was designed to ensure all the aspects of the result's type and value. So, an error will be raised if you try to create or handle a result with an unexpected type or value.
Standalone versus Mixin mode
The standalone mode creates an object that knows how to produce and validate results based on the defined expectations. Look at the example below:
module Divide
Result = Solid::Result::Expectations.new(
success: %i[numbers division_completed],
failure: %i[invalid_arg division_by_zero]
)
def self.call(arg1, arg2)
arg1.is_a?(Numeric) or return Result::Failure(:invalid_arg, 'arg1 must be numeric')
arg2.is_a?(Numeric) or return Result::Failure(:invalid_arg, 'arg2 must be numeric')
arg2.zero? and return Result::Failure(:division_by_zero, 'arg2 must not be zero')
Result::Success(:division_completed, arg1 / arg2)
end
end
In the code above, we define a constant Divide::Result
. And because of this (it is a constant), we can use it inside and outside the module.
Look what happens if you try to create a result without one of the expected types.
Divide::Result::Success(:ok)
Divide::Result::Failure(:err)
The mixin mode is similar to Solid::Result::Mixin
, but it also defines the expectations for the result's types and values.
class Divide
include Solid::Result::Expectations.mixin(
success: %i[numbers division_completed],
failure: %i[invalid_arg division_by_zero]
)
def call(arg1, arg2)
validate_numbers(arg1, arg2)
.and_then(:validate_nonzero)
.and_then(:divide)
end
private
def validate_numbers(arg1, arg2)
arg1.is_a?(Numeric) or return Failure(:invalid_arg, 'arg1 must be numeric')
arg2.is_a?(Numeric) or return Failure(:invalid_arg, 'arg2 must be numeric')
Success(:numbers, [arg1, arg2])
end
def validate_nonzero(numbers)
return Success(:numbers, numbers) unless numbers.last.zero?
Failure(:division_by_zero, 'arg2 must not be zero')
end
def divide((number1, number2))
Success(:division_completed, number1 / number2)
end
end
This mode also defines an Result
constant to be used inside and outside the module.
PROTIP:
You can use the Result
constant to mock the result's type and value in your tests. As they will have the exact expectations, your tests will check if the result clients are handling the result correctly.
Now that you know the two modes, let's understand how expectations can be beneficial and powerful for defining contracts.
⬆️ back to top
Type checking - Result Hooks
The Solid::Result::Expectations
will check if the type of the result is valid. This checking will be performed in all methods that depend on the result’s type, such as #success?
, #failure?
, #on
, #on_type
, #on_success
, #on_failure
, and #handle
.
#success?
and #failure?
When checking whether a result is a success or failure, Solid::Result::Expectations
will also verify if the result type is valid/expected. In case of an invalid type, an error will be raised.
Success example:
result = Divide.new.call(10, 2)
result.success?
result.success?(:numbers)
result.success?(:division_completed)
result.success?(:ok)
Failure example:
result = Divide.new.call(10, '2')
result.failure?
result.failure?(:invalid_arg)
result.failure?(:division_by_zero)
result.failure?(:err)
PS: The Divide
implementation is here.
⬆️ back to top
#on
and #on_type
If you use #on
or #on_type
to execute a block, Solid::Result::Expectations
will check whether the result type is valid/expected. Otherwise, an error will be raised.
result = Divide.new.call(10, 2)
result
.on(:invalid_arg, :division_by_zero) { |msg| puts msg }
.on(:division_completed) { |number| puts "The result is #{number}" }
result.on(:number) { |_| :this_type_does_not_exist }
PS: The Divide
implementation is here.
⬆️ back to top
#on_success
and #on_failure
If you use #on_success
or #on_failure
to execute a block, Solid::Result::Expectations
will check whether the result type is valid/expected. Otherwise, an error will be raised.
result = Divide.new.call(10, '2')
result
.on_failure(:invalid_arg, :division_by_zero) { |msg| puts msg }
.on_success(:division_completed) { |number| puts "The result is #{number}" }
result
.on_success { |number| puts "The result is #{number}" }
.on_failure { |msg| puts msg }
result.on_success(:ok) { |_| :this_type_does_not_exist }
result.on_failure(:err) { |_| :this_type_does_not_exist }
PS: The Divide
implementation is here.
⬆️ back to top
#handle
The Solid::Result::Expectations
will also be applied on all the handlers defined by the #handle
method/block.
result = Divide.call(10, 2)
result.handle do |on|
on.type(:ok) { |_| :this_type_does_not_exist }
end
result.handle do |on|
on.success(:ok) { |_| :this_type_does_not_exist }
end
result.handle do |on|
on.failure(:err) { |_| :this_type_does_not_exist }
end
PS: The Divide
implementation is here.
⬆️ back to top
Type checking - Result Creation
The Solid::Result::Expectations
will be used on the result creation Success()
and Failure()
methods. So, when the result type is valid/expected, the result will be created. Otherwise, an error will be raised.
This works for both modes (standalone and mixin).
Mixin mode
module Divide
extend Solid::Result::Expectations.mixin(success: :ok, failure: :err)
def self.call(arg1, arg2)
arg1.is_a?(Numeric) or return Failure(:invalid_arg, 'arg1 must be numeric')
arg2.is_a?(Numeric) or return Failure(:invalid_arg, 'arg2 must be numeric')
arg2.zero? and return Failure(:division_by_zero, 'arg2 must not be zero')
Success(:division_completed, arg1 / arg2)
end
end
Divide.call('4', 2)
Divide.call(4, 2)
⬆️ back to top
Standalone mode
module Divide
Result = Solid::Result::Expectations.new(success: :ok, failure: :err)
def self.call(arg1, arg2)
arg1.is_a?(Numeric) or return Result::Failure(:invalid_arg, 'arg1 must be numeric')
arg2.is_a?(Numeric) or return Result::Failure(:invalid_arg, 'arg2 must be numeric')
arg2.zero? and return Result::Failure(:division_by_zero, 'arg2 must not be zero')
Result::Success(:division_completed, arg1 / arg2)
end
end
Divide.call('4', 2)
Divide.call(4, 2)
⬆️ back to top
Value checking - Result Creation
The Result::Expectations
supports two types of validations. The first is the type checking only, and the second is the type and value checking.
To define expectations for your result's values, you must declare a Hash with the type as the key and the value as the value. A value validator is any object that responds to #===
(case equality operator).
Mixin mode:
module Divide
extend Solid::Result::Expectations.mixin(
success: {
numbers: ->(value) { value.is_a?(Array) && value.size == 2 && value.all?(Numeric) },
division_completed: Numeric
},
failure: {
invalid_arg: String,
division_by_zero: String
}
)
def self.call(arg1, arg2)
arg1.is_a?(Numeric) or return Failure(:invalid_arg, 'arg1 must be numeric')
arg2.is_a?(Numeric) or return Failure(:invalid_arg, 'arg2 must be numeric')
arg2.zero? and return Failure(:division_by_zero, 'arg2 must not be zero')
Success(:division_completed, arg1 / arg2)
end
end
Standalone mode:
module Divide
Result = Solid::Result::Expectations.new(
success: {
numbers: ->(value) { value.is_a?(Array) && value.size == 2 && value.all?(Numeric) },
division_completed: Numeric
},
failure: {
invalid_arg: String,
division_by_zero: String
}
)
def self.call(arg1, arg2)
arg1.is_a?(Numeric) or return Result::Failure(:invalid_arg, 'arg1 must be numeric')
arg2.is_a?(Numeric) or return Result::Failure(:invalid_arg, 'arg2 must be numeric')
arg2.zero? and return Result::Failure(:division_by_zero, 'arg2 must not be zero')
Result::Success(:division_completed, arg1 / arg2)
end
end
The value validation will only be performed through the methods Success()
and Failure()
.
⬆️ back to top
Success()
Divide::Result::Success(:ok)
Divide::Result::Success(:numbers, [1])
Divide::Result::Success(:division_completed, '2')
Failure()
Divide::Result::Failure(:err)
Divide::Result::Failure(:invalid_arg, :arg1_must_be_numeric)
Divide::Result::Failure(:division_by_zero, msg: 'arg2 must not be zero')
⬆️ back to top
Pattern Matching Support
The value checking has support for handling pattern-matching errors, and the cleanest way to do it is using the one-line pattern matching operators (=>
since Ruby 3.0) and (in
Ruby 2.7).
How does this operator work? They raise an error when the pattern does not match but returns nil when it matches.
Because of this, you will need to enable nil
as a valid value checking. You can do it through the Solid::Result.configuration
or by allowing it directly on the mixin config.
module Divide
extend Solid::Result::Expectations.mixin(
config: {
pattern_matching: { nil_as_valid_value_checking: true }
},
success: {
division_completed: ->(value) { value => (Integer | Float) }
},
failure: { invalid_arg: String, division_by_zero: String }
)
def self.call(arg1, arg2)
arg1.is_a?(Numeric) or return Failure(:invalid_arg, 'arg1 must be numeric')
arg2.is_a?(Numeric) or return Failure(:invalid_arg, 'arg2 must be numeric')
arg2.zero? and return Failure(:division_by_zero, 'arg2 must not be zero')
Success(:division_completed, String(arg1 / arg2))
end
end
Divide.call(10, 5)
# value "2" is not allowed for :division_completed type ("2": Float === "2" does not return true) (Solid::Result::Contract::Error::UnexpectedValue)
⬆️ back to top
Solid::Result::Expectations.mixin
add-ons
The Solid::Result::Expectations.mixin
also accepts the config:
argument. It is a hash that can be used to define custom behaviors for the mixin.
Continue
It is similar to Solid::Result.mixin(config: { addon: { continue: true } })
. The key difference is that the expectations will ignore the Continue(value)
.
Based on this, use the Success()
to produce a terminal result and Continue()
to produce a result that will be used in the next step.
class Divide
include Solid::Result::Expectations.mixin(
config: { addon: { continue: true } },
success: :division_completed,
failure: %i[invalid_arg division_by_zero]
)
def call(arg1, arg2)
validate_numbers(arg1, arg2)
.and_then(:validate_nonzero)
.and_then(:divide)
end
private
def validate_numbers(arg1, arg2)
arg1.is_a?(::Numeric) or return Failure(:invalid_arg, 'arg1 must be numeric')
arg2.is_a?(::Numeric) or return Failure(:invalid_arg, 'arg2 must be numeric')
Continue([arg1, arg2])
end
def validate_nonzero(numbers)
return Continue(numbers) unless numbers.last.zero?
Failure(:division_by_zero, 'arg2 must not be zero')
end
def divide((number1, number2))
Success(:division_completed, number1 / number2)
end
end
result = Divide.new.call(4, 2)
result.success?(:ok)
⬆️ back to top
Solid::Output
The Solid::Output
is a Solid::Result
, meaning it has all the features of the Solid::Result
. The main difference is that it only accepts keyword arguments as a value, which applies to the and_then
: The called methods must receive keyword arguments, and the dependency injection will be performed through keyword arguments.
As the input/output are hashes, the results of each and_then
call will automatically accumulate. This is useful in operations chaining, as the result of the previous operations will be automatically available for the next one. Because of this behavior, the Solid::Output
has the #and_expose
method to expose only the desired keys from the accumulated result.
Defining successes and failures
As the Solid::Result
, you can declare success and failures directly from Solid::Output
.
Solid::Output::Success(:ok, a: 1, b: 2)
#<Solid::Output::Success type=:ok value={:a=>1, :b=>2}>
Solid::Output::Failure(:err, message: 'something went wrong')
#<Solid::Output::Failure type=:err value={:message=>"something went wrong"}>
But different from Solid::Result
that accepts any value, the Solid::Output
only takes keyword arguments.
Solid::Output::Success(:ok, [1, 2])
Solid::Output::Failure(:err, { message: 'something went wrong' })
Solid::Output::Success(:ok, **{ message: 'hashes can be converted to keyword arguments' })
#<Solid::Output::Success type=:ok value={:message=>"hashes can be converted to keyword arguments"}>
⬆️ back to top
Hash methods
The Solid::Output
only accepts hashes as its values. Because of this, its instances have some Hash's methods to query/access the values. The available methods are:
#slice
to extract only the desired keys.#[]
, #dig
, #fetch
to access the values.#values_at
and #fetch_values
to get the values of the desired keys.
result = Solid::Output::Success(:ok, a: 1, b: 2, c: {d: 4})
result[:a]
result.fetch(:a)
result.dig(:c, :d)
result.slice(:a, :b)
result.values_at(:a, :b)
result.fetch_values(:a, :b)
These methods are available for Solid::Output::Success
and Solid::Output::Failure
instances.
⬆️ back to top
Solid::Output.mixin
As in the Solid::Result
, you can use the Solid::Output.mixin
to add the Success()
and Failure()
methods to your classes/modules.
Let's see this feature and the data accumulation in action:
Class example (Instance Methods)
require 'logger'
class Divide
include Solid::Output.mixin
def call(arg1, arg2, logger: ::Logger.new(STDOUT))
validate_numbers(arg1, arg2)
.and_then(:validate_nonzero)
.and_then(:divide, logger: logger)
end
private
def validate_numbers(arg1, arg2)
arg1.is_a?(::Numeric) or return Failure(:err, message: 'arg1 must be numeric')
arg2.is_a?(::Numeric) or return Failure(:err, message: 'arg2 must be numeric')
Success(:ok, number1: arg1, number2: arg2)
end
def validate_nonzero(number2:, **)
return Success(:ok) if number2.nonzero?
Failure(:err, message: 'arg2 must not be zero')
end
def divide(number1:, number2:, logger:)
result = number1 / number2
logger.info("The division result is #{result}")
Success(:ok, number: result)
end
end
Divide.new.call(10, 5)
#<Solid::Output::Success type=:ok value={:number=>2}>
Divide.new.call('10', 5)
#<Solid::Output::Failure type=:err value={:message=>"arg1 must be numeric"}>
Divide.new.call(10, '5')
#<Solid::Output::Failure type=:err value={:message=>"arg2 must be numeric"}>
Divide.new.call(10, 0)
#<Solid::Output::Failure type=:err value={:message=>"arg2 must not be zero"}>
⬆️ back to top
and_expose
This allows you to expose only the desired keys from the accumulated result. It can be used with any Solid::Output
object.
Let's add it to the previous example:
class Divide
include Solid::Output.mixin
def call(arg1, arg2)
validate_numbers(arg1, arg2)
.and_then(:validate_nonzero)
.and_then(:divide)
.and_expose(:division_completed, [:number])
end
private
def validate_numbers(arg1, arg2)
arg1.is_a?(::Numeric) or return Failure(:err, message: 'arg1 must be numeric')
arg2.is_a?(::Numeric) or return Failure(:err, message: 'arg2 must be numeric')
Success(:ok, number1: arg1, number2: arg2)
end
def validate_nonzero(number2:, **)
return Success(:ok) if number2.nonzero?
Failure(:err, message: 'arg2 must not be zero')
end
def divide(**input)
Success(:ok, number: input.values.reduce(:/), **input)
end
end
Divide.new.call(10, 5)
#<Solid::Output::Success type=:division_completed value={:number=>2}>
As you can see, even with divide
success exposing the division number with all the accumulated data (**input
), the #and_expose
could generate a new success with a new type and only with the desired keys.
Remove the #and_expose
call to see the difference. This will be the outcome:
Divide.new.call(10, 5)
#<Solid::Output::Success type=:ok value={:number=>2, :number1=>10, :number2=>5}>
PS: The #and_expose
produces a terminal success by default. This means the next step will not be executed even if you call #and_then
after #and_expose
. To change this behavior, you can pass terminal: false
to #and_expose
.
⬆️ back to top
Module example (Singleton Methods)
Solid::Output.mixin
can also produce singleton methods. Below is an example using a module (but it could be a class, too).
module Divide
extend self, Solid::Output.mixin
def call(arg1, arg2)
validate_numbers(arg1, arg2)
.and_then(:validate_nonzero)
.and_then(:divide)
.and_expose(:division_completed, [:number])
end
private
def validate_numbers(arg1, arg2)
arg1.is_a?(::Numeric) or return Failure(:err, message: 'arg1 must be numeric')
arg2.is_a?(::Numeric) or return Failure(:err, message: 'arg2 must be numeric')
Success(:ok, number1: arg1, number2: arg2)
end
def validate_nonzero(number2:, **)
return Success(:ok) if number2.nonzero?
Failure(:err, message: 'arg2 must not be zero')
end
def divide(number1:, number2:)
Success(:ok, number: number1 / number2)
end
end
Divide.call(10, 5)
#<Solid::Output::Success type=:division_completed value={:number=>2}>
Divide.call('10', 5)
#<Solid::Output::Failure type=:err value={:message=>"arg1 must be numeric"}>
Divide.call(10, '5')
#<Solid::Output::Failure type=:err value={:message=>"arg2 must be numeric"}>
Divide.call(10, 0)
#<Solid::Output::Failure type=:err value={:message=>"arg2 must not be zero"}>
⬆️ back to top
Solid::Output::Expectations
The Solid::Output::Expectations
is a Solid::Result::Expectations
with the Solid::Output
features.
This is an example using the mixin mode, but the standalone mode is also supported.
class Divide
include Solid::Output::Expectations.mixin(
config: {
pattern_matching: { nil_as_valid_value_checking: true }
},
success: {
division_completed: ->(value) { value => { number: Numeric } }
},
failure: {
invalid_arg: ->(value) { value => { message: String } },
division_by_zero: ->(value) { value => { message: String } }
}
)
def call(arg1, arg2)
arg1.is_a?(Numeric) or return Failure(:invalid_arg, message: 'arg1 must be numeric')
arg2.is_a?(Numeric) or return Failure(:invalid_arg, message: 'arg2 must be numeric')
arg2.zero? and return Failure(:division_by_zero, message: 'arg2 must not be zero')
Success(:division_completed, number: (arg1 / arg2))
end
end
Divide.new.call(10, 5)
#<Solid::Output::Success type=:division_completed value={:number=>2}>
As in the Solid::Result::Expectations.mixin
, the Solid::Output::Expectations.mixin
will add a Result constant in the target class. It can generate success/failure results, which ensure the mixin expectations.
Let's see this using the previous example:
Divide::Result::Success(:division_completed, number: 2)
#<Solid::Output::Success type=:division_completed value={:number=>2}>
Divide::Result::Success(:division_completed, number: '2')
⬆️ back to top
Mixin add-ons
The Solid::Output.mixin
and Solid::Output::Expectations.mixin
also accepts the config:
argument. And it works the same way as the Solid::Result
mixins.
given
This addon is enabled by default. It will create the Given(*value)
method. Use it to add a value to the result chain and invoke the next step (through and_then
).
You can turn it off by passing given: false
to the config:
argument or using the Solid::Result.configuration
.
The Given()
addon for a Solid::Output can be called with one or more arguments. The arguments will be converted to a hash (to_h
) and merged to define the first value of the result chain.
continue
The Solid::Output.mixin(config: { addon: { continue: true } })
or Solid::Output::Expectations.mixin(config: { addon: { continue: true } })
creates the Continue(value)
method and change the Success()
behavior to terminate the step chain.
So, if you want to advance to the next step, you must use Continue(**value)
instead of Success(type, **value)
. Otherwise, the step chain will be terminated.
Let's use a mix of Solid::Output
features to see in action with this add-on:
module Division
require 'logger'
extend self, Solid::Output::Expectations.mixin(
config: {
addon: { continue: true },
pattern_matching: { nil_as_valid_value_checking: true }
},
success: {
division_completed: ->(value) { value => { number: Numeric } }
},
failure: {
invalid_arg: ->(value) { value => { message: String } },
division_by_zero: ->(value) { value => { message: String } }
}
)
def call(arg1, arg2, logger: ::Logger.new(STDOUT))
Given(number1: arg1, number2: arg2)
.and_then(:require_numbers)
.and_then(:check_for_zeros)
.and_then(:divide, logger: logger)
.and_expose(:division_completed, [:number])
end
private
def require_numbers(number1:, number2:)
number1.is_a?(::Numeric) or return Failure(:invalid_arg, message: 'arg1 must be numeric')
number2.is_a?(::Numeric) or return Failure(:invalid_arg, message: 'arg2 must be numeric')
Continue()
end
def check_for_zeros(number1:, number2:)
return Failure(:division_by_zero, message: 'arg2 must not be zero') if number2.zero?
return Success(:division_completed, number: 0) if number1.zero?
Continue()
end
def divide(number1:, number2:, logger:)
result = number1 / number2
logger.info("The division result is #{result}")
Continue(number: result)
end
end
Division.call(14, 2)
#<Solid::Output::Success type=:division_completed value={:number=>7}>
Division.call(0, 2)
Division.call('14', 2)
#<Solid::Output::Failure type=:invalid_arg value={:message=>"arg1 must be numeric"}>
Division.call(14, '2')
#<Solid::Output::Failure type=:invalid_arg value={:message=>"arg2 must be numeric"}>
Division.call(14, 0)
#<Solid::Output::Failure type=:division_by_zero value={:message=>"arg2 must not be zero"}>
⬆️ back to top
Pattern Matching
The Solid::Result
and Solid::Output
also provides support to pattern matching.
Solid::Result
In the further examples, I will use the Divide
lambda to exemplify its usage.
Divide = lambda do |arg1, arg2|
arg1.is_a?(::Numeric) or return Solid::Result::Failure(:invalid_arg, 'arg1 must be numeric')
arg2.is_a?(::Numeric) or return Solid::Result::Failure(:invalid_arg, 'arg2 must be numeric')
return Solid::Result::Failure(:division_by_zero, 'arg2 must not be zero') if arg2.zero?
Solid::Result::Success(:division_completed, arg1 / arg2)
end
Array
/Find
patterns
case Divide.call(4, 2)
in Solid::Failure[:invalid_arg, msg] then puts msg
in Solid::Failure[:division_by_zero, msg] then puts msg
in Solid::Success[:division_completed, num] then puts num
end
case Divide.call(4, 0)
in Solid::Failure[:invalid_arg, msg] then puts msg
in Solid::Failure[:division_by_zero, msg] then puts msg
in Solid::Success[:division_completed, num] then puts num
end
⬆️ back to top
Hash
patterns
case Divide.call(10, 2)
in Solid::Failure(type: :invalid_arg, value: msg) then puts msg
in Solid::Failure(type: :division_by_zero, value: msg) then puts msg
in Solid::Success(type: :division_completed, value: num) then puts num
end
case Divide.call('10', 2)
in Solid::Failure(type: :invalid_arg, value: msg) then puts msg
in Solid::Failure(type: :division_by_zero, value: msg) then puts msg
in Solid::Success(type: :division_completed, value: num) then puts num
end
You can also use Solid::Result::Success
and Solid::Result::Failure
as patterns.
⬆️ back to top
Solid::Output
In the further examples, I will use the Divide
lambda to exemplify its usage.
Divide = lambda do |arg1, arg2|
arg1.is_a?(::Numeric) or return Solid::Output::Failure(:invalid_arg, err: 'arg1 must be numeric')
arg2.is_a?(::Numeric) or return Solid::Output::Failure(:invalid_arg, err: 'arg2 must be numeric')
return Solid::Output::Failure(:division_by_zero, err: 'arg2 must not be zero') if arg2.zero?
Solid::Output::Success(:division_completed, num: arg1 / arg2)
end
Array
/Find
patterns
case Divide.call(4, 2)
in Solid::Failure[:invalid_arg, {msg:}] then puts msg
in Solid::Failure[:division_by_zero, {msg:}] then puts msg
in Solid::Success[:division_completed, {num:}] then puts num
end
case Divide.call(4, 0)
in Solid::Failure[:invalid_arg, {msg:}] then puts msg
in Solid::Failure[:division_by_zero, {msg:}] then puts msg
in Solid::Success[:division_completed, {num:}] then puts num
end
⬆️ back to top
Hash
patterns
If you don't provide the keys :type and :value, the pattern will match the result value.
case Divide.call(10, 2)
in Solid::Failure({msg:}) then puts msg
in Solid::Success({num:}) then puts num
end
case Divide.call(10, 2)
in Solid::Failure(type: :invalid_arg, value: {msg:}) then puts msg
in Solid::Failure(type: :division_by_zero, value: {msg:}) then puts msg
in Solid::Success(type: :division_completed, value: {num:}) then puts num
end
case Divide.call('10', 2)
in Solid::Failure(type: :invalid_arg, value: {msg:}) then puts {msg:}
in Solid::Failure(type: :division_by_zero, value: {msg:}) then puts msg
in Solid::Success(type: :division_completed, value: {num:}) then puts num
end
You can also use Solid::Output::Success
and Solid::Output::Failure
as patterns.
⬆️ back to top
How to pattern match without the concept of success and failure
You can use the classes Solid::Result
and Solid::Output
as patterns, and the pattern matching will work without the concept of success and failure.
case Divide.call(10, 2)
in Solid::Output(:invalid_arg, {msg:}) then puts msg
in Solid::Output(:division_by_zero, {msg:}) then puts msg
in Solid::Output(:division_completed, {num:}) then puts num
end
case Divide.call(10, 2)
in Solid::Result(:invalid_arg, msg) then puts msg
in Solid::Result(:division_by_zero, msg) then puts msg
in Solid::Result(:division_completed, num) then puts num
end
The Solid::Result
will also work with the Solid::Output
, but the opposite won't.
⬆️ back to top
Solid::Result.event_logs
Use Solid::Result.event_logs(&block)
to track all the results produced in the same or between different operations (it works with Solid::Result
and Solid::Output
). When there is a nesting of event_logs
blocks, this mechanism will be able to correlate parent and child blocks and present the duration of all operations in milliseconds.
When you wrap the creation of the result with Solid::Result.event_logs
, the final one will expose all the event log records through the Solid::Result#event_logs
method.
class Division
include Solid::Result.mixin(config: { addon: { continue: true } })
def call(arg1, arg2)
Solid::Result.event_logs(name: 'Division', desc: 'divide two numbers') do
Given([arg1, arg2])
.and_then(:require_numbers)
.and_then(:check_for_zeros)
.and_then(:divide)
end
end
private
ValidNumber = ->(arg) { arg.is_a?(Numeric) && (!arg.respond_to?(:finite?) || arg.finite?) }
def require_numbers((arg1, arg2))
ValidNumber[arg1] or return Failure(:invalid_arg, 'arg1 must be a valid number')
ValidNumber[arg2] or return Failure(:invalid_arg, 'arg2 must be a valid number')
Continue([arg1, arg2])
end
def check_for_zeros(numbers)
num1, num2 = numbers
return Failure(:division_by_zero, 'num2 cannot be zero') if num2.zero?
num1.zero? ? Success(:division_completed, 0) : Continue(numbers)
end
def divide((num1, num2))
Success(:division_completed, num1 / num2)
end
end
module SumDivisionsByTwo
extend self, Solid::Result.mixin
def call(*numbers)
Solid::Result.event_logs(name: 'SumDivisionsByTwo') do
divisions = numbers.map { |number| Division.new.call(number, 2) }
if divisions.any?(&:failure?)
Failure(:errors, divisions.select(&:failure?).map(&:value))
else
Success(:sum, divisions.sum(&:value))
end
end
end
end
Let's see the result of the SumDivisionsByTwo
call:
result = SumDivisionsByTwo.call(20, 10)
result.event_logs
{
:version => 1,
:metadata => {
:duration => 0,
:trace_id => nil,
:ids => {
:tree => [0, [[1, []], [2, []]]],
:matrix => { 0 => [0, 0], 1 => [1, 1], 2 => [2, 1]},
:level_parent => { 0 => [0, 0], 1 => [1, 0], 2 => [1, 0]}
}
},
:records=> [
{
:root => {:id=>0, :name=>"SumDivisionsByTwo", :desc=>nil},
:parent => {:id=>0, :name=>"SumDivisionsByTwo", :desc=>nil},
:current => {:id=>1, :name=>"Division", :desc=>"divide two numbers"},
:result => {:kind=>:success, :type=>:_given_, :value=>[20, 2], :source=><Division:0x0000000102fd7ed0>},
:and_then => {},
:time => 2024-01-26 02:53:11.310346 UTC
},
{
:root => {:id=>0, :name=>"SumDivisionsByTwo", :desc=>nil},
:parent => {:id=>0, :name=>"SumDivisionsByTwo", :desc=>nil},
:current => {:id=>1, :name=>"Division", :desc=>"divide two numbers"},
:result => {:kind=>:success, :type=>:_continue_, :value=>[20, 2], :source=><Division:0x0000000102fd7ed0>},
:and_then => {:type=>:method, :arg=>nil, :method_name=>:require_numbers},
:time => 2024-01-26 02:53:11.310392 UTC
},
{
:root => {:id=>0, :name=>"SumDivisionsByTwo", :desc=>nil},
:parent => {:id=>0, :name=>"SumDivisionsByTwo", :desc=>nil},
:current => {:id=>1, :name=>"Division", :desc=>"divide two numbers"},
:result => {:kind=>:success, :type=>:_continue_, :value=>[20, 2], :source=><Division:0x0000000102fd7ed0>},
:and_then => {:type=>:method, :arg=>nil, :method_name=>:check_for_zeros},
:time=>2024-01-26 02:53:11.310403 UTC
},
{
:root => {:id=>0, :name=>"SumDivisionsByTwo", :desc=>nil},
:parent => {:id=>0, :name=>"SumDivisionsByTwo", :desc=>nil},
:current => {:id=>1, :name=>"Division", :desc=>"divide two numbers"},
:result => {:kind=>:success, :type=>:division_completed, :value=>10, :source=><Division:0x0000000102fd7ed0>},
:and_then => {:type=>:method, :arg=>nil, :method_name=>:divide},
:time => 2024-01-26 02:53:11.310409 UTC
},
{
:root => {:id=>0, :name=>"SumDivisionsByTwo", :desc=>nil},
:parent => {:id=>0, :name=>"SumDivisionsByTwo", :desc=>nil},
:current => {:id=>2, :name=>"Division", :desc=>"divide two numbers"},
:result => {:kind=>:success, :type=>:_given_, :value=>[10, 2], :source=><Division:0x0000000102fd6378>},
:and_then => {},
:time => 2024-01-26 02:53:11.310424 UTC
},
{
:root => {:id=>0, :name=>"SumDivisionsByTwo", :desc=>nil},
:parent => {:id=>0, :name=>"SumDivisionsByTwo", :desc=>nil},
:current => {:id=>2, :name=>"Division", :desc=>"divide two numbers"},
:result => {:kind=>:success, :type=>:_continue_, :value=>[10, 2], :source=><Division:0x0000000102fd6378>},
:and_then => {:type=>:method, :arg=>nil, :method_name=>:require_numbers},
:time => 2024-01-26 02:53:11.310428 UTC
},
{
:root => {:id=>0, :name=>"SumDivisionsByTwo", :desc=>nil},
:parent => {:id=>0, :name=>"SumDivisionsByTwo", :desc=>nil},
:current => {:id=>2, :name=>"Division", :desc=>"divide two numbers"},
:result => {:kind=>:success, :type=>:_continue_, :value=>[10, 2], :source=><Division:0x0000000102fd6378>},
:and_then => {:type=>:method, :arg=>nil, :method_name=>:check_for_zeros},
:time => 2024-01-26 02:53:11.310431 UTC
},
{
:root => {:id=>0, :name=>"SumDivisionsByTwo", :desc=>nil},
:parent => {:id=>0, :name=>"SumDivisionsByTwo", :desc=>nil},
:current => {:id=>2, :name=>"Division", :desc=>"divide two numbers"},
:result => {:kind=>:success, :type=>:division_completed, :value=>5, :source=><Division:0x0000000102fd6378>},
:and_then => {:type=>:method, :arg=>nil, :method_name=>:divide},
:time => 2024-01-26 02:53:11.310434 UTC
},
{
:root => {:id=>0, :name=>"SumDivisionsByTwo", :desc=>nil},
:parent => {:id=>0, :name=>"SumDivisionsByTwo", :desc=>nil},
:current => {:id=>0, :name=>"SumDivisionsByTwo", :desc=>nil},
:result => {:kind=>:success, :type=>:sum, :value=>15, :source=>SumDivisionsByTwo},
:and_then => {},
:time => 2024-01-26 02:53:11.310444 UTC
}
]
}
⬆️ back to top
metadata: {ids:}
The :ids
metadata property is a hash with three properties:
:tree
, a graph/tree representation of the id of each event_logs
block.:level_parent
, a hash with the level (depth) of each block and its parent id.:matrix
, a matrix representation of the event logs ids. It is a simplification of the :tree
property.
Use these data structures to build your own visualization.
Check out Event Logs Listener example to see how a listener can be used to build a STDOUT visualization, using these properties.
0
|- 1 # [1, [[2, []]]],
| |- 2 # [3, []],
|- 3
|- 4 # [5, []],
| |- 5 # [6, [[7, []]]]
| |- 6 # ]],
| |- 7 # [8, []]
|- 8
0
|- 1 # 1 => [1, 0],
| |- 2 # 2 => [2, 1],
|- 3
|- 4 # 4 => [1, 0],
| |- 5 # 5 => [2, 4],
| |- 6 # 6 => [2, 4],
| |- 7 # 7 => [3, 6],
|- 8
0 | 1 | 2 | 3 | 4
- | - | - | - | -
0 | | | |
1 | 1 | 2 | |
2 | 3 | | |
3 | 4 | 5 | 6 | 7
4 | 8 | | |
⬆️ back to top
Configuration
Turning on/off
You can use Solid::Result.config.feature.disable!(event_logs)
and Solid::Result.config.feature.enable!(event_logs)
to turn on/off the Solid::Result.event_logs
feature.
Solid::Result.configuration do |config|
config.feature.disable!(event_logs)
end
result = SumDivisionsByTwo.call(20, 10)
result.event_logs
{
:version=>1,
:records=>[],
:metadata=>{
:duration=>0,
:ids=>{:tree=>[], :matrix=>{}, :level_parent=>{}}, :trace_id=>nil
}
}
⬆️ back to top
Setting a trace_id
fetcher
You can define a lambda (arity 0) to fetch the trace_id. This lambda will be called before the first event logs block and will be used to set the :trace_id
in the :metadata
property.
Use to correlate different or the same operation (executed multiple times).
Solid::Result.config.event_logs.trace_id = -> { Thread.current[:solid_result_event_logs_trace_id] }
⬆️ back to top
Setting a listener
You can define a listener to be called during the event logs tracking (check out this example). It must be a class that includes Solid::Result::EventLogs::Listener
.
Use it to build your additional logic on top of the tracking. Examples:
- Log the event logs.
- Perform the tracing.
- Instrument the event logs (measure/report).
- Build a visualization (Diagrams, using the
records:
+ metadata: {ids:}
properties).
After implementing your listener, you can set it to the Solid::Result.config.event_logs.listener=
:
Solid::Result.config.event_logs.listener = MyEventLogsListener
See the example below to understand how to implement one:
class MyEventLogsListener
include Solid::Result::EventLogs::Listener
def initialize
end
def on_start(scope:)
end
def around_event_logs(scope:)
yield
end
def around_and_then(scope:, and_then:)
yield
end
def on_record(record:)
end
def on_finish(event_logs:)
end
def before_interruption(exception:, event_logs:)
end
end
⬆️ back to top
Setting multiple listeners
You can use Solid::Result::EventLogs::Listeners[]
to creates a listener of listeners (check out this example), which will be called in the order they were added.
Attention: It only allows one listener to handle around_and_then
and another around_event_logs
records.
The example below defines different listeners to handle around_and_then
and around_event_logs,
but it is also possible to define a listener to handle both.
class AroundAndThenListener
include Solid::Result::EventLogs::Listener
def self.around_and_then?
true
end
def around_and_then(scope:, and_then:)
end
end
class AroundEventLogsListener
include Solid::Result::EventLogs::Listener
def self.around_event_logs?
true
end
def around_event_logs(scope:)
end
end
class MyEventLogsListener
include Solid::Result::EventLogs::Listener
end
How to use it:
Solid::Result.config.event_logs.listener = Solid::Result::EventLogs::Listeners[
MyEventLogsListener,
AroundAndThenListener,
AroundEventLogsListener
]
Check out this example to see a listener to print the event logs and another to store them in the database.
⬆️ back to top
Solid::Result.configuration
The Solid::Result.configuration
allows you to configure default behaviors for Solid::Result
and Solid::Output
through a configuration block. After using it, the configuration is frozen, ensuring the expected behaviors for your application.
Note: You can use Solid::Result.configuration(freeze: false) {}
to avoid the freezing. This can be useful in tests. Please be sure to use it with caution.
Solid::Result.configuration do |config|
config.addon.enable!(:given, :continue)
config.constant_alias.enable!('Result', 'Solid::Output')
config.pattern_matching.disable!(:nil_as_valid_value_checking)
end
Use disable!
to disable a feature and enable!
to enable it.
Let's see what each configuration in the example above does:
config.addon.enable!(:given, :continue)
This configuration enables the Continue()
method for Solid::Result.mixin
, Solid::Output.mixin
, Solid::Result::Expectation.mixin
, and Solid::Output::Expectation.mixin
. Link to documentations: (1) (2).
It is also enabling the Given()
which is already enabled by default. Link to documentation: (1) (2).
config.constant_alias.enable!('Result', 'Solid::Output')
This configuration make Result
a constant alias for Solid::Result
, and Solid::Output
a constant alias for Solid::Output
.
Link to documentations:
config.pattern_matching.disable!(:nil_as_valid_value_checking)
This configuration disables the nil_as_valid_value_checking
for Solid::Result
and Solid::Output
. Link to documentation.
config.feature.disable!(:expectations)
This configuration turns off the expectations for Solid::Result
and Solid::Output
. The expectations are helpful in development and test environments, but they can be disabled in production environments for performance gain.
PS: I'm using ::Rails.env.production?
to check the environment, but you can use any logic you want.
⬆️ back to top
Solid::Result.config
The Solid::Result.config
allows you to access the current configuration.
Solid::Result.config.addon
Solid::Result.config.addon.enabled?(:continue)
Solid::Result.config.addon.enabled?(:given)
Solid::Result.config.addon.options
Solid::Result.config.constant_alias
Solid::Result.config.constant_alias.enabled?('Result')
Solid::Result.config.constant_alias.options
Solid::Result.config.pattern_matching
Solid::Result.config.pattern_matching.enabled?(:nil_as_valid_value_checking)
Solid::Result.config.pattern_matching.options
Solid::Result.config.feature
Solid::Result.config.feature.enabled?(:expectations)
Solid::Result.config.feature.options
⬆️ back to top
Solid::Result#and_then!
In the Ruby ecosystem, several gems facilitate operation composition using classes and modules. Two notable examples are the interactor
gem and the u-case
gem.
interactor
gem example
class PlaceOrder
include Interactor::Organizer
organize CreateOrder,
PayOrder,
SendOrderConfirmation,
NotifyAdmins
end
u-case
gem example
class PlaceOrder < Micro::Case
flow CreateOrder, PayOrder, SendOrderConfirmation, NotifyAdmins
end
class PlaceOrder < Micro::Case
def call!
call(CreateOrder)
.then(PayOrder)
.then(SendOrderConfirmation)
.then(NotifyAdmins)
end
end
To facilitate migration for users accustomed to the above approaches, solid-result
includes the Solid::Result#and_then!
/Solid::Output#and_then!
methods, which will invoke the method call
of the given operation and expect it to return a Solid::Result
/Solid::Output
object.
Solid::Result.configure do |config|
config.feature.enable!(:and_then!)
end
class PlaceOrder
include Solid::Output.mixin
def call(**input)
Given(input)
.and_then!(CreateOrder.new)
.and_then!(PayOrder.new)
.and_then!(SendOrderConfirmation.new)
.and_then!(NotifyAdmins.new)
end
end
⬆️ back to top
Dependency Injection
Like #and_then
, #and_then!
also supports an additional argument for dependency injection.
In Solid::Result
class PlaceOrder
include Solid::Result.mixin
def call(input, logger:)
Given(input)
.and_then!(CreateOrder.new, logger)
end
end
In Solid::Output
class PlaceOrder
include Solid::Output.mixin
def call(logger:, **input)
Given(input)
.and_then!(CreateOrder.new, logger:)
end
end
⬆️ back to top
Configuration
Solid::Result.configure do |config|
config.feature.enable!(:and_then!)
config.and_then!.default_method_name_to_call = :perform
end
Explanation:
⬆️ back to top
Analysis: Why is and_then!
an Anti-pattern?
The and_then!
approach, despite its brevity, introduces several issues:
-
Lack of Clarity: The input/output relationship between the steps is not apparent.
-
Steps Coupling: Each operation becomes interdependent (high coupling), complicating implementation and compromising the reusability of these operations.
We recommend cautious use of #and_then!
. Due to these issues, it is turned off by default and considered an antipattern.
It should be a temporary solution, primarily for assisting in migration from another to gem to solid-result
.
⬆️ back to top
#and_then
versus #and_then!
The main difference between the #and_then
and #and_then!
is that the latter does not check the result source. However, as a drawback, the result source will change.
Attention: to ensure the correct behavior, do not mix #and_then
and #and_then!
in the same result chain.
⬆️ back to top
Analysis: Why is #and_then
the antidote/standard?
The Solid::Result#and_then
/Solid::Output#and_then
methods diverge from the above approach by requiring explicit invocation and mapping of the outcomes at each process step. This approach has the following advantages:
-
Clarity: The input/output relationship between the steps is apparent and highly understandable.
-
Steps uncoupling: Each operation becomes independent (low coupling). You can even map a failure result to a success (and vice versa).
See this example to understand what your code should look like:
class PlaceOrder
include Solid::Output.mixin(config: { addon: { continue: true } })
def call(**input)
Given(input)
.and_then(:create_order)
.and_then(:pay_order)
.and_then(:send_order_confirmation)
.and_then(:notify_admins)
.and_expose(:order_placed, %i[order])
end
private
def create_order(customer:, products:)
CreateOrder.new.call(customer:, products:).handle do |on|
on.success { |output| Continue(order: output[:order]) }
on.failure { |error| Failure(:order_creation_failed, error:) }
end
end
def pay_order(customer:, order:, payment_method:, **)
PayOrder.new.call(customer:, payment_method:, order:).handle do |on|
on.success { |output| Continue(payment: output[:payment]) }
on.failure { |error| Failure(:order_payment_failed, error:) }
end
end
def send_order_confirmation(customer:, order:, payment:, **)
SendOrderConfirmation.new.call(customer:, order:, payment:).handle do |on|
on.success { Continue() }
on.failure { |error| Failure(:order_confirmation_failed, error:) }
end
end
def notify_admins(customer:, order:, payment:, **)
NotifyAdmins.new.call(customer:, order:, payment:)
Continue()
end
end
⬆️ back to top
About
Rodrigo Serradura created this project. He is the Solid Process creator and has already made similar gems like the u-case and kind. This gem can be used independently, but it also contains essential features that facilitate the adoption of Solid Process (the method) in code.
⬆️ back to top
Development
After checking out the repo, run bin/setup
to install dependencies. Then, run bundle exec rake
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 the created tag, and push the .gem
file to rubygems.org.
⬆️ back to top
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/solid-process/solid-result. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
⬆️ back to top
License
The gem is available as open source under the terms of the MIT License.
⬆️ back to top
Code of Conduct
Everyone interacting in the Solid::Result project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.