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.
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.
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.
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.
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
And using the dry-transaction
gem
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.
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.
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.
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.