Refactoring a Concern into a Service Object

I like abstractions, those little buckets that hide complexity and, in turn, make everything easier to reason about. For a time, I really appreciated using ActiveSupport::Concern to hide complexity and increase re-usability in my Rails controllers. But lately, I’ve switched to plain old ruby objects.

Why? POROs are easier to test.

The Problem with Controller Concerns

Let’s say we have a Controller and it’s Concern:

class ManhattansController < ApplicationController
include ExpertMixer

def create
@manhattan = Manhattan.create(mixer_params)
stir_it(@manhattan)
end
# ...
end

module ExpertMixer
extend ActiveSupport::Concern

def stir_it(mixable)
# do things
end
end

This works, right? But let’s consider our options for testing. . .

Option 1: Check for the Side Effect in the Controller Test

describe ManhattansController do
describe '.create' do
it 'makes a stirred manhattan' do
post :create, { manhattan: valid_attributes }
expect(assigns(:manhattan).stirred?).to eq(true)
end
end
end

This is sub-optimal. While there is a place for this high-level integration test, we have no confidence that the ExpertMixer was the thing that mutated this object’s state. It is conceivable that something else could have toggled the stirred? state.

You also have to build the whole world around the concern just so you can test it (params, dependent objects, etc.). No thanks.

Also, assigns are naughty, and I believe are going away in Rails 5.

Option 2: Isolate out a Dummy Controller

describe ExpertMixer do
before do
class FakeController < ApplicationController
include ExpertMixer
end
end
after { Object.send :remove_const, :FakeController }
describe '.stir_it' do
# ...
end
end

This approach successfully isolates the concern, but at what cost? If you absolutely require an ApplicationController and all of its bits, then it may be useful, but I try not to write tests that test the framework I’m using.

Wouldn’t it be better to just test the mutator in isolation?

Option 3: Refactor to a Service Object

Let’s drop down a level, extract an object to handle this interaction, and then see how much it helps the testing scenario.

class ManhattansController < ApplicationController
def create
@manhattan = Manhattan.create(mixer_params)
ExpertMixer.new(@manhattan).stir_it
end
end

class ExpertMixer
def initialize(mixable)
@mixable = mixable
end

def stir_it
# do things
end
end

No more ActiveSupport::Concern, just a plain old ruby object that is trivial to test in isolation. We also get the added documentation on where the stir_it method is coming from, which becomes relevant when you have several concerns modifying the same controller.

You may argue, “Hey Zack! This works because you really aren’t doing a Controllery thing here, you are modifying a model.”

Well, that’s true, but we are talking about Object Oriented Programming. Almost anything can be objectified and extracted in this manner, even Controllery things.

The test is as easy as we’d expect:

describe ExpertMixer do
let(:mixable) { # a double, a manhattan, whatever you want... }
let(:expert_mixer) { ExpertMixer.new(mixable)}

describe '.stir_it' do
before do
expert_mixer.stir_it
end

it 'stirs the manhattan' do
expect(mixable.stirred?).to eq(true)
end
end

We know that the ExpertMixer mutates the mixable object exactly as we expect. We can then add an integration test for more confidence, if we want.

An added bonus of this approach is that it keeps the surface area of the controller under control. All those methods that would have been decorating it are tucked away in the PORO.

Of course, there’s still plenty of places for ActiveSupport::Concern, but I find I’m reaching for it considerably less often than I used to.

Published under programming