The Hidden Cost of Callbacks

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:

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.

By moving side effects out of your models, your test suite will get faster, and your code will become much easier to reason about.