"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
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
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
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
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
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?
After:
[1] pry> InvoiceCreator.new(params).call
# => Error in PaymentGateway (line 12)
6. Migration Strategy
-
Identify harmful callbacks (look for
after_commit
, external API calls) - Extract to POROs (one class per workflow)
- Write integration tests (verify the whole flow)
- 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:
- Pick one messy model
- Extract just its worst callback
- Compare test speeds
Radically simplified your Rails app? Share your story below!
Top comments (0)