Service objects (sometimes referred to as services) is a holy grail in Ruby On Rails development that helps to decompose your fat ActiveRecord models and keep your controllers slim and readable.
When to use Service Objects? The pattern is so simple and powerful that it may become a bit overused in time; it is particularly useful when we need a place to define complex actions, processes with many steps, callbacks or interactions with multiple models which do not fit anywhere else. Service objects are also commonly used to mitigate problems with model callbacks that interact with external classes (read more…).
How to get most out of Service Object pattern?
1. Stick to one naming convention
One of the most difficult tasks in programming is assigning things proper, self-explanatory names. There is a popular way to name service objects with words ending with “or”, i.e.
CodeObfuscator, etc. Sometimes such names may become a bit awkward, i.e.,
OrderCompleter. Therefore, I find naming service objects after commands or actions a bit more comprehensible.
CompleteOrder — it is clear what a given service object is responsible for. Regardless, try not to mix multiple naming conventions if you start using one. Be consistent.
2. Do not instantiate service objects directly
We usually do not have much use for a service object instance other than just for executing a
call method on it. If this is the case, consider using the following abstraction to shorten a notation of calling service objects:
module Callable extend ActiveSupport::Concern class_methods do def call(*args) new(*args).call end end end
Including this module will allow you to simplify the
CreateUser.new.call(params) notations into
CreateUser.call(params). It is shorter and more readable. Still it leaves an option to instantiate the service object, which might be useful when we need to retrieve its internal state.
3. Stick to one way of calling service objects
While I personally like using the
call method, there is no particular reason not to use a different one (
execute are also good candidates). It is important though to always do it the same way, as our class name already states what the class responsibility is — there is no need to make it even more clear. The approach will relieve you from a burden of thinking about the right name every time you implement a new service object and will also be clear for other programmers how to use service object even without having a peek inside its implementation.
4. Keep one responsibility per service object
This rule is somehow enforced by sticking to one way of calling a service object, yet it is not impossible to sneak in multiple responsibilities in such service objects as well. While service objects excel in orchestrating multiple actions, we should ensure that only one set of such actions is executed. An anti-pattern here might be to introduce e.g. the
ManageUser service object, which would be responsible for creating and deleting users. Firstly, “Manage” does not say much; it is also not clear how to control which action should be executed. Introducing
CreateUser services instead makes the whole thing more readable and natural.
5. Keep service objects constructors simple
It is a good idea to keep constructors simple in most of the classes we implement. Still, when our primary way of calling services is through the call class method, it might be even more beneficial to make constructor responsible for only saving arguments in service instance variables.
class DeleteUser include Callable def initialize(user_id:) @user = User.find(user_id) end def call #… end end
class DeleteUser include Callable def initialize(user_id:) @user_id = user_id end def call #… end private attr_reader :user_id def user @user ||= User.find(user_id) end end
Not only can we focus on testing the call method rather than the constructor, but we can also draw a clear line between what can live in the constructor and what cannot do so. As developers we have to make a number of decisions everyday, why not take one off our plate by standardizing our approach.
6. Keep the arguments of call methods simple
If more than one argument is provided to the service object, it might be reasonable to consider introducing keywords arguments to make those arguments more comprehensive. Even if service object accepts one argument only, using keyword argument might make it more readable as well, e.g.
UpdateUser.call(attributes: params[:user], send_notification: false)
7. Return results through state readers
You will seldom need to retrieve information from your service objects. In case you needed to do so, there are a couple of ways you can approach this problem. A service object might return the result from its call method — for instance, returning
true would indicate successful execution, while returning
falsewould indicate failure. On the other hand, you create much more flexible solution when you make the call method return the service object itself. This way we can take advantage of reading service object instance state , e.g.
update_user = UpdateUser.call(attributes: params[:user]) unless update_user.success? puts update_user.errors.inspect end
In some cases it is more effective to communicate edge cases which are unlikely to happen by raising exceptions instead, e.g.
begin UpdateUser.call(attributes: params[:user]) rescue UpdateUser::UserDoesNotExistException puts “User does not exist!” end
8. Focus on readability of your call method
call method is the heart of your service object. It is a good practice to focus on making it as readable as possible — preferably just by describing the steps involved and reducing extra logic to the minimum. As an option, we can also control the flow of particular steps using
class DeleteUser #… def call delete_user_comments delete_user and send_user_deletion_notification end private #… end
9. Consider wrapping call methods in transactions
Sometimes when multiple steps are involved to fulfill the responsibility of a service object, it might be a good idea to wrap the steps in a transaction, so that if any of the steps fails, we can always rollback the changes made in previous steps.
10. Group service objects in namespaces
Sooner or later we will end up with tens of service objects. To improve code organization it is a good practice to group common service objects into namespaces. Those namespaces can group service objects by external services, high-level features or any other dimension we can think of. Still we need to keep in mind that the primary goal is to keep service object names and locations straightforward and readable. Sticking to one convention will allow you to quickly decide on the appropriate location — in this case leaving yourself fewer choices is a good thing.
A service object is a simple and powerful pattern which is easy to test and which has a wide area of use. The ease of implementing it is also a threat to keeping its implementation under control. Using concise convention when naming service objects, calling and getting results out of them consistently in the same way as well as keeping internals of service object classes simple and readable will ensure that your codebase will benefit from this pattern to a great extent.
If you feel you might need some simple abstraction around service object pattern, consider trying the BusinessProcess gem or consider a very similar Use Case pattern. An even thinner layer is provided by the rails-patterns gem.