Welcome to probably the most important article when it comes to improving Rails with dry-rb. After coming out with Rails criticism in our last article we’re moving further (with some more criticism of course!). This time we will tackle the root of all evil - callbacks.
Similarly to the previous case where we discussed validations, it’s crucial to understand when it’s ok to use such mechanisms and to appreciate them even though their capabilities end quickly. After all, we’ve all gone through them, understood them, and used them in our projects. It’s a surprising statement considering we’ve dubbed callbacks the root of all evil in the first paragraph but here we go - even callbacks could help you develop your app given certain circumstances.
Now to the point. We’re grown-ups and everyone knows what callbacks are so we’re not going to cover that, but why do Rails use them? Again, as with validations, the answer to this question is prosaic: they’re easy to implement. No surprise here. We’ll underline that on every possible occasion - Rails are designed for beginners, and they aim to be as new-developer-friendly as they can get. And they’re absolute rockstars at that. Sadly, for more advanced web developers we need to repeat the diagnosis from the previous article again: Rails do not scale, and that’s a fact
The problem
It’s time to explain exactly why we consider callbacks the root of all evil. It’s straightforward, it’s about control, or rather the lack of it. In terms of process designs, explicitness always beats implicitness. When you work on some flow you want to define it as clearly as possible in case any other developer takes your place and needs to change something along the way or fix a bug. Or, frightfully, you yourself have to review your old code and understand it once again. One could argue that in some situations putting lots of stress on visibility is not that necessary and explicitness is not the ultimate answer to everything. Fair enough, we could agree. But not in the case of processes. Not in the case of logical flow of operations. When designing actions simplicity and proper order of operations have to be our primary goals.
Oh, and when we talk about processes we mean comprehensive handling of one task as a whole, in our case it would be an endpoint. An example of a process would be sign-up or profile editing. It starts with the request, does its job, and ends with the response. In our understanding processes consist of operations, which are smaller, atomic tasks. Authentication, authorization, persistence, or serialization are all instances of operations that can appear in a process.
Ok, but that’s a lot of high-level abstract blabbering with no clear reasons so far why callbacks are so petrifying. What is it? Where is that hate toward callbacks coming from? Here it is: Callbacks are side effects. We could finish the article here and let you wonder for yourself. Maybe you could find that revelation as monumental as we do. This is the key thing here, by implementing callbacks we’re introducing side effects to the code. Choosing side effects we’re accepting operations being fired indirectly, implicitly on other operations which creates a domino effect with pieces branching out with no control. That’s the very essence of the problem callbacks pose to software design.
Let’s do a little exercise to better visualize the issue. Which action is more understandable to you?
The one using callbacks…
…or the one without them?
Before y’all start analyzing those processes please note that the operations shown on both of the graphics are only examples and might make no sense at all. They are meant to just show some common system requirements, they are not necessarily consistent (although this time I need to admit I’ve drank 2 coffees already).
Alright, by now we should be all well aligned. But let’s go into some more detail if you still weren’t convinced, shall we? There’s nothing simpler than step-by-step processes, we handle just one thing at a time. Of course, firing workers or scheduling jobs is still possible, but the flow generally remains its form of one stream of operations. Even explaining the process is oh-so-much easier. With callbacks, everything suddenly gets more difficult. We can see at first glance that now the process is fragmented and distributed over an unpredictable number of classes. That’s not good already. One callback fires another and we’re left with the necessity of going through all the associations. On many occasions, this means traversing a complex tree of models. It’s a nightmare. Not only do we drop control over what happens and in what order, but we also need to learn and analyze all different kinds of those callbacks. We need to know or check all the subtle differences between after_create
, after_validation
, after_save
, after_commit
, and others. Can you tell the proper order of execution on the spot? And last but not least, the callbacks are independent of any other process. It means that more often than not they are shared and one callback is being fired within many different actions. Many times it’s what we want, yet so frequently it’s what causes issues and makes us go bug-hunting.
Let’s get back to standalone processes. They’re beautiful, but where’s the catch? There is none if you play your cards right. Obviously, sooner or later we’re going to find out that some of the operations need to be fired in many different actions and it either becomes tedious to implement the same or similar methods for all of them or a need to extract those operations arises. Of course, repeating the exact same code for the sake of having independent processes is not a good idea. We’d be creating a serious maintenance issue this way. While in general, we wouldn’t demonize repeating code that much as we see that sometimes there are conditions where it makes sense, for processes within the same context we shouldn’t be duplicating code under the threat of later divergence. Luckily we’re grown-ups and we speak Ruby well. Extracting pieces of code is not a task that would normally keep us awake at night.
The makeover
Ready for some action? We sure are! Let us introduce you to something… we don’t really understand. For real. What are monads? We’ve read the definition, yet we didn’t even bother trying to comprehend it. It’s beyond us, sorry.
Everything we need to know is that dry-monads is a kind of successor gem to dry-transaction. We’d highly recommend reading on dry-transaction before getting into monads, it should clear things up for you.
We’ll get into some more details later.
First, let’s see a sample implementation of a regular sign-up with callbacks.
class User < ApplicationRecord
before_create :generate_confirmation_token, if: :signed_up_with_email?
after_create :send_confirmation_email, if: :signed_up_with_email?
after_create :register_on_3rd_party_service
after_destroy :remove_from_3rd_party_service
after_create :create_default_profile, if: -> { profile.nil? }
end
class Profile < ApplicationRecord
before_create :generate_slug
after_create :create_empty_portfolio
after_touch -> { user.touch }
end
class AuthenticationController < ApplicationController
def sign_up
@user = User.create!(user_params)
render @user
end
private
def user_params
params.require(:user).permit(:first_name, :last_name, :email, :password)
end
end
Again, this just serves as an example.
And there’s nothing fancy here, so we won’t go into deep analysis with it. One thing to note is that having experience with Rails this code looks very innocent. Thanks to limited scope, as we implemented nothing but callbacks, it doesn’t look that bad visually either. We just hope at this point it’s clear to you already where’s the problem.
Let’s see what the alternative could look like!
require 'dry/monads'
require 'dry/monads/do'
module Authentication
class SignUp
include Dry::Monads[:result]
include Dry::Monads::Do.for(:call)
def call(params)
ActiveRecord::Base.transaction do
data = yield validate(params)
user = yield create_user(data)
profile = yield create_profile(user)
portfolio = yield create_portfolio(profile)
yield register_on_3rd_party_service(user)
yield send_welcome_email(user)
Success(user)
end
end
private
def validate(params, contract: SignUpContract.new)
contract.call(params).to_monad
end
def create_user(data)
User.create(data, confirmation_token: random_token)
user.persisted? ? Success(user) : Failure(user.errors)
end
def create_profile(user)
profile = Profile.create(user: user, slug: profile_slug(user))
profile.persisted? ? Success(profile) : Failure(profile.errors)
end
def create_portfolio(profile)
portfolio = Portfolio.create!(profile: profile)
portfolio.persisted? ? Success(portfolio) : Failure(portfolio.errors)
end
def register_on_3rd_party_service(user, service: Some3rdPartyService.new)
service.call(user)
Success()
end
def send_welcome_email(user, mailer: SomeMailer.new)
mailer.send(welcome_email(user))
Success()
end
# auxiliary methods
def random_token
# ...
end
def profile_slug(user)
# ...
end
def welcome_email(user)
# ...
end
end
end
class AuthenticationController < ApplicationController
def sign_up(transaction: Authentication::SignUp.new)
@user = transaction.call(params.to_unsafe_h)
render @user
end
end
The models can be cleared entirely from callbacks. The controller action didn’t change much and we’ve created a new class that uses monads. Now you can tell everything the endpoint is doing just by looking at the #call
method. We got rid of side effects (yay! 🎉). The same result we could achieve in the controller itself without creating another class, but obviously, we’d clutter it pretty quickly. The whole idea of having controllers handling many endpoints within just one class is suboptimal anyway, to say the least. So we use it as some sort of a gateway to single actions, or transactions if we want to follow dry-rb nomenclature.
The implementation above is still far from perfect but we made a very important step. Now the whole request processing happens in one class, apart from rendering the response in this case. We’ve turned a tangled callbacks mess into a nice, clean, explicit chain of operations. This makes debugging extremely easy and is also an exceptionally scalable solution. When a wild error appears you know exactly where to look for it as there’s a class designed specifically to handle the request and all operations have a specific tasks to perform. Nothing will surprise you when there’s no side effects.
But why did we use monads exactly when we could’ve achieved the same results with plain ruby objects? Where’s the optimization here precisely? What does this dry library have to do with our solution? So far we’ve seen a somewhat strange use of yield
keyword. Not to say it feels kind of abusive.
Well, those are all valid questions, let us explain. In the example above it seems like we’ve done some pretty happy programming™. The operations are fired one by one just like regular procedural code with complete disregard for the possibility that one of them could fail. Or to be precise: could fail silently, without raising any exception or error. Usually, if something like this happens all hell breaks loose. Everything that goes afterward the failed operation is out of order as it’s based on a false premise. This is where monads step in. And this is why we’ve been wrapping the methods’ outcomes with Success
or Failure
. As long as we’re successful everything will go exactly as predicted, but once we encounter a failure the whole execution stops. That means, that no transaction code will be run past the failure point. And that’s thanks to the do
-notation introduced by dry-monads. Failure
interface also allows us to pass some arguments that we could use when dealing with the error. Please note that we consistently abstain from explaining how specific dry-rb mechanisms work. The details of dry-rb tools are well-documented on their website. There’s no point duplicating them.
Before discussing the disadvantages of this solution let’s see how could it still be improved. Our focus was to show how dry-monads could be used and try not to introduce too many concepts at once. Notice how we used the contract we discussed in the previous article. We pass it as an abstract dependency by using a keyword argument with a default value. The contract instance does its job and thanks to the #to_monad
method the #validate
step is 1 line only. The latter create methods use models directly. Instead of embedding models into the methods’ bodies and “hardcoding” them in a way, we could create another class - a repository and pass it as a dependency with a keyword argument as we did with #validate
. This technique has many benefits, but that’s a whole new topic. Let’s focus on the repositories themselves. In real-life scenarios working with data is much more complex than in our example and it requires more room. We can find it by designing completely new classes. This way our transaction methods would be dependent on repositories, and the data processing logic would be hidden as we’re not interested in it right here right now. The transactions in turn would be leaner. Profits everywhere. We’d also get rid of auxiliary methods that would move to corresponding repository classes. Oh, and we could share the same repository actions across many transactions. Simply lovely.
Ok, now the downsides. Uhmmm… there must be some, right? Of course, there are. The main difference with callbacks is when you use a console or scripts. For some manual tasks, you’ll be reusing repositories or other components, for some others you’ll want to run the whole transactions. If those transactions accept a request or a specific data structure - you’d need to prepare it first and lots of focus will be spent there. In general, when working manually you’d need to put a bit more care and sometimes effort. Another debatable issue with this approach is the need to always be extra cautious with every new endpoint. Every new transaction has to be composed entirely from scratch. You start with 0 code and of course, you can reuse some code already implemented but you need to do it yourself explicitly. We say it’s debatable because while it seems like there’s more work to do and it’s trickier it is at the same time freeing from most likely undocumented and unclear side effects that have been implemented months if not years ago. With great power comes great responsibility.
The wrap
- Rails’ conventions do not scale and aren’t suitable for complex projects.
- Rails’ models are overloaded with responsibilities which lead to maintenance issues.
- The 2 conclusions above are copied in the exact same form from the previous article about validations. They still apply.
- Rails’ controllers manage too many endpoints which more often than not results in their overgrowth. No one likes fatty classes.
- Callbacks are simple to implement but they complicate things a lot by being fired indirectly.
- The indirect execution of callbacks causes all the processes to be vague and tangled.
- Maintaining callbacks, especially in the long run, is harder than managing processes one by one.
- There is no feeling more desperate for a developer than not knowing what’s happening and not being able to find where the problem is. Callbacks make this all easy.
- Designing standalone processes instead of maintaining callbacks will finally bring some peace to your life.
- Proper operations design enhances visibility. If done right the
#call
method of action class gives us clear understanding at first glance. - The real improvement to application design is switching the approach from tangled implicit multi-starting-points processes to standalone explicit one-start-point processes. This constitutes a software design change.
- The conclusion above is set to imitate one from the previous article where we underlined a software design change related to validations. Both are essential to improving daily work. The rest is merely a way of executing those changes.
- You don’t need to listen to us and use dry-rb. You can use whatever you want, even plain ruby objects or libraries that you can code yourself. As long as you stick to the design rules outlined here you should be fine with your complex project.
- Again, we’ve barely scratched the surface of dry-rb monads. Please pay a visit to dry-rb main page and see both dry-transaction and dry-monads through your own eyes.
- dry-monads look odd and are difficult to grasp if we wanted to truly understand their nature, but they are simple in use and helpful when building processes.
- dry-monads help facilitate failure management eliminating the need of handling exceptions all the time or fortifying our code with conditions.
Credits
Cover image by pch.vector on Freepik