Building Event-Driven Rails

How I peeled callback-heavy Rails models into explicit in-process pub/sub with Wisper, Rails Event Store, and ActiveSupport::Notifications, without leaving the monolith.

A creator on the platform filed a ticket on a Wednesday morning. “All my customers got charged twice this month and the app shows them as having two active subscriptions.” We pulled the logs. Apple’s SubscriptionRenewal server-to-server notification had been retried after our endpoint returned a 200 OK slightly past the 30 second deadline. The renewal handler had no idempotency. Every retry created a new creator_subscriptions row. The cause was easy to spot once you saw the stack. The row was created inside an after_create callback chained off Receipt, which itself was created inside an after_save on IapTransaction. Three hops of callback magic doing one logical thing. Nobody on the squad had read all three files in the same hour.

That morning is the reason I’m a hard believer in explicit in-process events for Rails monoliths. You don’t need Kafka. You don’t need to break the monolith. You need to stop pretending callbacks are events.

Why callbacks lie

Active Record callbacks look like events but behave like inline procedure calls. They run in the same transaction. They run in a fixed order Rails decides. They fail silently when you mix after_commit with after_save. And they hide control flow inside the model.

class IapTransaction < ApplicationRecord
  after_create :create_receipt
  after_create :notify_creator
  after_create :enqueue_revenue_aggregation
  after_commit :ping_analytics, on: :create

  private

  def create_receipt
    Receipt.create!(iap_transaction: self, raw_payload: payload)
  end

  def notify_creator
    CreatorMailer.purchase(creator_id, self).deliver_later
  end

  def enqueue_revenue_aggregation
    RevenueAggregator.perform_async(creator_id, amount_cents)
  end

  def ping_analytics
    Analytics.track('iap.purchased', user_id: user_id, amount: amount_cents)
  end
end

Read that code again. There’s no event. Just a side-effect tree pretending to be one. New developer adds a feature, drops a fifth callback, lifts the transaction with a slow HTTP call, and now every signup hangs on a 4s deliver_later push. I’ve seen this exact pattern twice. Once at the digital product agency I led, inside a Rails app that was meant to be a “small admin tool” until it wasn’t. Once at the creator-economy platform I worked at, inside the branded-mobile-app billing path.

The intermediate step

Wisper is the smallest possible upgrade. It gives you publish and subscribe without bringing in a message broker. The publisher emits a named event with a payload. Subscribers do whatever they want with it. The publisher does not know who is listening.

# Gemfile
gem 'wisper', '~> 3.0'

class IapTransaction < ApplicationRecord
  include Wisper::Publisher

  after_commit :publish_purchased, on: :create

  private

  def publish_purchased
    broadcast(:iap_purchased, transaction_id: id, creator_id: creator_id, amount_cents: amount_cents, original_transaction_id: apple_original_transaction_id)
  end
end

class ReceiptListener
  def iap_purchased(transaction_id:, **)
    ReceiptCreator.perform_async(transaction_id)
  end
end

class CreatorNotificationListener
  def iap_purchased(creator_id:, transaction_id:, **)
    CreatorMailer.purchase(creator_id, transaction_id).deliver_later
  end
end

Wisper.subscribe(ReceiptListener.new)
Wisper.subscribe(CreatorNotificationListener.new)

You haven’t bought anything. No new infrastructure, no broker, no Kafka, no SNS. But you’ve drawn a real boundary. The model emits one fact. Listeners react. Adding a sixth side effect now means writing a sixth listener, not stuffing another callback into the model. A new engineer reads the publisher file and sees the entire vocabulary of what this aggregate emits.

There’s a catch. Wisper is in-process and synchronous by default. If you want a listener to run async, hand its work to Sidekiq from inside the listener, as above. Never broadcast from inside a transaction unless you mean it.

When you need history

Wisper is great until somebody asks “what happened to this subscription on October 12th.” Then you need an event log, not a pub/sub channel. That’s Rails Event Store. It writes events to PostgreSQL, lets you replay them, and gives you typed event classes you can pattern-match on.

# Gemfile
gem 'rails_event_store', '~> 2.14'
gem 'aggregate_root', '~> 2.14'

module Billing
  class IapPurchased < RailsEventStore::Event; end
  class IapRenewed < RailsEventStore::Event; end
  class IapRefunded < RailsEventStore::Event; end
end

class IapTransaction < ApplicationRecord
  after_commit :publish_purchased, on: :create

  private

  def publish_purchased
    event = Billing::IapPurchased.new(data: {
      transaction_id: id,
      creator_id: creator_id,
      amount_cents: amount_cents,
      original_transaction_id: apple_original_transaction_id,
      received_at: Time.current
    })
    Rails.configuration.event_store.publish(event, stream_name: "Subscription$#{apple_original_transaction_id}")
  end
end

class CreatorSubscriptionProjection
  def call(event)
    return if CreatorSubscription.exists?(idempotency_key: idempotency_key_for(event))
    CreatorSubscription.create!(
      creator_id: event.data[:creator_id],
      original_transaction_id: event.data[:original_transaction_id],
      idempotency_key: idempotency_key_for(event),
      activated_at: event.data[:received_at]
    )
  end

  private

  def idempotency_key_for(event)
    "#{event.data[:original_transaction_id]}:#{event.event_id}"
  end
end

Rails.configuration.event_store.subscribe(
  CreatorSubscriptionProjection.new,
  to: [Billing::IapPurchased]
)

The projection is what saved us after the duplicate-billing incident. With a stream per subscription and a unique idempotency key on the projection, Apple could retry a notification fifteen times and the projection would still write a single creator_subscriptions row. Same notification UUID, same original_transaction_id, same key, one row. We backfilled by replaying the stream against a corrected projection. That’s what Rails Event Store is for, and it’s wildly underused in Rails shops.

What ActiveSupport::Notifications is actually for

Here’s where teams get confused. ActiveSupport::Notifications is in the standard library. People reach for it as a third pub/sub system. Don’t. It’s instrumentation. Use it to publish observability signals, not domain events. The semantics are different. Subscribers run inline, exceptions in subscribers crash the producer, and there’s no durable log.

ActiveSupport::Notifications.instrument(
  'billing.iap_purchased',
  transaction_id: id,
  creator_id: creator_id,
  amount_cents: amount_cents
)

ActiveSupport::Notifications.subscribe('billing.iap_purchased') do |_name, started, finished, _id, payload|
  Datadog::Metrics.increment('billing.iap_purchased.count', tags: ["creator:#{payload[:creator_id]}"])
  Datadog::Metrics.histogram('billing.iap_purchased.amount_cents', payload[:amount_cents])
end

That’s it. Counters and histograms. Audit traces. Sentry breadcrumbs. Stuff you’d put on a Datadog dashboard. Not subscription state.

The Apple retry story, told properly

So how did we actually fix the duplicate subscription bug. The first move was the wrong one. A frontend “fix” went out within an hour, showing only the latest subscription row per customer. Visible-only. Apple had still billed each card twice. The creator escalated to legal by the end of the day.

The real fix was two layers. First, a cleanup script keyed on apple_original_transaction_id + notification_uuid to dedupe the rows already written. Second, a rewrite of the renewal handler around Rails Event Store. The HTTP endpoint now returns 200 OK in under 5s by enqueueing a Sidekiq job that appends an event to a per-subscription stream. The projection has a database-level unique index on the idempotency key. Apple’s retries became safe at the queue level. Took us four days to coordinate refunds with Apple’s developer API. The structural fix held. Event log plus a unique key is a contract Apple’s retry behavior can’t break.

Another story, same lesson

Different shop. The combat-sports tournament platform I was acting CTO at, years earlier. A live tournament being broadcast on a Saturday. The standings-projector consumer kept rebalancing every 30 seconds and the public leaderboard froze at 14:32 local. That consumer wasn’t Rails, but the failure shape is identical to the one I keep seeing in Rails monoliths. One stale pod with a different config killed the consumer group. Fix was small. Lesson was big. Pin your config to the event, not to the moment a callback happens to fire. Twelve minutes of stale standings. The federation called the next Monday. The runbook now starts with a checklist of consumer config drift.

Rails apps make this worse, not better, because callbacks let you bury the equivalent of consumer config inside three files in app/models. Explicit events surface it.

The migration path I actually use

You don’t replace callbacks all at once. You do this:

class IapTransaction < ApplicationRecord
  include Wisper::Publisher

  # New publication. Kept alongside legacy callbacks for one release.
  after_commit :publish_purchased, on: :create

  # Legacy callbacks. Each one gets a deletion PR once its listener is live.
  after_create :create_receipt
  after_create :notify_creator
  # after_create :enqueue_revenue_aggregation  removed once RevenueListener shipped

  private

  def publish_purchased
    broadcast(:iap_purchased, transaction_id: id, creator_id: creator_id, amount_cents: amount_cents)
  end
end

Ship a publication. Ship a listener that mirrors one callback. Verify in staging that both fire and produce the same downstream effect. Delete the callback. Repeat. On the branded-mobile-app billing path we did this over five PRs across two weeks. Nobody noticed the migration. Nothing broke. The model file got shorter every PR.

Takeaways

  • Callbacks are not events. They’re inline procedure calls disguised as ones.
  • Wisper is the smallest useful upgrade. Start there before reaching for a broker.
  • Rails Event Store gives you a typed log, replay, and projections. Use it when state depends on history.
  • ActiveSupport::Notifications is for instrumentation. Don’t make it carry domain events.
  • Migrate incrementally. Publish first, listen second, delete the callback last.
  • A unique idempotency key on the projection is the contract that survives upstream retries.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.