Interactors over fat models

When we use Rails, we are encouraged by the framework to use the MVC pattern. It is also a common practice to follow a popular advice Skinny Controller, Fat Model. But writing fat models can have its limits.

Callback hell

By writing all our business logic in the model and using callbacks we can write spaghetti code that can be a nightmare to test or to debug.

For example, in one of our project, we have a User model. Each time a user is created, we need to replicate it in an external system called Brivo. To perform this logic, we used Active Record Callbacks provided by Rails.

class User
  after_create :create_brivo
  
  def create_brivo
    # Call external API
  end
end

It seemed fine and the right thing to do.

Now, any user created by an API call, by our back office, in the rails console or in a rake task will create a user on Brivo system, which was the behaviour we wanted to have. However, in our tests, each time we create a user by calling the factory create(:user) we trigger an api call to Brivo. We can prevent the api call by stubbing it using VCR.

VCR.use_cassette('create_brivo_user') do
  create(:user)
end

But that implied updating all our tests that were creating a user, even the ones that did not need to interact with Brivo. So we tried something else and add an external_attribute to condition the creation of the brivo user.

class User
  attr_accessor :skip_brivo

  after_create :create_brivo, unless: :skip_brivo

  def create_brivo
    # Call external API
  end
end

Now we can do create(:user, skip_brivo: true) in our test if we don’t need to trigger this API call. What happens next is that we did exactly the same for 2 others external systems: HID et Stripe. So now, we had to do create(:user, skip_brivo: true, skip_stripe: true, skip_hid: true). Since it was the majority of our use cases, we set the default value of the skip_xxx attributes to true to only write create(:user). It worked for a time but then we had bugs in production that were not catch by our tests because of this attributes and new developers had a hard time understanding what was happening.

We used this pattern for callbacks but also for validations.

class Vacation < ApplicationRecord
  attr_accessor :edited_by_user_who_can_do_edit_admin

  validate :no_change_after_admin_validation, unless: :edited_by_user_who_can_do_edit_admin

  def no_change_after_admin_validation
    no_new_validation = !(rejected_changed? || admin_validation_changed?)
    errors.add(:base, :no_change_after_admin_validation) if admin_validation && no_new_validation
  end
end

class VacationsController < ApplicationController
  def update
    @vacation = Vacation.find(params[:id])
    @vacation.edited_by_user_who_can_do_edit_admin = @current_user.can_manage_vacation_requests
    # ...
  end
end

After some time, we decided that the fat model had reached its limit and it was time to use something else. We could have use Rails custom context system instead of using an attribute_accessor but in our opinion it has the same flaws. It creates spaghetti code and hides the flow where it should be explicit.

Interactors gems

We call interactor an object that only has a #call method and that contains the business logic of our application. It should be easily testable and should not depend on the framework. The idea is to move some of the validations that are contextual or some callbacks in the interactor instead of the model so that the flow of execution is explicit.

We studied two gems interactor and dry-transactions.

Using this two gems, the example provided at the beginning could become:

class CreateUser
  include Interactor

  def call
    create_active_record_user(context[:user_hash])
    create_brivo_user(context.user)
  end

  private

  def create_active_record_user(user_hash)
    user = User.new(user_hash)
    if user.save
      context.user = user
    else
      context.fail!({ message: user.errors.full_messages })
    end
  end

  def create_brivo_user(user)
    # Call external API
  end
end

And using the dry-transaction gem

class CreateUser
  step :create_active_record_user
  step :create_brivo_user

  def create_active_record_user(input)
    user = User.new(input[:user_hash])
    if user.save
      Success(user)
    else
      Failure({ message: user.errors.full_messages })
    end
  end

  def create_brivo_user(user)
    # Call external API
  end
end

The gems provide a success and a failure state which is a very nice feature. They also allow us to declare the flow of operations at the beginning of the file which helps deal with spaghetti code and explicitly states what’s going on. What we don’t like is the fact that the arguments of the interactor are not explicit. When we use CreateUser, we have to look at the implementation to find what the arguments are. For both gems, the idea behind having a hash as argument was to provide syntactic sugar to combine interactors.

We decided that the syntactic sugar is not worth hiding the arguments. We will not use those gems but we will use what we liked in those gems to build our own interactor system.

A custom but simple interactor

We rely on dry-monads used by dry-transactions to have a success and a failure state. We wanted explicit arguments so we write call function arguments as keywords arguments.

class CreateUser
  include Dry::Monads::Result::Mixin
  include Dry::Monads::Do.for(:call)

  def call(user_hash:)
    user = yield create_active_record_user(user_hash)
    yield create_brivo_user(user)
  end

  def create_active_record_user(input)
    user = User.new(user_hash)
    if user.save
      Success(user)
    else
      Failure({ message: user.errors.full_messages })
    end
  end

  def create_brivo_user(user)
    # Call external API
  end
end

class UsersController < ApplicationController
  def create
    result = CreateUser.new.call(user_hash: params[:user])
    if result.success?
      render_json_success(result.value!)
    else
      render_failure(result.failure)
    end
  end
end

And that’s it 🎉. We do not need other gems or other features. The yield keyword can seems a bit strange in this context but it is a very helpful feature called do notation that improves readability. We can still improve our interactor by refactoring some code that will be shared with all interactors.

class BaseInteractor
  include Dry::Monads::Result::Mixin

  def Failure(code:, details:) # rubocop:todo Naming/MethodName
    super(code: code, details: details)
  end

  def around_transaction
    transaction_open = ActiveRecord::Base.connection.transaction_open?
    result = nil

    ActiveRecord::Base.transaction(requires_new: transaction_open) do
      result = yield
      raise ActiveRecord::Rollback if result.failure?
    end

    result
  end
end

We override the #Failure method to provide a unique interface and makes sure that all our failure will have a code and a details. We also write a helper to wrap our interactor in a database transaction to rollback if there is a Failure. When we use callbacks, everything is wrapped in the same transaction. Since we move the code out from the callbacks, we need to wrap it in a transaction to keep the same behaviour.

Our interactors are simple ruby objects and therefore they are easily testable. We only need to invoke their #call method.

test 'should create user in brivo' do
  assert_difference -> { User.count } do
    VCR.use_cassette('create_brivo_user') do
      @result = CreateUser.new.call(user_hash: attributes_for(:user))
      assert @result.success?
    end
  end
  assert_not_nil @result.value!.brivo_id
end

Conclusion

We saw how we created a new object called interactor to implement business logic. It helps us to write explicit flows and deal with spaghetti code. By using dry-monads, we have a clear result of our interactor and can easily handle successes or failures. We still rely on convention and code reviews to enforce that call arguments are explicit keywords arguments but it should be possible to create a custom rule with rubocop to analyze that and enforce this rule programmatically.

We did not ban the callbacks, but we now use them with parsimony and use interactors as a preferred alternative.