DEV Community

Cover image for Stop putting side effects in Rails callbacks
Hassan Farooq
Hassan Farooq

Posted on

Stop putting side effects in Rails callbacks

Callbacks are one of those Rails features that feel great for about three months and then quietly ruin your afternoon. The question I got asked in an interview was simple: are model callbacks good to use, and how do you avoid overusing them? My answer is that they're good for data and bad for behavior, and the trouble starts the moment you forget which is which.

Where callbacks earn their keep

The good use is small, record-local cleanup. Stuff that's only about keeping the row itself tidy, and that should happen no matter who saves the record.

class User < ApplicationRecord
  before_validation :normalize_email

  private

  def normalize_email
    self.email = email.to_s.strip.downcase
  end
end
Enter fullscreen mode Exit fullscreen mode

Downcasing an email, stripping whitespace, setting a default, generating a slug from a title. These are cheap, predictable, and they don't reach outside the record. Nobody gets surprised by a callback that lowercases an email. This is exactly what before_validation and before_save are for, and I'd reach for them without a second thought.

Where they turn on you

Now the bad use. Side effects.

class Order < ApplicationRecord
  after_create :charge_customer      # external API
  after_create :send_confirmation    # email
  after_create :sync_to_crm          # another external API
end
Enter fullscreen mode Exit fullscreen mode

This looks tidy. It isn't. A plain Order.create now secretly charges a card, sends an email, and pokes a third party, and none of that is visible at the place you called create.

Here's how it bites. Six months later someone writes a rake task to backfill old orders, calls Order.create, and accidentally emails five thousand customers and runs five thousand charges. Or you sit down to write a test for something unrelated to payments, and you can't, because every create in the suite now needs Stripe, the mailer, and the CRM stubbed. The save and the side effects are welded together, and you can't pull them apart.

There's also a sharp edge most people meet by accident. destroy_all runs callbacks, delete_all skips them. Same intent, "remove these rows," two different behaviors depending on which method you typed. If your cleanup logic lives in a callback, one of those quietly does the wrong thing.

How to keep them from spreading

The trick is noticing when a callback stopped being cleanup and turned into a workflow. The fix is to pull that workflow out into a service object you call on purpose.

class OrderCreator
  def self.call(user:, params:)
    order = user.orders.create!(params)

    PaymentCharger.call(order)
    OrderMailer.confirmation(order.id).deliver_later
    CrmSyncJob.perform_later(order.id)

    order
  end
end
Enter fullscreen mode Exit fullscreen mode

Same work, completely different feel. The side effects are right there in front of you, in order, on purpose. A test that just needs an order calls create! and gets an order, nothing else. The jobs are queued because you asked, not because saving a row happened to trigger them. When the CRM sync breaks, you know exactly where to look.

The line I'd actually say

Use callbacks for data, not for behavior. If a callback only touches the record's own attributes, leave it. The second it reaches outside the record, like sending mail, calling an API, or orchestrating other models, it belongs in a service object.

One honest caveat so you don't sound like a zealot. after_commit is a reasonable middle ground for "enqueue a job once this record is actually saved," because it only fires after the transaction commits, not on a rollback. Even then I usually enqueue from the service layer instead, just to keep the flow visible. Both are defensible. The thing worth being able to explain is why fat callbacks hurt, because that's the part that tells an interviewer you've been burned by them before.

Top comments (0)