Servactory
A set of tools for building reliable services of any complexity.
Contents
Requirements
- Ruby >= 2.7
Getting started
Conventions
- Services are subclasses of
Servactory::Base
and are located in theapp/services
directory. It is common practice to create and inherit fromApplicationService::Base
, which is a subclass ofServactory::Base
. - Name services by what they do, not by what they accept. Try to use verbs in names. For example,
UsersService::Create
instead ofUsersService::Creation
.
Installation
Add this to Gemfile
:
gem "servactory"
And execute:
bundle install
Preparation
As a first step, it is recommended to prepare the base class for further inheritance.
ApplicationService::Errors
# app/services/application_service/errors.rb
module ApplicationService
module Errors
class InputArgumentError < Servactory::Errors::InputArgumentError; end
class OutputArgumentError < Servactory::Errors::OutputArgumentError; end
class InternalArgumentError < Servactory::Errors::InternalArgumentError; end
class Failure < Servactory::Errors::Failure; end
end
end
ApplicationService::Base
# app/services/application_service/base.rb
module ApplicationService
class Base < Servactory::Base
configuration do
input_argument_error_class ApplicationService::Errors::InputArgumentError
output_argument_error_class ApplicationService::Errors::OutputArgumentError
internal_argument_error_class ApplicationService::Errors::InternalArgumentError
failure_class ApplicationService::Errors::Failure
end
end
end
Usage
Minimal example
class MinimalService < ApplicationService::Base
make :call
private
def call
# ...
end
end
Call
Services can only be called via .call
and .call!
methods.
The .call
method will only fail if it catches an exception in the input arguments.
Internal and output attributes, as well as methods for failures - all this will be collected in the result.
The .call!
method will fail if it catches any exception.
Via .call
UsersService::Accept.call(user: User.first)
Via .call!
UsersService::Accept.call!(user: User.first)
Result
All services have the result of their work. For example, in case of success this call:
service_result = UsersService::Accept.call!(user: User.first)
Will return this:
#<Servactory::Result:0x0000000107ad9e88 @user="...">
And then you can work with this result, for example, in this way:
Notification::SendJob.perform_later(service_result.user.id)
Input attributes
Isolated usage
With this approach, all input attributes are available only from inputs
. This is default behaviour.
class UsersService::Accept < ApplicationService::Base
input :user, type: User
make :accept!
private
def accept!
inputs.user.accept!
end
end
As an internal argument
With this approach, all input attributes are available from inputs
as well as directly from the context.
class UsersService::Accept < ApplicationService::Base
input :user, type: User, internal: true
make :accept!
private
def accept!
user.accept!
end
end
Optional inputs
By default, all inputs are required. To make an input optional, specify false
in the required
option.
class UsersService::Create < ApplicationService::Base
input :first_name, type: String
input :middle_name, type: String, required: false
input :last_name, type: String
# ...
end
As (internal name)
This option changes the name of the input within the service.
class NotificationService::Create < ApplicationService::Base
input :customer, as: :user, type: User
output :notification, type: Notification
make :create_notification!
private
def create_notification!
self.notification = Notification.create!(user: inputs.user)
end
end
An array of specific values
class PymentsService::Send < ApplicationService::Base
input :invoice_numbers, type: String, array: true
# ...
end
Inclusion
class EventService::Send < ApplicationService::Base
input :event_name, type: String, inclusion: %w[created rejected approved]
# ...
end
Must
Sometimes there are cases that require the implementation of a specific input attribute check. In such cases must
can help.
class PymentsService::Send < ApplicationService::Base
input :invoice_numbers,
type: String,
array: true,
must: {
be_6_characters: {
is: ->(value:) { value.all? { |id| id.size == 6 } }
}
}
# ...
end
Output attributes
class NotificationService::Create < ApplicationService::Base
input :user, type: User
output :notification, type: Notification
make :create_notification!
private
def create_notification!
self.notification = Notification.create!(user: inputs.user)
end
end
Internal attributes
class NotificationService::Create < ApplicationService::Base
input :user, type: User
internal :inviter, type: User
output :notification, type: Notification
make :assign_inviter
make :create_notification!
private
def assign_inviter
self.inviter = user.inviter
end
def create_notification!
self.notification = Notification.create!(user: inputs.user, inviter:)
end
end
Make
Minimal example
make :something
def something
# ...
end
Condition
make :something, if: -> { Settings.something.enabled }
def something
# ...
end
Several
make :assign_api_model
make :perform_api_request
make :process_result
def assign_api_model
self.api_model = APIModel.new
end
def perform_api_request
self.response = APIClient.resource.create(api_model)
end
def process_result
ARModel.create!(response)
end
Inheritance
Service inheritance is also supported.
Failures
The methods that are used in make
may fail. In order to more informatively provide information about this outside the service, the following methods were prepared.
Fail
make :check!
def check!
return if inputs.invoice_number.start_with?("AA")
fail!("Invalid invoice number")
end
Fail for input
make :check!
def check!
return if inputs.invoice_number.start_with?("AA")
fail_input!(:invoice_number, "Invalid invoice number")
end
I18n
All texts are stored in the localization file. All texts can be changed or supplemented by new locales.
Testing
Testing Servactory services is the same as testing regular Ruby classes.
Thanks
Thanks to @sunny for Service Actor.
Contributing
- Fork it (https://github.com/afuno/servactory/fork);
- Create your feature branch (
git checkout -b my-new-feature
); - Commit your changes (
git commit -am "Add some feature"
); - Push to the branch (
git push origin my-new-feature
); - Create a new Pull Request.