Introduction
Service objects encapsulate a single piece of business logic, keeping it out of controllers and models. They’re easy to test and easy to reuse. They’re also easy to make inconsistent, so a few conventions go a long way.
The most important one: decide what call returns, and stick to it. In
this post call returns the record it produced, or nil on failure. Every
example follows that rule.
What service objects are
They’re Plain Old Ruby Objects that wrap one workflow. Say you need to create
a user, send a welcome email, and notify an admin: instead of spreading that
across a controller and a model, a CreateUser service orchestrates it in one
place.
The benefits are the usual ones (single responsibility, easy unit testing, thinner controllers, reusable logic), but only if the objects stay disciplined.
One entry point: call
Give every service a single public method, call. It’s the whole interface:
class CreateUser
def initialize(params)
@params = params
end
def call
ActiveRecord::Base.transaction do
user = create_user
send_welcome_email(user)
user
end
end
private
def create_user
User.create!(@params)
end
def send_welcome_email(user)
UserMailer.welcome_email(user).deliver_now
end
end
user = CreateUser.new(params).call
call returns the created user. Wrapping the steps in a transaction means
that if any of them raises, the whole thing rolls back.
Keep the constructor simple
initialize should take only what the service needs to start. Passing an id
and loading the record inside keeps the service in charge of its own data:
def initialize(user_id)
@user_id = user_id
end
Whether you pass the id or the object is a judgement call: the id is safer against the record being mutated elsewhere, the object saves a query. Just be deliberate about it.
Expose the result, and the errors
Use attr_reader to surface what happened without leaking internal state. The
return value still follows the contract (record or nil), and the errors are
readable afterwards:
class CreateUser
attr_reader :user, :errors
def initialize(params)
@params = params
@errors = []
end
def call
@user = User.create(@params)
unless @user.persisted?
@errors = @user.errors.full_messages
return nil
end
@user
end
end
service = CreateUser.new(params)
if service.call
redirect_to service.user
else
flash[:error] = service.errors.to_sentence
end
Break the steps into private methods
Keep call readable by giving each step its own private method:
def call
ActiveRecord::Base.transaction do
validate_input
user = create_user
notify_admin(user)
user
end
end
private
def validate_input
raise "Invalid input" unless @params[:email]
end
def create_user
User.create!(@params)
end
def notify_admin(user)
AdminMailer.new_user_notification(user).deliver_now
end
If a step raises, the transaction rolls back and the exception propagates. If
you’d rather signal failure with a return value than an exception, rescue and
return nil, but pick one convention and hold to it.
An optional valid?
For expensive or irreversible work, a valid? method lets a caller check
inputs before committing. Note that call still returns the record or nil,
never a bare boolean:
class CreateUser
attr_reader :errors
def initialize(params)
@params = params
@errors = []
end
def valid?
validate_email
errors.empty?
end
def call
return nil unless valid?
User.create!(@params)
end
private
def validate_email
if @params[:email].blank?
@errors << "Email is required"
elsif User.exists?(email: @params[:email])
@errors << "Email already taken"
end
end
end
Organise by context
Group services by domain, both in the directory tree and in module names:
app/services/
users/
create.rb
update_profile.rb
orders/
process.rb
cancel.rb
module Users
class Create
# ...
end
end
Users::Create.new(params).call
Wrapping up
Service objects aren’t complicated, and that’s the point. One public call, a
return value you can rely on, a constructor that takes only what it needs, and
one private method per step. Keep those consistent and a service stays
something you can read top to bottom and trust.
Have comments or want to discuss this topic?
Send an email to ~bounga/public-inbox@lists.sr.ht