Rails Service Objects - Reconstruct Your Application
In this article, I will provide you with a thorough understanding of Rails Service Objects. Whether you're new to the concept and wondering, "What are service objects?" or looking for guidance on how to effectively implement them in your Rails applications.
When working on small Rails applications, it is a common practice to include the business logic in either Models or Controllers. The controllers and models start clean but before you know it, they're filled with complex, lengthy and hard logic, which leads to scenarios where the conventional Fat Controller Slim Model mantra breaks. That is where the Rails Service Object architecture saves the day. To offload this load from controllers we use Rails Service Objects, which splits the complex business logic into small understandable pieces of code. We can say that a service object is simply a Ruby object that performs a single action. It encapsulates some business logic, and they are often called in controllers.
Why Use Service Objects?
Let's consider the scenario where we need to implement a function that retrieves a list of movies from an external API. The code of the controller would look something like this:
class MoviesController < ApplicationController
def index
api_key = 'your_api_key'
api_url = "https://api.movieapi.com/v1/movies"
@movies = HTTParty.get(api_url, query: { api_key: api_key })
end
end
Now, at first glance, this code doesn't seem too shabby, does it? But imagine for a moment that we have to filter movies based on a specific string and also sort them by release date. Suddenly, things start to get a bit messy. Take a look at what our code would turn into:
class MoviesController < ApplicationController
def index
@movies = fetch_movies_from_api
if @movies.nil?
flash[:error] = 'Failed to fetch movies from the API.'
@movies = []
else
@movies = filter_movies_by_title('specific_string')
@movies = sort_movies_by_date(@movies)
end
end
private
def fetch_movies_from_api
api_key = 'your_api_key'
api_url = 'https://api.movieapi.com/v1/movies'
begin
response = HTTParty.get(api_url, query: { api_key: api_key })
return JSON.parse(response.body) if response.success?
rescue StandardError => e
Rails.logger.error("Error fetching movies from API: #{e}")
end
nil
end
def filter_movies_by_title(title)
return [] if title.blank?
@movies.select { |movie| movie['title'].include?(title) }
end
def sort_movies_by_date(movies)
movies.sort_by { |movie| Date.parse(movie['release_date']) }
end
end
As we examine the current implementation, it becomes apparent that even a single functionality requires multiple functions and some lines of code within the controller. Now, imagine a scenario where we have numerous controller action methods, each handling different functionalities, and each functionality demanding its own set of methods. The end result? The resulting controller would quickly become a complex, congested behemoth, reducing scalability and affecting maintainability.
So wouldn't it be better if we could isolate the functionalities into separate classes within our application and simply call them from the controller whenever necessary? This is precisely where service objects come into play. By encapsulating the feature logic within a service object, we can significantly improve our code structure. Gone are the days of messed-up controllers!
With the help of Rails Service Objects, we were able to reduce the size of the controller drastically making it more manageable and readable. In the Creating Rails Service Objects section below, we will create a service that will implement the example given above.
Other Advantages
Many other benefits can be gained by using Rails Service Objects. Some of them are listed as follows:
Easy Controller Testing: With services handling the core logic, controllers become lean and easier to test. We can focus on verifying whether specific methods within the controller are called when certain actions occur.
Slim Controllers: Controllers are responsible for interpreting requests and converting sessions, parameters, and cookies into arguments passed to the service object for action. The controller then handles the service's response by redirecting or rendering. Even in larger applications, controllers using Rails services tend to have fewer lines of code.
Separating Domain and Framework: Rails controllers interact with services, which in turn interact with domain objects. This decoupling makes scalability easier, especially when transitioning from a monolithic service to a microservice architecture. Services can be easily extracted and moved with minimal modifications.
Reusability: Rails service objects can be utilized not only by controllers but also by other components like queued jobs and service objects. Controllers serve as the application's central hub, facilitating interactions between users, models, and views. They host many essential ancillary services and handle the routing of external requests to internal objects.
Isolation: Services are small Ruby objects separated from their environment, making them easier and faster to test. We can stub out collaborators and verify that all steps are performed correctly during the service's execution.
Creating Rails Service Objects
Unlike Controllers, Models, Helpers and Libs, provided in the scaffolded Rails application, the service objects are not provided by default, so we have to set them up manually. I will guide you through a simple step-by-step process to manually integrate service objects into your Rails projects.
Firstly, we will create a Rails Application. We will skip some of the goodies Rails comes bundled with since they are not required for this demo.
rails new rails-service \
--skip-action-text \
--skip-active-storage \
--skip-javascript \
--skip-spring -T \
--skip-turbolinks \
--skip-sprockets \
--skip-test \
--api
Once our application is all set, we will go into the app
directory and will create a directory named services
. And we will also be creating a file called application_service.rb
, which will be responsible for the common service configurations. Now in this directory, we will be implementing our services.
mkdir app/services && touch app/services/application_service.rb
In the Application Service file, we will be adding a method to make our service's behavior more like a proc
i.e it can be called as ExampleService.call(message)
instead of ExampleService.new(message).call
. Although, this is completely optional and is dependent on your requirements.
# app/services/application_service.rb
class ApplicationService
def self.call(*args, &block)
new(*args, &block).call
end
end
There is usually only one public method in a service which is responsible for the execution of the complete functionality provided by the service (in our case call
and it can be replaced by names like perform
or execute
). There can be some private methods that can act as helpers.
Now going back to the Movies example mentioned above, let's create a service that performs the functionality we implemented in the controller.
We will start by making a directory named movie_manger (the directory name can be referenced to the functionality or the controller for which the functionality is being implemented) followed by the creation of a file named get_movies.rb
. This will introduce the concept of namespacing to our application and will make it more scaleable. The directory structure of an application with multiple services should look something like this:
services
├── application_service.rb
└── movie_manager
├── get_movies_service.rb
└── some_other_service.rb
└── anime_manager
├── get_anime_service.rb
└── some_other_service.rb
We will now move the code defined in the controller above to the get_movies.rb
file. In the service we will have the following:
The Initialize method will be acting as a constructor.
The Call method will be the executor of the service as it will be the method that can be called externally to invoke the service.
The Private methods will be acting as helper functions.
# services/movie_manager/get_movies_service.rb
module MovieManager
class GetMoviesService < ApplicationService
def initialize(filter_string)
@filter_string = filter_string
end
def call
movies = fetch_movies_from_api
movies = filter_movies_by_title(@filter_string)
movies = sort_movies_by_date(movies)
end
private
def fetch_movies_from_api
api_key = 'your_api_key'
api_url = 'https://api.movieapi.com/v1/movies'
response = HTTParty.get(api_url, query: { api_key: api_key })
JSON.parse(response.body) if response.success?
nil
end
def filter_movies_by_title(title)
return [] if title.blank?
@movies.select { |movie| movie['title'].include?(title) }
end
def sort_movies_by_date(movies)
movies.sort_by { |movie| Date.parse(movie['release_date']) }
end
end
end
Finally, we'll tie it all together by invoking this service within the controller, wrapping up the implementation of this very basic service object.
class MoviesController < ApplicationController
def index
@movies = MovieManager::GetMoviesService.call(params[:filter])
end
end
Return Types
We've covered the process of invoking our service object and returning an object, but what else can be the return value? When it comes to the return value of the service object, there are three common approaches to consider:
Boolean: In some cases, the service object's purpose is to perform a specific action and determine whether it was successful or not. In such scenarios, the service object can simply return a boolean value of true or false to indicate the outcome.
Value: For certain functionalities, the service object may need to compute and return a specific value or result. It could be a single object, an array, or any other relevant data structure. This approach allows the service object to provide the necessary information for further processing.
Enum: In more complex scenarios, where there are multiple possible outcomes or states, the service object can make use of an Enum to define a set of predefined values. This allows the service object to convey different states or statuses, providing more detailed information about the operation.
Choosing the appropriate return approach depends on the specific requirements of your application and the nature of the functionality being implemented.
Good Practices
Some good practices that should be followed when creating a Service Object are listed below:
Give clear names to services. A good practice is to include words ending with or, er or using _service as a suffix. For example, a service to create a movie can be MovieCreator or CreateMovieService.
To ensure clarity and simplicity, a service object should focus on performing a single business action. Therefore, it should have only one public method dedicated to that action. Other methods within the service object should be private and only known by the public method.
Each service object should have a single responsibility aligned with the business action mindset of Rails service objects. Avoid creating service objects that perform multiple unrelated actions. If code needs to be shared among service objects, consider creating a base or helper module and use mixins to include the shared functionality.
In larger applications, as you introduce more service objects, it's beneficial to group related service objects into namespaces for improved code organization.
Conclusion
Service objects play a crucial role in maintaining clean and organized code by keeping our business logic separate from controllers. By adopting this approach, we can significantly reduce complexity within our codebase. We can also create services using Gems like BusinessProcess gem which will be covered in future articles.
In a future article, we will dive deeper into the Interactor gem and explore how it can further streamline and optimize our application development process. So, stay tuned and keep learning.
Subscribe to my newsletter
Read articles from Hasnat Raza directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by