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:
- Using the
interactor
gem
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.