Guide to Rails Service Objects: Everything You Need to Know

Ruby on Rails comes equipped with everything needed to rapidly prototype applications. However, as your codebase expands, you may encounter situations where the traditional “Fat Model, Skinny Controller” approach becomes inadequate. When your business logic no longer neatly fits into either a model or a controller, service objects provide a solution by enabling you to separate each business action into its own dedicated Ruby object.

An example request cycle with Rails service objects

This article will delve into when a service object is necessary, how to write clean and organized service objects, the guidelines I follow to directly link them to my business logic, and how to avoid turning them into a dumping ground for miscellaneous code.

When Are Service Objects Necessary?

Consider this scenario: how would you handle tweeting the content of params[:message] in your application?

If you’ve primarily worked with standard Rails, you might have done something like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class TweetController < ApplicationController
  def create
    send_tweet(params[:message])
  end

  private

  def send_tweet(tweet)
    client = Twitter::REST::Client.new do |config|
      config.consumer_key        = ENV['TWITTER_CONSUMER_KEY']
      config.consumer_secret     = ENV['TWITTER_CONSUMER_SECRET']
      config.access_token        = ENV['TWITTER_ACCESS_TOKEN']
      config.access_token_secret = ENV['TWITTER_ACCESS_SECRET']
    end
    client.update(tweet)
  end
end

While this works, it adds around ten lines of code to your controller that don’t truly belong there. Furthermore, if you needed to reuse this functionality in another controller, you’d have to move it to a concern. However, this code doesn’t inherently belong in controllers at all. Wouldn’t it be more convenient if the Twitter API provided a ready-made object to handle this?

The initial time I encountered this issue, my previously sleek Rails controllers became bloated, and I felt uncertain about the solution. Ultimately, I resolved the problem by introducing a service object.

Before we proceed, let’s establish a few assumptions:

  • This application interacts with a Twitter account.
  • “The Rails Way” refers to the typical Ruby on Rails conventions, and there is no specific book by that title.
  • For the sake of this article, let’s assume I am the Rails expert I’m often told I am.

What Are Service Objects?

Service objects are Plain Old Ruby Objects (POROs) designed to execute a single action within your domain logic effectively. Let’s revisit the earlier example: our method already handles the specific task of creating a tweet. What if we encapsulated this logic within a dedicated Ruby class that we could instantiate and call a method on? It might look something like this:

1
2
3
4
5
6
7
tweet_creator = TweetCreator.new(params[:message])
tweet_creator.send_tweet


# Later on in the article, we'll add syntactic sugar and shorten the above to:

TweetCreator.call(params[:message])

This essentially captures the essence of a service object. Once our TweetCreator service object is created, it can be invoked from anywhere in the application, and it will reliably perform its designated function.

Creating a Service Object

Let’s start by creating a new TweetCreator class in a folder named app/services:

1
$ mkdir app/services && touch app/services/tweet_creator.rb

Next, we’ll move our logic into this new Ruby class:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# app/services/tweet_creator.rb
class TweetCreator
  def initialize(message)
    @message = message
  end

  def send_tweet
    client = Twitter::REST::Client.new do |config|
      config.consumer_key        = ENV['TWITTER_CONSUMER_KEY']
      config.consumer_secret     = ENV['TWITTER_CONSUMER_SECRET']
      config.access_token        = ENV['TWITTER_ACCESS_TOKEN']
      config.access_token_secret = ENV['TWITTER_ACCESS_SECRET']
    end
    client.update(@message)
  end
end

Now, you can call TweetCreator.new(params[:message]).send_tweet from any part of your app, and it will function correctly. Rails will automatically load this object due to its autoloading mechanism for everything under the app/ directory. You can verify this by running:

1
2
3
4
5
6
$ rails c
Running via Spring preloader in process 12417
Loading development environment (Rails 5.1.5)
 > puts ActiveSupport::Dependencies.autoload_paths
...
/Users/gilani/Sandbox/nazdeeq/app/services

To learn more about the autoload mechanism, refer to the Autoloading and Reloading Constants Guide.

Simplifying Rails Service Objects with Syntactic Sugar

While conceptually sound, the expression TweetCreator.new(params[:message]).send_tweet is undeniably verbose. It feels clunky and repetitive. If only Ruby had a way to execute something immediately with given parameters… wait a minute, it does! Enter Proc#call.

The call method invokes the block, effectively setting the block’s parameters to the values provided in params using a mechanism akin to method calling semantics. The value of the last expression evaluated within the block is returned.

1
2
3
4
5
aproc = Proc.new {|scalar, values| values.map {|value| valuescalar } }
aproc.call(9, 1, 2, 3)    #=> [9, 18, 27]
aproc[9, 1, 2, 3]         #=> [9, 18, 27]
aproc.(9, 1, 2, 3)        #=> [9, 18, 27]
aproc.yield(9, 1, 2, 3)   #=> [9, 18, 27]

Documentation

To put it simply, a proc can be call-ed to execute itself with supplied parameters. This means if TweetCreator were a proc, we could call it using TweetCreator.call(message), and the result would be equivalent to TweetCreator.new(params[:message]).call, which closely resembles our original TweetCreator.new(params[:message]).send_tweet.

Let’s make our service object behave more like a proc.

Since we anticipate reusing this behavior across all service objects, let’s follow the Rails Way and create a class named ApplicationService:

1
2
3
4
5
6
# app/services/application_service.rb
class ApplicationService
  def self.call(*args, &block)
    new(*args, &block).call
  end
end

Notice how we added a class method called call. This method instantiates the class with the arguments or block passed to it and then calls the call method on that instance, achieving exactly what we aimed for! The final step is to rename the method in our TweetCreator class to call and have it inherit from ApplicationService:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# app/services/tweet_creator.rb
class TweetCreator < ApplicationService
  attr_reader :message
  
  def initialize(message)
    @message = message
  end

  def call
    client = Twitter::REST::Client.new do |config|
      config.consumer_key        = ENV['TWITTER_CONSUMER_KEY']
      config.consumer_secret     = ENV['TWITTER_CONSUMER_SECRET']
      config.access_token        = ENV['TWITTER_ACCESS_TOKEN']
      config.access_token_secret = ENV['TWITTER_ACCESS_SECRET']
    end
    client.update(@message)
  end
end

Now, let’s call our streamlined service object in the controller:

1
2
3
4
5
class TweetController < ApplicationController
  def create
    TweetCreator.call(params[:message])
  end
end

Organizing Similar Service Objects with Namespacing

Our example only uses one service object. However, real-world applications can be far more complex. Imagine having hundreds of services, with a significant portion representing related business actions, such as a Follower service for following other Twitter accounts. Managing 200 distinct files in a single folder would be overwhelming. Fortunately, we can draw inspiration from another Rails convention: namespacing.

Suppose we need to create a service object to follow other Twitter profiles.

Let’s analyze the name of our existing service object: TweetCreator. It suggests an individual or a role within an organization, someone responsible for creating tweets. I prefer naming service objects as if they were roles in an organization. Therefore, I’ll call this new object ProfileFollower.

To maintain order, I’ll create a managerial position within my service hierarchy, delegating responsibility for both services to this position. I’ll call this position TwitterManager.

Since this manager’s sole purpose is management, we’ll make it a module and nest our service objects under it. Our folder structure will now look like this:

1
2
3
4
5
services
├── application_service.rb
└── twitter_manager
      ├── profile_follower.rb
      └── tweet_creator.rb

And here’s how our service objects will look:

1
2
3
4
5
6
# services/twitter_manager/tweet_creator.rb
module TwitterManager
  class TweetCreator < ApplicationService
  ...
  end
end
1
2
3
4
5
6
# services/twitter_manager/profile_follower.rb
module TwitterManager
  class ProfileFollower < ApplicationService
  ...
  end
end

Our calls will now become TwitterManager::TweetCreator.call(arg) and TwitterManager::ProfileManager.call(arg).

Service Objects for Database Interactions

While our previous examples involved API calls, service objects are equally valuable when dealing solely with database operations. This is particularly useful when business actions require multiple database updates within a transaction. For instance, the following code demonstrates using services to record a currency exchange:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
module MoneyManager
  # exchange currency from one amount to another
  class CurrencyExchanger < ApplicationService
    ...
    def call
      ActiveRecord::Base.transaction do
        # transfer the original currency to the exchange's account
        outgoing_tx = CurrencyTransferrer.call(
          from: the_user_account,
          to: the_exchange_account,
          amount: the_amount,
          currency: original_currency
        )

        # get the exchange rate
        rate = ExchangeRateGetter.call(
          from: original_currency,
          to: new_currency
        )

        # transfer the new currency back to the user's account
        incoming_tx = CurrencyTransferrer.call(
          from: the_exchange_account,
          to: the_user_account,
          amount: the_amount * rate,
          currency: new_currency
        )

        # record the exchange happening
        ExchangeRecorder.call(
          outgoing_tx: outgoing_tx,
          incoming_tx: incoming_tx
        )
      end
    end
  end

  # record the transfer of money from one account to another in money_accounts
  class CurrencyTransferrer < ApplicationService
    ...
  end

  # record an exchange event in the money_exchanges table
  class ExchangeRecorder < ApplicationService
    ...
  end

  # get the exchange rate from an API
  class ExchangeRateGetter < ApplicationService
    ...
  end
end

Return Values from Service Objects

We’ve covered how to call our service object, but what should it return? There are three main approaches:

  • Return true or false
  • Return a value
  • Return an Enum

Returning true or false

This is the simplest approach: return true if the action succeeds, and false otherwise:

1
2
3
4
5
  def call
    ...
    return true if client.update(@message)
    false
  end

Returning a Value

If your service object retrieves data, it makes sense to return that value:

1
2
3
4
5
  def call
    ...
    return false unless exchange_rate
    exchange_rate
  end

Responding with an Enum

For more complex service objects with multiple scenarios, using enums can enhance control flow:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class ExchangeRecorder < ApplicationService
  RETURNS = [
    SUCCESS = :success,
    FAILURE = :failure,
    PARTIAL_SUCCESS = :partial_success
  ]

  def call
    foo = do_something
    return SUCCESS if foo.success?
    return FAILURE if foo.failure?
    PARTIAL_SUCCESS
  end

  private

  def do_something
  end
end

Then, in your application, you can use:

1
2
3
4
5
6
7
8
    case ExchangeRecorder.call
    when ExchangeRecorder::SUCCESS
      foo
    when ExchangeRecorder::FAILURE
      bar
    when ExchangeRecorder::PARTIAL_SUCCESS
      baz
    end

lib/services vs. app/services - Where to Store Service Objects?

The placement of service objects is subjective, with opinions varying among developers. Some prefer lib/services, while others opt for app/services. I belong to the latter group. Rails’ Getting Started Guide designates the lib/ folder for “extended modules for your application.”

In my view, “extended modules” refers to modules that are not central to core domain logic and can potentially be reused across projects. To quote a random Stack Overflow answer, it’s the place for code that “can potentially become its own gem.”

Evaluating the Merits of Service Objects

The suitability of service objects depends on the specific use case. If you’re reading this, you’re likely grappling with code that doesn’t naturally fit into a model or controller. Recently, I came across this article arguing that service objects are an anti-pattern. While I respect the author’s perspective, I disagree.

Overusing service objects is not a reflection of their inherent value. At my startup, Nazdeeq, we utilize both service objects and non-ActiveRecord models, with a clear distinction between their roles. We confine all business actions to service objects, while non-ActiveRecord models house resources that don’t require persistence. Ultimately, the best pattern depends on your specific needs.

However, do I generally endorse service objects? Absolutely! They enhance code organization. Ruby’s object-oriented nature further strengthens my confidence in using POROs. Ruby has a deep affinity for objects; it’s something to behold! Just look at this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
 > 5.is_a? Object # => true
 > 5.class # => Integer


 > class Integer
?>   def woot
?>     'woot woot'
?>   end
?> end # => :woot

 > 5.woot # => "woot woot"

As you can see, even the number 5 is treated as an object.

Unlike many languages where numbers and other primitive types are not objects, Ruby, inspired by Smalltalk, treats all types as objects with methods and instance variables. This simplifies Ruby usage, as rules applicable to objects apply universally. Ruby-lang.org

When Not to Use a Service Object

This is straightforward. I follow these rules:

  1. Code Handling Routing, Params, or Controller-Specific Logic: Such code belongs in the controller, not a service object.
  2. Code Shared Across Controllers: Concerns are a better fit for code shared among controllers.
  3. Model-Like Code Without Persistence: Non-ActiveRecord models are suitable for this scenario.
  4. Code Representing a Specific Business Action: This is where service objects shine. Use them for actions like “Take out the trash,” “Generate a PDF using this text,” or “Calculate customs duty using these rules,” as such logic doesn’t naturally fit into controllers or models.

These are my personal guidelines, and you’re free to adapt them. They’ve served me well, but your experience may vary.

Rules for Well-Crafted Service Objects

I adhere to four principles when creating service objects. While not absolute, I strongly believe in them, and I might ask you to reconsider breaking them during code reviews unless you have a compelling reason.

Rule 1: One Public Method per Service Object

Service objects represent single business actions. You can name your public method as you see fit. I prefer call, while Gitlab CE’s codebase uses execute, and others might use perform. The key is to have only one public method per service object. If you need more, split it into two objects.

Rule 2: Name Service Objects as Descriptive Roles

Service objects are single business actions. Imagine assigning the task to a person; what would their job title be? If their job is to create tweets, call the service object TweetCreator. If they read tweets, call it TweetReader.

Rule 3: Avoid Generic Objects for Multiple Actions

Service objects should handle single business actions. In our example, we separated the functionality into TweetReader and ProfileFollower. We didn’t create a generic TwitterHandler object to handle all API interactions. Resist the urge to do this. It goes against the “business action” principle and makes the service object seem vague. If you need to share code, create a BaseTwitterManager object or module to mix into your service objects.

Rule 4: Handle Exceptions Within the Service Object

I cannot overstate this: Service objects are single business actions. If you have a person reading tweets, they’ll either provide the tweet or inform you if it’s unavailable. Similarly, prevent your service object from panicking and halting everything with an error. Return false and allow the controller to proceed gracefully.

Acknowledgments and Further Exploration

This article wouldn’t exist without the exceptional Ruby developer community at Toptal. Their immense talent and willingness to help have been invaluable.

If you work with service objects, you might wonder about controlling their behavior during testing. I recommend this article on creating mock service objects in Rspec to return predictable results without actually executing the service object!

To delve deeper into Ruby techniques, explore “Creating a Ruby DSL: A Guide to Advanced Metaprogramming” by Toptaler Máté Solymosi. It demystifies the routes.rb file’s unique syntax and guides you in building your DSL.

Licensed under CC BY-NC-SA 4.0