Rails - the Missing Parts - Interactors

Here at Grouper, we’re long-time users of Ruby on Rails – along with other New York startups like RapGenius and Kickstarter. It’s simple to pick up and is optimized for developer productivity.

However, people have started noticing the downsides – once codebases evolve past a few thousand lines of code, test suites often become sluggish and the framework load-time increases.

We’ve come to realise it doesn’t have to be this way – a number of unhelpful Rails features encourage poor design patterns, which in turn lead to tightly-coupled code and slow, unmaintainable test-suites.

While Rails may be the perfect tool for building an MVP, it isn’t optimised for medium and large codebases. We’re solving these problems with 3 concepts we believe should be part of any “Advanced” Rails deployment; Interactors, Policies and Presenters.

Part 1 – Interactors

The overwhelming trend in Rails codebases is for the majority of business logic to reside in very large God classes in the ActiveRecord /models directory. This is normally a clue that each class has too many responsibilities, a vast public API and methods that require the presence of a complex graph of associated objects in order to function at all.

The nail in the coffin is ActiveRecord callbacks; before_save hooks on one class that modify the state of other objects. This is a problem because those other objects are now required to be present before we can play with the object we care about. When testing, we are stuck between tediously slow test-suites (as these objects are often fetched from the database) or the laborious process of stubbing out callbacks and long chains of method calls.

The solution is a concept called “Interactors” (often called “Service Objects”. Interactors demand that the core of your application should live in a set of plain-old-Ruby-objects (POROs) that are responsible for the main use-cases of your application, leaving your ActiveRecord classes as skinny interfaces to your data-store. By looking at the names of your Interactors, you should be able to tell what your application does; SignUp, BookGrouper, AssignBarForGrouper, etc. Classes like Member and Bar just validate and store attributes like name, location and date.

(By way of background – Grouper organises “Groupers” – 3-on-3 drinks events at bars between groups of friends. They’re a lot of fun)

There’s a very lightweight gem called Interactor which exposes a couple of convenient methods like success? and failure?, so your controllers can now look like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class GroupersController < ApplicationController::Base
  
  def create
    interactor = ConfirmGrouper.perform(leader: current_member)

    if interactor.success?
      redirect_to home_path
    else
      flash[:error] = interactor.message
      render :new
    end
  end
  
end

and your interactor might look like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# Responsible for creating a Grouper, email
#
#
class ConfirmGrouper
  include Interactor

  def perform
    grouper = Grouper.new(leader: member)
    fail!(grouper.errors.full_messages) unless grouper.save
    send_emails_for(grouper)
    assign_bar_for(grouper)
  end

  private

  def send_emails_for(grouper)
    LeaderMailer.grouper_confirmed(member: grouper.leader.id).deliver
    WingMailer.grouper_confirmed(wings: grouper.wings.map(&:id)).deliver
    AdminMailer.grouper_confirmed(grouper: grouper.admin.id).deliver
  end

  def assign_bar_for(grouper)
    # Asynchronous job because it’s a little slow
    AssignBarForGrouper.enqueue(grouper.id)
  end
end

The advantages are numerous – both in terms of code-complexity and test-speed. For one, your tests should now be lightning-fast because you rarely need to hit the database, and you can test each ActiveRecord model in isolation without worrying about its associations. The Interactors themselves can be be tested with doubles rather than ActiveRecord models, and Rails often doesn’t even need to be loaded by your test suite.

Your controller spec might look like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
describe GroupersController do
  
  describe #create” do
    subject { post :create }

    before { ConfirmGrouper.stub(:perform).and_return(interactor) }

    let(:interactor) { double(Interactor, success?: success, message: foo) }

    context when the interactor is a success do
      let(:success) { true }

      it { should redirect_to home_path }
    end

    context when the interactor fails do
      let(:success) { false }

      it { should render :new }
    end
  end
end

With all ActiveRecord database calls stubbed out, this test file now runs in around 0.15 seconds, compared to around 4 or 5 seconds previously.

Additionally, your application will be easier to reason about because each class has a single responsibility – the Bar class knows where the bar is located and when it’s open, for example. It doesn’t need to know about Groupers. The complex logic for determining which Bar to assign to which Grouper can sit safely on its own in the AssignBarForGrouper Interactor.

Finally, if you keep your Interactors small and single-purpose, you can compose multiple Interactors together for more complex operations. This mix-and-match approach further decouples your codebase and lets you reuse operations as necessary.

Conclusion

You obviously need to choose the patterns that fit the problem you’re trying to solve – it’s rarely one-size-fits-all, and some of these principles may be overkill in a very simple 15-minute blog application. Our objection is that Rails feels like it’s a Beginners’ Version, often suggesting that one size does fit all. Once your codebase surpasses a very basic level of complexity, you’re left on your own grasping for solutions. We’d suggest Interactors are one of these solutions.

For the next in the series, see Policy Objects – slimming down your controllers.

If you’re interested in Rails design-patterns and best-practices, you should check out our jobs page – we’re hiring.

You can also get involved in the discussion on hacker news.