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
Result (app/services/application_service/result.rb):
module ApplicationService
class Result < Servactory::Result; end
end
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
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
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 aResultobject 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
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
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
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
This can be called from a controller or a background job:
def refresh
Countries::Refresh.call!
end
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
Ruby Gem
The examples in this post are based on Servactory.
Repository: github.com/servactory/servactory
Documentation: servactory.com
servactory
/
servactory
Powerful Service Object for Ruby applications
A set of tools for building reliable services of any complexity
📚 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"
Define service
class UserService::Authenticate < Servactory::Base
input :email, type: String
input :password, type: String
output :user, type: User
make :authenticate!
private
def authenticate!…
Top comments (0)