All About Service Objects: Part 1 - Rails Controllers

Sam Turner
5 min readJan 23, 2022

A service object is simply a Ruby object that performs a single action. It encapsulates some business logic, and they are often found in controllers like this one.

The service object is called on line 4. Notice the class name is the action performed— “ValidateDiscountCode”. The class has a single method “.call”.

A service object is just another tool in your software engineering tool belt. To use it effectively, it’s important to understand its purpose, advantages, disadvantages, and alternatives. It’s similar to the way an electrician must decide between using a screwdriver or a power drill.

This is the first of a three part series that will answer these questions about service objects:

  1. What is its purpose?
  2. What are the advantages?
  3. What are the disadvantages?
  4. What are the alternatives?

This first post describes the benefits of using service objects in Rails controllers. Part two describes a separate purpose for service objects: applying the method object pattern. The final post discusses the disadvantages and alternatives to service objects.

Problem

As an application grows, it’s common for business logic to start creeping into controllers. Let’s walk through an example by rewinding the clock to a time when our discount controller did not use a service object.

Version 1 (Business logic in controller)

It’s only 16 lines of code, but already, we find business logic in the controller. It knows that a valid discount code must meet two conditions:

  1. Must exist in the database.
  2. Expiration date must be in the past.

Business logic in controllers is a problem for several reasons.

Problem #1: Violates single responsibility principle

The single responsibility principle means each class should have one single purpose. The controller’s purpose is to handle requests. It knows how to understand requests and respond appropriately.

Understanding discount code validation rules is outside the scope of the controller.

Problem #2: Not reusable

Business logic in the controller is not reusable. As the application grows, more things will need to know how to validate a discount code. Without a centralized class that defines these conditions, they will be scattered over the codebase.

Now imagine we need to change the conditions. Perhaps we must add a rule that a discount code can only be used once. This new rule will need to be added to everything that knows about the existing rules — making this change more difficult.

This is called shotgun surgery. It’s a code smell in which a single conceptual change requires many small changes across the codebase.

Problem #3: Slows test performance

These are our tests for the discount controller.

Version 1 (Business logic in controller)

Notice line #1 “require ‘rails_helper’”. In order to run these specs, Rails must be loaded into memory. This slows down test performance. This performance hit can be mitigated by using a pre-loader like spring, but it’s still going to be slower than running the specs without Rails.

Even with spring, it takes almost two seconds to run these tests (1.2 seconds to load + 0.69 seconds to run). That might not seem like a long time, but if you are running your tests constantly, it noticeably slows down development. When tests become exceedingly slow, developers may decide it’s not worth it to run them often.

Solution

One way to extract the business logic out of the controller is to move it into a new class.

Version 2 (Business logic pulled out of controller)

Let’s walkthrough how this new code solves the three problems presented by version 1.

Problem #1: Violates single responsibility principle

Solved.

The controller no longer knows about discount validation rules. It can simply call the new class and render the appropriate response.

Problem #2: Business logic not reusable

Solved.

The discount code validation rules now live in a single class. If any other class needs to know if a discount code is valid, it can simply call the new class.

Problem #3: Slow test performance

Improved.

Let’s take a look at our new specs after introducing the service object.

Version 2 (Business logic pulled out of controller)

The number of specs in the controller has dropped from three to two because there are now only two codepaths in the controller — one for valid and one for invalid. The controller no longer knows about the ways in which a discount code can be valid or invalid. This logic moved to ValidateDiscountCode.

The big win here is when the discount code validation rules change, only the ValidateDiscountCode specs need to be run, and these specs run faster than the controller specs.

The ValidateDiscountCode specs run one second faster (using spring) than the old controller specs. Faster tests provide faster feedback.

The test performance can be improved even further. The ValidateDiscountCode specs are slowed down significantly by the dependency on Rails. If we can remove this dependency, these tests become lightning fast.

The challenge is removing the internal references to Rails methods. In this case, ValidateDiscountCode references the active record method “.find_by”. There are a number of ways to remove this dependency. Each approach has pros & cons, but describing them all is beyond the scope of this article.

Just to see how much faster these specs run without Rails, let’s remove it using dependency injection.

Version 3: Decouple service object from Rails

Decoupling ValidateDiscountCode from Rails improves test performance by 5X without the need for spring.

One caveat to this approach is that it can be difficult to maintain this decoupling. Rails provides a lot of useful functionality. So you might decide the functionality offered by Rails is worth slower test performance. Treat this pattern like any other tool in your toolbox, and use it when it makes sense for your situation.

Final Improvements

You might have noticed that ValidateDiscountCode doesn’t look exactly like a service object yet. It creates an instance by call “.new”, and then it calls “#call” on that instance. When using a service object, typically “.call” is called directly.

You will also notice the instance of ValidateDiscountCode is never actually used again. So creating an instance within the controller adds no value, and instead, we can just call “.call” directly.

This new approach has the advantage of narrowing the interface. The controller now knows even less about ValidateDiscountCode which promotes decoupling.

It also improves the testability of this code. In the previous version, both “.new” and “#call” needed to be stubbed in the controller specs.

Version 3: Decouple service object from Rails

By creating a class method “.call” and calling it directly, we can eliminate the stub on “.new”, and we now have a fully featured service object.

Version 4: Narrower interface on service object

Summary

Let’s recap the iterations of this code and the wins we achieved along the way.

  1. Started with a controller entangled with business logic
  2. Extracted business logic into a new class — ValidateDiscountCode
  • WIN: Respects single responsibility principle
  • WIN: Promotes code reuse

3. Decoupled new classes from Rails

  • WIN: Improved test performance

4. Narrowed the interface for the new class (making it a true service object)

  • WIN: Improved testability

Next Steps

Using services objects in controllers can make our code more readable, reusable, and testable. You may have already guessed that this pattern doesn’t only apply to controllers. It can be used to pulled business logic out of any class where it doesn’t belong.

In the second part of this series, we’ll discuss a different use case for service objects — implementing the method object pattern.

--

--