Home
Blog
Essential RubyOnRails Patterns — Part 1: Service Objects

Essential RubyOnRails Patterns — Part 1: Service Objects

・7 min read
Essential RubyOnRails Patterns — Part 1: Service Objects

You may also like:

Design SaaS Product: 4 Reasons Why You Should Choose Ruby On Rails Framework

Design SaaS Product: 4 Reasons Why You Should Choose Ruby On Rails Framework

Read more


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. UserCreator, TwitterAuthenticator, 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. CreateUser, AuthenticateUsingTwitter, ObfuscateCode, 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(params).call or 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 (perform, run or 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 DeleteUser and 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

versus

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(params[:user], false)

versus

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

The 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 and and or e.g.

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.

Summary

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.


Rate this article:

5,0

based on 0 votes
Our services
See what we can create for You
Our services

Awards & Certificates

reviewed on
30 reviews
  • Top 1000 Companies Global 2021
  • Top Development Company Poland 2021
HR dream team
  • 2020 HR Dream Team Award
  • 2016 Employer Branding Featured
  • 2015 HR Dream Team Award
ISO CertificateISO Certificate
  • Information Security Management System compliant with PN-EN ISO/IEC 27001
  • Business Continuity Management compliant with ISO 22301