DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’» is a community of 964,423 amazing developers

We're a place where coders share, stay up-to-date and grow their careers.

Create account Log in
Cover image for Using Rails Service Objects
Aleksandr Ulanov
Aleksandr Ulanov

Posted on • Updated on • Originally published at ualeks.dev

Using Rails Service Objects

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 you have your Models responsible for data, Views responsible for templates and Controllers responsible for requests handling. But the bigger your app gets, the more features it has - the more business logic you will have. And here comes the question, where do you put your business logic? Obviously it's not views that should handle it. Controllers or Models? That will make them fat and unreadable pretty soon. 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 kepp it maintainable.

Let's say you have a project for handling cab trips, we'll take a look at the particular controller action, which updates trip records. But it should not only update trips based on user input params (e.g. starting address, destination address, riders count, etc.), but it should also calculate some fields based on those params and save it 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 which 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
Enter fullscreen mode Exit fullscreen mode

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 service object to 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
Enter fullscreen mode Exit fullscreen mode

So this class method named call creates a new instance of the service object with arguments or block 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# app/services/trips/send_trip_service.rb
module Trips
  class SendTripService < BaseService
    ...
  end
end
Enter fullscreen mode Exit fullscreen mode

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 rollback 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 in consistency 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
Enter fullscreen mode Exit fullscreen mode

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. 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. Separation of business logic to service objects also allows you to test your service objects and your sontrollers 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 shopuld not propagate up to the call stack. And if an exception can't be handled correctly within the rescue block, you should raise 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.
Btw, Ruby on Rails is used for this example only, you can use same pattern with other frameworks.

Top comments (0)

πŸ‘‹ Have You Posted on DEV Yet?

Head over to our Welcome Thread and tell us a bit about yourself!