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.
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.
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.
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.
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.
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.
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.
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.
Thanks for reading. If you’ve got thoughts, send them my way.