"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)