Ruby on Rails Simple Service Objects and Testing in Isolation Updated Jun 28, 2019

5 minute read

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.