
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.