μ-service (Micro::Service)
Create simple and powerful service objects.
The main goals of this project are:
- The smallest possible learning curve (input >> process/transform >> output).
- Referential transparency and data integrity.
- No callbacks.
- Compose a pipeline of service objects to represents complex business logic.
Table of Contents
- μ-service (Micro::Service)
- Table of Contents
- Required Ruby version
- Installation
- Usage
- How to define a Service Object?
- What is a
Micro::Service::Result
? - How to create a pipeline of Service Objects?
- What is a strict Service Object?
- Is there some feature to auto handle exceptions inside of services/pipelines?
- How to validate Service Object attributes?
- Examples
- Comparisons
- Benchmarks
- Development
- Contributing
- License
- Code of Conduct
Required Ruby version
>= 2.2.0
Installation
Add this line to your application's Gemfile:
gem 'u-service'
And then execute:
$ bundle
Or install it yourself as:
$ gem install u-service
Usage
How to define a Service Object?
class Multiply < Micro::Service::Base
# 1. Define its inputs as attributes
attributes :a, :b
# 2. Define the method `call!` with its business logic
def call!
# 3. Return the calling result 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 Service Object 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 Service Object instance #
#-----------------------------------#
result = Multiply.new(a: 2, b: 3).call
result.value # 6
# Note:
# ----
# The result of a Micro::Service::Base.call
# is an instance of Micro::Service::Result
What is a Micro::Service::Result
?
A Micro::Service::Result
carries the output data of some Service Object. 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 flow of your application.#service
if the result is a failure the service will be accessible through this method. This feature is handy to use with pipeline failures (this topic will be covered ahead).
What are the default types of a Micro::Service::Result
?
Every result has a type and these are the default values: :ok when success, and :error/:exception when failures.
class Divide < Micro::Service::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.service # raises `Micro::Service::Error::InvalidAccessToTheServiceObject: only a failure result can access its service object`
# 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.service # #<Divide:0x0000 @__attributes={"a"=>2, "b"=>"2"}, @a=2, @b="2", @__result=#<Micro::Service::Result:0x0000 @service=#<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.service # #<Divide:0x0000 @__attributes={"a"=>2, "b"=>0}, @a=2, @b=0, @__result=#<Micro::Service::Result:0x0000 @service=#<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.
How to define custom result types?
Answer: Use a symbol as the argument of Success() and Failure() methods and declare a block to set the value.
class Multiply < Micro::Service::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
Is it possible to define a custom result type without a block?
Answer: Yes, it is. But only for failure results!
class Multiply < Micro::Service::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.service.attributes # {"a"=>2, "b"=>"2"}
# Note:
# ----
# This feature is handy to respond to some pipeline failure
# (this topic will be covered ahead).
How to use the result hooks?
As mentioned earlier, the Micro::Service::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::Service::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 is a success:
# 6
#=============================#
# Raising an error if failure #
#=============================#
Double
.call(number: -1)
.on_success { |number| p number }
.on_failure { |_msg, service| puts "#{service.class.name} was the service responsible for the failure" }
.on_failure(:invalid) { |msg| raise TypeError, msg }
.on_failure(:lte_zero) { |msg| raise ArgumentError, msg }
# The outputs because is a failure:
# Double was the service responsible for the failure
# (throws the error)
# ArgumentError (the number must be greater than 0)
# Note:
# ----
# The service responsible for the failure will be accessible as the second hook argument
What happens if a hook is declared multiple times?
Answer: The hook will be triggered if it matches the result type.
class Double < Micro::Service::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
How to create a pipeline of Service Objects?
module Steps
class ConvertToNumbers < Micro::Service::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::Service::Strict
attribute :numbers
def call!
Success(numbers: numbers.map { |number| number + 2 })
end
end
class Double < Micro::Service::Strict
attribute :numbers
def call!
Success(numbers: numbers.map { |number| number * 2 })
end
end
class Square < Micro::Service::Strict
attribute :numbers
def call!
Success(numbers: numbers.map { |number| number * number })
end
end
end
#-------------------------------------------------#
# Creating a pipeline using the collection syntax #
#-------------------------------------------------#
Add2ToAllNumbers = Micro::Service::Pipeline[
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 pipeline using classes #
#-------------------------------------------------------#
class DoubleAllNumbers
include Micro::Service::Pipeline
pipeline 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 pipeline 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 service object responsible for this
# will be accessible in the result
result = SquareAllNumbers.call(numbers: %w[1 1 b 2 3 4])
result.failure? # true
result.service.is_a?(Steps::ConvertToNumbers) # true
result.on_failure do |_message, service|
puts "#{service.class.name} was the service responsible for the failure" } # Steps::ConvertToNumbers was the service responsible for the failure
end
Is it possible to compose pipelines with other pipelines?
Answer: Yes, it is.
module Steps
class ConvertToNumbers < Micro::Service::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::Service::Strict
attribute :numbers
def call!
Success(numbers: numbers.map { |number| number + 2 })
end
end
class Double < Micro::Service::Strict
attribute :numbers
def call!
Success(numbers: numbers.map { |number| number * 2 })
end
end
class Square < Micro::Service::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 syntaxes/approaches to create the pipelines) - examples.
What is a strict Service Object?
Answer: Is a service object which will require all keywords (attributes) on its initialization.
class Double < Micro::Service::Strict
attribute :numbers
def call!
Success(numbers.map { |number| number * 2 })
end
end
Double.call({})
# The output (raised an error):
# ArgumentError (missing keyword: :numbers)
Is there some feature to auto handle exceptions inside of services/pipelines?
Answer: Yes, there is!
Service Objects:
Like Micro::Service::Strict
the Micro::Service::Safe
is another special kind of Service object. It has the ability to auto wrap an exception into a failure result. e.g:
require 'logger'
AppLogger = Logger.new(STDOUT)
class Divide < Micro::Service::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.) # E, [2019-08-21T00:05:44.195506 #9532] ERROR -- : divided by 0
end
# Note:
# ----
# If you need a specific error handling,
# I recommend the usage of a case statement. e,g:
result.on_failure(:exception) do |exception, service|
case exception
when ZeroDivisionError then AppLogger.error(exception.)
else AppLogger.debug("#{service.class.name} was the service responsible for the exception")
end
end
# Another note:
# ------------
# It is possible to rescue an exception even when is a safe service.
# Examples: https://github.com/serradura/u-service/blob/a6d0a8aa5d28d1f062484eaa0d5a17c4fb08b6fb/test/micro/service/safe_test.rb#L95-L123
Pipelines:
As the safe services, safe pipelines have the ability to 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::Service::Pipeline::Safe
pipeline ProcessParams, ValidateParams, Persist, SendToCRM
end
end
# or
module Users
Create = Micro::Service::Pipeline::Safe[
ProcessParams,
ValidateParams,
Persist,
SendToCRM
]
end
How to validate Service Object attributes?
Requirement:
To do this your application must have the activemodel >= 3.2 as a dependency.
#
# By default, if your project has the activemodel
# any kind of service attribute can be validated.
#
class Multiply < Micro::Service::Base
attributes :a, :b
validates :a, :b, presence: true, numericality: true
def call!
return Failure(:validation_error) { self.errors } unless valid?
Success(number: a * b)
end
end
#
# But if do you want an automatic way to fail
# your services if there is some invalid data.
# You can use:
# In some file. e.g: A Rails initializer
require 'micro/service/with_validation' # or require 'u-service/with_validation'
# In the Gemfile
gem 'u-service', require: 'u-service/with_validation'
# Using this approach, you can rewrite the previous sample with fewer lines of code.
class Multiply < Micro::Service::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::Service::Strict and Micro::Service::Safe classes will inherit this new behavior.
Examples
- Rescuing an exception inside of service objects
-
An example of how to use services pipelines to sanitize and validate the input data, and how to represents a common use case, like: create an user.
-
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 app flow.
Comparisons
Check it out implementations of the same use case with different libs (abstractions).
Benchmarks
https://github.com/serradura/u-service/tree/master/benchmarks/interactor
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-service. 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::Service project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.