DEV Community

Alex Aslam
Alex Aslam

Posted on

Reducing ActiveRecord Callback Chains by 80% Using POROs

"Our callback hell was so bad, even Rails creators would’ve cried."

ActiveRecord callbacks start innocent—a quick before_save here, an after_commit there. But before you know it, your User model has 14 nested callbacks, your test suite takes 12 minutes to boot, and debugging feels like defusing a bomb.

We cut our callback madness by 80%—without losing functionality—by embracing Plain Old Ruby Objects (POROs). Here’s how we did it (and why you should too).


1. The Callback Spiral of Doom

What Went Wrong?

Our Invoice model once looked like this:

class Invoice < ApplicationRecord
  before_validation :generate_number
  after_create :send_welcome_email
  after_save :update_customer_stats
  after_commit :notify_accounting_team, on: :create
  after_commit :sync_with_erp, if: -> { status_changed? }
  # ...and 9 more
end
Enter fullscreen mode Exit fullscreen mode

The Problems:

  • Untestable (need to run full ActiveRecord lifecycle)
  • Brittle (change one callback, break three others)
  • Slow (every Invoice.save triggered 12+ side effects)

2. The PORO Rescue Plan

We replaced callbacks with single-responsibility service objects.

Before (Callback Madness)

# app/models/invoice.rb
after_create :send_welcome_email

def send_welcome_email
  InvoiceMailer.created(self).deliver_later
end
Enter fullscreen mode Exit fullscreen mode

After (PORO Clarity)

# app/services/invoice_creator.rb
class InvoiceCreator
  def initialize(params)
    @invoice = Invoice.new(params)
  end

  def call
    Invoice.transaction do
      @invoice.save!
      InvoiceMailer.created(@invoice).deliver_later # Explicit > implicit
      AccountingSync.new(@invoice).perform
    end
  end
end

# Usage:
InvoiceCreator.new(params).call
Enter fullscreen mode Exit fullscreen mode

Key Benefits:
Explicit flow (no hidden side effects)
Easier testing (mock dependencies individually)
Faster tests (no ActiveRecord callbacks = no DB bloat)


3. Real-World Refactoring

Case 1: Payment Processing

Before (Nested Callbacks):

class Payment < ApplicationRecord
  after_create :charge_credit_card
  after_commit :update_invoice_status

  private

  def charge_credit_card
    PaymentGateway.charge(amount) # What if this fails?
    update!(status: :processed) # Another callback trigger?
  end

  def update_invoice_status
    invoice.mark_as_paid! # Yet more callbacks...
  end
end
Enter fullscreen mode Exit fullscreen mode

After (PORO Orchestration):

class PaymentProcessor
  def initialize(payment)
    @payment = payment
  end

  def call
    Payment.transaction do
      PaymentGateway.charge(@payment.amount)
      @payment.update!(status: :processed)
      InvoiceMarker.new(@payment.invoice).mark_as_paid!
    end
  rescue PaymentGateway::Error => e
    @payment.fail!(e.message) # Controlled error handling
  end
end
Enter fullscreen mode Exit fullscreen mode

Result:

  • 50% fewer bugs in payment flows
  • 3x faster tests (no cascading callbacks)

4. When to Keep Callbacks

Not all callbacks are evil. Keep them for:
Simple persistence logic (e.g., before_save :normalize_name)
Non-critical path operations (e.g., after_create :log_creation)

Golden Rule:

If it talks to external services or other models, it belongs in a PORO.


5. The Hidden Bonus: Better Debugging

Before:

# Debugging callback chains
[1] pry> invoice.save
# ??? Which of the 14 callbacks failed?
Enter fullscreen mode Exit fullscreen mode

After:

[1] pry> InvoiceCreator.new(params).call
# => Error in PaymentGateway (line 12)
Enter fullscreen mode Exit fullscreen mode

6. Migration Strategy

  1. Identify harmful callbacks (look for after_commit, external API calls)
  2. Extract to POROs (one class per workflow)
  3. Write integration tests (verify the whole flow)
  4. Monitor in production (compare error rates)

Pro Tip: Use the after_party gem to migrate existing callback data safely.


"But Our Team Loves Callbacks!"
Start small:

  1. Pick one messy model
  2. Extract just its worst callback
  3. Compare test speeds

Radically simplified your Rails app? Share your story below!

Top comments (0)