In the beginning, Rails callbacks like after_save or after_commit feel like magic. You save a user, and—poof—a welcome email is sent. It’s easy, it’s fast, and it keeps your controller clean.
But as your application grows, these “invisible” side effects become a maintenance nightmare. Today, we’ll explore why callbacks are often technical debt in disguise and how to move toward a more explicit, modern architecture.
1. The Problem: The “Side Effect” Trap
The biggest issue with callbacks is that they are implicit. When you call user.save, you expect a database write. You don’t necessarily expect a 3rd-party API call to Stripe, a background job enqueued to Intercom, and a cache purge for the entire dashboard.
Why this hurts:
- Testing Hell: Your unit tests become slow because every
create(:user)triggers a chain reaction of logic that isn’t relevant to the test at hand. - Fragility: Changing a minor field might trigger a callback that fails, preventing the entire record from saving.
- Circular Dependencies: Object A saves Object B, which has a callback to update Object A… and suddenly you have an infinite loop.
2. The Alternative: Explicit Service Objects
The modern Rails way is to favor Explicit over Implicit. Instead of hiding logic inside the Model, move it to a dedicated Service Object. This makes the flow of data obvious and easy to test in isolation.
The Callback Way (Hiding the logic):
1
2
3
4
5
6
7
8
9
class User < ApplicationRecord
after_create :send_welcome_email
private
def send_welcome_email
UserMailer.welcome(self).deliver_later
end
end
The Service Object Way (Explicit logic):
1
2
3
4
5
6
7
8
9
10
11
12
class UserRegistrationService
def self.call(params)
user = User.new(params)
if user.save
UserMailer.welcome(user).deliver_later
Analytics.track("User Signed Up", user_id: user.id)
end
user
end
end
Now, when you look at the service, you know exactly what happens when a user registers. No surprises.
3. Modern Observers: ActiveSupport::Notifications
If you truly need a “decoupled” way to handle side effects without bloating your service objects, the modern “Observer” pattern in Rails is ActiveSupport::Notifications.
This follows the Pub/Sub (Publish/Subscribe) pattern. The Model (or Service) simply broadcasts that something happened, and “Subscribers” listen and react.
Step 1: Broadcast the event
1
2
3
4
5
6
7
class User < ApplicationRecord
def register
if save
ActiveSupport::Notifications.instrument("user.registered", user: self)
end
end
end
Step 2: Subscribe to the event (The Modern Observer)
1
2
3
4
5
6
7
# config/initializers/events.rb
ActiveSupport::Notifications.subscribe("user.registered") do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
user = event.payload[:user]
UserMailer.welcome(user).deliver_later
end
Use Callbacks for Data, Services for Business
Are callbacks always evil? No.
- Use Callbacks for: Internal data integrity (e.g., downcasing an email, setting a slug).
- Avoid Callbacks for: Business logic, sending emails, hitting APIs, or updating unrelated models.
By moving side effects out of your models, your test suite will get faster, and your code will become much easier to reason about.