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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

The Real Cost of Hiring a Backend Developer in Barcelona Once You Add Employer Contributions

The salary on the offer letter is only part of what a Barcelona backend hire actually costs. Most founders find out the rest after they've already committed.

Read more

When Staging Access Requires Manager Approval

Ever waited hours just to test a feature on staging? When every access request has to go through a manager, productivity takes a hit.

Read more

Freelancers vs Agencies vs In-House Teams

“Should we hire freelancers, an agency, or build an in-house team?” The answer isn’t about which is best—it’s about what your situation actually needs.

Read more

How to Decide What Skills Will Actually Get You More Work

Not every skill you learn brings more projects or higher pay. Here’s how to pick the ones that truly make you marketable.

Read more