Rails Callbacks: What You Should Know

Learn how Rails callbacks work, when they run in the Active Record lifecycle, and when to avoid them for cleaner, safer code.


By Jean Emmanuel Cadet • 23 min read

Rails Callbacks: What You Should Know
Share with friends

Rails callbacks are one of the first Active Record features that feel like magic. You add a one-line before_save, and every email address gets cleaned up, every slug gets generated, and every record behaves the way you expect, no matter where it was saved from. Then, a year later, you open a model with fourteen callbacks and have no idea what actually happens when you call save.

That tension is what this article is about. Ruby on Rails callbacks are a genuinely useful way to hook into the Active Record lifecycle, but they are also one of the most common sources of hidden side effects in Rails applications. You will learn how Active Record callbacks work, the order they run in, how they interact with transactions, how conditions work, and how to test them. More importantly, you will learn how to decide when a callback is the right tool and when a service object, a background job, or plain explicit code is the better choice.


What Are Rails Callbacks?

A callback is a method that Active Record runs automatically at a specific moment in an object's lifecycle: before it is validated, after it is created, before it is destroyed, and so on. You register the callback in the model, and Rails calls it for you.

class User < ApplicationRecord
before_validation :normalize_email

private

def normalize_email
self.email = email.to_s.strip.downcase
end
end

Whenever a user is validated, which happens on every save, create, and update, Rails calls normalize_email first. No controller, form, or job needs to remember to do it. The behavior lives with the data it protects.

Why Rails Provides Callbacks

Active Record follows the Active Record pattern: an object wraps a database row and carries behavior related to that row. Callbacks let a model enforce rules about its own data at the right moment, regardless of which controller, console session, background job, or script triggers the save. That is very much in the spirit of convention over configuration. You declare what should happen, and Rails decides when to call it.

The benefit is consistency. The cost is implicitness: the code that runs is not visible at the place where you call save. Almost every guideline in this article comes from managing that trade-off.

Callbacks are not limited to saving and destroying. Active Record also provides after_initialize, after_find, and after_touch, which fire when an object is built, loaded from the database, or touched. This article focuses on the validation, persistence, and transaction callbacks you will use most often.


The Active Record Lifecycle and Callback Order

Every Active Record object moves through a lifecycle. It is instantiated (new, or loaded from the database), validated, written with an INSERT or UPDATE, and eventually destroyed. Callbacks are hooks placed at each stage, and understanding the Rails Active Record lifecycle is the foundation for using them safely.

One detail matters more than any other: Rails wraps save and destroy in a database transaction. The before_* and after_* callbacks for saving, creating, updating, and destroying all run inside that transaction. Only after_commit and after_rollback run after the transaction has finished.

Creating a Record

When you call Post.create!(title: "Hello"), Rails runs the following steps in order:

before_validation
(validations run)
after_validation
before_save
before_create
INSERT
after_create
after_save
after_commit (only once the transaction has committed)

The around_save and around_create callbacks, which we cover in the next section, wrap the save and the INSERT. Notice that before_save and after_save surround the more specific create callbacks.

Updating a Record

An update follows almost the same path, with update callbacks in place of create callbacks:

before_validation
(validations run)
after_validation
before_save
before_update
UPDATE
after_update
after_save
after_commit

Calling save on a record with no changes still runs the save and update callbacks, even though Rails skips the UPDATE statement. That is why conditions matter, which we will get to shortly.

Destroying a Record

Destruction skips validation and persistence-specific save callbacks entirely:

before_destroy
(dependent: :destroy associations are destroyed here)
DELETE
after_destroy
after_commit

A dependent: :destroy option on an association is itself implemented as a before_destroy callback, which runs at the point where the association is declared. That detail will matter in the cleanup example later.

Validation Callbacks vs Persistence Callbacks

Validation callbacks (before_validation and after_validation) run whenever valid? is called, even if you never save anything. Persistence callbacks (before_save, after_create, and friends) run only when Rails is about to write to or has just written to the database.

This distinction has a practical consequence. A before_validation callback is a good place to normalize data, because it may run many times without harm. It is a bad place for side effects like sending an email, because calling valid? in a form object or a test would trigger them.

Seeing the Order in Action

The fastest way to internalize the order is to watch it. This model logs each step:

class Post < ApplicationRecord
before_validation { log_step("before_validation") }
after_validation { log_step("after_validation") }
before_save { log_step("before_save") }
before_create { log_step("before_create") }
after_create { log_step("after_create") }
after_save { log_step("after_save") }
after_commit { log_step("after_commit") }

private

def log_step(name)
Rails.logger.info("[Post callback] #{name}")
end
end

Running Post.create!(title: "Hello") in the console produces log lines in this order:

[Post callback] before_validation
[Post callback] after_validation
[Post callback] before_save
[Post callback] before_create
[Post callback] after_create
[Post callback] after_save
[Post callback] after_commit

If you later call post.update!(title: "Hello again"), the before_create and after_create lines disappear, and before_update and after_update would appear in their place if you had defined them. This exercise is worth repeating in a scratch app whenever the order surprises you.


Common Active Record Callbacks

Active Record groups its callbacks by the operation they belong to. Here is the full set you will use in everyday Rails work:

  • Validation callbacks: before_validation and after_validation run around the validation step.
  • Save callbacks: before_save, around_save, and after_save run on every write, whether it is a new record or an existing one.
  • Create callbacks: before_create, around_create, and after_create run only when a new row is inserted.
  • Update callbacks: before_update, around_update, and after_update run only when an existing row is updated.
  • Destroy callbacks: before_destroy, around_destroy, and after_destroy run when a record is destroyed.
  • Transaction callbacks: after_commit and after_rollback run once the surrounding database transaction has committed or rolled back.

Create, Update, Save, and Destroy Callbacks

The difference between these groups is about scope. Save callbacks are the broad hook: they run for both creates and updates. Create and update callbacks are narrower and run inside the save callbacks. A good rule of thumb is to use before_save when the behavior should apply to every write, and before_create when it only makes sense for a brand-new record, such as assigning a default or an initial token.

Around callbacks wrap an operation. Your code runs before the yield, the operation runs at the yield, and your code runs again after it returns. If you forget to yield, the wrapped operation never happens.

class Report < ApplicationRecord
around_save :measure_save_time

private

def measure_save_time
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
yield
ensure
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
Rails.logger.info("Saved Report #{id} in #{elapsed.round(3)}s")
end
end

This callback measures how long the wrapped save takes and logs it. Around callbacks are rare in practice, because before_* and after_* cover most needs. Reach for them only when you genuinely need to wrap an operation, such as timing it or guarding it with ensure.

Callback Methods vs Inline Blocks

You can register a callback with a method name, a block, or a lambda. All three work, and the choice affects readability.

class Article < ApplicationRecord
# Callback method: named, private, easy to test and easy to find in a stack trace
before_validation :strip_title

# Inline block: acceptable for a single, obvious line
before_validation { self.summary = summary.to_s.squish }

private

def strip_title
self.title = title.to_s.strip
end
end

Named methods are usually better. They describe intent, they can be private, they can be tested directly, and they show up with a meaningful name when something fails. Use a block only when the body is a single line that needs no explanation.


Practical Rails Callback Examples

The examples below show callbacks in situations where they fit, along with what can go wrong when they are asked to do too much.

Normalizing Data Before Saving

We opened with an email normalization callback. Here it is again, in a more complete form:

class User < ApplicationRecord
before_validation :normalize_email

validates :email, presence: true, uniqueness: true

private

def normalize_email
self.email = email.to_s.strip.downcase
end
end

The callback runs before validation so that the uniqueness check compares the cleaned value. If it ran in before_save, a user could register as [email protected] after [email protected] already exists, and the validation would pass before the value was lowercased. The callback is appropriate because it is small, it touches only this record, and it should always happen.

Rails 7.1 added a built-in alternative for exactly this case:

class User < ApplicationRecord
normalizes :email, with: ->(email) { email.strip.downcase }
end

normalizes also applies the same transformation to finder arguments, so User.find_by(email: " [email protected] ") finds the right record. If your Rails version supports it, prefer it for simple attribute cleanup and keep callbacks for logic that normalizes cannot express.

What goes wrong is scope creep. It is tempting to extend normalize_email to also check whether the domain exists, call an email verification API, or update a related Profile. Each addition makes a simple, safe callback slower and less predictable.

Generating a Value Before Creation

Generating a value that the record needs from its first save is another classic callback use:

class Order < ApplicationRecord
before_validation :assign_reference_code, on: :create

validates :reference_code, presence: true, uniqueness: true

private

def assign_reference_code
self.reference_code ||= "ORD-#{SecureRandom.alphanumeric(8).upcase}"
end
end

The on: :create option limits the callback to new records. Using ||= means an importer or a test can supply its own code without the callback overwriting it. The value is assigned before validation so the presence and uniqueness validations can see it.

Keep in mind that a uniqueness validation is not a guarantee under concurrent requests, so the column also needs a unique database index. For simple random tokens, has_secure_token may be all you need. The version of this callback that goes wrong is the one that computes a sequential number by querying for the current maximum. That adds a hidden query to every create and is a classic source of race conditions.

Cleaning Up Associated Data

Cleanup that belongs to a record's lifecycle is a natural fit for destroy callbacks, along with the dependent option:

class Account < ApplicationRecord
before_destroy :ensure_no_open_invoices

has_many :invitations, dependent: :destroy
has_many :invoices

private

def ensure_no_open_invoices
return unless invoices.where(status: "open").exists?

errors.add(:base, "Cannot delete an account with open invoices")
throw :abort
end
end

Placement matters here. Because dependent: :destroy is itself a before_destroy callback, the guard must be declared before the has_many line, otherwise the invitations are already gone by the time the guard runs and aborts. If you cannot reorder the declarations, pass prepend: true to the callback instead.

Calling throw :abort halts the chain. destroy then returns false, and destroy! raises ActiveRecord::RecordNotDestroyed. Also notice that dependent: :destroy loads each child record and runs its callbacks, which is correct but can be slow for large associations. dependent: :delete_all issues a single SQL statement but skips the children's callbacks.

What goes wrong is putting external cleanup in these callbacks, such as deleting a remote resource before the database delete has succeeded. If the transaction later rolls back, the remote resource is gone and the record is still there.


Conditional Callbacks with :if and :unless

Callbacks run on every matching operation, including saves where nothing relevant changed. Conditions let you narrow them. Both :if and :unless accept a method name, a lambda, or an array of either:

class User < ApplicationRecord
# Symbol: runs only when the email attribute is changing
before_save :normalize_email, if: :email_changed?

# Lambda: an inline condition
before_save :reset_confirmation, if: -> { email_changed? && !new_record? }

# unless: skips the callback for a category of records
after_update_commit :sync_to_crm, unless: :internal_user?

# Arrays: every :if must be true, and no :unless may be true
after_update_commit :send_plan_change_notice,
if: [:subscribed?, :saved_change_to_plan?],
unless: :internal_user?
end

A callback with if: :email_changed? is skipped on saves that do not touch the email, which avoids needless work and unintended side effects. When you pass several conditions, all :if conditions must be true and none of the :unless conditions may be true.

Some APIs here are version-sensitive. Inside before_* callbacks, email_changed? and the more explicit will_save_change_to_email? both report pending changes. Since Rails 5.1, inside after_* callbacks (including after_commit) the pending-change predicates no longer describe the save that just happened. Use saved_change_to_email?, saved_changes, or email_before_last_save there instead, and remember they describe the most recent save.

Validation and transaction callbacks also accept an :on option, for example before_validation :assign_reference_code, on: :create or after_commit :sync_to_crm, on: [:create, :update]. Rails also provides shortcuts like after_create_commit, after_update_commit, after_destroy_commit, and after_save_commit, which read better than the on: form.


How Callbacks Interact with Transactions

Because save and destroy run inside a transaction, an exception raised in any before_* or after_* callback rolls back the whole operation, including the main record. Calling throw :abort in a before_* callback does the same by halting the chain. This is a feature: if an after_save callback updates a related row in the same database, both changes succeed or fail together.

The catch is that the transaction only protects the database. If an after_save callback sends an email, calls an API, or pushes a message to a queue, a later rollback cannot undo it. For that reason, Rails provides transaction callbacks:

  • after_commit runs after the outermost transaction has successfully committed.
  • after_rollback runs after the transaction has been rolled back, which is useful for releasing something a callback reserved outside the database.

When you wrap several saves in your own transaction block, the commit callbacks fire once the outer transaction commits, not after each individual save. For a deeper look at nesting, rollbacks, and ActiveRecord::Rollback, see our practical guide to Rails transactions.


after_commit vs after_save in Rails

This is the most important distinction in the transaction callbacks, and the one that causes the most real-world bugs. Consider this model:

class Order < ApplicationRecord
after_save :enqueue_confirmation

private

def enqueue_confirmation
OrderConfirmationJob.perform_later(id)
end
end

after_save runs inside the transaction, before the data is committed. That creates two problems. First, a fast background worker can pick up the job before the commit and call Order.find(id), raising ActiveRecord::RecordNotFound for a new order or reading stale data for an updated one. Second, if the transaction rolls back after the callback has run, the job still exists for a record that was never saved, and the customer could be notified about an order that does not exist.

Moving the side effect to after_commit addresses both problems:

class Order < ApplicationRecord
after_create_commit :enqueue_confirmation

private

def enqueue_confirmation
OrderConfirmationJob.perform_later(id)
end
end

Now the job is enqueued only after the order is durably saved, so the worker can always find it. As a rule, use after_save and after_create for database changes that should succeed or fail together with the record. Use after_commit for side effects outside the database.

Be precise about what after_commit does not do, because it does not make an external side effect reliable on its own:

  • A crash can still lose the work. If the process dies after the commit but before the job is enqueued, the notification is lost and nothing retries it. For events that must not be lost, consider an outbox table or a queue that enqueues within the same database transaction.
  • Errors surface after the data is saved. An exception raised in an after_commit callback reaches the caller even though the record is already committed, so a raised error does not mean nothing was saved.
  • Changes there start a new transaction. Calling update inside after_commit opens a fresh transaction and runs the callbacks again.
  • Newer Rails can help, with care. Rails 7.2 added config.active_job.enqueue_after_transaction_commit, which can defer job enqueueing until the surrounding transaction commits. Check the documentation for your Rails version and your configuration before relying on it.

Callback Chains, Ordering, and Inheritance

Callbacks of the same type form a chain, and they run in the order they are defined. A before_* callback can stop the chain with throw :abort. In modern Rails, returning false no longer halts anything. When the chain is halted, save returns false, and save! raises ActiveRecord::RecordNotSaved.

class Subscription < ApplicationRecord
before_save :normalize_plan_name
before_save :apply_default_trial_length
end

If apply_default_trial_length depends on the value normalize_plan_name produces, you have created an invisible ordering dependency. Swapping two lines changes behavior, and nothing in the code says so. Treat that as a design smell rather than something to document.

Ordering also varies by callback type. Historically, after_commit callbacks ran in the reverse of the order they were defined. Rails 7.1 introduced config.active_record.run_after_transaction_callbacks_in_order_defined, which new apps enable through load_defaults 7.1, but an application that was upgraded may still use the old order. Do not depend on the relative order of commit callbacks without checking.

Callbacks are also inherited. A callback defined on a parent class runs for every subclass, and it runs before the subclass's own callbacks:

class Vehicle < ApplicationRecord
before_save :normalize_plate
end

class Truck < Vehicle
before_save :set_default_capacity
end

When you save a Truck, normalize_plate runs first, then set_default_capacity. If a subclass needs to opt out, skip_callback :save, :before, :normalize_plate removes the inherited callback for that class. Be careful with callbacks on ApplicationRecord itself, because they apply to every model in your application.

Callbacks that live in concerns follow the same rules: they run in the position where the concern is included. Moving callbacks into a concern tidies the model file but does not make the behavior less hidden, so read when and how to use Rails concerns before reaching for that as a fix.


Skipping Callbacks Carefully

Many Active Record methods bypass callbacks, validations, or both. Knowing which ones is part of understanding your own code:

  • update_column, update_columns, update_all, delete, delete_all, insert_all, upsert_all, and increment! or decrement! write directly to the database and skip callbacks.
  • save(validate: false) skips validations and validation callbacks but still runs the save callbacks.
  • update_attribute skips validations but still runs the save callbacks.

The danger is easy to miss:

# Skips normalize_email, all validations, and every after_commit side effect
User.where(plan: "legacy").update_all(plan: "standard")

For a backfill or a large data fix, that may be exactly what you want. Problems appear when the skipped callback was maintaining an invariant, such as a counter, a normalized value, or a cache. Developers should understand the callback behavior of any method they call, especially when bypassing the normal lifecycle on purpose.

Skipping at the class level, with skip_callback or by toggling flags to disable behavior temporarily, is riskier still. It changes shared state and is easy to forget. If you find yourself skipping a callback regularly, that is a strong signal the logic does not belong in a callback.


When to Use Rails Callbacks

Callbacks work best for small pieces of behavior that are tightly tied to a model's own lifecycle:

  • Data normalization: trimming, casing, and formatting attributes before validation.
  • Defaults and generated values: reference codes, tokens, and initial state.
  • Model-level invariants: keeping derived attributes in sync with the data they come from.
  • Lifecycle cleanup: removing or guarding dependent records when a record is destroyed.
  • Internal behavior that must always happen whenever the model is persisted.

Here is an example of an invariant that a callback maintains well:

class Article < ApplicationRecord
enum :status, { draft: 0, published: 1 }

before_save :stamp_published_at, if: :will_save_change_to_status?

private

def stamp_published_at
self.published_at ||= Time.current if published?
end
end

This callback runs only when the status is changing, touches only the record's own attributes, and does no I/O. It also encodes a rule that should hold no matter where the article is published from: a controller, an admin tool, or a console session.

Three questions make a useful test. Would it be a bug if this did not happen on every save? Does it only touch this record, or its direct children, in the same database? Is it fast and predictable? If all three answers are yes, a callback is probably a good fit.


When Not to Use Rails Callbacks

Callbacks become a liability when they stop describing the model and start orchestrating the application. Warning signs include:

  • Complex business workflows: placing an order involves payment, inventory, and notifications. That is a process, not a lifecycle event.
  • Emails as hidden side effects: a save call that also sends an email surprises everyone who reads or tests it.
  • External API calls: these add latency, can fail, and cannot be rolled back.
  • Large amounts of business logic: a model should not need scrolling to understand its callbacks.
  • Triggering unrelated models: callbacks that reach into other aggregates create hidden coupling.
  • Expensive synchronous work: every save pays the cost, including saves from tests, seeds, and imports.
  • Difficult-to-follow chains: if you need a diagram to explain what save does, the design has outgrown callbacks.

Here is what that looks like in practice:

class Order < ApplicationRecord
after_create :reserve_inventory
after_create :charge_payment_method
after_create :send_confirmation_email
after_create :notify_warehouse
after_create :update_customer_lifetime_value
end

Creating an Order in a test, a seed file, or the console now charges a card and emails a customer. The order of the callbacks matters but is not explicit, a failure in the fourth one is hard to reason about, and every new feature adds another line to the chain.

Better Alternatives: Service Objects, Jobs, and Explicit Workflows

A service object makes the workflow visible in one place. Our guide on Rails service objects covers the pattern in depth, but a minimal version looks like this:

module Orders
class Place
def initialize(user:, params:)
@user = user
@params = params
end

def call
order = Order.transaction do
order = @user.orders.create!(@params)
Inventory.reserve!(order)
order
end

OrderConfirmationJob.perform_later(order.id)
WarehouseNotificationJob.perform_later(order.id)
order
end
end
end

The controller calls it explicitly:

def create
order = Orders::Place.new(user: current_user, params: order_params).call
redirect_to order, notice: "Order placed."
rescue ActiveRecord::RecordInvalid => error
@order = error.record
render :new, status: :unprocessable_entity
end

Every step is now visible and testable on its own. The database work happens inside a transaction, and the jobs are enqueued after the block finishes, so they only run for a committed order. Slow or unreliable work such as emails and API calls moves into background jobs, where it can retry independently. The same reliability caveats from the after_commit section still apply to those jobs.

Service objects placed in app/services are autoloaded, so file names must match constant names. If you ever hit a NameError on a class that clearly exists, it helps to understand how Rails autoloading works with Zeitwerk.

Callbacks are not the enemy here. Plain, explicit application code is simply easier to read, test, and change when the behavior involves more than one record or more than one system.


Common Callback Mistakes: Performance and Maintainability

Most callback problems come from a small set of recurring mistakes. They share a theme: the cost or consequence is not visible where the code is called.

Hidden Queries and Extra Writes

class Post < ApplicationRecord
belongs_to :author

after_save :refresh_author_post_count

private

def refresh_author_post_count
author.update!(posts_count: author.posts.count)
end
end

Every save of a post now runs a COUNT query and an UPDATE, and it runs the author's own callbacks too. This happens on saves that never changed anything relevant. A built-in counter cache, enabled with belongs_to :author, counter_cache: true, does the same job with an atomic increment and no extra callback.

Recursive and Circular Callbacks

class Article < ApplicationRecord
after_save :generate_slug

private

def generate_slug
update(slug: title.parameterize) # triggers another save, which runs the callbacks again
end
end

The update call inside after_save triggers another save, which runs after_save again, and so on until Ruby raises SystemStackError. The fix is to assign the value in a before_* callback, where it becomes part of the save that is already happening. If you truly need the record's id first, update_column avoids re-running callbacks, but you have then skipped validations and callbacks on purpose, so document why. Circular behavior can also span models, such as a post that updates its author while the author's callback updates the post.

Expensive Work and External Requests

A synchronous API call inside a callback makes every save as slow as the remote service. It also keeps the database transaction open for the duration of the request, which holds a connection and can hold locks. If the service is down, saving a record fails for a reason unrelated to your data. Move that work into a job triggered after the commit.

Unexpected Behavior During Bulk Operations

Callbacks run only when you go through the normal lifecycle, and bulk methods behave differently. update_all and insert_all skip them, while User.find_each(&:save!) runs them for every row, which can mean thousands of emails or webhooks during what was meant to be a quiet data cleanup. Seed files, console sessions, importers, and test factories all trigger callbacks too. Always check what a model's callbacks will do before you run a bulk operation against it.

Chains That Are Hard to Follow

To understand what save does, you may need to read the model, its concerns, its parent classes, and any gems that add callbacks. When you are lost, the console can tell you what is registered:

Order._create_callbacks.map { |callback| [callback.kind, callback.filter] }
Order._commit_callbacks.map { |callback| [callback.kind, callback.filter] }

If you need this trick often, that is itself a sign the model has too many callbacks.


Rails Callback Best Practices

These Rails callback best practices keep callbacks useful and predictable:

  • Keep each callback small and single-purpose, with a name that states what it does.
  • Prefer callback methods over blocks, and make them private.
  • Use before_* callbacks to change the record's own attributes, and after_* callbacks to react to something that already happened.
  • Use after_commit for side effects outside the database, and remember it is not a guarantee of delivery.
  • Add conditions so callbacks run only when they are relevant.
  • Make callbacks idempotent where you can, since they may run more than once for the same record.
  • Do not depend on the order of unrelated callbacks. If one needs the result of another, combine them or make the dependency explicit.
  • Avoid network calls and cross-model writes inside callbacks.
  • Treat skip_callback and frequent bypassing as a smell.
  • Prefer built-in features when they fit: normalizes, counter_cache, has_secure_token, enum, and dependent.

How to Test Rails Callbacks

Test the behavior a callback produces, not the fact that it was registered. A test that asserts before_save was declared breaks when you refactor and proves nothing about the outcome. Instead, trigger the lifecycle event and check the result. Here are examples using Minitest, which Rails generates by default:

require "test_helper"

class UserTest < ActiveSupport::TestCase
test "normalizes the email before validation" do
user = User.new(email: " [email protected] ")
user.valid?

assert_equal "[email protected]", user.email
end
end

For after_commit side effects, Rails tests wrap each test in a transaction, and since Rails 5 commit callbacks still fire inside it. That means you can assert on enqueued jobs directly:

class OrderTest < ActiveSupport::TestCase
include ActiveJob::TestHelper

test "enqueues a confirmation job after the order is created" do
assert_enqueued_jobs 1, only: OrderConfirmationJob do
Order.create!(user: users(:ada), total_cents: 1_500)
end
end

test "does not enqueue a job when an update leaves the order unchanged" do
order = orders(:pending)

assert_no_enqueued_jobs only: OrderConfirmationJob do
order.update!(note: "Leave at the door")
end
end
end

Always test the negative case for conditional callbacks, as in the second test: it confirms the condition really prevents the callback from running. Guards that abort the chain deserve a test as well:

test "cannot destroy an account with open invoices" do
account = accounts(:with_open_invoice)

assert_not account.destroy
assert_includes account.errors[:base], "Cannot delete an account with open invoices"
assert Account.exists?(account.id)
end

If you use RSpec, the same ideas apply, with matchers like have_enqueued_job. As a side benefit, moving heavy logic out of callbacks into service objects makes tests faster and simpler, because you no longer have to save a record just to exercise a rule.


Conclusion

Rails callbacks are useful when behavior is tightly connected to an Active Record lifecycle, such as normalizing attributes, generating a value, maintaining a small invariant, or cleaning up dependent records. Used that way, they keep your models consistent no matter where a save comes from. They should stay focused and predictable, and they should not become a hidden container for complex business logic. When a workflow spans several records, calls other systems, or needs to be easy to read and test, make it explicit with a service object and background jobs.

Jean Emmanuel Cadet
Written by Jean Emmanuel Cadet
Jean Emmanuel is a Full-Stack Software Engineer specializing in Ruby on Rails and the modern Rails ecosystem. He builds scalable, maintainable web applications using Rails, Hotwire (Turbo & Stimulus), PostgreSQL, and SQLite, with a focus on fast, dynamic user experiences. Through CodeCurious, he shares practical lessons, development insights, and real-world solutions for modern developers.

Code. Learn. Grow.

A friendly newsletter sharing dev tips, lessons, and wins from my journey.