Rails Callbacks Flatten Layered Architecture
Many bits have been spilled arguing over Rails callbacks. Those argue against callbacks usually point to the testing difficulties. Those in favor highlight their simple approach to ensuring an action always occurs.
Both arguments focus on the symptoms, but they miss the bigger picture. Over time, I have found the root of the problem with Active Record callbacks: Callbacks are not evil because they are difficult to test, they are problematic because they flatten layered architectures.
In short: Growing Rails applications should rarely use callbacks in the interest of keeping the layers of their application intact.1
This blog post will unpack that thesis by showing how some of the code smells we talk about when discussing callbacks are really problems with circular dependencies. Circular dependencies destroy layered architectures within our applications.
Our First Callback
For the remainder of this post, we’ll use the following user story to drive our development: As a user, I would like to receive a confirmation email when I sign up.
Seems easy enough! We should have this whipped up in no time. A simple callback scheduling a Sidekiq job should do the trick:
# app/models/user.rb
class User < ActiveRecord::Base
after_commit :send_welcome_email, on: [:create]
private
def send_welcome_email
UserWelcomeEmailWorker.perform_async(id)
end
end
For completeness, here’s what our other two classes will look like:
# app/workers/user_welcome_email_worker.rb
class UserWelcomeEmailWorker
include Sidekiq::Worker
def perform(user_id)
user = User.find(user_id)
UserMailer.welcome_email(user).deliver_now
end
end
# app/mailers/user_mailer.rb
class UserMailer < ActionMailer::Base
def welcome_email(user)
@user = user
mail(to: @user.email, subject: 'Welcome Aboard!')
end
end
We’ll omit the actual mailer view for this example.
If we look at a dependency graph in this situation, we’ll notice a few interesting things:
Even with this simple approach, we’ve introduced two cycles in our dependency graph. One direct, one indirect.
The direct circular dependency comes from the User
class knowing about the existence and interface of UserWelcomeEmailWorker
, and vice versa.
The indirect circular dependency comes from the UserMailer
knowing the interface of the User
(specifically that a User
responds to email
). This is illustrated with a dotted-line because the mailer could receive something that is User
-like, like a PORO, and still function correctly.
It’s unfortunate because now the User
not only has to deal with the failure scenarios of the underlying database, but also with the failure scenarios of Sidekiq and the underlying Redis instance. Our model layer now needs to know about the operational details of our worker layer.
Still the risk of this tight circular dependency isn’t going to cause any immediate problems. Enter your organization’s sales team…
Sales Team Causing Problems Revenue
After completing the user story, you pat yourself on the back and treat yourself to a vacation. You’ve earned it.
While you’re paddleboarding in Costa Rica, a member of your sales team messages a developer on your team. The sales rep explains they closed a huge deal and need to onboard a customer quickly. They promised the client just one small thing: the client will forward a list of email addresses for your coworker to onboard automatically.
“Not a problem,” your developer colleague thinks. “I’ll just fire up a production console and manually create the User records.”
Unfortunately, your colleague didn’t know about that callback you landed. Their script wound up emailing every address in the list. The client hadn’t informed their employees of the migration yet, so everyone’s confused.
Not a huge deal, but now your team looks less professional.
And that’s because this callback-based approach makes it impossible to create a User
without emailing them. This could be a feature of your development process, especially in a world of YAGNI and agile development.
Now we know, so let’s conditionalize sending the email:
# app/models/user.rb
class User < ActiveRecord::Base
after_commit :send_welcome_email, on: [:create], if: -> { !@being_created_for_sales_team }
attr_writer :being_created_for_sales_team
private
def send_welcome_email
UserWelcomeEmailWorker.perform_async(id)
end
end
I’ve given this variable a silly name to highlight that User
now depends on something entirely outside of our app.
This is another reason I strongly dislike callbacks: They don’t grow gracefully. With callbacks, it’s difficult to keep the system sound (i.e. not introduce unnecessary dependencies) and extend existing behavior. Remember: Code is not just about how it exists today but how it can grow healthily in the future.
Instead of digging this hole deeper, let’s see if there is another approach.
A Detour into Layered Architecture
When Rails was first introduced in the early aughts, it provided sensible defaults in its convention over configuration approach.
One of those sensible defaults was the introduction of a Model-view-controller (MVC) paradigm. This web-style interpretation of MVC felt like a distant cousin to desktop-style MVC.2 Regardless of the accuracy of the name, the introduction of these three layers made writing Rails apps a breeze. (The default at the time appeared to be a concatenation of PHP scripts.)
MVC provides a blueprint for layered architecture in web apps. We can write simple components and connect them together. It encourages the Single Responsibility Principle.
MVC enforces a directionality to the knowledge of our system: Controllers know about views and models, views know about models, models think they live in their own world. These layers ensure the thing talking to the database doesn’t also need to parse HTTP headers.
Although Rails gives us sensible defaults for this layered architecture, it also provides tools to undo them. Circular dependencies, whether direct or indirect, corrupt the layering. A well-designed system should have no circular dependencies.
Circular dependencies cause problems. In some languages, code with circular dependencies simply doesn’t compile. In more forgiving languages, circular dependencies will embrittle our system. It becomes difficult to change one component without changing another.
In my mind, there are two important types of circular dependencies, direct and indirect. A direct dependency is when a single pair of edges connects two nodes.
Indirect circular dependencies are “longer” and touch multiple parts of the system.
They’re just as dangerous as direct dependencies because they introduce new failure modes into components. An indirect circular dependency has a presenter worry about network failures, or a model worry about authentication schemes (Am I being modified by an admin?).
Active Record callbacks introduce both direct and indirect circular dependencies.
Before we understand how, we need to take another detour to talk about how Rails applications grow.
Adding Layers to Rails
As Rails applications grow, the MVC layers become inadequate for expressing the application’s complexity.
Typically, you’ll see the following layers added to a growing Rails application:
- A worker layer, through something like Sidekiq
- A mailer layer, likely via Action Mailer
- A business layer, usually through service objects
By introducing these layers, our application becomes more performant and easier to reason about. The layers help to thin “fat” models and “fat” controllers.
Most Rails applications I’ve worked on include these extra layers.
Now that we’ve set the stage for callbacks and layered architecture within Rails, let’s talk about how we can untangle these cycles.
Enter the Service Object
Service Objects are nothing new. Developers write blog posts about them time and again, especially in the context of growing Rails applications. Let’s understand why they’re great for untangling callbacks.
Instead of using callbacks, let’s introduce a new service object:
class UserCreator
def self.create!(params)
User.transaction do
user = User.create!(params)
UserWelcomeEmailWorker.perform_async(user.id)
end
end
end
Now User
looks like this:
class User < ActiveRecord::Base
end
Here’s our new dependency graph:
Although this graph looks more complicated, it’s without circular dependencies. Let’s quickly spell out the dependencies here:
- The
UserCreator
depends onUserWelcomeEmailWorker
andUser
- The
UserWelcomeEmailWorker
depends onUser
andUserMailer
- The
UserMailer
expects to receive somethingUser
-shaped
Callbacks Often Cause Circular Dependencies
Callbacks are not evil themselves, but they’re difficult to use without introducing circular dependencies.
This isn’t usually obvious, especially because Ruby does not require you to import dependencies explicitly. The circular dependency reveals itself when trying to write tests for the User
class.
Circular dependencies flatten the layered hierarchy we try so hard to maintain.
As a corrolary, a healthy Rails code base keeps its models as leaves in its dependency graph.
Recommendations
After spending time in a variety of code bases, I have some recommendations for callbacks.
In young Rails applications:
- Callbacks are fine. You have better things to worry about than the purity of your dependency graph.
In growing Rails applications:1
- Callbacks should only be used for syncing data to other systems via tools like Kafka or simple Sidekiq setups. Although doing so no longer keeps our models at the true edges of our dependency graph, this is my only preferred use of callbacks.
- Use service classes to ensure your models remain as leaves in your application’s dependency graph
This will lead to a code base with smaller components, fewer god objects, and a cleaner dependency graph.
Special thanks to Alex Gerstein, Seema Ullal, Sihui Huang, and Justin Duke for providing feedback on early drafts of this post.
-
I define a growing/large Rails application as any Rails app with more than 100K lines of code. ↩ ↩2
-
This is entirely driven by the interaction latency. Desktop-style MVC often has models updating views because it must be done at 30 or 60 Hz. Web apps deal with a stateless request every few seconds at most. ↩