Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 089 / 100

Callbacks: use with care — practical tips and trade-offs

Hidden side effects hurt.

Expona Admin6 min read

Introduction

Rails callbacks are powerful—too powerful. We've all inherited codebases where a simple model save triggers a cascade of side effects: emails flying out, external APIs being called, database queries multiplying in the shadows. Callbacks: use with care — practical tips and trade-offs is the mantra we should adopt, because hidden side effects hurt. By the time you realize what's happening, your test suite is flaky, your performance is degraded, and your teammates are struggling to reason about the code flow.

Over our years running production systems—from e-commerce platforms to SaaS dashboards—we've learned that callbacks work best for narrow, predictable tasks and become a liability the moment they hide business logic. This article cuts through the philosophy and gives you the concrete patterns we use to keep callbacks manageable and explicit.

When callbacks are fine: simple normalization

Callbacks shine when they handle straightforward data normalization. Trimming whitespace, slugifying a title, downcasing an email—these are safe, fast operations that belong in the model layer and should fire every time that field changes.

ruby
class User < ApplicationRecord
  before_save :normalize_email

  private

  def normalize_email
    self.email = email.downcase.strip if email.present?
  end
end

This is defensive programming. You don't want to rely on the controller or service layer to always normalize email; the callback ensures consistency at the source. The operation is synchronous, deterministic, and adds negligible overhead.

Similarly, generating a slug from a title or formatting a phone number are ideal callback tasks:

ruby
class Product < ApplicationRecord
  before_validation :generate_slug, if: :name_changed?

  private

  def generate_slug
    self.slug = name.parameterize if name.present?
  end
end

The key characteristic: the callback modifies only the record being saved and has no external dependencies. No I/O, no side effects, no surprises. These callbacks are predictable enough that you can safely fire them in tests and migrations without fear.

The hidden trap: email and notifications in callbacks

One of the most common mistakes we see is sending emails inside callbacks, and it usually starts innocently. A user signs up, you want to send a welcome email. Why not do it in an after_create hook?

ruby
class User < ApplicationRecord
  after_create :send_welcome_email

  private

  def send_welcome_email
    UserMailer.welcome(self).deliver_now
  end
end

The problem surfaces when you run migrations, seed data, or write tests:

  • Migrations send production emails: You migrate a user's name and suddenly they receive a welcome email weeks later.
  • Tests become slow and flaky: Every user creation in your test suite now makes an HTTP call to your mail service.
  • Transaction boundaries break: If the save succeeds but the email fails, the user exists in the database but they never received their welcome email.
  • Rollback doesn't unsend emails: Rails can roll back a transaction, but it cannot unsend an email.

Notifications, Slack messages, and webhook calls belong in the same category. They are external side effects that should never be hidden inside a callback.

Use after_commit for jobs, not for side effects

If you must trigger something on save, after_commit is safer than after_create or after_save because it fires only after the transaction commits—meaning your database state is durable. The right use case for after_commit is to enqueue background jobs, which themselves can fail and retry independently.

ruby
class User < ApplicationRecord
  after_commit :enqueue_welcome_job, on: :create

  private

  def enqueue_welcome_job
    SendWelcomeEmailJob.perform_later(id)
  end
end

Now the email is sent asynchronously and decoupled from the save operation. If the job fails, your job queue retries it. If you need to resend, you can manually re-enqueue. And your database transaction is clean and fast.

This is a significant trade-off: you gain resilience and explicitness at the cost of a small delay. The email doesn't go out immediately, which is usually fine for most workflows.

However, note that even after_commit can still hide intent. A developer reading the model doesn't immediately see that saving a user triggers job creation. And if you chain multiple after_commit hooks, you're building an implicit state machine that becomes hard to debug.

Prefer explicit service calls

The most maintainable approach we've found is to avoid callbacks for anything beyond simple normalization and let the caller—the controller, service, or job—decide what happens next. This is more explicit and easier to reason about.

ruby
class Users::RegisterService
  def initialize(params)
    @params = params
  end

  def call
    user = User.new(@params)
    
    if user.save
      SendWelcomeEmailJob.perform_later(user.id)
      user
    else
      raise StandardError, user.errors.full_messages.join(", ")
    end
  end
end

In the controller:

ruby
class UsersController < ApplicationController
  def create
    service = Users::RegisterService.new(user_params)
    @user = service.call
    render :show, status: :created
  rescue StandardError => e
    render json: { error: e.message }, status: :unprocessable_entity
  end
end

Now the flow is crystal clear: create a user, then send the email. If you need to create users in a migration or seed file without sending emails, you just don't call the service—you call User.create directly or wrap it in a block that disables callbacks:

ruby
User.skip_callbacks do
  User.create(name: "Seed User", email: "seed@example.com")
end

The explicit pattern also makes it easier to add conditional logic. Maybe you only send a welcome email to users in certain regions, or maybe you send different email templates based on the signup source. With a service, this is straightforward:

ruby
class Users::RegisterService
  def call
    user = User.new(@params)
    
    if user.save
      SendWelcomeEmailJob.perform_later(user.id) if user.region == 'US'
      TrackSignupEvent.call(user)
      user
    else
      raise StandardError, user.errors.full_messages.join(", ")
    end
  end
end

With callbacks, this conditional logic gets buried in private methods or requires additional flags on the model, making the code harder to follow.

Common mistakes

  • Chaining callbacks: One callback triggers another, which triggers a third. Your User model ends up with five after_save hooks that fire in a confusing order. Use a service instead.
  • Querying other records inside callbacks: A callback that fetches related records for a side effect is now doing N+1 queries every time the model saves. Move this logic to a job or service where you can optimize the query path.
  • Testing callbacks in isolation: Your test suite tests the callback independently, but in production, the callback fires in a different context (maybe within a transaction, maybe not). Test the full flow end-to-end.
  • Forgetting if guards: A callback that should only fire on create accidentally fires on update because you forgot the on: :create guard. Always specify when a callback should run.
  • Using update_attribute to bypass callbacks: Someone bypasses a callback with update_attribute, thinking they're being clever, and now your data is in an inconsistent state. Document why callbacks exist and what invariants they maintain.

Key takeaways

  • Callbacks are appropriate for data normalization (trimming, slugifying, formatting) that always needs to happen and has no external dependencies.
  • Never send emails, make HTTP calls, or trigger external side effects inside callbacks. These operations should be explicit and decoupled from model saves.
  • Use after_commit to enqueue background jobs, which can retry independently if they fail, but recognize that even this hides a layer of intent.
  • Prefer service objects for coordinating multiple steps (save user, send email, track event). They make the flow explicit and testable without relying on hidden callbacks.
  • Callbacks multiply in complexity over time. Start with no callbacks and add them only when the alternative (repeating normalization logic everywhere) becomes clearly worse.
  • Always document why a callback exists and what invariants it maintains. Future developers need to understand the implicit contract.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles