Rails Service Layer for keeping models skinny too!

Sasikala Ravichandran, 8 months ago

     Three Core layers of Rails M V C allow us to organize the programming logic in an application. But when the app grows, we have a lot of business logic to deal with. Usually, model layer is the layer where we write our business logic and the result we end up with is bloated models. Bloated Models indicate the violation of the single responsibility design principle which is the core of writing the highly maintainable and easily changeable code.  

     To address fat model design issues in Rails, a service layer can be introduced in the app. Our business logic can be converted as service objects, protecting our models by getting fat and making them just to deal with the CRUD operations of Active Record objects. First and foremost, the benefit of adding the service layer to our application is that it allows us to hold onto the single responsibility design principle, which in turn paves the way for us to manage the dependencies between objects easily, making the application align with clean code architecture.

Adding a service layer in Rails

     To add the service layer, we can create a new folder under the app folder called services. Rails 4.2 autoloads any classes under app folder. If you are using any of the earlier versions, you may want to set the configuration accordingly for loading the service objects.

      In all, Service objects are nothing but Plain Old Ruby Object(PORO) (i.e they do not inherit from any gem like active_record, etc.). We can transform every single business logic into a service object in the app. Applying certain rules while designing/creating service objects results in cleaner code for an easily changeable and highly maintainable app.

Simple Rules for creating service object

  • Holds only one responsibility
  • Hides the core business logic
  • Exposes only a few public interfaces
  • Manages the dependent service objects

Example of an InventoryProcessService service object


app/services/inventory_process_service.rb

class InventoryProcessService
def initialize(products, inventory: nil)
   @products = product
  @inventory = inventory || inventory_obj
  @user = user
  perform
end

def perform
  process_products
  update_inventory
end

private

attr_accessor :products, :new_products, :user, :inventory

def process_products
  if products.returned.any?
    @new_products = products.select { |p| p.returned == false }
    notify_admin(products.returned)
    add_to_inventory(prods_to_add: new_products)
  else
    add_to_inventory
  end
end

def add_to_inventory(prods_to_add: products)
   inventory.add!(products)
end

def notify_admin(prods_returned)
  NotifyMailer.send(user, prods_returned).deliver_later
end

def inventory_obj
  InventoryService.new
 end

 def update_inventory
    inventory.update!
 end
end

We can call the inventory_process_service object anywhere in our app in two ways:

InventoryProcessService.new(admin, products_imported_today, inventory: InventoryService.new) # Dependency Injection

or

InventoryProcessService.new(admin, products_imported_today)

Holding Single Responsibility

     Creating service objects which do only one thing in an app often results in highly decoupled classes. Having highly cohesive and highly decoupled classes in the app are a good sign to having the clean and DRY code. In the above example, InventoryProcessService is a service class whose responsibility is to process today's imported products. If any returned products are in the list, it first notifies the admin about the returned products, and then sends the imported products to Inventory for adding.

Hiding critical details and expose stable interface

     Critical details of the service objects are nothing but business logic. The possibility for these details to change in future is fairly high, so hiding them in private methods helps to reduce the butterfly effects on the caller of the service object.

     In InventoryProcessService, process_products (private method) holds the business logic. In the future, if the business logic changes, the only place this change happens is in the process_products method, and any caller of this class does not know anything about the change.

Tips to decide the access modifiers for the methods

1)Private methods (including the attr_accessor)

     Keep all logic details in the private methods, including the attr_accessor. Every getter and setter method can be private as long as they are just going to be used in their own class. In our InventoryProcessService, none of the instance variables are exposed to any caller as there is no need to do that.

2) Public methods

     Use these methods as the interface for the callers to depend on. Public methods are the ones which invoke the private methods. The nature of the public methods is that it does not change very often compare to private methods. In our InventoryProcessService, I called perform method (which starts the inventory processing) in the initialize method, as it is not returning anything. But in some cases, if the inventory_process_service object needs to return something to the callers, I would have made perform method as public method/interface and made the values available to the callers.

For example,

class InventoryProcessService
  def preform
    process_products
    return "Success" if update_inventory
  end
 # Remaining methods
end

We can call like this:

result = InventoryProcessService.new(admin, products_imported_today).perform

Managing the dependencies

    Most often, one service object needs to depend on the other service objects. In our case, InventoryProcessService depends on the InventoryService service object. There are two ways to manage this dependency in the app:

  1) Dependency injection

  2) Hiding the coupling in private methods

Dependency injection (Rule #1)

     Calling service object using dependency injection:

 InventoryProcessService.new(admin, products_imported_today, InventoryService.new) 

     The caller of a service object can inject any other service objects.In this way, we achieve less coupling between the service objects. To decide whether the service object needs dependency injection or not, I tend to follow the following rules.

     If the controller is the only caller of the service object, then I go with injection method and completely eliminate the coupling between the two service objects and also make sure that receives the other service object injection in the constructor with some defaults to avoid any random errors. In the inventory process example, inventory: nil is provided as default in def initialize(products, inventory: nil) definition.

     If the service object has many callers, then I use Rule #2

Hiding the coupling in private methods (Rule #2)

     Let's assume that I am going to call InventoryProcessService.new(admin, products_imported_today) in a couple of places in the app, then I use private coupling method like inventory_obj. Doing this takes off the burden on the callers of this service object as every caller does not need to know about the dependent service objects. So in future, if anything changes with InventoryService class, then the only place the change takes place is in inventory_obj method.

def inventory_obj
  InventoryService.new
end

Wrapping Up

     In general, adding service layer to our apps helps us to keep changeability under control. It also helps us to test the business logic easily and provides good readability.  

Like What you see? We should talk!