DEV Community

Cover image for The Service Object pattern in Ruby applications with unified approach
Anton
Anton

Posted on • Edited on

The Service Object pattern in Ruby applications with unified approach

Service is a class that contains some (often overused) logic. Instead of implementing logic in controllers, models, workers, etc., it is implemented in services, and then these services are used in the same controllers, models, etc.

The purpose of this post is to demonstrate how to prepare the base for further development of uniform services in Ruby projects.


Preparation

Before starting to write services, it is necessary to prepare the basis for them in the project. The examples will use gem servactory. This gem will be the basis for all services in the project.

Creation of base class

All services will inherit from the same base class. Create a file app/services/application_service/base.rb with the following content:

class ApplicationService::Base < Servactory::Base
  configuration do
    # Learn more: https://servactory.com/getting-started
  end
end
Enter fullscreen mode Exit fullscreen mode

All of the project's services will also be located in the app/services directory.

Creation of service

Most often the following is required of the service:

  • should get some data;
  • should process the incoming data (if any), and do something additional (if necessary);
  • should return some result;
  • should be able to work properly with other project services, including services for working with API.

Servactory as a basis implements and controls all of this.

Simple example

As an example, consider a simple service for creating a customer and then notifying them about it.

class CustomersService::Create < ApplicationService::Base
  input :email, type: String

  input :first_name, type: String
  input :middle_name, type: String, required: false
  input :last_name, type: String

  output :customer, type: Customer

  make :create!
  make :notify

  private

  def create!
    outputs.customer = Customer.create!(
      email: inputs.email,
      first_name: inputs.first_name,
      middle_name: inputs.middle_name,
      last_name: inputs.last_name
    )
  end

  def notify
    Notification::Customer::WelcomeJob.perform_later(outputs.customer)
  end
end
Enter fullscreen mode Exit fullscreen mode

This service expects 4 arguments, 1 of which is optional. Also this service should return 1 expected attribute. Each of the attributes has a type that the service also expects. If there is a problem at these stages, the service will fall with an error.

The service also has methods, each of which is executed in turn through make.

There are two ways to call this service - via .call! or via .call. Calling via .call! will always throw an exception. Use the .call method to check the result of the operation (success or failure) manually.

The controller code will be like this:

def create
  service_result = CustomersService::Create.call!(**customer_params)

  @customer = service_result.customer
end

# or like this:
# def create
#   service_result = CustomersService::Create.call(**customer_params)
#
#   if service_result.success?
#     redirect_to ...
#   else
#     flash.now[:message] = service_result.error.message
#     render :new
#   end
# end

private

def customer_params
  params.require(:customer).permit(:email, :first_name, :middle_name, :last_name)
end
Enter fullscreen mode Exit fullscreen mode

Something more complicated

Now let's take a look at the complex example of updating the list of countries in the database. This will demonstrate additional features and interaction between services, including inheritance.

Service for working with API

Requests to the external API are also made through services, and all for the same reason.

Through services written in servactory, control incoming and outgoing attributes by validating their type and controlling their obligation. This allows you to better control the requests and their responses.

In this example, services for working with the API will also be located in the app/services directory.

The base class

For the final service that works with the API client, a base class is created that will initialize the API client and form the basic rules for working.

class CountriesService::API::Base < ApplicationService::Base
  make :perform_api_request!

  private

  def perform_api_request!
    outputs.response = api_request

  # The error comes from the client API. For the purposes of this note, we will not delve into it.
  # The API client can be implemented and return an error in any way.
  rescue CountriesApi::Errors::Failed => e
    fail!(message: e.message)
  end

  def api_request
    fail!(message: "Need to specify the API request")
  end

  def api_client
    # CountriesApi is the module of client API.
    @api_client ||= CountriesApi::Client.new
  end
end
Enter fullscreen mode Exit fullscreen mode
Service for receiving countries

The end service that inherits from the client API base class.

class CountriesService::API::Receive < CountriesService::API::Base
  output :response, type: CountriesApi::Responses::List

  private

  def api_request
    api_client.countries.list
  end
end
Enter fullscreen mode Exit fullscreen mode

So, to make a request and get a result with a list of countries, just call the service this way:

CountriesService::API::Receive.call
Enter fullscreen mode Exit fullscreen mode

Database update

The result obtained from the external API must be updated in the database. This logic will also be placed in the service. Check this example:

class CountriesService::Refresh < ApplicationService::Base
  internal :response, type: CountriesApi::Responses::List

  make :perform_request
  make :create_or_update!

  private

  def perform_request
    service_result = CountriesService::API::Receive.call

    return fail!(message: service_result.error.message) if service_result.failure?

    internals.response = service_result.response
  end

  def create_or_update!
    internals.response.items.each do |item|
      country = Country.find_or_initialize_by(code: item.code)
      country.assign_attributes(**item)
      country.save!
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

The examples above demonstrate the division of code with different logic into classes. Also shown how these classes (services) interact with each other.

As a result, the final service can be called in the controller or worker in the same way:

# Action in the controller
def refresh
  CountriesService::Refresh.call!
end
Enter fullscreen mode Exit fullscreen mode

Testing

It is obvious how to test classes and their methods in Ruby. In the case of servactory, it is almost the same, except the approach. Servactory provides a unified view for working with attributes and for result of the service. You should work with this, when writing tests.

Ruby Gem

The examples in this post were based on Servactory.

Repository in GitHub: github.com/servactory/servactory
Documentation: servactory.com

GitHub logo servactory / servactory

Powerful Service Object for Ruby applications

Servactory

A set of tools for building reliable services of any complexity

Gem version Release Date

Documentation

See servactory.com for documentation.

Quick Start

Installation

gem "servactory"
Enter fullscreen mode Exit fullscreen mode

Define service

class UserService::Authenticate < Servactory::Base
  input :email, type: String
  input :password, type: String

  output :user, type: User

  private

  def call
    if (user = User.authenticate_by(email: inputs.email, password: inputs.password)).present?
      outputs.user = user
    else
      fail!(message: "Authentication failed")
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Usage in controller

class SessionsController < ApplicationController
  def create
    service_result = UserService::Authenticate.call(**session_params)
    if service_result.success?
      session[:current_user_id] = service_result.user.id
      redirect_to service_result.user
    else
      flash.now[:message] = service_result.error.message
      render :new
    end
  end

  private

  def session_params
    params
Enter fullscreen mode Exit fullscreen mode

Top comments (0)