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 encapsulates a unit of business logic. Instead of scattering logic across controllers, models, and workers, you extract it into services and then use those services wherever needed.

The purpose of this post is to show how to set up a foundation for uniform, typed services in Ruby projects.


Preparation

The examples use Servactory — a gem for building service objects with typed attributes, validations, and structured results.

Base class

All services inherit from one base class. Create these files under app/services/application_service/:

Exceptions (app/services/application_service/exceptions.rb):

module ApplicationService
  module Exceptions
    class Input < Servactory::Exceptions::Input; end
    class Output < Servactory::Exceptions::Output; end
    class Internal < Servactory::Exceptions::Internal; end

    class Failure < Servactory::Exceptions::Failure; end
  end
end
Enter fullscreen mode Exit fullscreen mode

Result (app/services/application_service/result.rb):

module ApplicationService
  class Result < Servactory::Result; end
end
Enter fullscreen mode Exit fullscreen mode

Base (app/services/application_service/base.rb):

module ApplicationService
  class Base < Servactory::Base
    configuration do
      input_exception_class ApplicationService::Exceptions::Input
      internal_exception_class ApplicationService::Exceptions::Internal
      output_exception_class ApplicationService::Exceptions::Output

      failure_class ApplicationService::Exceptions::Failure

      result_class ApplicationService::Result
    end

    fail_on! ActiveRecord::RecordNotFound,
             ActiveRecord::RecordInvalid
  end
end
Enter fullscreen mode Exit fullscreen mode

The fail_on! line tells Servactory to catch these common Rails exceptions and convert them into service failures automatically.

Creating a service

A typical service needs to:

  • accept and validate input data;
  • do something with that data;
  • return a structured result;
  • interact with other services when needed.

Servactory handles all of this through a declarative DSL.

Simple example

A service that creates a customer and sends a welcome notification:

class Customers::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 inputs (1 optional) and returns 1 output. Every attribute has a declared type. If any type check fails, the service raises an error immediately.

Methods listed via make are executed sequentially, top to bottom.

Calling the service

There are two ways to call a service:

  • .call! — always raises an exception on failure.
  • .call — returns a Result object so you can check success or failure manually.

In controllers, .call is typically more practical:

class CustomersController < ApplicationController
  def create
    service_result = Customers::Create.call(**customer_params)

    if service_result.success?
      @customer = service_result.customer
      redirect_to @customer
    else
      flash.now[:alert] = service_result.error.message
      render :new, status: :unprocessable_entity
    end
  end

  private

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

Something more complex

Let's look at updating a list of countries from an external API. This demonstrates service inheritance and composition.

API service base

API requests go through services too — for the same reasons: type safety on inputs and outputs, structured error handling.

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

  private

  def perform_api_request!
    outputs.response = api_request
  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
    @api_client ||= CountriesApi::Client.new
  end
end
Enter fullscreen mode Exit fullscreen mode

Service for fetching countries

A concrete service inheriting from the API base:

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

  private

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

Calling Countries::API::Receive.call makes the request and returns a typed response.

Database update

The data from the API gets saved to the database in a separate service. Note the use of fail_result! — it forwards the error from a nested service call automatically:

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

  make :perform_request
  make :create_or_update!

  private

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

    return fail_result!(service_result) if service_result.failure?

    internals.response = service_result.response
  end

  def create_or_update!
    Country.upsert_all(
      internals.response.items.map(&:to_h),
      unique_by: :code
    )
  end
end
Enter fullscreen mode Exit fullscreen mode

This can be called from a controller or a background job:

def refresh
  Countries::Refresh.call!
end
Enter fullscreen mode Exit fullscreen mode

Testing

Servactory provides RSpec matchers for testing service attributes and behavior:

RSpec.describe Customers::Create, type: :service do
  describe ".call!" do
    subject(:perform) { described_class.call!(**attributes) }

    let(:attributes) do
      {
        email:,
        first_name:,
        middle_name:,
        last_name:
      }
    end

    let(:email) { "john@example.com" }
    let(:first_name) { "John" }
    let(:middle_name) { nil }
    let(:last_name) { "Kennedy" }

    describe "inputs" do
      it do
        expect { perform }.to(
          have_input(:email)
            .type(String)
            .required
        )
      end

      it do
        expect { perform }.to(
          have_input(:first_name)
            .type(String)
            .required
        )
      end

      it do
        expect { perform }.to(
          have_input(:middle_name)
            .type(String)
            .optional
        )
      end

      it do
        expect { perform }.to(
          have_input(:last_name)
            .type(String)
            .required
        )
      end
    end

    describe "outputs" do
      it do
        expect(perform).to(
          have_output(:customer)
            .instance_of(Customer)
        )
      end
    end

    it { expect(perform).to be_success_service }
  end
end
Enter fullscreen mode Exit fullscreen mode

Ruby Gem

The examples in this post are based on Servactory.

Repository: 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 Downloads Ruby version

📚 Documentation

See servactory.com for comprehensive documentation, including:

  • Detailed guides for all features
  • Advanced configuration options
  • Best practices and patterns
  • Migration guides
  • API reference

💡 Why Servactory?

Building reliable services shouldn't be complicated. Servactory provides a battle-tested framework for creating service objects with:

  • 🛡️ Type Safety - Enforce types on inputs and outputs, catch errors early
  • Built-in Validation - Rich validation DSL with custom rules
  • 🧪 Test-Friendly - RSpec matchers and service mocking helpers
  • 📊 Structured Output - Consistent Result object pattern
  • 🔧 Highly Configurable - Extensions, helpers, and custom options
  • 📚 Well Documented - Comprehensive guides and examples

🚀 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

  make :authenticate!

  private

  def authenticate!
Enter fullscreen mode Exit fullscreen mode

Top comments (0)