Rails 8 Callbacks: Patterns and Pitfalls

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.

10 claps
← Back to Blog