Hedronite Lesson · Polyglot-Dev / Rails · Thu 2026-09-17

Active Record Callbacks

A callback runs at a named moment in a record's life cycle so side effects stay next to the model.

Lesson Class: Duha (Rails Guides track)
Focus: life cycle · before_validation · after_create · after_commit · throw :abort
Code Blocks: clean blocks, explanation in prose
Done-criteria: register callback · order · after_commit · skip + throw :abort
Grounding: rails-guides/active_record_callbacks.html · VERSION v8.1.3.1 · Flanagan not cited
The hook
before_* and after_* methods run at named life-cycle moments.
The commit
after_commit sees a durable row; after_save may still roll back.
The halt
throw :abort stops the chain; raises surprise callers of save/create.
A callback runs at a named moment in a record's life cycle so side effects stay next to the model.

<!-- hal:authoritative:yaml -->

A callback is a method Rails invokes at a named moment in a record’s life cycle (before or after validate, save, create, update, or destroy) so side effects stay next to the model instead of scattered through controllers.

§I — Frame

Duha session 05 for the Rails track. Dual-fire beside Rust. The spine remains the official Rails Guides snapshot on disk: rails-guides/ at v8.1.3.1. Today’s file is active_record_callbacks.html.

Session 04 gated writes with validations. Callbacks are the next layer: when the object is about to change or has just changed, run a method. Keep validations for “may this persist?” Keep callbacks for “what else must happen around that persistence?”

By the end, register a callback (method, block, or proc), know the create/update/destroy order, prefer after_commit for work that needs a durable row, know which APIs skip callbacks, and halt a chain with throw :abort instead of raising for control flow.

Associations depth and the query interface stay later syllabus rows.

§II — Life cycle, then registration

Objects are initialized, validated, created, updated, destroyed, and loaded. Callbacks hook those moments. The smallest form:

class BirthdayCake < ApplicationRecord
  after_create -> { Rails.logger.info("Congratulations, the callback has run!") }
end

BirthdayCake.create runs the lambda after the insert succeeds in the normal save path.

Four registration styles appear in the guide:

  1. Macro + private method — before_validation :ensure_username_has_value then a private def.
  2. Block — short one-liners on the macro itself.
  3. Proc / lambda — before_validation ->(user) { … }.
  4. Callback object — a class or module with a matching class method (self.before_validation(record)).

Declare callback methods private. Do not call update / save from inside a callback to “fix” state; assign attributes (self.attr = …) in a before-* hook instead. Nested saves breed surprise recursion and commit-order bugs.

Scope a callback to a context with :on:

before_validation :ensure_username_has_value, on: :create
after_validation :set_location, on: [:create, :update]

§III — Order on create, update, destroy

Creating runs, in order:

before_validation → after_validation → before_save → around_save → before_create → around_create → after_create → after_save → after_commit / after_rollback

Updating swaps create for update in the middle, but keep this rule: save callbacks always wrap the more specific create/update callbacks. after_save runs after after_create or after_update, regardless of declaration order in the class body.

Destroying (destroy, not delete) runs:

before_destroy → around_destroy → after_destroy → after_commit / after_rollback

around_* callbacks yield to the wrapped operation:

around_save :log_saving

def log_saving
  Rails.logger.info("Saving…")
  yield
  Rails.logger.info("Saved.")
end

Validation callbacks also fire when you call valid? / invalid? directly, not merely during save.

§IV — after_commit vs after_save

after_save / after_create / after_update run inside the same database transaction as the write. If a later step rolls the transaction back, those callbacks already ran. Side effects that must see a committed row (enqueue mail, ping an external API, touch a cache that other processes read) belong on after_commit (or the commit variants scoped to create/update/destroy).

after_rollback is the sibling for cleanup when the transaction aborts.

Rule of thumb from this page’s shape: use after_* save/create/update for in-transaction model bookkeeping; use after_commit when the outside world must only hear about durable rows.

§V — Skipping and halting

Same spirit as validations: some APIs bypass the callback chain. The guide’s skip list includes delete / delete_all / delete_by, update_column / update_columns / update_all, insert / insert_all, upsert / upsert_all, counter helpers, and related forms. destroy runs destroy callbacks; delete does not. If you call a skipper, you own the missing hooks.

To stop the chain without treating it as an unexpected exception, throw :abort from a before-* callback:

before_destroy :check_admin_count

def check_admin_count
  if admin? && User.where(role: "admin").count == 1
    throw :abort
  end
end

Raising inside a callback rolls back the transaction and re-raises. That surprises callers who expect save / create to return false on soft refusal. Prefer throw :abort for deliberate halts; reserve raises for real errors.

Conditional callbacks use :if / :unless the same way validations do. Keep the predicate cheap and free of further persistence.

§VI — One complete proof

  1. Add before_validation that fills a blank attribute, and after_create (or after_commit on: :create) that logs or enqueues a no-op job.
  2. Create a record and confirm the before-hook ran before validations and the after-hook ran after insert.
  3. Name one API that skips callbacks (delete, update_columns, …) and say when you would refuse it for ordinary form flow.
  4. Say aloud: after_save can run inside a transaction that later rolls back; after_commit waits for durability.

When those four hold, this Guides page’s selected depth is done.

§VII — Closing

Callbacks attach behavior to the object life cycle. Register them privately, assign in before-* hooks, and keep nested saves out. Learn the create/update/destroy order and the save-wraps-create/update rule. Put external side effects on after_commit. Know the skip list. Halt with throw :abort. Session 04’s validations still decide whether a save should proceed; callbacks decide what else happens around that decision. Associations are the next unmarked row.

Done-criteria: register a life-cycle callback, state create vs update order (including save wrapping), prefer after_commit for durable side effects, and name a skip method plus throw :abort.

Related