Using service objects pattern in a Rails project, or any MVC framework for that matter, is a good coding practice to avoid "fat" models that violate the Single Responsibility Principle.

We can break down the responsibilities in a "fat" model and put them into individual service objects. We cannot just stop here though, sometimes a service object may also depends on another to perform a task. For example, the following code posts a social status update using a Twitter client:

class PostSocialUpdate
  def post(social_update)
    social_client = Twitter::REST::Client.new
    social_client.update(social_update.text)
    social_update.set_posted!
  end
end

This code has a major drawback. We are coupling PostSocialUpdate with Twitter::REST::Client, and it's impossible to replace Twitter::REST::Client with a different social networking client without modifying PostSocialUpdate. Software requirements change, so our code should be comfortable to change as well.

Instead of inserting the responsibility of instantiating social networking client to PostSocialUpdate, we move it outside of it. i.e. when we create PostSocialUpdate service. This is often called dependency injection.

Dependency injection definition on Wikipedia

In software engineering, dependency injection is a software design pattern that implements inversion of control for resolving dependencies. A dependency is an object that can be used (a service). An injection is the passing of a dependency to a dependent object (a client) that would use it.

Specifically, we are doing constructor dependency injection here, since we are passing the dependencies to the constructor.

class PostSocialUpdate
  def initialize(social_client: social_client)
    @social_client = social_client
  end

  def post(social_update)
    @social_client.update(social_update.text)
    social_update.set_posted!
  end
end

post_social_update = PostSocialUpdate.new(social_client: Twitter::REST::Client.new)

This simple change brings us a couple of benefits.

  1. Switching to a different service is a no brainer. PostSocialUpdate is no longer coupled with Twitter::REST::Client and can be dynamically set to another service at run time.
  2. Testing PostSocialUpdate becomes straightforward. Whereas the first version may require us to mock Twitter::REST::Client object, the latter just needs a dummy service object that responds to update and can be verified that update has indeed been called.
class DummySocialClient
  attr_reader :called_with

  def update(social_update)
    @called_with = social_update.text
  end
end

describe PostSocialUpdate do
  test "posts update" do
    social_client = DummySocialClient.new
    post_social_update = PostSocialUpdate.new(social_client: social_client)
    post_social_update.post(social_update)
    expect(social_client.called_with).to eq(social_update.text)
  end
end

While we've made good progress so far, we still have a problem when we decide to switch to a Facebook client koala, which has the following API:

@api.put_wall_post("Making a post")

One way to make it work is to wrap it with an adapter with the update method we want. But please hold that thought for a second, and come back to PostSocialUpdate.

There is coupling here we've missed. PostSocialUpdate is coupled with the method update of Twitter::REST::Client. Now, let's imagine we are starting from scratch with PostSocialUpdate and not knowing yet which social networking client to work with.  What would be the service object we wish we had? I'll say anything that has a call method that performs posting a social update.

class PostSocialUpdate
  def initialize(social_client: social_client)
    @social_client = social_client
  end

  def post(social_update)
    @social_client.call(social_update.text)
    social_update.set_posted!
  end
end

class TwitterClientAdapter
  def initialize
    @api = Twitter::REST::Client.new
  end

  def call(text)
    @api.update(text)
  end
end

post_social_update = PostSocialUpdate.new(social_client: TwitterClientAdapter.new)

class FacebookClientAdapter
  def initialize
    @api = Koala::Facebook::API.new(ENV['FACEBOOK_TOKEN'])
  end

  def call(text)
    @api.put_wall_post(text)
  end
end

post_social_update = PostSocialUpdate.new(social_client: FacebookClientAdapter.new)

While it seems no different than wrapping Koala::Facebook::API with an update method, this gives us abstraction and flexibility. We are able to keep coding without knowing which social networking clients to work with beforehand.

Furthermore, what if our business now requires posting an update to both Facebook and Twitter. All we need to do is swapping in the following service object.

class CompositeSocialClient
  def initialize(clients)
    @clients = clients
  end

  def call(text)
    @clients.each { |c| c.call(text) }
  end
end

social_client = CompositeSocialClient.new([FacebookClientAdapter.new, TwitterClientAdapter.new])
post_social_update = PostSocialUpdate.new(social_client: social_client)

We've certainly improved our initial code and gained flexibility and extensibility. Note that since we are allowing anything that responds to a call here, we can even use a plain function.

post_social_update = PostSocialUpdate.new(social_client: -> (text) { ... })

This is the power of abstraction and composition!