TECH

Why Do We Need Service Objects?

blogpost

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.

Read more on our blog

Check out the knowledge base collected and distilled by experienced
professionals.
bloglist_item
Tech

Over the years I had to deal with applications and system that have a long history of already being "legacy".
On top of that I met with clients/product owners that never want you to spend time ref...

bloglist_item
Tech

How many times have you searched for that one specific library that meets your needs? How much time have you spent customizing it to fit your project's requirements? I must admit, waaay too much. T...