There has been a lot of discussion within the Ruby community around the service objects pattern. The term "service objects" may be unfamiliar to even seasoned Rails developers, because Rails itself implements the Model-View-Controller design pattern. There is no concept of a 'service' in a new Rails application. So it's understandable that this concept may seem foreign, and indeed non-obvious. However, I believe that the service objects pattern is well worth considering for any non-trivial Rails application.
So before I discuss how to implement service objects in Rails, I'll first describe the concepts so you can decide whether it's worth doing so.
What are services?
A 'service' describes system interactions. Usually, these will involve more than one business model in our application.
As an example; we have a
User model and this encapsulates a password. If a user has forgotten their password, the business rules dictate that we have to send them an e-mail with a link to reset it. This functionality is a service.
There are other scenarios in which the user's password could be reset. For instance, the system administrator may decide to reset their password on their behalf.
In the above example, the service behaviour fits into the MVC world as follows:
- Model (
User) - a user has a password. It must be present and a minimum of 8 characters. The model is a representation of the user and doesn't really have an opinion about the different circumstances in which the password could be reset.
- View - the 'forgot password' form and success/failure states
- Controller - responds to the form in the view being submitted by attempting to instantiate the 'forgotten password' service and then rendering the response (error or success) from the view
- Service - responsible for locating the user, sending the reset password e-mail and reporting back to the originator (in this case the controller)
This is the general definition of a service, so it's up to you whether you think this makes sense.
There's several approaches to encapsulating service logic within a Rails application:
- Fat model, skinny controller - this principle encourages one to put the service logic in the model. Therefore the model very quickly becomes filled with methods which encapsulate system interactions rather than domain objects. It becomes difficult to identify which methods are domain and which are interactions. Inter-dependancy between model classes can quickly become a problem, which can make refactoring and testing very difficult.
- Concerns - these attempt to address the 'overloaded model' syndrome where you have a very large model file, simply by separating methods into modules which are then mixed into the model. It's the approach encouraged in Rails 4 with its' 'models/concerns' folder which is created by default. This can sometimes make sense, but increases the level of abstraction which can make it difficult to figure out where a method is defined. Additionally, it doesn't address the potential inter-dependancy issues that can arise, and frequently you can end up with massive methods, or multiple methods doing similar things to handle several different scenarios. In my view, it's the equivalent of sweeping the dirt under the rug.
- Observers and callbacks - these hook into the lifecycle of a model object. For instance, we could create and save a ForgottenPassword object, which has a callback or observer which then sends the e-mail. The disadvantage of this approach is that you are restricted to the lifecycle of persistent objects. So not only do you have to create and persist the ForgottenPassword object for it to work (is this necessary?), but the behaviours can very quickly become difficult to track, especially with observers which are running outside of the request cycle, and callbacks can make things very difficult to test or to predict behaviour.
- Fat controller - use the controller to co-ordinate the interactions between the objects. This can be a good approach, but the drawbacks are the controllers can become large enough to be unwieldy, and very difficult to test. Additionally, if you're using the console or a Rake task or background job you'll have to duplicate the functionality there as well.
- Service objects
Service Objects explained
Service objects implement individual services, as described above. Following the 'forgotten password' scenario, we might have a class called
ForgottenPassword. Here's what it might look like:
class ForgottenPassword def initialize(user_id) user = User.find user_id mail = UserMailer.password_reset @user mail.deliver end end
This is a very simple example - as I'm sure you can appreciate this logic can often be far more complex. We might also want to develop an exception handler here and return more logical errors to the controller (or Rake task or whatever is calling it).
Using this service object, we can remove the equivalent
User model method, breaking the dependancy between
UserMailer, and removing any assumption about how the password can be reset from the model. Our tests on the
User model just got a lot simpler, and our tests on the
ForgottenPassword service are straightforward and segregated too.
ForgottenPasswordController might look something like this:
class ForgottenPasswordController def create begin ForgottenPassword.new(params[:user_id]) # render the view rescue # Handle exceptions and render the view end end end
Again, our tests on the controller will be very simple and you don't run the risk of turning them into integration tests.
When to use them
In my view, the full benefits of service objects are realised when modelling all interactions as discrete service objects. Doing so will enable you to segregate your testing into very discrete and logical areas.
However, you will see these benefits by adopting the approach for new functionality in an existing application, too. Developing and maintaining that functionality will be easier.
Services are a concept which Rails doesn't really recognise out of the box. But I feel that the benefits of doing so are well worth it. While it can result in slightly more code in a simple app (although the above example demonstrates this can be fairly trivial), I've found it helps write tests faster and produces more maintainable code. I hope you'll consider using them.