The Service Object pattern in Ruby applications with unified approach
Tags: ruby, rails, service
Published: 2023-06-19 21:00:11

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](https://servactory.com/getting-started). 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:

```ruby
class ApplicationService::Base < Servactory::Base
  configuration do
    # Learn more: https://servactory.com/getting-started
  end
end
```

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.

```ruby
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
```

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:

```ruby
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
```

### 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.

```ruby
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
```

##### Service for receiving countries

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

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

  private

  def api_request
    api_client.countries.list
  end
end
```

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

```ruby
CountriesService::API::Receive.call
```

#### 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:

```ruby
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
```

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:

```ruby
# Action in the controller
def refresh
  CountriesService::Refresh.call!
end
```

## 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](https://github.com/servactory/servactory)
Documentation: [servactory.com](https://servactory.com)

{% embed https://github.com/servactory/servactory %}