If you’ve worked on any large Rails app, you’ve probably come across the service object pattern, a useful abstraction for handling business logic. Recently, we’ve been playing with a particular implementation of this pattern using the Interactor gem.
To understand the benefits of interactors, it’s helpful to first talk about service objects and how they’re often used in Rails.
In the context of Rails, a service object is simply a Ruby object used for a specific business action. This code usually doesn’t belong in the controller or model layer, because it might involve multiple data models, or it hits a third-party API.
Here’s a contrived example of a service object, in the context of a user responding to a survey, where the user gets some kind of reward points for responding:
You would use this in the controller like this:
At Reflektive, we have made extensive use of service objects to encapsulate actions such as changing the manager of employees, launching review cycles, or processing Real-Time Feedback from Slack.
A problem we’ve encountered as our team and codebase has grown is a lack of established conventions for how service objects should be written, so engineers on different teams would write them in different ways. Some had many public methods, while others had only one. Some handled errors while others didn’t. Some were concerned about data integrity, running anything data-related in ActiveRecord transactions in case they failed.
As a team, we discussed what we wanted from our service objects: Uniformity, data integrity, single responsibility, and error handling.
This lead us to the Interactor gem, created by the good folks over at Collective Idea. This gem provides a great API for much that we desire in service objects and more. I encourage you to read the docs as the library is quite simple and well-written.
As you can see from the example of our service object above, we don’t have a very elegant way to handle errors in the above example, rescuing any errors in the controller. Also, you could argue that our
ReplyToSurveyservice violates the single responsibility principle, since it actually does three things (creates the response, adds reward points, and sends notifications). There are plenty of ways we could solve this manually, but lets see how we could solve this using Interactors:
Note: The above would all be in separate files.
And in the controller, this would look like:
Using Interactors, we’ve split each step into its own class and we get a uniform way of handling errors at each step. Awesome!
There are a couple of highlights of this library that I want to point out:
Often, many things need to happen in a service. This can lead to services being called from within services — but with Organizers, you can list the sequence in which you need your Interactors to be run. This encourages writing small services with single responsibilities. Also, the
organize API allows for great self-documenting code:
organize CreateOrder, ChargeCard, SendThankYou
Some services might create data, but fail at a certain point. Interactors provide a
rollback method that gives you a chance to undo anything that has been done. If you have a five Interactors and the fourth one fails, the rollback method will be called on each in reverse order.
Drawbacks of Interactor
Our team had some concerns about the Interactor gem, but we established a few conventions to address these issues:
Lack of Class Signature
With Interactors, you don’t define an
initialize method because all parameters passed to an Interactor are attached to a
context object that is shared and mutated (a scary word to many programmers) between Interactors. To make it easier to quickly understand what parameters we can expect the Interactor to have, we recommended using a
delegate call at the top of our classes:
As I mentioned before, you can attach values to the
context object. This is convenient when you have multiple Interactors called one after the other using the
organize method. One of the concerns we had was that it might be unclear what's in a
context object if you can just attach values wherever and whenever you want.
To make this more predictable, we recommended a convention of only attaching values to the
context at the end of the
call method or within the
if something.save... block, as you can see above.
Check out the gem! While its quite robust, the library is actually quite small and is extremely well documented. And let us know what strategies you prefer to encapsulate business logic.