paint-brush
Keeping Code Clean with Rails Service Objects by@ualeks
541 reads
541 reads

Keeping Code Clean with Rails Service Objects

by Aleksandr UlanovAugust 28th, 2022
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

Ruby on Rails is an MVC (Model-View-Controller) framework. But the bigger your app gets, the more features it has - the more business logic you have. That’s where Service Objects come to the rescue. In this article we’ll find out what are Service Objects and how you can use them to make your app cleaner and maintainable.

Company Mentioned

Mention Thumbnail
featured image - Keeping Code Clean with Rails Service Objects
Aleksandr Ulanov HackerNoon profile picture


If you’re developing web apps using Ruby on Rails, you probably already know that Rails is an MVC (Model-View-Controller) framework, which means that your Models are responsible for data, Views are responsible for templates, and Controllers are responsible for requests’ handling. But, the bigger your app gets, the more features it has and the more business logic you will have.


And here comes the question, where do you put your business logic? Obviously, it’s shouldn’t be left to views to handle. So should it be up to Controllers or Models? That will make them fat and unreadable pretty soon. This is where Service Objects come to the rescue.


In this article, we’ll find out what Service Objects are and how you can use them to make your app cleaner and keep it maintainable.


Let’s say you have a project for handling cab trips, we’ll take a look at the particular controller action that updates trip records. It should not only update trips based on user input parameters (e.g. starting address, destination address, riders count, etc.), but it should also calculate some fields based on those parameters and save them to the database. So, we have a controller action like this:


class TripsController < ApplicationController
  def update
    @trip = Trip.find(params[:id])
    if update_trip(trip_params)
      redirect_to @trip
    else
      render :edit
    end
  end

  private

  def update_trip(trip_params)
    distance_and_duration = calculate_trip_distance_and_duration(trip_params[:start_address],
                                                                 trip_params[:destination_address])
    @trip.update(trip_params.merge(distance_and_duration))
  end

  def calculate_trip_distance_and_duration(start_address, destination_address)
    distance = Google::Maps.distance(start_address, destination_address)
    duration = Google::Maps.duration(start_address, destination_address)
    { distance: distance, duration: duration }
  end
end


The problem here is that you’ve added at least ten lines to your controller, but this code does not really belong to the controller. Also, if you want to update trips in another controller, for example by importing them from a csv file, you will have to repeat yourself and rewrite this code. Or you create a service object, i.e. TripUpdateService and use that in any place you need to update trips.

What are Service Objects?

Basically, a service object is a Plain Old Ruby Object (“PORO”), a Ruby class that returns a predictable response and is designed to execute one single action. So it encapsulates a piece of business logic.


The job of a service object is to encapsulate functionality, execute one service, and provide a single point of failure. Using service objects also prevents developers from having to write the same code over and over again when it’s used in different parts of the application.

All service objects should have three things:

  • an initialization method
  • a single public method
  • return a predictable response after execution


Let’s replace our controller logic by calling a service object for trip updates:


class TripsController < ApplicationController
  def update
    @trip = Trip.find(params[:id])
    if TripUpdateService.new(@trip, trip_params).update_trip
      redirect_to @trip
    else
      render :edit
    end
  end
end

Looks much cleaner, right? Now let’s take a look at how do we implement a service object.

Implementing a Service Object

In a Rails app there are two folders that are commonly used for storing service objects: lib/services and app/services. Basically, you can choose whichever you want, but we’ll use app/services for this article.


So we’ll add a new Ruby class (our service object) in app/services/trip_update_service.rb:

# app/services/trip_update_service.rb
class TripUpdateService
  def initialize(trip, params)
    @trip = trip
    @params = params
  end

  def update_trip
    distance_and_duration = calculate_trip_distance_and_duration(@params[:start_address],
                                                                 @params[:destination_address])
    @trip.update(@params.merge(distance_and_duration))
  end

  private

  def calculate_trip_distance_and_duration(start_address, destination_address)
    distance = Google::Maps.distance(start_address, destination_address)
    duration = Google::Maps.duration(start_address, destination_address)
    { distance: distance, duration: duration }
  end
end


Alright, service object added, now you can call TripUpdateService.new(trip, params).update_trip anywhere in your app, and it will work. Rails will load this object automatically because it autoloads everything under app/ folder.


This already looks pretty clean, but we can actually make it even better. We can make the service object execute itself when called, so we can make calls to it even shorter. If we want to reuse this behavior for other service objects, we can add a new class called BaseService or ApplicationService and inherit from it for our TripUpdateService:

# app/services/base_service.rb
class BaseService
  def self.call(*args, &block)
    new(*args, &block).call
  end
end


So, this class method named call creates a new instance of the service object with arguments or blocks passed to it, and then calls the call method on that instance. Then we need to make our service to inherit from BaseService and implement call method:


# app/services/trip_update_service.rb
class TripUpdateService < BaseService
  def initialize(trip, params)
    @trip = trip
    @params = params
  end

  def call
    distance_and_duration = calculate_trip_distance_and_duration(@params[:start_address],
                                                                 @params[:destination_address])
    @trip.update(@params.merge(distance_and_duration))
  end

  private

  def calculate_trip_distance_and_duration(start_address, destination_address)
    distance = Google::Maps.distance(start_address, destination_address)
    duration = Google::Maps.duration(start_address, destination_address)
    { distance: distance, duration: duration }
  end
end


then let’s update our controller action to call the service object correctly:


class TripsController < ApplicationController
  def update
    @trip = Trip.find(params[:id])
    if TripUpdateService.call(@trip, trip_params)
      redirect_to @trip
    else
      render :edit
    end
  end
end

Where should you put your service objects

As we’ve discussed earlier two base folders for storing service objects are: lib/services and app/services and you can use whichever you want. Another good practice for storing your service objects will be storing them under different namespaces, i.e. you can have TripUpdateService, TripCreateService, TripDestroyService, SendTripService, and so on. But what will be common for all of them is that they’re related to Trips. So we can put them under app/services/trips folder, in other words under the trips namespace:


# app/services/trips/trip_update_service.rb
module Trips
  class TripUpdateService < BaseService
    ...
  end
end


# app/services/trips/send_trip_service.rb
module Trips
  class SendTripService < BaseService
    ...
  end
end

Don’t forget to use new namespace when calling those services, i.e. Trips::TripUpdateService.call(trip, params), Trips::SendTripService.call(trip, params).

Wrap your code in one transaction

If your service object is going to perform multiple updates for different objects, you better wrap it in a transaction block. In this case, Rails will roll back the transaction (i.e. all of the performed db changes) if any of the service object methods fail. This is a good practice because it will keep your db consistent in case of a failure.

# archive route with all of its trips
class RouteArchiver < BaseService
  ...
  def call
    ActiveRecord::Base.transaction do
      # first archive the route
      @route.archive!

      # then archive route trips
      trips = TripsArchiver.call(route: @route)

      # create a change log record
      CreatChangelogService.call(
        change: :archive,
        object: @route,
        associated: trips
      )

      # return response
      { success: true, message: "Route archived successfully" }
    end
  end
end


It’s a simple example of updating multiple records in a single transaction. If any of the updates fails with an exception (e.g. route can’t be archived, changelog create fails), the transaction will be rolled back and the db will be in a consistent state.

Passing Data to Service Objects and Returning Response

Basically, you can pass to your service objects almost anything, depending on the operations they perform ActiveRecord objects, hashes, arrays, strings, integers, etc. But you should always pass the minimum amount of data to your service objects. For example, if you want to update a trip, you should pass the trip object and the params hash, but you should not pass the whole params hash, because it will contain a lot of unnecessary data. So you should pass only the data you need, i.e. TripUpdateService.call(trip, trip_params).


Service Objects can perform complex operations. They can be used to modify records in the database, send emails, perform calculations or call 3d party APIs. So it’s quite possible that something can go wrong during those operations. That’s why it’s a good practice to return a response from your service objects. You can return a boolean value or a hash with a boolean value and some additional data. For example, if you want to update a trip, you can return a boolean value indicating whether the trip was updated successfully or not, and you can also return the trip object itself, so you can use it in your controller action.


The thing you should keep in mind though is that your response from the service object should be predictable. It should always return the same response, no matter what. So if you return a boolean value, it should always return a boolean value, and if you return a hash, it should always return a hash with the same keys. This will make your service objects more predictable and easier to test.

What are the benefits of using Service Objects?

Service Objects are a great way to decouple your application logic from your controllers. You can use them to separate concerns and reuse them in different parts of your application. With this pattern you get multiple benefits:


  • Clean controllers. The controller shouldn’t handle business logic. It should be only responsible for handling requests and turning the request params, sessions, and cookies into arguments that are passed into the service object to perform the action. And then perform redirect or render according to the service response.


  • Easier testing. The separation of business logic to service objects also allows you to test your service objects and your controller independently.


  • Reusable Service Objects. A service object can be called from app controllers, background jobs, other service objects, etc. Whenever you need to perform a similar action, you can call the service object and it will do the work for you.


  • Separation of concerns. Rails controllers only see services and interact with the domain object using them. This decrease in coupling makes scalability easier, especially when you want to move from a monolith to a microservice. Your services can easily be extracted and moved to a new service with minimal modification.

Service Objects Best Practices

  • Name rails service objects in a way that makes it obvious what they’re doing. The name of a service object must indicate what it does. With our trips example, we can name our service object like: TripUpdateService, TripUpdater, ModifyTrip, etc.


  • Service Object should have single public method. Other methods must be private and be accessible only within particular service object. You can call that single public method the way you want, just be consistent and use the same naming for all your service objects.


  • Group service objects under common namespaces. If you have a lot of service objects, you can group them under common namespaces. For example, if you have a lot of service objects related to trips, you can group them under Trips namespace, i.e. Trips::TripUpdateService, Trips::TripDestroyService, Trips::SendTripService, etc.


  • Use syntactic sugar for calling your service objects. Use proc syntax in your BaseService or ApplicationService and inherit from it in other services. then you can use just .call on your service object class name to perform an action, i.e. TripUpdateService.call(trip, params)

  • Don’t forget to rescue exceptions. When service object fails, due to exception, those exceptions should be rescued and handled properly. They should not propagate up to the call stack. And if an exception can’t be handled correctly within the rescue block, you should raise a custom exception specific to that particular service object.


  • Single responsibility. Try keeping single responsibility for each of your service objects. If you have a service object that does too many things, you can split it into multiple service objects.

Conclusion

Service objects are a great way to decouple your application logic from your controllers. They can be used to separate concerns and reuse them in different parts of your application. This pattern can make your application more testable and easier to maintain as you add more and more features. It also makes your application more scalable and easier to move from a monolith to a microservice. If you haven’t used service objects before, you should definitely try it. By the way, Ruby on Rails is used for this example only, you can use the same pattern with other frameworks.



Also published here.