Rails Callbacks — The Rules I Follow to Not Regret Them Later
by Eric Hanson, Backend Developer at Clean Systems Consulting
Why callbacks accumulate and why that's dangerous
Callbacks feel like the right tool in the moment. You need to send a welcome email when a user registers — after_create :send_welcome_email. You need to invalidate a cache when a record updates — after_save :clear_cache. You need to sync a slug when a title changes — before_save :generate_slug. Each one feels local and contained. Together they produce a model where you can't call save without understanding the full callback chain and its side effects.
The failure mode is specific: a developer writes a test, creates a record in the test setup, and unknowingly triggers four callbacks they didn't account for — one fires a network request, one enqueues a job, one updates a counter on another model, one logs to an audit table. The test becomes slow, flaky, and dependent on mocking infrastructure that isn't relevant to what it's testing. In production, a data migration creates records and inadvertently sends emails to real users.
These aren't hypothetical. They're the specific bugs that make teams distrust callbacks across the board and strip them out wholesale — which overcorrects in the other direction. The goal is a ruleset that preserves the legitimate uses.
Rule 1: callbacks only touch the model's own data
The most important rule. A callback is appropriate when it reads or writes the model's own columns. It is not appropriate when it reaches into associated models, calls external services, enqueues jobs, or sends notifications.
# Appropriate — modifies own data
before_validation :normalize_email
before_save :generate_slug
before_save :set_default_status
# Not appropriate — reaches outside own state
after_create :send_welcome_email # external: mailer
after_save :sync_to_crm # external: API call
after_destroy :decrement_owner_count # reaches into associated model
after_commit :enqueue_processing_job # external: job queue
The line is the model's own database columns. Anything beyond that belongs in a service object that orchestrates the full operation explicitly.
The test you can apply mechanically: can this callback run correctly in a database seed script that has no access to external services? If yes, it may belong here. If no, it belongs in a service object.
Rule 2: after_commit over after_save for anything irreversible
after_save fires before the transaction commits. If you enqueue a job in after_save and the transaction rolls back afterward, the job runs against data that no longer exists. This produces some of the most confusing production bugs — a job fires, queries the database, finds nothing, raises an error, and the error is logged without context because the save that triggered it was rolled back.
after_commit fires after the transaction commits successfully. For any side effect that's irreversible or external — sending mail, enqueuing jobs, updating external systems — after_commit is correct and after_save is a latent bug:
# Latent bug — job may run against rolled-back data
after_save :enqueue_processing_job
# Correct — job runs only after successful commit
after_commit :enqueue_processing_job, on: :create
The on: option scopes the callback to specific lifecycle events. on: :create fires only on insert. on: :update fires only on update. on: [:create, :update] fires on both but not destroy. Always specify on: for after_commit — the default behavior (all events) is almost never what you want.
Rule 3: conditional callbacks are a smell
before_save :send_confirmation, if: :email_changed?
after_create :track_registration, if: -> { source == "organic" }
before_validation :set_admin_defaults, if: :admin?
Each individual conditional seems reasonable. Three together means reading a record's state correctly requires understanding which conditions were met during each lifecycle phase. Five together and you have a state machine implemented in callbacks without any of the clarity a state machine library would provide.
Conditional callbacks are the symptom of one model serving multiple contexts — registration flow, admin creation, API import, data migration. Each context added its own condition. The right fix is separate contexts with separate code paths — form objects for different creation flows, service objects for different update operations — rather than a single model that branches internally.
When you find yourself writing if: :some_condition? on a callback, ask whether the condition is about the model's own invariants (acceptable) or about the context in which the operation is being called (should be in a service object).
Rule 4: never rescue inside a callback
after_commit :sync_to_analytics do
Analytics.track(self)
rescue => e
Rails.logger.error("Analytics sync failed: #{e.message}")
end
This looks defensive. It's actually dangerous. If Analytics.track raises and you rescue silently, the callback chain continues, the save completes, and the analytics sync failure is a log line that nobody sees unless they're watching logs in real time. The model's state is inconsistent with the analytics system, and nothing in the calling code knows.
If the external call can fail acceptably — it's fire-and-forget with no correctness dependency — use deliver_later or enqueue a job and let the job's retry mechanism handle failures. If the failure matters, let the exception propagate and handle it at the boundary (controller, job, service object) where there's enough context to respond correctly.
Never rescue inside a callback unless you're logging and re-raising. Swallowing exceptions in callbacks is how silent data inconsistencies build up undetected.
Rule 5: touch with care
touch updates updated_at on a record without triggering full save callbacks. It's commonly used to invalidate caches when an association changes:
class Comment < ApplicationRecord
belongs_to :post, touch: true
end
Saving any Comment now updates Post#updated_at. This sounds reasonable until your codebase has three levels of touch: true associations, and saving a Comment triggers a cascade that updates Post, then Author, then Blog, and every cache keyed on any of those records expires simultaneously. Under load, this produces a cache stampede.
touch: true is appropriate for one level of association where the cache invalidation semantics are deliberate and understood. For deeper cascades, use explicit cache key design — include the association's updated_at in the parent's cache key — rather than relying on touch propagation.
Also: touch bypasses before_save and after_save callbacks (it calls update_columns directly) but does fire after_touch and after_commit. If you have callbacks that assume they fire on every updated_at change, touch will break that assumption silently.
The callbacks worth keeping
Given these rules, the set of callbacks that survive scrutiny is small:
before_validation: Normalizing input before validation runs. Downcasing email, stripping whitespace, formatting phone numbers. Operates only on own attributes. Idempotent.
before_save: Deriving stored values from other attributes. Generating slugs from titles, computing full_name from components, setting default timestamps. Operates only on own attributes.
before_destroy: Checking preconditions before deletion. Raising if the record has dependencies that prevent safe deletion. Operates only on own state.
after_commit (scoped): Enqueuing a background job for post-creation processing, where the job must run only after the record is durably committed. Scoped to on: :create. The job does the work; the callback only enqueues.
Everything else — sending emails directly, calling external APIs, updating counters on associated models, doing work that could fail — belongs in a service object.
When you inherit a model full of callbacks
The practical problem: every Rails codebase has models with callbacks that violate these rules. Removing them correctly is a multi-step operation:
First, audit what each callback does. List every callback in the model and classify it: own-data modification, association side effect, external call, or job enqueue.
Second, add characterization tests before touching anything. Tests that verify the current behavior, even if that behavior is wrong. These are the safety net for the extraction.
Third, extract external calls and association side effects to a service object, calling that service object explicitly from every place that previously called save with the assumption those callbacks would fire.
Fourth, remove the callbacks one at a time, running the characterization tests after each removal.
The step most teams skip is the third one — updating every call site. Removing a callback that something in production depends on, without finding and updating the call sites that depended on it, is how you ship a regression. Find every user.save, user.update!, and User.create! in the codebase and verify they either don't need the side effect or now call the service object explicitly.
Callbacks are not inherently bad. They're a sharp tool with a specific safe operating range. Own-data modification before save, precondition checking before destroy, job enqueuing after commit — these are the uses that pay off. Everything outside that range should be in a service object where it's visible, testable, and under explicit control.