Create simple and powerful service objects.


Keywords
business-logic, command-pattern, no-callbacks, ruby, ruby-gem, service-object, u-attributes, use-cases
License
MIT
Install
gem install u-service -v 0.14.0

Documentation

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.
  2. Referential transparency and data integrity.
  3. No callbacks, compose a pipeline of service objects to represents complex business logic. (input >> process/transform >> output)

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 create a Service Object?

class Multiply < Micro::Service::Base
  attributes :a, :b

  def call!
    if a.is_a?(Numeric) && b.is_a?(Numeric)
      Success(a * b)
    else
      Failure(:invalid_data)
    end
  end
end

#====================#
# Calling a service  #
#====================#

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

p result.success? # true
p result.value    # 4

# Note:
# The result of a Micro::Service#call
# is an instance of Micro::Service::Result

#----------------------------#
# Calling a service instance #
#----------------------------#

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

p result.success? # true
p result.value    # 6

#===========================#
# Verify the result failure #
#===========================#

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

p result.success? # false
p result.failure? # true
p result.value    # :invalid_data

How to use the result hooks?

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 when is a success:
# 6

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

Double
  .call(number: -1)
  .on_success { |number| p number }
  .on_failure(:invalid) { |msg| raise TypeError, msg }
  .on_failure(:lte_zero) { |msg| raise ArgumentError, msg }

# The output (raised an error) when is a failure:
# ArgumentError (the number must be greater than 0)

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]

#=================================================================#
# Attention:                                                      #
# When happening a failure, the service object responsible for it #
# 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 by the failure" } # Steps::ConvertToNumbers was the service responsible by the failure
end

What is a strict Service Object?

A: 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)

How to validate Service Object attributes?

Note: 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(errors: 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', '~> 0.12.0', 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 classes will inherit this new behavior.

It's possible to compose pipelines with other pipelines?

Answer: Yes

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.

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.

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.