Refactoring Your Rails App With Service Objects

Rails apps tend to start simple, with clean models and controllers. Then you start adding features. Before you know it, your models and controllers are big, unwieldy, and hard to understand. Refactoring into service objects is a great way to split these big pieces up, so they're easier to understand, test, and maintain.

A service object is a Ruby object that performs a single action. It encapsulates a process in your domain or business logic. Imagine that you need to create a book instance in an imaginary library application; in a plain Rails app, you'd do the following:

class BookController < ApplicationController
  def create
    Book.new(*args)
  end
end

This is fine for simple things. However, as the app grows, you may end up with lots of boilerplate surrounding it:

class BookController < ApplicationController
 def create
    default_args = { genre: find_genre(), author: find_author() }
    Book.new(attrs.merge(default_args))
 end

 private

 def find_genre
   // ...
 end

  def find_author
   // ...
 end
end

Service objects allow you to abstract this behavior into a separate class. Then, your code becomes simple again:

class BookController < ApplicationController
  def
    BookCreator.create_book
  end
end

Why You Need Service Objects

Rails is designed to natively support the MVC (e.g., models, controllers, views, and helpers) organizational structure. This structure is adequate for simple applications. However, as your application grows, you may begin to see domain/business logic littered across the models and the controller. Such logics do not belong to either the controller or the model, so they make the code difficult to re-use and maintain. A Rails service object is a pattern that can help you separate business logic from controllers and models, enabling the models to be simply data layers and the controller entry point to your API.

We get a lot of benefits when we introduce services to encapsulate business logic, including the following:

  • Lean Rails controller - The controller is only responsible for understanding requests and turning the request params, sessions, and cookies into arguments that are passed into the service object to act. The controller then redirects or renders according to the service response. Even in large applications, controller actions using service objects are usually not more than 10 lines of code.

  • Testable controllers - Since the controllers are lean and serve as collaborators to the service, it becomes really easy to test, as we can only check whether certain methods within the controller are called when a certain action occurs.

  • Ability to test business process in isolation - Services are easy and fast to test since they are small Ruby objects that have been separated from their environment. We can easily stub all collaborators and only check whether certain steps are performed within our service.

  • Re-usable services - Service objects can be called by controllers, other service objects, queued jobs, etc.

  • Separation between the framework and business domain - 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.

Creating a Service Object

First, let’s create a new BookCreator in a new folder called app/services for an imaginary library management application:

$ mkdir app/services && touch app/services/book_creator.rb

Next, let’s just dump all our logic inside a new Ruby class:

# app/services/book_creator.rb
class BookCreator
  def initialize(title:, description:, author_id:, genre_id:)
    @title = title
    @description = description
    @author_id = author_id
    @genre_id = genre_id
  end

  def create_book
    Boook.create!(
    title: @title
    description: @description
    author_id: @author_id
    genre_id: @genre_id
    )
    rescue ActiveRecord::RecordNotUnique => e
     # handle duplicate entry
    end
  end
end

Then, we can call the service object in the controller or anywhere within the application:

class BookController < ApplicationController
  def create
    BookCreator.new(title: params[:title], description: params[:description], author_id: params[:author_id], genre_id: params[:genre_id]).create_book
  end
end

Service Object Syntactic Sugar

We can simplify the BookCreator.new(arguments).create chain by adding a class method that instantiates the BookCreator and calls the create method for us:

# app/services/book_creator.rb
class BookCreator
  def initialize(title:, description:, author_id:, genre_id:)
    @title = title
    @description = description
    @author_id = author_id
    @genre_id = genre_id
  end

  def call(*args)
    new(*args).create_book
  end

  private

  def create_book
    Boook.create!(
    title: @title
    description: @description
    author_id: @author_id
    genre_id: @genre_id
    )
    rescue ActiveRecord::RecordNotUnique => e
     # handle duplicate entry
    end
  end
end

In the controller, the book creator can now be called as follows:

class BookController < ApplicationController
  def create
    BookCreator.call(
    title: params[:title],
    description: params[:description],
    author_id: params[:author_id],
    genre_id: params[:genre_id])
  end
end

To keep our code DRY(Don't Repeat Yourself) and reuse this behavior with other service objects, we can abstract the call method into a base ApplicationService class that every service object will inherit from:

class ApplicationService
  self.call(*args)
      new(*args).call
  end
end

With this code, we can refactor the BookCreator to inherit from the ApplicationService:

# app/services/book_creator.rb
class BookCreator < ApplicationService
  def initialize(title:, description:, author_id:, genre_id:)
    @title = title
    @description = description
    @author_id = author_id
    @genre_id = genre_id
  end

  def call
    create_book
  end

  private

  def create_book
    # ...
  end
end

Creating Service Objects Using the BusinessProcess Gem

With the BusinessProcess gem, you don't have to create a base application service class or define the initialize method because the gem has all these configurations built into it. Your service object just has to inherit from the BusinessProcess::Base.

In your gem file, add the following:

gem 'business_process'

And then run the bundle command in your terminal

class BookCreator < BusinessProcess::Base
  # Specify requirements
  needs :title
  needs :description
   needs :author_id
    needs :genre_id

  # Specify process (action)
  def call
    create_book
  end

   private

  def create_book
    # ...
  end
end

Guides to Creating Good Service Objects

One public method

A service object is supposed to perform one business action and do it well, so it should only expose a single public method for doing that. Other methods should be private and called by the public method. You can choose to name the public method whatever you prefer, as long as the naming is consistent across all service objects. In our example, we have named it call. Other conventional names are perform and execute.

Name Service Objects According to the Role They Perform

The name of a service object should indicate what it does. There is a popular way of naming service objects with words ending with “or” and "er". For instance, if the job of the service object is to create a book, name it BookCreator, and if the job is to read a book, name it BookReader.

Do not instantiate service objects directly

Use abstractions like the syntactic sugar pattern or gems like BusinessProcess to shorten the notation of calling service objects. Using this approach would allow you simplify BookCreator.new(*args).call or BookCreator.new.call(*args) into BookCreator.call(*args), which is shorter and more readable.

Group service objects in namespaces

Introducing service objects, especially in a large application, means that you would grow from one service object to tens of service objects. To improve code organization, it is a good practice to group common service objects into namespaces. In the library application, for instance, we would group all book-related services together and group all author-related services in a separate namespace. Our folder structure will now look like this:

services
├── application_service.rb
└── book
├── book_creator.rb
└── book_reader.rb

Our service objects would look like this:

# services/book/book_creator.rb
module Book
  class BookCreator < ApplicationService
  ...
  end
end
# services/twitter_manager/book_reader.rb
module Book
  class BookReader < ApplicationService
  ...
  end
end

Our calls will now become Book::BookCreator.call(*args) and Book::BookReader.call(*args).

One responsibility per service object

Having a service object that does more than one thing goes against the "business action" mindset of service objects. Conventionally, having a generic service object that performs multiple actions is discouraged. If you want to share code among service objects, create a base or helper module and use mixins to include in your service objects.

Rescue exceptions and raise custom exceptions

The purpose of a service object is to encapsulate implementation details inside it, such as interactions between third-party services or libraries or database manipulation with Rails ActiveRecord. If an error occurs, such as ActiveRecord::RecordNotUnique, while interacting with an ActiveRecord, the service needs to rescue the exception properly. Errors should not be allowed to propagate up the call stack. If it can't be handled within the rescue block, raise a custom-defined exception specific to that service object.

Conclusion

The service object pattern can greatly improve your application's overall design as you add new features to your application. It will make your codebase more expressive, easier to maintain, and less painful to test.

What to do next:
  1. Try Honeybadger for FREE
    Honeybadger helps you find and fix errors before your users can even report them. Get set up in minutes and check monitoring off your to-do list.
    Start free trial
    Easy 5-minute setup — No credit card required
  2. Get the Honeybadger newsletter
    Each month we share news, best practices, and stories from the DevOps & monitoring community—exclusively for developers like you.
    author photo

    Godwin Ekuma

    Godwin enjoys learning new things through his work at FairMoney.ai, whose mission is increasing access to financial services in developing markets.

    More articles by Godwin Ekuma
    Stop wasting time manually checking logs for errors!

    Try the only application health monitoring tool that allows you to track application errors, uptime, and cron jobs in one simple platform.

    • Know when critical errors occur, and which customers are affected.
    • Respond instantly when your systems go down.
    • Improve the health of your systems over time.
    • Fix problems before your customers can report them!

    As developers ourselves, we hated wasting time tracking down errors—so we built the system we always wanted.

    Honeybadger tracks everything you need and nothing you don't, creating one simple solution to keep your application running and error free so you can do what you do best—release new code. Try it free and see for yourself.

    Start free trial
    Simple 5-minute setup — No credit card required

    Learn more

    "We've looked at a lot of error management systems. Honeybadger is head and shoulders above the rest and somehow gets better with every new release."
    — Michael Smith, Cofounder & CTO of YvesBlue

    Honeybadger is trusted by top companies like:

    “Everyone is in love with Honeybadger ... the UI is spot on.”
    Molly Struve, Sr. Site Reliability Engineer, Netflix
    Start free trial
    Are you using Sentry, Rollbar, Bugsnag, or Airbrake for your monitoring? Honeybadger includes error tracking with a whole suite of amazing monitoring tools — all for probably less than you're paying now. Discover why so many companies are switching to Honeybadger here.
    Start free trial
    Stop digging through chat logs to find the bug-fix someone mentioned last month. Honeybadger's built-in issue tracker keeps discussion central to each error, so that if it pops up again you'll be able to pick up right where you left off.
    Start free trial