Gem Build Status Maintainability Test Coverage

μ-service (Micro::Service)

Create simple and powerful service objects.

The main goals of this project are:

  1. The smallest possible learning curve (input >> process/transform >> output).
  2. Referential transparency and data integrity.
  3. No callbacks.
  4. Compose a pipeline of service objects to represents complex business logic.

Table of Contents

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

⬆️ Back to Top

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

⬆️ Back to Top

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.

⬆️ Back to Top

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

⬆️ Back to Top

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

⬆️ Back to Top

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

⬆️ Back to Top

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

⬆️ Back to Top

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

⬆️ Back to Top

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.

⬆️ Back to Top

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)

⬆️ Back to Top

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.message) # 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.message)
  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

⬆️ Back to Top

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.

⬆️ Back to Top

Examples

  1. Rescuing an exception inside of service objects
  2. Users creation

    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.

  3. CLI calculator

    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.

⬆️ Back to Top

Comparisons

Check it out implementations of the same use case with different libs (abstractions).

Benchmarks

interactor VS u-service

https://github.com/serradura/u-service/tree/master/benchmarks/interactor

interactor VS u-service

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.