Servactory

A set of tools for building reliable services of any complexity.

Gem version Release Date

Contents

Requirements

  • Ruby >= 2.7

Getting started

Conventions

  • Services are subclasses of Servactory::Base and are located in the app/services directory. It is common practice to create and inherit from ApplicationService::Base, which is a subclass of Servactory::Base.
  • Name services by what they do, not by what they accept. Try to use verbs in names. For example, UsersService::Create instead of UsersService::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

More examples

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.

See en.yml file

Testing

Testing Servactory services is the same as testing regular Ruby classes.

Thanks

Thanks to @sunny for Service Actor.

Contributing

  1. Fork it (https://github.com/afuno/servactory/fork);
  2. Create your feature branch (git checkout -b my-new-feature);
  3. Commit your changes (git commit -am "Add some feature");
  4. Push to the branch (git push origin my-new-feature);
  5. Create a new Pull Request.