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