Active Record callbacks offer powerful hooks into an object's lifecycle. They also represent one of the most abused features in Rails applications. Understanding when callbacks help versus when they create hidden complexity separates maintainable codebases from debugging nightmares.
The Callback Lifecycle
Rails 8 provides callbacks at every stage of an object's life. Before diving into patterns, understanding the execution order prevents surprises:
# app/models/order.rb
class Order < ApplicationRecord
before_validation :normalize_email
after_validation :log_validation_errors
before_save :calculate_totals
around_save :track_save_duration
after_save :schedule_confirmation_email
before_create :generate_reference_number
after_create :notify_warehouse
before_update :track_changes
after_update :sync_inventory
before_destroy :cancel_pending_shipments
after_destroy :archive_to_data_warehouse
after_commit :broadcast_status_update, on: [:create, :update]
after_rollback :log_transaction_failure
end
The execution flows through validation callbacks first, then save callbacks wrap create or update callbacks depending on whether the record is new. Commit callbacks fire only after the database transaction completes successfully.
Pattern 1: Data Normalization
Callbacks excel at ensuring data consistency before it hits the database. Normalization belongs in before_validation so validations run against clean data:
# app/models/user.rb
class User < ApplicationRecord
before_validation :normalize_contact_info
validates :email, presence: true, uniqueness: true
validates :phone, format: { with: /\A\d{10}\z/ }, allow_blank: true
private
def normalize_contact_info
self.email = email.to_s.downcase.strip
self.phone = phone.to_s.gsub(/\D/, "") if phone.present?
self.name = name.to_s.strip.titleize if name.present?
end
end
This pattern works well because normalization is intrinsic to the model, runs on every save path, and has no external dependencies. The callback remains fast and predictable.
Pattern 2: Computed Attributes
When one attribute derives from others, before_save keeps calculations synchronized:
# app/models/invoice.rb
class Invoice < ApplicationRecord
has_many :line_items, dependent: :destroy
before_save :calculate_totals
accepts_nested_attributes_for :line_items, allow_destroy: true
private
def calculate_totals
self.subtotal = line_items.reject(&:marked_for_destruction?).sum(&:amount)
self.tax = subtotal * tax_rate
self.total = subtotal + tax
end
end
# app/models/line_item.rb
class LineItem < ApplicationRecord
belongs_to :invoice, touch: true
before_save :calculate_amount
private
def calculate_amount
self.amount = quantity * unit_price
end
end
The touch: true option on the association triggers the parent's callbacks when line items change, keeping totals current.
Pattern 3: Safe Broadcasting with after_commit
External system interactions belong in after_commit, never in after_save. The distinction matters because after_save runs inside the transaction:
# app/models/article.rb
class Article < ApplicationRecord
belongs_to :author, class_name: "User"
# WRONG: Runs before transaction commits
# after_save :broadcast_update
# RIGHT: Runs after transaction succeeds
after_commit :broadcast_update, on: [:create, :update]
after_commit :notify_subscribers, on: :create
private
def broadcast_update
broadcast_replace_to "articles",
target: "article_#{id}",
partial: "articles/article",
locals: { article: self }
end
def notify_subscribers
NotifySubscribersJob.perform_later(id)
end
end
Using after_save for broadcasts creates race conditions. The WebSocket message arrives before the transaction commits, so clients query the database and find stale data. The after_commit callback guarantees data visibility.
The Pitfalls: When Callbacks Backfire
Callbacks become problematic when they create hidden side effects, especially those involving external systems or conditional logic:
# app/models/subscription.rb
# PROBLEMATIC: Hidden complexity, hard to test, unexpected side effects
class Subscription < ApplicationRecord
after_create :charge_payment
after_create :send_welcome_email
after_create :provision_account
after_create :notify_sales_team
after_create :sync_to_crm
after_update :handle_plan_change
after_update :adjust_billing
private
def charge_payment
return if trial?
PaymentGateway.charge(user, plan.price) # External API in callback!
end
def handle_plan_change
return unless saved_change_to_plan_id?
# 50 lines of conditional logic...
end
end
This model demonstrates several anti-patterns: external API calls in callbacks, complex conditional logic, and too many responsibilities. Testing becomes painful because creating any subscription triggers the entire callback chain.
The Solution: Extract to Service Objects
Complex workflows belong in explicit service objects. Keep callbacks for data integrity only:
# app/models/subscription.rb
class Subscription < ApplicationRecord
belongs_to :user
belongs_to :plan
before_save :set_period_dates
scope :active, -> { where(status: "active") }
scope :expiring_soon, -> { where(ends_at: ..3.days.from_now) }
private
def set_period_dates
return if starts_at.present?
self.starts_at = Time.current
self.ends_at = starts_at + plan.duration
end
end
# app/services/subscriptions/create_service.rb
module Subscriptions
class CreateService
def initialize(user:, plan:, payment_method:)
@user = user
@plan = plan
@payment_method = payment_method
end
def call
ActiveRecord::Base.transaction do
charge_payment!
create_subscription!
end
send_welcome_email
provision_account
notify_sales_team
sync_to_crm
Result.success(@subscription)
rescue PaymentError => e
Result.failure(e.message)
end
private
def charge_payment!
return if @plan.trial?
PaymentGateway.charge(@user, @plan.price, @payment_method)
end
def create_subscription!
@subscription = Subscription.create!(
user: @user,
plan: @plan,
status: @plan.trial? ? "trial" : "active"
)
end
def send_welcome_email
SubscriptionMailer.welcome(@subscription).deliver_later
end
# Additional methods...
end
end
The service object makes the workflow explicit and testable. The model retains only intrinsic data calculations.
Conditional Callbacks Done Right
When conditional callbacks are necessary, keep them focused and use the built-in options:
# app/models/document.rb
class Document < ApplicationRecord
belongs_to :project
before_save :generate_slug, if: :title_changed?
after_commit :reindex_search, on: [:create, :update], if: :searchable?
after_commit :clear_project_cache, on: [:create, :update, :destroy]
enum :status, { draft: 0, published: 1, archived: 2 }
private
def generate_slug
base_slug = title.parameterize
self.slug = ensure_unique_slug(base_slug)
end
def ensure_unique_slug(base)
slug = base
counter = 1
while Document.where(project: project, slug: slug).where.not(id: id).exists?
slug = "#{base}-#{counter}"
counter += 1
end
slug
end
def searchable?
published? && project.searchable?
end
def reindex_search
SearchIndexJob.perform_later("Document", id)
end
def clear_project_cache
Rails.cache.delete("project_#{project_id}_documents")
end
end
The :if and :on options keep callbacks targeted. Each callback handles one concern with clear conditions.
Skipping Callbacks Safely
Bulk operations and data migrations often need to bypass callbacks. Rails 8 provides several approaches:
# Skip all callbacks with update_column/update_columns
user.update_column(:login_count, user.login_count + 1)
# Skip callbacks with update_all
User.where(last_active_at: ...1.year.ago).update_all(status: "inactive")
# Skip in specific contexts with attribute
# app/models/article.rb
class Article < ApplicationRecord
attr_accessor :skip_search_indexing
after_commit :reindex_search, unless: :skip_search_indexing
end
# Usage in rake task or migration
article.skip_search_indexing = true
article.save!
Guidelines for Callback Decisions
Use callbacks for: normalizing data, calculating derived attributes, maintaining timestamps, clearing caches, and broadcasting Turbo updates.
Use service objects for: external API calls, sending emails, complex multi-step workflows, anything with conditional business logic, and operations that might fail independently of the save.
The rule of thumb: if explaining what happens when you call .save requires more than one sentence, extract the complexity into an explicit service object. Callbacks should be boring, predictable, and fast.