DEV Community

Rob Race
Rob Race

Posted on

The 3 Tenets of Service Objects in Ruby on Rails

Service Objects are becoming a staple in the toolbelt to slim down both Controllers and Models. Welcome to the world of fat services [folder]! A quick refresher to those who may not know what a Service Object…

A Service Object is a PORO(Plain old Ruby Object), that is meant to decompose business objects into manageable classes and methods. Without getting too deep into a discussion on the sound OOP principles and how they can apply to Ruby, a Service Object should have a Single Responsibility, be easily testable and a few other aspects which I will go further in depth.

This can be a departure from the Rails design principles of yesteryear. Previously you may have gone straight to adding another method to your fat model or more recently added logic into a mixin or model concern. While this may help with code separation, I have found it does not make the activities a Service Object can perform as simple, organized and discoverable. Additionally, as they are “mixed into the model class, the model still becomes enlarged and ties everything into the model class.

Note: This post is commentary used for the service object chapter in my upcoming book Building a SaaS Ruby on Rails 5. The book will guide you from humble beginnings through deploying an app to production. If you find this type of content valuable, you can grab a free chapter right now!

So…a service Object?

Before going deep on Service Objects, it may be a good time to discuss the name of Service Objects. As I have in fact seen this come up more and more recently in places such as Reddit, Twitter, etc.

There are a few alternatives here. One of the biggest would be using the name “Operations, which is taken from the TrailBlazer framework(which sits on the Rails framework).

Another approach I have seen is just to call them Objects and completely remove the services from the class names, folder, etc.

Personally, I don’t mind the Service Object name and feel it describes the fact that the Class/Objects purpose is to perform an action or service. Though, Operations comes in a close second as it describes Service Objects in a similar vain. Though, not breaking some of the current best practices for Service Objects is one good enough reason to stick with “Services”.

Tenet #1: Try REALLY hard only to have one public method.

Personally, I feel this goes hand in hand with the SRP for classes. If the class/object is only responsible for one thing, then there should only be one public method to make the object provide that single service operation. The most common method to use for the one public method constraint is .call .

Now, while this is one of the most agreed upon aspects of Service Object best practices, the method of Service Object instantialization can be formed into two broad groups.

First:

    #services/thing_service.rb
    class ThingService

      def initialize(param1)
        @param1 = param1
      end

      def call
        private_method
      end

      private
      attr_reader :param1

    ...
    end

    ### and calling by ThingService.new(param1).call

or Two:

    #services/thing_service.rb
    class ThingService


      def self.call(param1)
        new(param1).call
      end

      def call
        private_method
      end

      private

      def initialize(param1)
        @param1 = param1
      end


      attr_reader :param1

    ...
    end

    ### and calling by ThingService.call(param1)

Personally, I lean more towards the first way, as it fits by experience and most other Ruby developer’s experience creating POROs. However, the second method can be more succinct and readable when called.

The main idea here is that you are working to restrict your object to one public method that will be called throughout your application(mostly controllers).

An added benefit to one or minimal public methods is that you will have an easier time refactoring the service object and typically not need to change much of the Service Object calling throughout your application.

Tenet #2: Managing Dependencies can be hard. Do it privately and optimally.

It is nearly impossible outside the simplest of Service Objects that you won’t need to include another dependant Service Object. So what is the best way to include them?

One method that may come to mind is to instantiate the objects and include them as parameters. This approach, while it can work, would most likely lead to refactoring headaches and changing method signatures in a multitude of places.

Another method would just to instantiate the dependent service somewhere in the Service Objects public method or main private method(if you have one). While this is better, but it starts to break down the SRP of a method. It makes that method responsible and knowledgeable of the dependency directly.

In my experience, the best approach here would be to create a private method that will return a memoized version of the injected dependency. Here is an example:

    #services/thing_service.rb
    class ThingService

      def initialize(param1)
        @param1 = param1
      end

      def call
        result = other_service.call
        private_method(result)
      end

      private
      attr_reader :param1

      def other_service
        @other_service ||= OtherService.new(param1)
      end

    ...
    end

While this isn’t a huge change from other methods of dependency injection, this can make it easier to change the public method as you refactor as it is no longer directly responsible for another service’s instantiation.

Tenet #3 Return something meaningful. An object.

While your gut instinct would be to return a boolean as a result of a Service Object, it may not be your best option. While yes, the default idiom in Rails is to run a conditional off of an ActiveRecord update/create, a rich response object will pay dividends in the long run.

Think about it this way. There are more than just two outcomes for a service object. 1. Success. 2. Failure. 3. Unhandled Exception(s). It would be a disservice to your service to handle such outcomes just in a boolean response.

When taking these outcomes more in depth, there is, even more, a reason for a response object. Let’s take the success example. Firstly, a result object that can hold a success? Predicate method to handle conditional branching for controller responses, and more. Additionally, since it’s your own response object you could name the success/failure method how you would like. If signed_up? makes more sense, it’s as simple as defining it in your object.

Service objects may also have one or even multiple sets of data as a response. Suppose you were creating a Credit Card processing service. You may need to record multiple different tokens from the processor or other sets of data that you want to display to the user. This could be cumbersome without some sort of response object.

Now let's take the second two outcomes of a Service Object failure and exceptions. At first glance, it seems simple. Just make the results of either outcome as falsey and force some sort of error/failure browser response. However, that may not deliver full intent or context to the end user or developer. Let’s suppose an exception is triggered due to missing data from a form. That exception can be rescued, a message set or passed through a result object and used to inform the end user in a browser correctly. Now, with just a little bit more information from a response object, you have a more meaningful request/response cycle. More than you would get from just a true or false .

So, how can you use a response object? The easiest use can will normally be using a Struct or OpenStruct from the Ruby library.

    #services/thing_service.rb
    class ThingService

      def initialize(param1)
        @param1 = param1
      end

      def call
        private_method
      end

      private
      attr_reader :param1

      def private_method
        things = do_something(param1)
      rescue SomeSpecificException => exception
        OpenStruct(success?: false, things: nil, error: exception.message)
      else
        OpenStruct(success?: true, things: thingserror: nil)
      end
    end

While this is an extremely vague example, you should be able to get the point here. Aside from structs, you can even create your own Response classes/objects or use some pretty nifty gems that tend to wrap Response/Result object functionality rather well. One of my go to gems if I go that route is Github::DS gem which has a Github::Result object.

What else?

While those are what I consider the three main MUST DO parts of a Service Object, there can be other ways to make them better as well.

One of those would be to try and limit class methods to an absolute minimum and allow Service Objects to work as instantiated objects. This may seem simple and possible even more memory consuming, but will allow greater flexibility on allowing the Service Objects to work as instances. This might just save you a headache down the road.

Another tip may be to think of Service Objects in a very functional approach. In Functional Programming it is you treat methods as more of an input|output approach. Argument(s) go in and values come out. What this should translate to in the “Service Object arena is that you should send in simple arguments in and get simple values out as part of a Response Object. It’s not purely functional, but it should help keep you Service Object overhead down.

Do you have any tenets of Service Objects you follow all the time? Please share!

Top comments (1)

Collapse
 
ewanslater profile image
Ewan Slater

I like with this.

The fat model approach has always seemed a bit wrong to me. I know a lot of people use it, but putting the business logic out into a set of Service Objects seems a much better way to organise the application.