Ruby on Rails Simple Service Objects and Testing in Isolation

 
Gears represent Rails Service objects. Photo by Pixabay from Pexels

Service Objects are not a silver bullet but they can take you a long way in modeling your Rails app’s domain logic. In this blog post, I will describe how I usually work with service object pattern in a structured way. I will also cover testing in isolation with mocked services layer.

I first read about service objects in a great blog post about 7 Patterns to Refactor Fat ActiveRecord Models. Since then a new article about them pops up every now and then. I decided to add my two cents.


Reasoning behind Service Objects in Rails apps

A service object is a way to encapsulate an app’s logic to prevent fat models and cluttered controllers. It is recommended that they should have only one public method. Sticking to this rule enforces you to follow a single responsibility principle and helps to avoid the trap of overcomplicating your services code.

I usually follow the convention where a service object has only one public class method call. This method instantiates a new service instance and executes it. Let’s look at some code:

app/services/web/user_login.rb

class Web::UserLogin
  attr_reader :omniauth_data, :team_id

  def initialize(omniauth_data, team_id)
    @omniauth_data = omniauth_data
    @team_id = team_id
  end

  def self.call(omniauth_data, team_id)
    new(omniauth_data, team_id).call
  end

  def call
    ...
  end
end

Because I am lazy some time ago I created a simple gem. Smart Init saves me some typing when creating service objects. This is how a previous example looks written with the help of my gem:

app/services/web/user_login.rb

class Web::UserLogin
  extend SmartInit

  initialize_with :omniauth_data, :team_id
  is_callable

  def call
    ...
  end
end

It’s not only about avoiding boilerplate code but more about having a default scaffold for building services and saving yourself some thinking. Alternatively, you could use Struct but it does not check for a number of parameters provided in the initializer, exposes getters and instantiates unnecessary class instances.

Mock Service Objects in controller specs

Naming one public method call is an optimal solution. Not only because UserAuthenticator#authenticate is unnecessarily redundant, but it allows you to mock your services using a proc object. It comes super handy when you want to test other parts of your application in isolation from services layer.

Let’s say that you want to test how your controller behaves depending on whether a payment operation was successful or not. In theory, your specs could execute the whole checkout process e.g. by using a VCR gem. Unfortunately for any non-trivial flow, it could be difficult to simulate all possible edge cases in an integration test.

Instead, you can use a simple proc object to simulate any outcome of running your services. Let’s take a look at an example controller and spec:

app/controllers/web/subscriptions_controller.rb

class Web::SubscriptionsController < Web::BaseController
  def create
    if Subscription::Maker.call(params: params)
      head 201
    else
      head 400
    end
  rescue => e
    ExceptionNotifier.notify_exception(e)
    head 500
  end
end

spec/controllers/web/subscriptions_controller_spec.rb

require 'rails_helper'

describe Web::SubscriptionsController do
  describe "#create" do
    let(:params) do
      ...
    end

    context "payment successful" do
      before do
        allow(Subscription::Maker).to receive(:new) {
          # proc object, it responds to a 'call' method
          -> { true }
        }
      end

      it "returns a correct status code" do
        post :create, params: params
        expect(response.status).to eq 201
      end
    end

    context "payment failed" do
      before do
        allow(Subscription::Maker).to receive(:new) {
          -> { false }
        }
      end

      it "returns a correct status code" do
        post :create, params: params
        expect(response.status).to eq 400
      end
    end

    context "something went really wrong" do
      before do
        allow(Subscription::Maker).to receive(:new) {
          -> {
            raise "Unexpected error"
          }
        }
      end

      it "returns a correct status code" do
        post :create, params: params
        expect(response.status).to eq 500
      end
    end
  end
end

As you can see not only can you mock returned values but also simulate any kind of side effect because of proc objects being callable chunks of code. Here I used it to raise a runtime exception, but it could be any code. It gives you a real flexibility in simulating edge cases without doing a complex data setup with fixtures/factories.

Beyond true and false; “Enums” for control flow

In the previous example, I used a single if/else statement to detect if an operation was successful and exception handling for critical edge cases. In practice, operation result is not always as simple as success or failure and you might need to handle more than 2 possible outcomes.

I work mainly in Swift nowadays and one of the features I miss the most when I come back to Ruby are enums. I am not talking about database Rails enums here. A real enum is a variable which can have only predefined values and it is validated during a compile time.

Obviously, there is no such thing as compile-time validation in Ruby, and the closest thing to enums I managed to come up with is an array of constants. You could use this technique to provide at least a minimal protection from typos if you would like your services to return different “status codes” as a result of their execution. Let’s take a look at an example:

app/services/subscription/maker.rb

class Subscription::Maker
  extend SmartInit

  initialize_with :params
  is_callable

  RESULTS = [
    PAYMENT_SUCCESS = :payment_success,
    INSUFFICIENT_FOUNDS = :insufficient_founds,
    INVALID_CARD_DATA = :invalid_card_data,
    GATEWAY_TIMEOUT = :gateway_timeout
  ]

  def call
    ...
    return PAYMENT_SUCCESS if sth
    return INSUFFICIENT_FOUNDS if sth_else
    ...
  end
end

In the controller you can switch on a result of service execution:

app/controllers/web/subscriptions_controller.rb

class Web::SubscriptionsController < Web::BaseController
  def create
    case Subscription::Maker.call(params: params)
    when Subscription::Maker::PAYMENT_SUCCESS
      ...
    when Subscription::Maker::INSUFFICIENT_FOUNDS
      ...
    when Subscription::Maker::INVALID_CARD_DATA
      ...
    when Subscription::Maker::GATEWAY_TIMEOUT
      ...
    default
      raise "It should never happen! ☠☠☠"
    end
  end
end

As you can see because of how Ruby handles constant namespaces, you can access them directly and not necessarily through RESULTS array. It is a bit verbose but still better than tracking a typo bug for hours.

Final remarks

I hope that some of those tips would prove useful in how you work with services in your apps. I am open to suggestions on what could be improved in what I describe.



Back to index