If you’re following trends in Rails you’ve probably heard the word ‘service’ being tossed around. I was surprised to find that even though there seems to be consensus that service objects are a good idea, there is no standard pattern for building or consuming them.
Why Service Objects
Let’s start by looking at the problem that service objects try to solve. Here is a common Rails controller that creates Users.
This should hopefully look fairly standard. Let’s introduce a common feature - we want to send a welcome email when a user is created. So here’s the crux of the matter - where does this code go? If you’re following the standard Rails mantra of ‘Fat models, Skinny controllers’ you would probably lean towards doing this inside the model.
This works, but this code is rather problematic:
- This code breaks the Single Responsibility Principle (SRP). This design principle (the ‘S’ in the popular SOLID design principles says that a class should only have a single responsibility. While this is not the only principle to consider, SRP helps in designing clean and maintainable objects. To approach this from another angle, just look at the size of your
user.rbmodel - in most Rails applications this model is well over 500 lines, often 1000 lines or more. You don’t need an architect to tell you that this is a maintenance headache.
- Creating a user and sending the welcome email is now tied together. Forever. If we ever wanted to create a user without sending this email we can’t - for example, if we wanted to create users in an admin interface or via some import task we can’t do so without sending welcome emails to users. This is also problematic for testing - every single feature test in our application will probably require at least one user to be exist, which will now in turn send an email as well, slowing down the test suite and adding additional noise.
Usermodel now has a dependency on the
UserMailerhas a dependency on the
Usermodel - this tighly coupling is another code smell.
None of these problems are particularly scary by themselves, but tend to become really troublesome as your application grows. At some point we might want to add analytics around creating a user, send information to a data warehouse or create an account in a CRM like Intercom or Desk. As your application grows the models tend to get bloated and cluttered with external dependencies.
So what’s the alternative? One approach would be to push this functionality back into the controller. This solves the problem of bloated models, but now we end up with bloated controllers instead. Also, if we have multiple endpoints that perform similar functions (for example, if we added a mobile API endpoint to create users or created an admin endpoint to create users in bulk) we will end up with duplication. The better approach is to use service objects.
How to Use Service Objects
Service objects live between the controller layer and the model layer. These objecs are usually responsible for coordinating the different pieces of logic associated with a specific use case or feature.
As we’re designing service objects we need to keep in mind the requirements of the consuming object - the
UsersController in this case. The controller needs to know if the creation of the user object succeeded and needs access to the user object itself (in order to display validation errors, which are tied to the model, as well as binding previously entered values to the form). A solution which hides the user object from the controller is not going to work.
So what do we want the interaction with the service to look like? And how do we name these services? This is an area with plently of healthy debate. For now, let’s just call this
UserService and circle back to that question. Here is one possible implementation of the
UsersController#create action, using a service object. (Note that the rest of the controller class has been excluded for brevity, but will look exactly like the original example)
Here’s another possible way to consume the service.
All of these are legitimate options. Testing the contoller should be straightforward since we can stub/mock the service dependency and test the logic of the controller in isolation. Most of the tradeoffs revolve around how much state we want the service object to have as well as the type of response we get from the different service actions.
When I’m faced with potential tradeoffs I usually try to choose the most functional approach - even though Ruby is not a functional language you can apply functional concepts to your code in order to make it more readable, testable and maintainable. That means I tend to prefer less state and pure functions. While it’s not really possible to implement our service object using a pure function, we can at least minimize the amount of state maintained within the service object itself.
And here is the consuming code for the service.
Let’s consider the different responsibilities of the service object - specifically what it’s not doing.
1. It doesn’t maintain any state
We might change this if the service needs to access the current user or some other dependency - in which case we probably want to pass it into the constructor - but for right now there is no state - which is great. This makes the class easy to maintain and the actions easy to reason about.
2. It doesn’t do any validation
This responsibility lies with the model, not the service. We’re also not dealing with any error messages or doing any munging of form fields. This is a very simple example, but if we needed to manipulate the request parameters at all I would suggest we create a separate object that deals with this - we don’t want to add additional responsibilities to the service object.
3. The result of the service action is an actual
Result object, not a boolean or
This makes the action particularly easy to test - we simply need to call the action and inspect the result. (Although we still might want to test that the mailer is being invoked correctly.)
In the end we end up with a pretty simple service object that is responsible for coordinating the different pieces of logic associated with a specific use case. The controller now has very clear responsibilities - whitelisting params, security (authentication and authorization), rendering views (or redirecting) and invoking the appropriate service action. All of these responsibilities definitely belong in the controller.
Naming Your Services
There are only two hard things in Computer Science: cache invalidation and naming things.
– Phil Karlton
Earlier I mentioned that there is healthy debate about the best convention for naming services. A common starting point is to use name of the domain model you’re interacting with -
ProductService. The downside to this approach is that it doesn’t provide a good name for actions which involve multiple domain models and implies that all actions associated with a certain domain model is done through a single service. Trying to group all actions for a given domain object into a single service will eventually lead to bloated services.
Another approach is to name your service according to the use case you’re supporting, so
LikeComment. This approach suggests that each service has a single responsibility and purpose. You can take this one step further and have a standard name for the action the service performs - this method is often called
call. An added benefit of using
call as the name of your action is that the service can be stubbed with a lambda - since you invoke a lambda with
call - helping to standardize controller tests.
Since we’re trying to adhere to SOLID design principles it makes sense for each service to have a single purpose. We therefore need to choose between
RegisterUserService. When I’m confronted with this type of naming decision, I prefer to consider which name would be preferred by someone new to the codebase and the decision becomes obvious. Even though explicit naming can be cumbersome it helps new engineers to navigate the codebase.
In terms of a standard name for the service action - I think an argument can be made for any approach, but being consistent is the most important. I have a slight preference for using
call since it helps to standardize the tests as well as the services.
So to return to the first example, here is the service we end up with:
And here is the controller consuming the service:
Regardless of the ultimate semantics of your service objects, extracting your business logic into services will help keep your models and controllers ‘skinny’ - which leads to a better Rails application. Happy coding.