servo is a Ruby gem for building service objects (often called interactors) with validations, type checking, and background job support.
servo builds on the popular interactor gem, adding a handful of features to make service objects behave more like an ActiveModel record.
-
Context Restrictions - The
inputandoutputDSL declare what context variables are readable and writeable, respectively. Each variable definition can optionally mandate a type to further protect against unexpected side effects in the context. Types can be code Ruby classes, union types, or dry-types. -
ActiveModel Validations - All ActiveModel validation features, callbacks, and errors are built-in to the interactor life cycle. If a context is invalid, the service object will not execute its
callmethod and fail immediately instead. -
Callbacks -
before,after, andaroundcallbacks are available to wrap the call method with additional logic. -
Background Jobs - Run your interactor asynchronously with ActiveJob or Sidekiq.
servo can reduce the amount of boilerplate code in your controllers and service objects. Indeed, many controller actions can be reduced to one or two lines of code. See the examples below.
Add servo to the production environment in your Gemfile:
gem 'servo'For enhanced type checking with dry-types (optional):
gem 'dry-types'
gem 'servo'Then run:
bundle installThere are two ways to create an interactor:
class CreateUser < Servo::Base
input :email, type: String
input :name, type: String
output :user
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :name, presence: true
def call
self.user = User.create!(email: email, name: name)
user
end
endclass CreateUser
include Servo::Callable
input :email, type: String
input :name, type: String
output :user
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :name, presence: true
def call
self.user = User.create!(email: email, name: name)
user
end
endBoth approaches are equivalent. Use the style you prefer.
result = CreateUser.call(email: 'alice@example.com', name: 'Alice')
result.success? # => true
result.user # => #<User id: 1, email: "alice@example.com", name: "Alice">
result.data # => #<User ...> (same as return value of call)
# With invalid input
result = CreateUser.call(email: '', name: 'Bob')
result.failure? # => true
result.errors # => #<ActiveModel::Errors ...>
result.error_messages # => ["Email can't be blank", "Email is invalid"]Use input to declare expected parameters and output to declare values your interactor will
produce:
class ProcessOrder
include Servo::Callable
input :order_id
input :user
output :receipt
output :confirmation_number
def call
order = Order.find(order_id)
self.receipt = generate_receipt(order)
self.confirmation_number = SecureRandom.hex(8)
receipt
end
endBy default, Servo restricts context to only declared inputs and outputs. Attempting to set an undeclared variable raises an error:
class MyInteractor
include Servo::Callable
input :name
def call
context.undeclared = 'value' # Raises Servo::UndeclaredContextVariableError!
end
endTo disable restriction (not recommended):
class LegacyInteractor
include Servo::Callable
unrestrict_context!
def call
context.anything = 'allowed' # No error
end
endclass Greet
include Servo::Callable
input :name, type: String
input :count, type: Integer
def call
"Hello, #{name}!" * count
end
end
Greet.call(name: 'World', count: 3) # Success
Greet.call(name: 123, count: 3) # Failure: "Name must be a String"Accept multiple types using an array:
class ParseDate
include Servo::Callable
input :date, type: [String, Date, Time]
def call
case date
when String then Date.parse(date)
when Time then date.to_date
else date
end
end
endFor advanced type checking, add dry-types to your Gemfile:
class CreateProduct
include Servo::Callable
# Define types
Types = Dry.Types()
input :name, type: Types::String.constrained(min_size: 1)
input :price, type: Types::Coercible::Float.constrained(gt: 0)
input :tags, type: Types::Array.of(Types::String)
def call
Product.create!(name: name, price: price, tags: tags)
end
endSupported dry-types features:
- Constrained types:
Types::String.constrained(min_size: 1) - Array types:
Types::Array.of(Types::Integer) - Hash schemas:
Types::Hash.schema(name: Types::String) - Optional types:
Types::String.optional - Coercible types:
Types::Coercible::Integer
Servo includes ActiveModel::Validations for full validation support:
class TransferFunds
include Servo::Callable
input :amount
input :from_account
input :to_account
validates :from_account, :to_account, presence: true
validates :amount, numericality: { greater_than: 0 }
validate :sufficient_balance
def call
from_account.withdraw(amount)
to_account.deposit(amount)
end
private
def sufficient_balance
return if from_account.nil?
if from_account.balance < amount
errors.add(:amount, 'exceeds available balance')
end
end
endUse ActiveSupport callbacks to run code before, after, or around call:
class AuditedOperation
include Servo::Callable
input :action
input :user
output :audit_log
set_callback :call, :before, :start_audit
set_callback :call, :after, :complete_audit
set_callback :call, :around, :measure_duration
def call
# Main logic here
execute_action
end
private
def complete_audit
audit_log << "Completed: #{action}"
AuditLog.create!(entries: audit_log)
end
def measure_duration
start = Time.current
yield
audit_log << "Duration: #{Time.current - start}s"
end
def start_audit
self.audit_log = []
audit_log << "Started: #{action}"
end
endRun any interactor asynchronously:
class SendWelcomeEmail
include Servo::Callable
input :user_id
def call
user = User.find(user_id)
UserMailer.welcome(user).deliver_now
end
end
# Enqueue for background processing
SendWelcomeEmail.call_later(interactor_args: { user_id: 123 })
# With job options
SendWelcomeEmail.call_later(
interactor_args: { user_id: 123 },
job_args: { queue: 'mailers', wait: 5.minutes }
)Create job classes that include Callable:
class ProcessPaymentJob < Servo::Jobs::ActiveJob
input :amount
input :order_id
validates :order_id, :amount, presence: true
def call
order = Order.find(order_id)
PaymentGateway.charge(order, amount)
end
end
# Enqueue
ProcessPaymentJob.perform_later(order_id: 123, amount: 99.99)
# Run synchronously (returns interactor context)
result = ProcessPaymentJob.perform_now(order_id: 123, amount: 99.99)
result.success?class ImportDataJob < Servo::Jobs::SidekiqJob
input :file_path
def call
CSV.foreach(file_path) do |row|
Record.create!(row.to_h)
end
end
end
# Enqueue
ImportDataJob.perform_async(file_path: '/path/to/data.csv')Servo includes a concern for Rails controllers:
class Api::UsersController < ApplicationController
include Servo::Controllers::Concerns::Reply
def create
result = CreateUser.call(user_params)
reply(
condition: result.success?,
errors: result.errors,
failure: :unprocessable_entity,
record: result.user,
success: :created
)
end
private
def user_params
params.require(:user).permit(:email, :name)
end
endresult = CreateUser.call(email: 'invalid')
if result.failure?
# Access errors
result.errors # ActiveModel::Errors object
result.errors[:email] # ["can't be blank", "is invalid"]
result.error_messages # ["Email can't be blank", "Email is invalid"]
result.errors.full_messages # Same as error_messages
endInteractors can inherit from other interactors:
class BaseInteractor
include Servo::Callable
input :current_user
validates :current_user, presence: true
end
class CreatePost < BaseInteractor
input :body
input :title
output :post
validates :title, presence: true
def call
self.post = current_user.posts.create!(title: title, body: body)
end
endEnhancements, bug fixes, suggestions, tests, sample code, and expanded documentation are welcome. Issues can be reported at GitHub.
# Clone the respository
git clone git@github.com:martinstreicher/servo.git
# Install dependencies
cd servo
bundle install
# Run tests
bundle exec rspec
# Run linter
bundle exec rubocopThe gem is available as open source under the terms of the MIT License.