Photo by Patrick Schneider on Unsplash
Imagine you have a controller with some complex business logic.
class UserController < ApplicationControllerdef createuser = User.new(user_params)if user.savesend_welcome_emailnotify_slackif @user.admin?log_new_adminelselog_new_userendredirect_to new_user_welcome_pathelserender 'new'endend# private methodsend
This code isn’t unreadable, but there’s a lot going on. The controller has too much to deal with—it needs private methods to cover everything from sending a welcome email to notifying slack to logging whether it’s an admin or user.
Clearly, if we want skinny controllers, the above approach isn’t the best.
We could extract all of the above into methods on the User model. Over time, though, we may find our model gets extremely cluttered. Want to read a 500 line model? Me neither.
A better solution? Service objects.
What are service objects?
Service objects are plain old Ruby objects (PORO’s) that do one thing.
They encapsulate a set of business logic, moving it out of models and controllers and into a more focused setting.
Here’s what a RegisterUser service object might look like:
class RegisterUserdef initialize(user)[@user](http://twitter.com/user) = userenddef executereturn nil unless [@user](http://twitter.com/user).savesend_welcome_emailnotify_slackif @user.admin?log_new_adminelselog_new_userend[@user](http://twitter.com/user)end# private methodsend
Our service object takes a newly instantiated user on initialize, and will either return a saved version of that user, or nil if there were problems saving.
And our slim controller:
class UserController < ApplicationControllerdef createuser = RegisterUser.new(User.new(user_params)).executeif userredirect_to new_user_welcome_pathelserender 'new'endend# private methodsend
We still have an if statement in our controller, but now it’s solely concerned with rendering and redirecting—normal controller concerns.
Dave Copeland on service objects:
Where a classic Rails design would add Yet Another Method™ to the nearest ActiveRecord object, using service objects allows us to keep all of our code separate and organized. This makes it easy to understand, modify, and test our business logic.
Returning values
The above code is a start, but it isn’t very useful if our user doesn’t save. We have no information on why saving failed.
Rather than returning either User or nil, we could return a result object.
A result object should tell us if the service was successful. If yes, it should contain any necessary return values. If no, it should give us errors.
You could write this as an OpenStruct:
class RegisterUserdef initialize(user)[@user](http://twitter.com/user) = userenddef executereturn OpenStruct.new(success?: false, user: nil, errors: [@user](http://twitter.com/user).errors) unless [@user](http://twitter.com/user).savesend_welcome_emailnotify_slackif @user.admin?log_new_adminelselog_new_userendOpenStruct.new(success?: true, user: [@user](http://twitter.com/user), errors: nil)end# private methodsend
And in our controller:
class UserController < ApplicationControllerdef createresult = RegisterUser.new(User.new(user_params)).executeif result.success?redirect_to new_user_welcome_pathelserender 'new', error: result.errorsendend# private methodsend
If we need the user itself, we could still access it via result.user. But the OpenStruct gives us all the information we need about what happened within the service.
You could also create a Result class instead of using OpenStruct.
Syntactic sugar
If you find the ServiceObject.new(arguments).execute chain to be ugly, you can simplify it like so:
class RegisterUserdef self.call(*args, &block)new(*args, &block).executeenddef initialize(user)[@user](http://twitter.com/user) = userenddef execute# old codeend# private methodsend
The self.call method means we can create a new RegisterUser object and invoke execute simply by calling RegisterUser.call(user_arguments).
Here’s it in practice:
class UserController < ApplicationControllerdef createresult = RegisterUser.call(User.new(user_params))if result.success?redirect_to new_user_welcome_pathelserender 'new', error: result.errorsendend# private methodsend
Nothing too crazy, but a little cleaner.
Calling dependent services
Let’s say our notifyslack method got out of hand, and we decided to expand _that to its own service object.
Here’s what it might look like:
class RegisterUserdef initialize(user)[@user](http://twitter.com/user) = userenddef executereturn OpenStruct.new(success?: false, user: nil, errors: [@user](http://twitter.com/user).errors) unless [@user](http://twitter.com/user).savesend_welcome_emailNotifySlack.call(@user)if @user.admin?log_new_adminelselog_new_userendOpenStruct.new(success?: true, user: [@user](http://twitter.com/user), errors: nil)end# private methodsend
Seems fine, but as Dave Copeland notes, the RegisterUser service now knows how to both create and invoke the NotifySlack service.
As he writes:
This means that if we need to change how [the child service] is created, we have to change [the parent method] (and likely its tests).
His solution? Extract the creation of the child service object to a private method:
class RegisterUserdef initialize(user)[@user](http://twitter.com/user) = userenddef executereturn OpenStruct.new(success?: false, user: nil, errors: [@user](http://twitter.com/user).errors) unless [@user](http://twitter.com/user).savesend_welcome_emailnotify_slack_service.executeif @user.admin?log_new_adminelselog_new_userendOpenStruct.new(success?: true, user: [@user](http://twitter.com/user), errors: nil)endprivatedef notify_slack_service[@notify_slack_service](http://twitter.com/notify_slack_service) ||= NotifySlack.new([@user](http://twitter.com/user))end# private methodsend
Note that this doesn’t work with our fancy call business above.
What should be a service object?
Service objects are great for encapsulating complex objects. But that doesn’t mean all heavy-duty logic requires a service object.
A bad approach would be to simply move a 500 line model straight into a service object.
A good service object is easy to test and follows the single responsibility principle.
Here’s what Amin Shah Gilani says:
Does your code handle routing, params or do other controller-y things? If so, don’t use a service object — your code belongs in the controller. Are you trying to share your code in different controllers? In this case, don’t use a service object — use a concern. Is your code like a model that doesn’t need persistence? If so, don’t use a service object. Use a non-ActiveRecord model instead. Is your code a specific business action? (e.g., “Take out the trash,” “Generate a PDF using this text,” or “Calculate the customs duty using these complicated rules”) In this case, use a service object. That code probably doesn’t logically fit in either your controller or your model.
A specific business action that does one thing. That’s our goal with service objects. If you can meet that definition, they can be a great way to separate logic into testable & digestible pieces.
Thanks for reading!