~3 minutes

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.

Tagged with: programming

My first novel is coming soon-ish!

Check out Singular