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.
class User < ApplicationRecord
before_save :normalize_email
private
def normalize_email
self.email = email.downcase.strip if email.present?
end
endThis 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:
class Product < ApplicationRecord
before_validation :generate_slug, if: :name_changed?
private
def generate_slug
self.slug = name.parameterize if name.present?
end
endThe 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?
class User < ApplicationRecord
after_create :send_welcome_email
private
def send_welcome_email
UserMailer.welcome(self).deliver_now
end
endThe 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.
class User < ApplicationRecord
after_commit :enqueue_welcome_job, on: :create
private
def enqueue_welcome_job
SendWelcomeEmailJob.perform_later(id)
end
endNow 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.
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
endIn the controller:
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
endNow 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:
User.skip_callbacks do
User.create(name: "Seed User", email: "seed@example.com")
endThe 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:
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
endWith 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_savehooks 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
ifguards: A callback that should only fire on create accidentally fires on update because you forgot theon: :createguard. Always specify when a callback should run. - Using
update_attributeto bypass callbacks: Someone bypasses a callback withupdate_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_committo 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.






Comments
Loading comments…