Refactor Service Objects

Using service objects in a Rails project, or an MVC framework for that matter, is a good coding practice to make sure classes follow Single Responsibility Principle. Responsibilities are broken down from "fat" models into different service objects that each serves a single purpose. Sometimes a service object may depend on another to perform its task. For example:

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

The code shown above 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 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 speaking, 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 will 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. We want abstractions at the boundaries where one object interacts with another to remove couplings. 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. But we have to write more code to construct these services, which may be cumbersome when we need to use them in many places. I will write about them in my future posts. Thanks for reading!

Loading comments...