ActiveRecord Callbacks — When to Use Them and When to Stop
ActiveRecord callbacks are one of Rails’s most convenient features and one of its most misused. before_save, after_create, after_commit — they let you hook into the model lifecycle and run code automatically, which sounds great until your model has twelve callbacks that interact in ways nobody fully understands anymore. Callbacks have legitimate uses, but they come with real costs that are worth understanding before you add another one.
What Callbacks Are For
Callbacks are lifecycle hooks on ActiveRecord models. They fire automatically when specific events occur — before/after validation, before/after save, before/after create/update/destroy, before/after commit.
Example:
class User < ApplicationRecord
before_validation :normalize_email
after_create :send_welcome_email
before_destroy :archive_user_data
private
def normalize_email
self.email = email.to_s.downcase.strip
end
def send_welcome_email
UserMailer.welcome_email(self).deliver_later
end
def archive_user_data
UserArchive.create!(user_id: id, data: attributes)
end
end
This looks clean. Three hooks, each doing one thing. The problem shows up at scale.
The Real Cost of Callbacks
They make models harder to reason about. When you call user.save, you’re not just saving. You’re potentially normalizing email, sending an email, archiving data, and triggering any callbacks defined in concern modules you might not even remember are included. The action is no longer local to the caller.
They create unexpected coupling. A callback in User that sends an email means you need to stub or disable email delivery in every test that saves a User — not just the tests for the welcome email feature. Tests that expected User.create! to be simple now have side effects to manage.
They make testing harder. Every test that touches a model with callbacks must account for those callbacks. For unit tests testing a single piece of behavior, this is unnecessary noise. For integration tests, it’s unavoidable but still worth knowing about.
They fire in bulk operations. User.update_all(active: false) skips callbacks (it goes to the database directly). But users.each(&:save) fires every callback on every record — potentially sending thousands of emails or making thousands of external API calls in a loop.
When Callbacks Are Appropriate
Despite the downsides, some use cases are genuinely well-suited to callbacks:
Data normalization before persistence
before_validation and before_save are appropriate for transforming data that should always be in a canonical form. Downcasing email, stripping whitespace, formatting phone numbers — these are data-layer concerns that belong in the model.
Example:
before_save :normalize_email, :strip_name
def normalize_email
self.email = email.to_s.downcase.strip
end
Maintaining internal consistency
If one attribute must always be derived from another — a slug from a title, a token generated on create — a callback is reasonable because it’s internal model logic with no external dependencies.
Example:
before_create :generate_auth_token
def generate_auth_token
self.auth_token = SecureRandom.urlsafe_base64(32)
end
Cleanup on destroy
Deleting associated files, clearing cache keys, cleaning up orphaned records — things that should always happen when a record is destroyed and that are genuinely coupled to the record’s lifecycle.
When to Use Service Objects Instead
The rule of thumb: if the callback does something observable outside the model — sends an email, calls an API, enqueues a job, logs to an analytics service — move it to a service object.
Before (callback with side effect):
class Order < ApplicationRecord
after_create :notify_warehouse, :charge_customer, :send_confirmation
def notify_warehouse
WarehouseAPI.create_shipment(id: id, items: line_items)
end
# ...
end
After (service object, explicit orchestration):
class PlaceOrder
def initialize(order_params, user:)
@order_params = order_params
@user = user
end
def call
Order.transaction do
order = Order.create!(@order_params)
PaymentService.new(order, @user).charge
WarehouseAPI.create_shipment(id: order.id, items: order.line_items)
OrderMailer.confirmation(@user, order).deliver_later
order
end
end
end
The service version is more verbose, but every step is visible to the caller, individually testable, and easy to reorder or skip in different contexts (order placed via admin, order placed via API, order created in tests).
Pro-Tip: Use
after_commitinstead ofafter_createorafter_savewhen your callback triggers external side effects.after_createfires before the database transaction commits — if the transaction rolls back, the callback has already run.after_commitfires only when the record is durably persisted. Sending an email inafter_createcan result in emails sent for orders that never actually saved.
The Test Signal
A useful heuristic: if you’re writing allow_any_instance_of(User).to receive(:send_welcome_email) in more than a handful of tests, that’s a signal the callback is causing friction. Either the callback belongs in a service object, or the test is testing the wrong thing.
Tests that need to frequently suppress callbacks (User.skip_callback(:create, :after, :send_welcome_email)) are signaling the same thing: this behavior isn’t inherently part of user creation — it’s part of a specific workflow that creates a user.
Conclusion
Callbacks are a power tool. Used for internal model consistency — normalization, token generation, cascade cleanup — they’re appropriate and clean. Used for external side effects — emails, API calls, analytics — they create invisible coupling, testing friction, and debugging headaches. The question to ask before adding a callback: “Does this always need to happen when the model changes, regardless of context?” If the answer is yes, a callback is defensible. If the answer is “most of the time” or “in this specific flow,” move it to a service object where it’s explicit and testable.
FAQs
Q1: Do callbacks fire on bulk operations like update_all?
No. update_all, delete_all, and insert_all bypass callbacks and validations — they generate SQL directly. If your business logic depends on callbacks, bulk operations silently skip it. This is a common source of data inconsistency bugs.
Q2: Can I conditionally run a callback?
Yes, with :if and :unless: after_create :send_welcome, if: -> { email_confirmed? }. For complex conditions, move the logic inside the callback method itself. Conditional callbacks in declarations become hard to read quickly.
Q3: How do I skip callbacks in tests?
User.skip_callback(:create, :after, :send_welcome_email) in a before block. Remember to re-enable with User.set_callback(...) afterward or use a block form. Better long-term: move the side effect out of the callback so tests don’t need to suppress it.
Q4: What’s the difference between after_save and after_commit?
after_save fires after the SQL write but while still inside the transaction. after_commit fires after the transaction commits. For external side effects (emails, webhooks, cache invalidation), always use after_commit — otherwise the external action can happen for records that get rolled back.
Q5: Should I use callbacks for cache invalidation?
after_commit for cache busting is appropriate — it’s closely tied to the persistence lifecycle and has no external API dependency. Rails.cache.delete(cache_key) in after_commit is a reasonable pattern.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀