Note: This tutorial is an excerpt for the service object chapter in my book Building a SaaS Ruby on Rails 5. The book will guide you from humble beginnings by deploying an app to production. If you find this type of content valuable, the book is on sale right now!
Also, the beta for my new project Pull Manager is almost ready. If you’re losing track of pull requests, have old ones lingering around or would just love a dashboard that aggregates these requests over multiple services (Github, Gitlab, and Bitbucket), check it out.
We have all been there when you see your controller action getting way too long and hold too much business logic. You know you need to email the user, adjust an account, maybe submit to Stripe and finally ping a Slack Webhook. Well, where should it go? This code needs to exist and doesn’t seem to fit in the model. This my friend, is where Service Objects come in!
What’s so great about a Service Object? I find in most projects there is nothing easier to reason about or test than a POO(Plain Old Object), which in our case is a PORO(Plain Old Ruby Object). Meaning, the Service Object will be a stand alone class the is not inheriting or extending any other classes from Ruby or Rails.
Note: I follow-up with this post, refactoring
Rails does not create a services folder by default so it is our job to create one. A little side-note though, depending on the size of your app or the complexity of business logic you may have the need to break services down even further by domain, type or other qualifiers. For the purpose of this post, you can just create a services folder in your app folder mkdir app/services
Restart your rails server to pick up the new folder as Rails will autoload directories in the app directory. Now we can create a file for a user registration service new_registration_service.rb
and fill it with our business logic.
class NewRegistrationServicedef initialize(params)@user = params[:user]@organization = params[:organization]end
def performorganization_createsend_welcome_emailnotify_slackend
private
def organization_createpost_organization_setup if @organization.saveend
def [email protected]_id = @[email protected]@user.add_role :admin, @organizationend
def send_welcome_emailWelcomeEmailMailer.welcome_email(@user).deliver_laterend
def notify_slacknotifier = 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
Ok! Lets go over the Service Object section by section!
class NewRegistrationServicedef initialize(params)@user = params[:user]@organization = params[:organization]end
Here we are creating the class and then adding the initialize method to create the needed instance variables upon the object being instantiated. In this case, we will pass the object the User model record object and the Organization record object previously created in a new user signup controller.
def performorganization_createsend_welcome_emailnotify_slackend
Personally, I like to wrap the functionality of the whole set of business logic in a perform method. I have also seen some proponents of calling different methods manually from the controller as well. It’s really a matter of your preference.
def organization_createpost_organization_setup if @organization.saveend
def [email protected]_id = @[email protected]@user.add_role :admin, @organizationend
Here we are creating the organization(which was previously validated in the controller that instantiated this Service Object) and then making the necessary updates to the user such as adding the organization_id needed for the belongs_to relation and assigning a role for the authorization system that is scoped to the previously created organization.
def send_welcome_emailWelcomeEmailMailer.welcome_email(@user).deliver_laterend
A quick call to a Rail’s ActionMailer with the user object…
def notify_slacknotifier = Slack::Notifier.new "https://hooks.slack.com/services/89ypfhuiwquhfwfwef908wefoij"notifier.ping "A New User has appeared! #{@organization.name} - #{@user.name} || ENV: #{Rails.env}"end
…and finally notifying the Slack Webhook I use for sign up notifications.
Now, you may be saying to yourself, this makes sense but how and where do you call this Service Object? The following is the code extracted from my Devise Registration controller handling the incoming sign up form and all it’s Devise goodness:
NewRegistrationService.new({user: resource, organization: @org}).perform
Some readers may start to question some of the aspects of the Service Object in this post. Service Objects do not have a strong convention that I have seen and I have fit this one to match my needs. Earlier, when I said you can call public methods individually, instead of using a perform method, would allow you to wrap some error handling logic. Such as this:
class NewRegistrationServicedef initialize(params)@user = params[:user]@organization = params[:organization]end
def organization_createbeginpost_organization_setup if @organization.saverescuefalseendend
....
..and in the controller, something like:
if NewRegistrationService.new({user: resource, organization: @org}).organization_create**success logic**else** redirect_to last_path, notice: 'Error saving record'end
Regardless of your Service Object structure, simply extracting your business logic out of your controllers and into Service Objects will help keep your controllers ‘skinny’ and your application’s code easy to follow.
Note: I follow up with this post with a refactored version of this Service