Clean up your Rails Controllers with Service Objects

Fat Models and skinny Controllers right?

You know what they say about the MVC design pattern, keep your Controllers light.
So at the beginning of your project, you added oil and gears to your Models and it was rolling just fine. Even if I'm not a big fan of the big Model that even makes coffee (sending an email, really?), I can say that you were in perfect harmony with the framework principles.
Well, a few iterations later, this idyllic codebase seems very far away and your Controllers and Models are way more '200 line'ish than they used to be.

Service Objects is a good solution to extract some logic out of them by transferring code into simple services that handles only one thing. This way, you end up with maintainable, super understandable and separated critical code.
On this article I'm focusing on Controllers, but the principles are the same for Models. Now, some may ask, why not using Controller Helpers instead?

My answer to that would be: don't choose, use both!

Service object and Controller helper

Controller Helpers do hold code shared between Controllers and are also a good way to keep slim Controllers. They are great to respect the DRY (Don't Repeat Yourself) principles. If you target the same workflows in many Controllers, you must consider putting this logic into a helper. The code you put here is a simple succession of actions you do and conditions you are evaluating. They are useful only if used by at least two Controllers, otherwise, you should think of some code refactoring.
Anyway, we often hear that backend logic should not belong to the Controller and just moving everything into a helper won't solve the problem.

For every operation tied to your business logic (calling an API, calculation) performed in this duplicated code or present in any Controller, you should definitely consider creating a service object for each chunk of logic.

Okay, now you know when to use a service object, but what is it exactly?

A League of Legends poro
A fluffy poro ! – Art by justduet on DeviantArt

A service object is a PORO. No, not that cute fluffy League of Legends NPC, but a Plain Old Ruby Object.
You just create a class not related to ActiveRecord (the RubyOnRails ORM) with it's own methods, put your code there, and WOW, you now have a PORO!
We will see how we construct it later, first we need to know where to put it in our project.

Break it to rebuild it

First, let's take this Controller. One thing we can notice is an API call in this get_weather method and this should not be Controller business.

class HomeController < ApplicationController
    require 'curb'
    def index
    end

    def get_weather
        http = Curl.get("#{ENV["api_base"]}#{ENV["api_key"]}")
        if http.status = "200 OK"
            response_body = JSON.parse http.body_str
            @current_weather = response_body["weather"][0]["description"]
            @current_temperature = "#{(response_body["main"]["temp"].to_f - 273.15).round(1)} °C"
        else
            @current_weather = "There was an error sorry: #{current_weather[:error]}"
        end
    end
end

According to the name of the design pattern, we will create a services folder at root_path/app/services/.
Okay, looks like it's about getting weather informations so we will name another folder accordingly.
Finally, we can create our service object file. For this, we will be the most explicit and we will use something like x_service.rb.

Being precise on naming and using the _service suffix is here to help other code maintainers to easily understand what is the purpose of the service and where to find it in the project.

Skeleton

This is how we should start every new service object. Here, we can note the import of the library "ostruct". This allows us to use the OpenStruct object in order to return a rich object containing error messages and a success? method.

class FolderName::NameOfTheService
    require "ostruct"
    
    def initialize(params={})
        @params = params
    end

    def call
        begin
        rescue => exception
            OpenStruct.new(success?: false,
                           error: exception.message)
        else
            OpenStruct.new(success?: true, 
                           error: nil)
        end
    end

end

Filling it up

Instead of just copying our Controller's code into our private method, we will use the wide known begin/rescue pattern to manage errors and will use it at our advantage to raise our own errors.

class Weather::GetCurrentWeatherService
  require "ostruct"

    def initialize(params={})
      @params = params
    end

    def call
      @api_base = ENV["api_base"]
      @api_key = ENV["api_key"]

      begin
        http = Curl.get("#{@api_base}#{@api_key}")
        if http.status == "200 OK"
          response_body = JSON.parse http.body_str
          if response_body["weather"] && response_body["main"]
            weather_string = response_body["weather"][0]["description"]
            temperature = "#{(response_body["main"]["temp"].to_f - 273.15).round(1)} °C"
          else
            raise
          end
        else
          raise
        end
      rescue => exception
          OpenStruct.new(success?: false,
                 error: exception.message)
        else
          OpenStruct.new(success?: true, 
                 weather_string: weather_string, 
                 temperature: temperature, 
                 error: nil)
        end
    end

end

Let's refactor this Controller

def get_weather
    current_weather = ::Weather::GetCurrentWeatherService.new.call(params: {})
    if current_weather.success?
        @current_weather = current_weather[:weather_string]
        @current_temperature = current_weather[:temperature]
    else
        @current_weather = "There was an error sorry: #{current_weather[:error]}"
    end
end

We simply call a new instance of our service in our Controller and use it's success? method to handle regular Controller rendering logic. Note the use of the prepending :: it's for preventing the Controller to look for the service only in its own repository.
Here, the params argument is just an empty hash that I do not use but this was to show that, like every PORO, a service can take many parameters.

This pattern is so great, but I've made a mess!

If our weather service was real, we would probably want to handle city search, forecast weather and so on. Maybe we would have extended our service object or created a bunch of others and now it's an obscure code to maintain and you can feel like you're drowning. No worries, it's just time for you to be rescued by NameSpacing!

In fact we already used it by putting our service in a weather folder in the first place. You should definitely classify all your new services by folders and never hesitate to create a sub folder when your folder holds too many service files.
If your services can be sliced by fields of usage or are really tied to a Model or Controller, create new folders and move your services under the corresponding ones.

For naming your folders, no master rule, but logic, efficiency, and consistency between the names.
Of course, you will need to rename your services. For instance, under a folder named my_folder/my_folder_2 your my_service will be renamed MyFolder::MyFolder2::MyService and you will now use the same name in your Controllers to use them.

So, SO or not?

As you may have notice, I was mostly giving advices on how to implement it and never refer to the official documentation.
This is because this pattern does not have any convention and everyone can implement it its own way. In order to avoid maintainability issues, I would really recommend keeping these concerns in mind:

  • always think concerns separation.
  • don't let your services grow big.
  • always return rich objects.
  • all your return objects should have the same structure.

Happy refactoring!

Blog

À lire également

SuperBOL: The COBOL revolution in the Cloud

COBOL, a programming language that is over 60 years old, continues to power a large proportion of the IT systems of the world's major companies, particularly in the financial and insurance sectors.
Features

Clever Cloud welcomes the first startups to the UP Programme

Clever Cloud is proud to announce the arrival of the first five startups selected to join its UP Programme, an initiative dedicated to supporting young technology companies in their growth phase.
Company

A minor update resulted in a cascade of errors: how it went wrong, what we’ve learnt

On Friday, August 2nd, 2024 Clever Cloud’s platform became very unstable, leading to downtime of varying duration and scope, for customers using services on the EU-FR-1 (PAR) region, and remote zones depending on the EU-FR-1 control plane (OVHcloud, Scaleway, and Oracle). Privates and on-premise zones weren’t impacted.
Company Engineering