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 learn about Service Objects and how to use them to make your app cleaner and keep it maintainable.
Introduction
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
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 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:
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 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
:
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 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:
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:
module Trips
class TripUpdateService < BaseService
...
end
end
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 Transaction Block
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.
class RouteArchiver < BaseService
...
def call
ActiveRecord::Base.transaction do
@route.archive!
trips = TripsArchiver.call(route: @route)
CreatChangelogService.call(
change: :archive,
object: @route,
associated: trips
)
{ 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 third 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 controllers 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 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. Btw, Ruby on Rails is used for this example only, you can use the same pattern with other frameworks. If you haven't used service objects before, you should definitely try it.
History
- 29th August, 2022: Initial version