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 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!