The buzzwords are Readability, Reusability, Maintainability. Here's the long version:
Modern web applications can grow in complexity. We often need to manage workflows more complex than simple CRUD operations. Some developers put this code in controllers, making them very elaborate. Others move some of this code to models, as these functions can be needed in multiple contexts/controllers. The result is often projects with models of 1000+ lines, which become hard to understand.
The Problem with Current Approaches
Controllers should be responsible for receiving requests and returning responses, while models should handle data access and persistence. When we overload these components with business logic, we compromise readability and maintainability.
Benefits of Service Objects
Service objects provide a dedicated place to put the complex business logic. This encapsulation helps:
- Readability: Code is easier to understand when responsibilities are well-defined.
- Reusability: Business logic can be reused across different parts of the application.
- Maintainability: Changes to business logic are localized, making them easier to manage.
Additionally, service objects are usually quite convenient to unit test. It's easier to test multiple paths in a service than in a controller.
Why don't you use PORO*?
I remember getting frustrated when, years ago, I received a pull request comment suggesting I move some code to a service object. When I asked how the service should be structured, the response was: "We don't have a predefined class for it; it's just a PORO."
That comment was quite vague and unhelpful. This lack of specificity showed later in the application where each developer had his own style and we ended up with a mishmash of approaches.
To address this, we had a discussion and established a set of requirements for services in a project:
- They need to have a unified interface: all services should be called the same way and return results and errors consistently.
- They should only be called once and return an instance of themselves.
- They should adhere to the Single Responsibility Principle.
To enforce this concept we found it easier to create a skeleton service class.
Implementing Service Objects
Unlike models or controllers that need to extend some Rails functionality, service objects can be defined freely. However, this flexibility can lead to issues with standardization. To avoid this, it's essential to have a common understanding and documentation of how service objects should be structured.
Example Implementation
Here’s a suggested implementation of a base service class:
class BaseService
attr_reader :error
def self.call(**args)
new(**args).tap { |s| catch(:service_error) { s.call } }
end
def warn(warning_messages = :warning)
warnings << warning_messages
warnings.flatten!
throw :service_error
end
def fail!(error_message = :error)
# not 100% sold on the name. remeber that 'fail' is reserved in rails.
@error = error_message
throw :service_error
end
def success?
error.blank?
end
def failure?
!success?
end
def warnings
@warnings ||= []
end
def result
@result ||= {}
end
end
Here's how you might define a user creation service using this base class:
module Users
class Create < BaseService
def initialize(user_params:, shop_params:)
@user_params = user_params
@shop_params = shop_params
end
def call
fail! :shop_invalid if CreateShopValidator.new.call(@shop_params).failure?
fail! :user_invalid if CreateUserValidator.new.call(@user_params).failure?
ActiveRecord::Base.transaction do
result[:shop] = Shop.create!(@shop_params)
result[:user] = User.create!(@user_params.merge(shop: result[:shop]))
end
RegistrationMailer.with(user_id: result[:user].id).welcome_email.deliver_later
end
end
end
To call it just use:
user_create = Users::Create.call(user_params: user_params, shop_params: shop_params)
Putting it to the test
When we introduced this scheme to our 8-year-old project, issues quickly surfaced. The main culprit was a misconception about the rule that services should only return message errors and never raise exceptions.
We ended up with services that looked like this:
module Users
class Create < BaseService
def initialize(...params…)
# … initialization …
end
def call
… run some code …
rescue => e
fail! e.to_sym
ensure
@result = { some result }
end
end
end
This code was very hard to debug because the only error we ended up having is a vague :service_error message.
Fail Fast, Fail Hard!
So as a clarification: unhandled Exceptions should still be allowed to interrupt a service. They are needed to rollback transactions or retry Sidekiq tasks if some issues like a Timeout to an external api occurred.
It's only properly handled issues that should be returned as error messages.
Conclusion
Service objects provide a clear approach to managing business logic in web applications. By defining a common interface and handling errors and warnings consistently, we can improve the readability, reusability, and maintainability of our codebase. Remember to handle errors gracefully and avoid sweeping unhandled exceptions under the rug.
PS:
When touching up this articles I had a quick look at what other people are writing about SOs, and one of the catchiest advice was: Name Service Objects Like Dumb Roles at a Company Here is a link to the article: https://www.toptal.com/ruby-on-rails/rails-service-objects-tutorial
PS2:
My friend Rafał wanted me to point out the neat trick of using throw instead of raise for optimization reasons. It avoid loosing time building an entire Exception object
*PORO: Plain Old Ruby Object. Usually used to describe a class not inheriting any complex structure from a framework.