In the previous chapter, we created a Sidekiq job to log tasks at the moment of their creation. Now, in this chapter, we will customize this message. For instance, if a task titled 'Update Rails Version' is created by Oliver Smith and assigned to Alice Smith, the message will be:
If the task is self-assigned, for example, by Oliver Smith, then the message will be:
We can modify TaskLoggerJob
to craft the appropriate message as follows:
The primary function of the TaskLoggerJob
is to log a message whenever a task is created. However, by incorporating the additional logic for customizing the message, the code has expanded and now encompasses more than one responsibility.
As the application grows, it's impractical to encapsulate multiple functionalities within a single controller, model, or job. This leads to difficulties in managing the code. Adhering to principles of clean architecture and maintainability, it's crucial to delegate distinct responsibilities to dedicated components, thereby simplifying the codebase and enhancing its testability. This is where the service objects come into play.
Service Objects
Service objects are Plain Old Ruby Objects (PORO) designed to efficiently execute a single, specific action within your domain logic. They are the go-to solution for operations that fall outside the scope of what a model should be concerned with, such as sending SMS, delivering emails, making calls to external services, or parsing CSV files. They are organized within app/services
directory of our application.
Let us now move the customization of the message to LoggerMessageBuilderService
. Rails does not provide any built-in generators to create services. Currently, we can only manually create the service as well as its test file.
Open the logger_message_builder_service.rb
file and paste the following:
Typically, at BigBinary, we structure a service with a process
method serving as the entry point. Additionally, the use of the !
(bang) operator is implemented to ensure exceptions are raised if either task_owner
or assigned_user
is not found. The importance of the bang method is covered in the Using bang method chapter.
The initialize
method in Ruby acts as a constructor, used to set up instance variables during object creation. This method is invoked implicitly even if no instance variables are initialized.
When an attribute needs to be accessed across multiple methods within the class, it's best practice to declare it as an instance variable within the initialize
method. At BigBinary, we use the attr_accessor
macro to facilitate access to instance variables throughout the class without having to prefix it with @
symbol.
While building a service, it is crucial to modularize the code effectively and assign appropriately descriptive names to each method. This practice ensures that the code remains readable and maintainable. Moreover, a well-organized and clearly named codebase supports the DRY (Don't Repeat Yourself) principle, preventing redundancy and facilitating easier updates and maintenance.
Now, let's modify the TaskLoggerJob
as follows:
Testing services
Another compelling reason for utilizing services is their ability to isolate business logic, allowing us to thoroughly test and ensure its correctness and reliability in various scenarios.
As mentioned earlier, there is no Rails generator for services.
So let's create the services
directory within test
and a testing file for LoggerMessageBuilderService
in it:
Open the file and paste the following into it:
When should you use a service?
Identifying scenarios when a service is the appropriate solution is crucial.
-
Controller Responsibilities: If your code is primarily involved in handling routing, parameters, or other controller-specific functions, then it is not suitable to use a service. Such code inherently belongs within the controller layer of your application.
-
Shared Code Across Controllers: When there is shared code among different controllers, refrain from utilizing a service. Instead, employ a concern to encapsulate shared functionality effectively. We will be talking about concerns in an upcoming chapter.
-
Specific Business Actions: Conversely, when the code represents a distinct business action, such as "generate a PDF", or "send an email", a service is highly appropriate. Such operations, which do not logically fit within either controllers or models, are ideal candidates for service object implementation.
-
Testing Requirements: If a piece of code, even a small one, demands thorough testing due to its critical nature or complexity, it's beneficial to encapsulate it in a service. Moving logic into a service allows for more focused and isolated tests, ensuring that the functionality is reliable and works as expected without being intertwined with other components of the application.
Now, let's commit the changes: