Skip to content

The best service object (for us)

Bogusław Tolarz edited this page Aug 10, 2018 · 1 revision

Service object


Rules

Always call service object, by call method

class Service
  def call
    puts 'hello'
  end
end

Service.new.call

Keep call simple and readable, describe its story by methods name

class Service
  def call
    set_as_completed or cancel
  end
end

Consider wrapping call methods in transactions

Sometimes when multiple steps are involved to fulfill the responsibility of a service object, it might be a good idea to wrap the steps in a transaction, so that if any of the steps fails, we can always rollback the changes made in previous steps. (2)

USE TRANSACTION ONLY FOR DB OPERATIONS


class Service
  def call
    ActiveRecord::Base.transaction do
      assign_line_items
      calcuate_totals
      set_as_completed or cancel
    end
  end
end

Return result object as struct

Result = Struct.new(:success, :value) do
  def success?
    success
  end

  def failure?
    !success
  end
end

Result.new(true, "Message sent!")

Always call service with params object, by call method and pass dependencies to initialize

class Service
  def initialize(facebook_adapter: FacebookAdapter.new)
    @facebook_adapter = facebook_adapter
  end
  
  def call(params)
    if @facebook_adapter.send_message(params[:user_id, params[:message])
        Result.new(true, "Message was sent")
    else
        Result.new(false, "Some error occured")
    end
  end
end

Service.new.call(123, "Please try Spree Commerce if you need good ecommerce framework")

Use private. Usually only initialize and call should be public.


Include ServiceObject module.


ChainIt

https://github.com/tier-tools/chainit

class UserUpdater
  def call
    ChainIt
      .chain { FindUser.call(1) }
      .chain { UpdateUser.call(email: 'user@example.com') }
      .chain { p :success}
      .result
  end
end

ServiceObject module


Concern

module ServiceObject
  extend ActiveSupport::Concern

  module ClassMethods
    def call(*args)
      new.call(*args)
    end
  end

  def call
    raise NotImplementedError
  end
end

Call shortcut

Service.call(args) instead of Service.new(args).call.


Raise exception on call if not defined

Call method is important. Really.


Shared examples

shared_examples_for "service object" do
  it { expect(subject.call).to_not raise_error }
end

Links

  1. The Power of Interfaces in Ruby - Shiroyasha
  2. Essential RubyOnRails patterns — part 1: Service Objects
  3. A Case For Use Cases - We build Envato
  4. Struct inheritance is overused - The Pug Automatic
  5. The 3 Tenets of Service Objects in Ruby on Rails – Hacker Noon
  6. Chain service objects like a boss – Benjamin roth – Medium