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
endExample:
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
endUsage:
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_exceededmethod (if validation passes) - If the validation fails (counter > 10),
performmethod will not be executed and the service will fail - If the validation passes
performmethod will be executed Resultobject will be returned withsuccess=trueandresult=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
endIt 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
performmethod
Running the run method causes the service to return a Result object.
If the service succeeds:
Resultobject will havesuccess=trueResultobject will have returned value fromperformmethod insideresultattributeResultobject will have emptyerrors
If the service fails:
Resultobject will havesuccess=falseResultobject will haveresult=nilResultobject will have one or more errors in theerrorsattribute
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
endIt 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: trueIn 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::AnyThe use of
Anytype 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