Understanding Rails Callbacks & Common Pitfalls

Understanding Rails Callbacks & Common Pitfalls

Why you should be careful when using Callbacks in Rails and their hidden gotchas

Context

I recently spent a good chunk of time debugging a bug and in turn, did a lot of research on how Transactions and Touch work with callbacks like after_commit. Below is the debugging "story" I shared on X, if you like reading those, give it a shot but it's not required for this reading.

Foreword

evil

Callbacks are Evil. There, I said it. Whew, feels good to get this off my chest in the first line itself. Okay, you may ask why or just think I am not a seasoned RoR dev yet to be making this statement(which, by the way, I’m not). But hear me out.

One of the crowning achievements of Rails is its ability to facilitate rapid development enabling businesses and ideas to scale quickly. But with this speed comes a potential pitfall. What I mean is that you do something in one place of the codebase and soon that pattern will get replicated everywhere. The "intent" of doing something gets lost pretty quickly if someone doesn't keep an eye on it and more so as the codebase gets bigger and complexity goes through the roof. This can make or break an application in the long run.

There will always be examples of "Oh we were able to do XYZ, if you can't that means you are not doing it right". But here’s the thing: in a framework as powerful as Rails, which often feels magical in its abstraction of complexities, it's really easy to misuse features beyond their intended purpose.

Callbacks are one of those abused things.

Now, the point of writing this next part is that I want to share a few edge cases I've come across. Think of this as a compilation of Callback gotchas that one might easily miss.

Callback Gotachs

Callbacks are easy and very convenient to use and that is the worst part.

Now the Rails Docs doesn't state anywhere what should ideally go in a callback. It shows how many different types are there and what they do, but what goes into them is still a blur. So you really need to be sure of what you put inside your callbacks before it's too late. But this post is not about what goes in a callback. This is about how it can surprise you once you are already dealing with it.

Some of the things I will mention are either in the API Docs or the Rails guide, but a few aren't mentioned anywhere(and can actually trip you up). Here are a few things you should keep in mind when dealing with callbacks.

1. Callback ordering:

The non-transactional callbacks run in the order they are defined.

But.. order

From Rails 7.1 this can be altered via configuration.

config.active_record.run_after_transaction_callbacks_in_order_defined = false

The default value from 7.1 is true.

2. Multiple transactional callbacks with the same method name override each other:

This means only the last one is executed. This goes for after_commit, after_*_commit, after_rollback, etc) override

The above 2 are mentioned in the Rails Guide, thankfully. The next one is not!

3. Multiple instances of the same record in a transaction:

In the context of a single transaction, if you interact with multiple loaded objects that represent the same record in the database, there's a crucial behavior in the after_commit and after_rollback callbacks to note. These callbacks are triggered only for the first object of the specific record that undergoes a change within the transaction. Other loaded objects, despite representing the same database record, will not have their respective after_commit or after_rollback callbacks triggered.

multi-record

As you can see in the above example, only 2 callbacks are executed. This behavior is not documented anywhere. I've raised a PR to add this in the guides, let's see what happens. Anyway, you need to be careful about this.

Some additional gotchas

There are a few more surprises in terms of when a rollback happens, and how Rails handles it, especially with nested transactions. Using nested transactions is considered an anti-pattern in a lot of cases, so think twice before using it.

Bonus: Confusing behavior of Touch with Transaction and after_commit

Now this is not a bug, but for me, it was the lack of clarity/docs on this that was confusing. Let's understand this with an example transaction

In the above example, the touch SQL queries are coalesced at the end to be more efficient but the Car model is actually touched after each touch hence triggering the after_commits in a specific order. This can be especially confusing when you are debugging stuck in something I like to call Callback Hell. This is also not mentioned anywhere, hence I raised another PR to add this to the API docs.

Overall, callbacks just seem like a bad design, because they are really powerful but so easy to use. It's more of a bad design based on how a human brain works rather than a programming one, but if your codebase already has a lot of these and you need to work with them a general rule to keep in mind when dealing with it would be

Try without a callback, if you really think it belongs there then move it into a callback. But don't start with a callback.