This gem aims to simplify and standardize the service interface to be used across the service layer in our ruby projects. It provides composition, typing, and built in validations to ensure that our complex service logic is both flexible and safe.
Add this line to your application's Gemfile:
gem 'hive-service'
And then execute:
$ bundle
Or install it yourself as:
$ gem install hive-service
To install the gem locally you need to have your bundle configured to fetch from our org's private repos. Follow the following steps to do so:
- Go to your github settings -> Developer settings -> Personal access tokens
- Create a new token and check the repo permissions
- Save your token somewhere
- On your local machine use the following command:
$ bundle config github.com 'YOUR_TOKEN_HERE'
- Restart your terminal session and bundling should work as expected!
We need to create a base service that will inherit from ::Hive::Service
:
class BaseService < ::Hive::Service
end
Example:
class ExampleService < BaseService
attribute :counter, type: Types::Integer, required: true
validate :counter_not_exceeded
def perform
@counter + 1
end
private
def counter_not_exceeded
add_error(:counter, :exceeded) if @counter > 10
end
end
Usage:
ExampleService.run(counter: 9)
=> Hive::Service::Result.new(result: 10, errors: nil, success: true)
The following happens:
- It will validate that the counter can be coerced in to integer
- If not, execution will be halted and service will fail
- It will run
counter_not_exceeded
method (if validation passes) - If the validation fails (counter > 10),
perform
method will not be executed and the service will fail - If the validation passes
perform
method will be executed Result
object will be returned withsuccess=true
andresult=counter+1
Note that:
ExampleService.run('counter' => 9)
is also a valid service invocation
The run!
method will raise an error Hive::Service::Failure
if validation fails, otherwise it will return the returned value from the perform
method without wrapping it in a Result
object.
Hive::Service::Failure
will have access to errors:
begin
ExampleService.run!('counter' => 11)
rescue Hive::Service::Failure => error
error.errors.to_h
end
=> { counter: [type: :exceeded] }
The compose
method allows us to compose multiple services within a service.
Usage:
def perform
counter = @counter + 10
new_counter = compose AnotherService, counter: counter, other_param: true
new_counter + counter
end
It will:
- Call
AnotherService.run()
with given attributes - If composed service fails it will halt whole execution and merge the errors with calling service's errors.
- If composed service succeeds it will return the unwrapped return value of the composed service's
perform
method
Running the run
method causes the service to return a Result
object.
If the service succeeds:
Result
object will havesuccess=true
Result
object will have returned value fromperform
method insideresult
attributeResult
object will have emptyerrors
If the service fails:
Result
object will havesuccess=false
Result
object will haveresult=nil
Result
object will have one or more errors in theerrors
attribute
Each instance of this class will have @errors
. At any point for lifecycle you can add errors.
After each step (validations, execution) service will check if there are some errors - if yes it will halt execution and return Result
object
to add error use add_error
method:
add_error(:some_field, :this_is_an_error)
Note: this will add error but will not halt current step
To add error and halt execution immediately use:
add_error!(:some_field, :this_is_error)
Errors object is similar to hash but with some adjustments.
If you only care about error values then you can run #to_h
.
If you want also full messages or the translations then run #full_details
.
It will return a hash similar to:
{
counter: {
type: :exceeded,
message: 'Counter was exceeded'
}
}
This gem uses I18n
for translation, it will look for a translation key as follows:
[service_name].errors.[some_attribute].[error_name]
.
The [service_name]
is the class of service name converted to under score like:
SomeNamespace::SomeModule::ExampleService
-> some_namespace.some_module.some_service
For errors coming from composed services, it will try to find translation for outer (caller) service first and then for inner (composed) service.
Attributes are validated/coerced using Dry::Types
.
To define a type for an attribute use:
attribute :some_attribute, type: Types::[some-type]
If the passed value has a different type the service will fail and add an error:
errors.add(:some_attribute, :wrong_type)
The value will be accessible by an instance variable: @some_attribute
So for example:
attribute :cool_attribute, type: Types::Integer
def perform
@cool_attribute + 1
end
It will be accessible by an instance method you define inside your service class.
By default Types
module has imported coercible types, if you want to use strict ones (that will raise error if passed attribute does not have exactly same type) you need to call it explicitly by using Strict
:
attribute :some_attribute, type: Types::Strict::[some-type]
By default every attribute is not required. You can change that by passing required: true
option to the attribute
attribute :some_attribute, type: Types::String, required: true
In this case, if some_attribute
will be missing service will add an error:
errors.add(:some_attribute, :blank)
You can also specify a default value for given attribute:
attribute :some_attribute, type: Types::String, default: 'this is the default value'
If some_attribute
will not be passed to service it will use the defined default value. Default values will be coerced.
type | example coercion |
---|---|
Types::String | 123 -> '123' , :symbol -> 'symbol' |
Types::Symbol | 'symbol' -> :symbol |
Types::Integer | '123' -> 123 , 123.13 -> 123 |
Types::Float | '123.123' -> 123.123 , 123 -> 123.0 |
Types::Date | '2010-10-10' -> Date.parse('2010-10-10') |
Types::Time | '2010-10-10 10:10' -> Time.parse('2010-10-10 10:10') |
Types::Bool | 'false' -> false , '1' -> true |
Instance can be used in two ways:
- Pure
dry-types
- Pure class
type | example |
---|---|
Types::Instance(SomeType) | SomeType.new |
SomeType | SomeType.new |
The gem will wrap anything passed as type
of attribute which is not a child of Dry::Types::Type
(or Array
) into Types::Instance()
Array can be used in two ways:
- Pure
dry-types
- Wrapped in syntax sugar
type | example |
---|---|
Types::Array(Types::Symbol) |
[:symbol, 'other-symbol'] |
[Types::Symbol] |
[:symbol, 'other-symbol'] |
[SomeType] |
[SomeType.new] |
Types::Array(Types::Instance(SomeType)) |
[SomeType.new] |
Using syntax sugar will allow us to pass normal classes inside []
and this classes will be wrapped into
Types::Instance()
Interface type should be used with dry-type
Interface
syntax:
attribute :some_attribute, type: Types::Interface(:some_method)
Interface type should be used with dry-type
Hash
sytnax:
attribute :some_attribute, type: Types::Hash(some_key: Types::Integer)
Note if you want to use classes for nested types then you need to wrap it into Types::Instance()
Any type should be used with dry-type
Any
syntax:
attribute :some_attribute, type: Types::Any
The use of
Any
type should be limited as much as possible since it contradicts the whole concept of typing in the first place!
You can use any type that comes from dry-types
. For reference please see: dry-types
You can find implementation examples in spec/hive/service_spec.rb