What is good service object?

  • Single responsibility
  • Checks it’s input
  • Command/query separation
  • Same level of abstraction
  • Transparent
  • Tested

Example of good service object

Let’s start by giving a simple example:

class UserCreator
  attr_reader :email, :first_name, :last_name
  attr_reader :created_user

  def initialize(email, first_name, last_name)
    @email      = email
    @first_name = first_name
    @last_name  = last_name

    raise ArgumentError, "Missing email" if email.nil?
  end

  def create_user
    check_user_existance
    save_user
  end

  def user_created?
    created_user.present?
  end

  private

  def check_user_existance
    raise "User already exists" if User.exists?(...)
  end

  def save_user
    @created_user = User.create(...)
  end
end

Single responsibility

As we see above, UserCreator is single responsible for creating user. No email sending or whatever. If you need, for example, to send welcome email it is probably should be done from another service, say WelcomeEmailSender. The smaller construction blocks in your program the less pain it would be to change them.

Checks it’s input

It’s simple, don’t check email address for being nil outside of the service. This will spread such checks all across the codebase. Object constructor is the one who’s responsible for setting object invariant - state in which object can perform without braking. If your service needs email address to work correctly and it is not given - brake early, throw exception from constructor.

Command/query separation

Usually objects have two types of methods: commands and queries. Commands change something and return nothing, queries in opposite return current state without changing anything. This separation brings clarity in how and when you use particular method, so you don’t endup in situation when asking object for it’s state also changes it or sends email.

Same level of abstraction

Term abstraction is about hiding details. In #create_user we hide checking user existence and saving user details inside #check_user_existance and #save_user methods. We’d brake this rule if some details are still left inside #create_user method.

Transparent

In other words you should be able to inspect input parameters, which usually got supplied through constructor, hense you should have attribute readers for every input parameter. This especially handy upon debugging.

Tested

According to Sandi Metz, you should test object query methods by asserting returned result, command methods - by asserting direct public side effect and outgoing command methods, by expecting to send them. Usually, I would test such service as so:

describe UserCreator do
  describe "#create_user" do
    it "creates user" do
      user_creator = UserCreator.new("ian@brown.com", "Ian", "Brown")
      user_creator.create_user

      expect(user_creator.created_user.email).to      eq("ian@brown.com")
      expect(user_creator.created_user.first_name).to eq("Ian")
      expect(user_creator.created_user.last_name).to  eq("Brown")
    end

    context "no email given" do
      it "raises exception" do
        expect {
          UserCreator.new(nil, "Ian", "Brown")
        }.to raise_error(/Missing email/)
      end
    end

    context "user already exists" do
      ...
    end
  end
end

The anatomy

Trying to visualize service object I came out with this picture:

Service anatomy picture

Further reading