Refactoring Code Smells With Service Objects
Service objects are a powerful tool for cleaning up code duplication, fat models, and bloated controllers.
This article accompanied a talk that I gave at Austin on Rails June 27, 2017.
Rails developer are known to be keenly aware of our code quality. We follow Rails' conventions, we implement best practices, we monitor security, and we test our code. We take pride not only in the finished product, but in the quality of the code that we shipped.
Almost every app has room for improvement. Even apps that we've worked on ourselves can sometimes succumb to time or budget constraints and end up with code that… shall we say, isn't the best quality. This shortcoming usually manifests itself as a code smell.
Code smell refers to issues in working code that may be indicative of a deeper problem. It's not that the code doesn't work, but something about it just doesn't feel right. Maybe your ActiveRecord model is starting to get really long, or your controller has non-REST routes, or you find yourself tracking session variables through your model code.
There are many techniques for refactoring code smells, but one of my favorite is service objects.
A service object is simply a PORO (plain old Ruby object) that does something and then returns a result. As a Rails developer it’s easy to forget that we’re Ruby developers first. It’s easy to get stuck thinking that the Rails MVC framework is immutable and forget that we really are working with Ruby. Since we’re working with Rails, if we’ve got code it has to go in one of Rails’ 4 places - model, view, controller, or helper, right?
No so - in fact doing this is a surefire way to end up with a bloated app full of spaghetti controllers and bloated models. Ruby, being an Object Oriented language, lets us create any kind of object we want. We’re not stuck with Rails’ default objects.
This isn’t really a talk about refactoring, but as you’re working on your project it’s a good idea to always see if you can simplify any part of your project that you happen to be working on.
What is a Service Object?
A service object is an object that encapsulates some discrete piece of business logic in your app, does something to it, and then returns a result. Service objects usually follow a few conventions:
- Initializes a new object with some value.
- Contains a single public
call
method that does something. - Does not store state.
- Returns a result object (not a boolean or a class).
Types of Service objects
In general, I've seen three types of service objects, though there might be more:
- Creators: creates and saves objects to the database when the process is more complicated than simply persisting a model record. Example would be creating a new customer, setting up an account, saving sample data to their account, and sending notifications.
- Updaters: update a record and also do some other work. For example, updating a record and then communicating with third-party APIs.
- Workers: a task that gets triggered when something happens in the background. The trigger might be handled by ActiveJob, but the actual processing might be handed off to a service object.
vs. Concerns
ActiveSupport already has a wonderful built-in tool called Concerns. Shouldn't we use that instead?
Depends. The chief difference between ActiveSupport::Concerns
and service objects is that concerns are modules that are intended to be mixed into other classes, while service objects are classes that can be instantiated. Concerns are used when there is shared functionality among several classes. By contrast, service objects are discrete objects that do not live within another class.
How do you know when you need one?
If you start to notice code smells, you probably need to think about refactoring. A few code smells that you're looking for include:
- Performing business logic inside ActiveRecord Models.
- Extra methods inside a controller.
- Non-restful routes.
- Using global variables inside models (
User.current_user
).
Specifically, the biggest candidate for service objects are when you find “god objects”. God objects are objects that know too much or does too much. A great example of overuse of god objects is Discourse. Discourse. Not knocking Discourse at all - it’s a great product. But it’s a pretty extreme example of how god objects can rear their ugly heads when Single Responsibility Principle is not observed. Take a look at the app/models
directory - there are 165 models, which isn’t necessarily a bad thing in itself. But there are over 18k lines of code there, and the largest, user.rb and topic.rb have 1140 and 1288 lines of code each. That’s a big model by any standard. You can bet that those models do a lot more than just talk to the database.
In one of his talks Ben Orenstein had a simple test to find code smells - just do a line count of your models (wc -l app/models | sort
), and see what the largest models are.
Ideally an ActiveRecord model just does one thing - provides an interface to the database. There are lots of things you can put into an ActiveRecord
model to express intent, like scopes, relationships, etc. But there’s a fine line somewhere, and you know when you've crossed it because the code starts to smell.
The smell that you're looking for is multiple responsibilities. Single Responsibility Principle states that each object or class "should have responsibility over a single part of the functionality provided by the software, and that responsibility should be entirely encapsulated by the class" (Wikipedia: Single responsibility principle). In one example that we'll look at below, we'll create a new tenant in our database, along with a user, we'll talk to Stripe, send data to our analytics dashboard, and send notifications.Each of those is a separate responsibility, and so we'll extract each out into it own service object.
Is refactoring really worth it?
We've all worked on apps where all that functionality lives in a controller, or is spread throughout the controller and one or more ActiveRecord models. Those are a nightmare to work on. A few hours of refactoring can yield an improvement both in terms of maintainability—the next time a developer opens up the app it'll be easier to understand what's going on, and testability—you'll be confident that the code you're delivering works as expected and any exceptions will be handled correctly.
Nuts & Bolts
Setting Up
It's very easy to set up service objects. You can place your files in RAILS_ROOT/app/services
, and Rails will automatically load any *.rb
files in that directory.
Initializing
Since a service object is just a PORO (plain old Ruby object) we can set it up and initialize it just like any other object.
Calling
Though there's no hard and fast rule, a common convention is to expose a single public method called call
to do the work inside your object. You can name this method whatever you want (I've see it named process!
). I like to use call
because it is more generic. Except in rare cases I use the same method name in all my service objects. That way I don't have to go digging through my files trying to remember what the method is called.
To call your object, you'd first instantiate it and then call it, like this: UserCreator.new(params).call
.
If you like, you can also define a call
class method. This would allow you to call the object directly: UserCreator.call(params)
.
Processing
I like to keep my public call
method as clean as possible. To that end, I'll extract any functionality from call
into other methods, or if it makes sense, into other service objects.
In our MessageCreator#call
we're using the process!
method to do the actual work. If the message is saved successfully, it calls deliver_messages
. Otherwise, it adds errors to the errors
array and returns. If our call
method becomes more than a few lines long it's a cue that our service object itself may need to be refactored.
Returning
It's tempting to return a boolean value, or the class that you're working on. For example if you're creating an object, a boolean would allow you to see if your process succeeded or failed. But it wouldn't tell you much more than that - why did it fail? Were there any errors? Was there an exception?
Likewise, returning the object that you were working on can be problematic. What if you need to return multiple objects? Doing this will lead to an inconsistent API and have you sorting through spaghetti code for the rest of your life.
That's why I like to return a consistent result
object. This object contains a success?
boolean, an array of errors (empty by default), and any objects that were operated on. This keeps the API consistent among various service objects, gives me confidence that I can operate on the results of a service object, and makes it easier to test.
In the WidgetCreator
example we're returning a result
object. We can then tell if the operation was successful or not through success?
, we can access any errors through errors
, and we can get the actual widget object.
Calling other service objects
Once you start calling other service objects, you'll start to appreciate that consistent API. Take a look at this example, which is trimmed-down from an actual production app.
First of all, TenantCreator calls persist!
. Then it makes a new instance of Billing::SubscriptionCreator
(another service object).
subscription_creator
delegates to a new instance of SubscriptionCreator
. If either tenant.save
or subscription_creator.call
fails (maybe their credit card was expired), it will return a result object where success?
is false
. It'll then add the subscription errors to this object's errors array, halt execution, and return the failure.
Otherwise, it'll proceed. It sends messages through the SignupMailer (just a regular Rails mailer), talks to the AnalyticsTracker, and seeds the data.
This keeps our SignupController nice and lean:
Testing
Service objects are quite easy to test. In fact, it's probably a lot easier to test a service object than it is to test all the different possible permutations and outcomes in a spaghetti controller.
Most of the time it's only necessary to test the single public method of your service object. You're testing various inputs and checking that the output is what you expect.
Should you find yourself needing to test your object's private methods, consider whether that itself is a code smell. Is the service object managing more than one responsibility? Consider splitting it into other objects.
Best practices
Service objects are pretty free-form, so there's a lot you can do with them. However that freedom can also lead to trouble unless you follow a few conventions.
Only one public method
- Consistent API among all your service objects.
- Allows easy testing—only test the public method.
Returning consistently
- Return a result object, rather than raising exceptions.
- Respond to
success?
. - Include any errors in the return object.
Handling errors & exceptions
- It's better to return a value rather than raise an exception.
- Exceptions interrupt the flow of execution.
- Exceptions can cascade, making debugging harder.
- If you need to raise an exception, mix in a concern instead of an object.
Naming conventions
- Name service objects for the job that they do.
- Use verbs instead of nouns:
- It sounds awkward to have a
CreateUser
. - It's much more natural to have an instance of
UserCreator
.
- It sounds awkward to have a
More on Service Objects
Service Objects are just one of many different kinds of object-oriented programming techniques that can help you make your projects more maintainable, more testable, and safer. We could also talk about Presenters, Decorators, Listeners, Form objects, Query objects, and more. I highly recommend Ben Orenstein's talk on Refactoring from Good to Great. Also check out these delicious links, many of which provided background for this article:
- Wikipedia: God object
- Wikipedia: Single responsibility principle
- Coding Horror: Code Smells
- The 3 Tenets of Service Objects in Ruby on Rails
- MultiThreaded: Anatomy of a Rails Service Object
- EngineYard Blog: Using Services to Keep Your Rails Controllers Clean and DRY
- HackerNoon: Service Objects in Ruby on Rails…and you
- HackerNoon: Going further with Service Objects in Ruby on Rails
- HackerNoon: The 3 Tenets of Service Objects in Ruby on Rails
- NetGuru: Service objects in Rails will help you design clean and maintainable code. Here's how.
ActiveSupport::Concerns
- RichOnRails: Code Concerns in Rails 4 Models
- Signal v. Noise: Put chubby models on a diet with concerns