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)