Rails Refactoring Part II: Services

This is the second in a series of three posts that will demonstrate implementation of three common Rails design patterns: the adapter pattern; service objects; the decorator pattern

You can find the code for these posts here You can find a demo of the app, deployed to Heroku, here.


In Part I of this series, we began refactoring a simple Rails app that helps us track issues on our GitHub repositories. You can view the code, pre- and post-refactor here.

Previously, we implemented the adapter pattern, building a wrapper for our application's interactions with the GitHub and Twilio APIs. This refactor helped us begin to clean up our controllers. However, both the RepositoriesController and the WebhooksController still contain a lot of ugly logic around:

  • How to create and save new Repository records
  • How to update or create new Issue records
  • How to send out email and text notifications to users.

Here, we'll build a series of services to encapsulate these responsibilities.

Let's get started!

What are Services?

A service is a Plain Old Ruby Object (PORO) that helps us keep our controllers clean and DRY.
Service objects encapsulate some of the user’s interactions with the application––the kind of stuff that doesn’t belong in the controller, but also doesn’t rightfully belong to the controller.
Think of them as the administrative assistant of your app.

Any of the above-mentioned jobs make great candidates for service objects.

Implementing Service Objects

The Repo Generator Service

Let's start with our RepositoriesController.

Code Smells

Here we can see that we have to do some string manipulation on our params in order to extract the data we need to create and save a new Repository record.

Fix It

Let's abstract this out into a service object.

  • Create a subdirectory, app/services.
  • Create a file, app/services/repo_generator.rb

Here we'll define our service and define a method, #generate_repo, that will contain our extracted code:

# app/services/repo_generator.rb

class RepoGenerator  
  def self.generate_repo(params, user)
    repo_owner = params[:url].split("/")[-2]
    repo_name = params[:url].split("/")[-1]
    Repository.new(name: repo_name, url: 
      params[:url], user: user)
  end
end  

Now, let's use our service object in our controller:

# app/controllers/repositories_controller.rb

def create  
  @repo =RepoGenerator.generate_repo(repo_params, 
    current_user)
  if @repo.save
    @client = Adapter::GitHub.new(@repo)
    @client.repo_issues
    @client.create_repo_webhook
  end
  respond_to do |f|
    f.js
    f.html {head :no_content; return}
  end
end  

Much cleaner!

The Issue Updater Service

Let's do the same type of clean up on our WebhooksController.

Code Smells

Here, we have a lot of messy logic surrounding how we update or create an issue, given the payload sent to us from GitHub (thanks to our webhook).

We are also carrying out two tasks: that of updating the issue's repository and updating the issue's own properties.

And, on top of all that, just like in our original Repository creation code, we're manipulating strings of data to retrieve the info we need to create new records.

Let's build a service to handle all of this for us.

Fix It

  • Create a file, app/services/issue_updater.rb

Here we'll define our service object class with a method #update_from_payload.

We'll also define a helper method, #update_issue_repo, to handle the job of finding or updating the issue's repository (in this app, an issue belongs to a repository and a repository has many issues). That is really a separate job that needs to occur in order for an issue to be properly updated.

# app/services/issue_updater.rb  
class IssueUpdater

  attr_accessor :issue

  def initialize(payload)
    @payload = payload
    @issue = Issue.find_or_create_by(url: 
      @payload["html_url"])
  end

  def update_from_payload
    update_issue_repo unless issue.repository
    issue.update(title: @payload["title"], 
      content: @payload["body"], assignee: 
      @payload["assignee"], status: 
      @payload["state"])
    issue
  end

  def update_issue_repo
    url_elements = 
      @payload["repository_url"].split("/")
    repo_url = "https://github.com/#
      {url_elements[-2]}/#{url_elements[-1]}"
    repo = Repository.find_by(url: repo_url)
    issue.update(repository: repo)
  end
end  

Now, let's use our service object in the controller:

# app/controllers/webhooks_controller.rb

def receive  
  if params[:zen]
    head :no_content
    return
  else
    issue = IssueUpdater.new(issue_params)
      .update_from_payload
    if owner.phone_number
       Adapter::TwilioWrapper.new(issue)
         .send_issue_update_text
      end
      UserMailer.issue_update_email(
        issue.user, issue).deliver_now
    head :no_content
    return
  end
end  

This controller action is starting to look much more organized!

The Issue Notifier Service

There is one other area that I'd like to address here though.

Code Smells

It seems to me that the job of sending the update text and sending the update email fall under the same umbrella––notifying the user of some change to one of their issues.

Fix It

Let's build a service object to wrap up this functionality.

  • Create a file, app/services/issue_notified.rb.

Here, we'll define our service object class and abstract away our code for sending emails and texts.

# app/services/issue_notifier.rb  
class IssueNotifier

  attr_accessor :issue

  def initialize(issue)
    @issue = issue
  end

  def execute
    send_email
    send_text
  end

  def send_email
    UserMailer.issue_update_email(issue.user, 
      issue).deliver_now
  end

  def send_text
    owner = issue.repository.user
    if owner.phone_number
      Adapter::TwilioWrapper.new(issue)
        .send_issue_update_text(owner)
    end
  end
end  

We've defined an #initialize method to set the initial state with the issue instance. We've defined two helper methods, one each for sending email and text. And, we've defined an #execute method, that calls on our email- and text-sending helper methods.

Let's call on our service in our controller.

def receive  
  if params[:zen]
    head :no_content
    return
  else
    issue = IssueUpdater.new(issue_params)
      .update_from_payload
    IssueNotifier.new(issue).execute
    head :no_content
    return
  end
end  

That looks clean and beautiful!

Coming Up...

Now that our controllers are clean and DRY, and we've build adapters and services to handle the appropriate logic, we'll move on to some view refactoring. In the next post, Part III of this series, we'll learn how to recognize some code smells in the views, and implement the decorator pattern to address them.

subscribe and never miss a post!

Blog Logo

Sophie DeBenedetto

comments powered by Disqus
comments powered by Disqus