DEV Community

Cover image for Going further with Service Objects in Ruby on Rails
Rob Race
Rob Race

Posted on

Going further with Service Objects in Ruby on Rails

Note: This tutorial is an excerpt for the service object chapter in my upcoming book Building a SaaS Ruby on Rails 5. The book will guide you from humble beginnings through deploying an app to production. If you find this type of content valuable, the book is on pre-sale right now!


As a follow-up to the recent post about Service Objects, Service Objects in Ruby on Rails…and you, I wanted to go deeper into Service Objects subject with details such as keeping objects to a single responsibility, better error handling, and clearer usage. To show you the differences I will be refactoring the Service Object I created in the first post on Service Objects.

I would like to also point out some blog posts that influenced this refactor such as My take on services in Rails and Anatomy of a Rails Service Object.

class NewRegistrationService  
  def initialize(params)  
    @user = params[:user]  
    @organization = params[:organization]  
  end

  def perform  
    organization_create  
    send_welcome_email  
    notify_slack  
  end

  private

  def organization_create  
    post_organization_setup if @organization.save  
  end

  def post_organization_setup  
    @user.organization_id = @organization.id  
    @user.save  
    @user.add_role :admin, @organization  
 end


  def send_welcome_email  
    WelcomeEmailMailer.welcome_email(@user).deliver_later  
  end

  def notify_slack  
    notifier = Slack::Notifier.new "https://hooks.slack.com/services/89ypfhuiwquhfwfwef908wefoij"  
    notifier.ping "A New User has appeared! #{@organization.name} -   #{@user.name} || ENV: #{Rails.env}"  
  end
end
Enter fullscreen mode Exit fullscreen mode

Let's start by calling out the fact that we're putting a bunch of different responsibilities into one Service Class. Additionally, it's not really following the errors or successes through the parent class into the controller that requested the Service. To start remedying that we will split off each of the responsibilities into their own classes.

Before showing the code for these classes, I would also like to touch on another change in the new service object, being the use of build to inject the depending classes:

class NewRegistration

  def self.build  
    new(OrganizationSetup.build, AdminUserSetup.build, SendWelcomeEmail.build, NotifySlack.build)  
  end

  def initialize(organization_setup, admin_user_setup,     send_welcome_email, notify_slack)  
    self.organization_setup = organization_setup  
    self.admin_user_setup = admin_user_setup  
    self.send_welcome_email = send_welcome_email  
    self.notify_slack = notify_slack  
  end

....
Enter fullscreen mode Exit fullscreen mode

The build/new technique has been previously proposed in a blog post by Piotr Solnica (of rom_rb and dry-rb fame) at http://solnic.eu/2013/12/17/the-world-needs-another-post-about-dependency-injection-in-ruby.html. With the idea being can call a build method on a Service class to instantiate the object and any dependents. If it is a child with none, you would just call new . This way the injected classes can be passed in, instead of hard coded and can pay dividends when setting up your objects to be tested.

Without further ado, here are the new child classes:

…setting up the organization(aka, saving the passed in record)

# app/services/new_registration/organization_setup.rb  
class NewRegistration  
  class OrganizationSetup
    def self.build  
      new  
    end

    def call(organization)  
      organization.save!  
    end
  end  
end
Enter fullscreen mode Exit fullscreen mode

…setting up the initial user from the newly created organization

# app/services/new_registration/admin_user_setup.rb  
class NewRegistration  
  class AdminUserSetup
    def self.build  
      new  
    end

    def call(user, organization)  
      user.organization_id = organization.id  
      user.save  
      user.add_role :admin, organization  
    end
  end  
end
Enter fullscreen mode Exit fullscreen mode

…sending the welcome email

# app/services/new_registration/send_welcome_email.rb  
class NewRegistration  
  class SendWelcomeEmail
    def self.build  
      new  
    end

    def call(user)  
      WelcomeEmailMailer.welcome_email(user).deliver_later  
    end
  end  
end
Enter fullscreen mode Exit fullscreen mode

…and finally, pinging slack

# app/services/new_registration/notify_slack.rb  
class NewRegistration  
  class NotifySlack
    def self.build  
      new  
    end

    def call(user, organization)  
      notifier = Slack::Notifier.new "[https://hooks.slack.com/services/89hiusdfhiwufhsdf89](https://hooks.slack.com/services/89hiusdfhiwufhsdf89)"  
      notifier.ping "A New User has appeared! #{organization.name} - #{user.name} || ENV: #{Rails.env}"  
    end
  end  
end
Enter fullscreen mode Exit fullscreen mode

In the new version of the services I have split up the child components a little differently to better embody each of the individual child services and potentially handle exceptions. Now, that we have our child services, we can call them in our parent service

# app/services/new_registration.rb
def call(params)  
    user = params[:user]  
    organization = params[:organization]
    begin  
      organization_setup.call(organization)  
      admin_user_setup.call(user, organization)  
      send_welcome_email.call(user)  
      notify_slack.call(user, organization)  
    rescue => exception  
      OpenStruct(success?: false, user: user, organization: organization, error: exception)  
    else  
      OpenStruct(success?: true, user: user, organization: organization, error: nil)  
    end  
  end
...
Enter fullscreen mode Exit fullscreen mode

As you can see, another change from the previous version of the NewRegistration service is that we are no longer using a .perform method and now using .call . Why is this important? Well one, it is actually a lot more common than perform, even the common understood standard, which commenters here and elsewhere have pointed out. Additionally, .call responds to lambdas which may help you out using these objects elsewhere.

We parse out the parameters much like we did in the previous version, but now into regular variables instead of instance variables as they are being passed into the new child services in the same public .call method. The order and ‘waterfall' of items to perform stay relatively the same throughout both versions though.

Now that we have individual child services we can catch exception from any of the services in a begin...rescue..end block. That way, we can catch an issue like the organization not saving, pass it back up through the parent object and the controller that called it to handle the exception. Additionally, now we are passing back what is essentially an results object with OpenStruct to the controller as well. This object will hold :success? , objects that were passed in to be returned, and any errors from the services.

With all these changes, the way we can call the Service from our controller is slightly different, but not much:

result = NewRegistration.build.call({user: resource, organization: @org})  
if result  
  redirect_to root_path(result.user)  
else  
  ** redirect_to last_path(result.user), notice: 'Error saving record'  
end
Enter fullscreen mode Exit fullscreen mode

If you want to go a lot deeper on success/error paths, chaining services and handling states with a pretty lightweight DSL, I recommend checking out this post from benjamin roth. As always with Ruby, the size of your app, business logic, readability needs or styles, and complexity can cause your needs for simple classes to DSLs to vary.

Lastly, now an example of how easy it can be to test the newly refactored service

# spec/services/new_registration_test.rb  
describe NewRegistration do  
   context "integration tests" do       
     before do  
      @service = NewRegistration.build  
      @valid_params = {user: Factory.create(:user), organization: Factory.build(:organization)}  
     end

     it "creates an organization in the database" do  
      expect { @service.call(@valid_params) }.to change { Organization.count }.by(1)  
     end
...etc, etc
Enter fullscreen mode Exit fullscreen mode

There you have it, refactored Registration Service objects that better encapsulates the Single Responsibility Principle(it may not be perfect to the most religious of followers), better result handling, improved readability, and improved dependency handling. Obviously, almost any code can continuously be improved and I look forward to the internet to pick apart this version of the Registration Service. :)

Top comments (0)