README.md in u-service-0.14.0 vs README.md in u-service-1.0.0

- old
+ new

@@ -7,25 +7,34 @@ ========================== Create simple and powerful service objects. The main goals of this project are: -1. The smallest possible learning curve. +1. The smallest possible learning curve (input **>>** process/transform **>>** output). 2. Referential transparency and data integrity. -3. No callbacks, compose a pipeline of service objects to represents complex business logic. (input >> process/transform >> output) +3. No callbacks. +4. Compose a pipeline of service objects to represents complex business logic. +## Table of Contents - [μ-service (Micro::Service)](#%ce%bc-service-microservice) + - [Table of Contents](#table-of-contents) - [Required Ruby version](#required-ruby-version) - [Installation](#installation) - [Usage](#usage) - - [How to create a Service Object?](#how-to-create-a-service-object) - - [How to use the result hooks?](#how-to-use-the-result-hooks) + - [How to define a Service Object?](#how-to-define-a-service-object) + - [What is a `Micro::Service::Result`?](#what-is-a-microserviceresult) + - [What are the default types of a `Micro::Service::Result`?](#what-are-the-default-types-of-a-microserviceresult) + - [How to define custom result types?](#how-to-define-custom-result-types) + - [Is it possible to define a custom result type without a block?](#is-it-possible-to-define-a-custom-result-type-without-a-block) + - [How to use the result hooks?](#how-to-use-the-result-hooks) + - [What happens if a hook is declared multiple times?](#what-happens-if-a-hook-is-declared-multiple-times) - [How to create a pipeline of Service Objects?](#how-to-create-a-pipeline-of-service-objects) + - [Is it possible to compose pipelines with other pipelines?](#is-it-possible-to-compose-pipelines-with-other-pipelines) - [What is a strict Service Object?](#what-is-a-strict-service-object) + - [Is there some feature to auto handle exceptions inside of services/pipelines?](#is-there-some-feature-to-auto-handle-exceptions-inside-of-servicespipelines) - [How to validate Service Object attributes?](#how-to-validate-service-object-attributes) - - [It's possible to compose pipelines with other pipelines?](#its-possible-to-compose-pipelines-with-other-pipelines) - - [Examples](#examples) + - [Examples](#examples) - [Comparisons](#comparisons) - [Benchmarks](#benchmarks) - [Development](#development) - [Contributing](#contributing) - [License](#license) @@ -51,61 +60,202 @@ $ gem install u-service ## Usage -### How to create a Service Object? +### How to define a Service Object? ```ruby 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(:invalid_data) + Failure { '`a` and `b` attributes must be numeric' } end end end -#====================# -# Calling a service # -#====================# +#================================# +# Calling a Service Object class # +#================================# +# Success result + result = Multiply.call(a: 2, b: 2) -p result.success? # true -p result.value # 4 +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#call +# ---- +# The result of a Micro::Service::Base.call # is an instance of Micro::Service::Result +``` -#----------------------------# -# Calling a service instance # -#----------------------------# +[⬆️ Back to Top](#table-of-contents) -result = Multiply.new(a: 2, b: 3).call +### What is a `Micro::Service::Result`? -p result.success? # true -p result.value # 6 +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). -#===========================# -# Verify the result failure # -#===========================# +[⬆️ Back to Top](#table-of-contents) -result = Multiply.call(a: '2', b: 2) +#### What are the default types of a `Micro::Service::Result`? -p result.success? # false -p result.failure? # true -p result.value # :invalid_data +Every result has a type and these are the default values: :ok when success, and :error/:exception when failures. + +```ruby +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 use the result hooks? +[⬆️ Back to Top](#table-of-contents) +#### 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. + ```ruby +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](#table-of-contents) + +##### Is it possible to define a custom result type without a block? + +Answer: Yes, it is. But only for failure results! + +```ruby +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](#table-of-contents) + +#### 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: + +```ruby class Double < Micro::Service::Base attributes :number def call! return Failure(:invalid) { 'the number must be a numeric value' } unless number.is_a?(Numeric) @@ -123,27 +273,69 @@ .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 +# 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 output (raised an error) when is a failure: -# ArgumentError (the number must be greater than 0) +# 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](#table-of-contents) + +##### What happens if a hook is declared multiple times? + +Answer: The hook will be triggered if it matches the result type. + +```ruby +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](#table-of-contents) + ### How to create a pipeline of Service Objects? ```ruby module Steps class ConvertToNumbers < Micro::Service::Base @@ -220,29 +412,96 @@ 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 # -#=================================================================# +# 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 by the failure" } # Steps::ConvertToNumbers was the service responsible by the failure + puts "#{service.class.name} was the service responsible for the failure" } # Steps::ConvertToNumbers was the service responsible for the failure end ``` +[⬆️ Back to Top](#table-of-contents) + +#### Is it possible to compose pipelines with other pipelines? + +Answer: Yes, it is. + +```ruby +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](#how-to-create-a-pipeline-of-service-objects)) - [examples](https://github.com/serradura/u-service/blob/master/test/micro/service/pipeline/blend_test.rb#L7-L34). + +[⬆️ Back to Top](#table-of-contents) + ### What is a strict Service Object? -A: Is a service object which will require all keywords (attributes) on its initialization. +Answer: Is a service object which will require all keywords (attributes) on its initialization. ```ruby class Double < Micro::Service::Strict attribute :numbers @@ -255,14 +514,102 @@ # The output (raised an error): # ArgumentError (missing keyword: :numbers) ``` +[⬆️ Back to Top](#table-of-contents) + +### 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: + +```ruby +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: + +```ruby +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](#table-of-contents) + ### How to validate Service Object attributes? -Note: To do this your application must have the [activemodel >= 3.2](https://rubygems.org/gems/activemodel) as a dependency. +**Requirement:** +To do this your application must have the [activemodel >= 3.2](https://rubygems.org/gems/activemodel) as a dependency. + ```ruby # # By default, if your project has the activemodel # any kind of service attribute can be validated. # @@ -270,11 +617,11 @@ attributes :a, :b validates :a, :b, presence: true, numericality: true def call! - return Failure(errors: self.errors) unless valid? + return Failure(:validation_error) { self.errors } unless valid? Success(number: a * b) end end @@ -285,11 +632,11 @@ # 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' +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 @@ -300,84 +647,27 @@ Success(number: a * b) end end # Note: +# ---- # After requiring the validation mode, the -# Micro::Service::Strict classes will inherit this new behavior. +# Micro::Service::Strict and Micro::Service::Safe classes will inherit this new behavior. ``` -### It's possible to compose pipelines with other pipelines? +[⬆️ Back to Top](#table-of-contents) -Answer: Yes +### Examples -```ruby -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](#how-to-create-a-pipeline-of-service-objects)) - [examples](https://github.com/serradura/u-service/blob/master/test/micro/service/pipeline/blend_test.rb#L7-L34). - -## Examples - 1. [Rescuing an exception inside of service objects](https://github.com/serradura/u-service/blob/master/examples/rescuing_exceptions.rb) 2. [Users creation](https://github.com/serradura/u-service/blob/master/examples/users_creation.rb) 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](https://github.com/serradura/u-service/tree/master/examples/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](#table-of-contents) ## Comparisons Check it out implementations of the same use case with different libs (abstractions).