Event-Driven Rails in Practice

How we built a lightweight event bus inside a Rails monolith - schemas, idempotency, error isolation, and the one-line call that decouples everything.

A payroll run gets approved. One action. But then: emails go out to every employee with a payslip. Slack gets a message. PostHog tracks the event. A todo gets created for the finance team. An audit log entry is written.

Six side effects from one operation. The tempting approach is to put them all in the operation. It works until Slack goes down and takes payroll approval with it. Or until you need to add a seventh side effect and have to open the approval code again.

The operation knows too much. It knows what happened and everything that should happen because of it. Those are different concerns. We separated them with a lightweight event bus - not event sourcing, not CQRS, just a pattern for keeping a growing monolith organized.

This is the whole system in one post. We wrote a 7-part deep dive if you want the full treatment.

One Line

Every operation that needs to announce something includes one module and makes one call:

module Payroll
class ApproveRun
include Events::Emitter
def call
# ... approve the payroll ...
emit_async :payroll_processed, @payroll_run
end
end
end

A symbol key, the domain object, done. The operation doesn’t know what happens next. It doesn’t know that emails, Slack messages, analytics events, or todos exist. Its job ended when the payroll was approved.

Schemas

Every event has a schema class that defines its name, its payload shape, and how to extract data from the domain object:

module Events::Schema::Leave
class RequestApproved < Events::Schema::Base
event_name "leave.request.approved"
from_subject do |request|
{
leave_request_id: request.id,
company_id: request.employee&.company_id,
employee_id: request.employee_id,
leave_kind: request.leave_kind&.code,
start_date: request.start_date,
end_date: request.end_date,
num_days: request.num_days
}
end
params do
required :actor
optional :comments
end
end
end

event_name auto-registers the schema. from_subject extracts scalar values only - ActiveRecord objects become IDs, times become ISO8601, strings get truncated. No objects survive serialization. The payload is plain data: safe for Sidekiq arguments, safe for logging, safe for analytics.

We have schemas across every domain. Each one is the contract between the operation that emits and every subscriber downstream. (Deep dive: Schemas)

The Emitter

emit_async does four things in one call:

def emit_async(event_key, subject = nil, **params)
schema = build_schema(event_key, subject, params)
return unless schema.valid?
captured_context = current_event_context
Events::DispatchJob.perform_later(schema.full_name, schema.extract, captured_context)
end

Look up the schema. Extract the payload. Capture the current request context - user_id, company_id, request_id, IP address, browser info. Enqueue a Sidekiq job.

The context capture matters. When the background job runs three seconds later, Current.user is nil and the request is long gone. The context travels with the event.

The Bus

The dispatcher is simple on purpose:

module Events::Subscribers
REGISTRY = []
def self.dispatch(event_name, payload:, context:)
event = { name: event_name, payload:, context:, occurred_at: Time.current }
REGISTRY.each do |subscriber|
dispatch_to_subscriber(subscriber, event)
end
end
def self.dispatch_to_subscriber(subscriber, event)
return unless subscriber.handles?(event)
subscriber.consume(event)
rescue => e
Rails.logger.error "[Events] #{subscriber.name} failed: #{e.message}"
end
end

Iterate. Check. Consume. Rescue. That rescue block is the design decision that justifies the whole system. PostHog down? Logged, loop continues. Emails still send. Todos still create. Audit still writes. One subscriber’s failure is isolated. (Deep dive: Subscribers)

Pattern Matching

Subscribers declare what they care about:

class PosthogSubscriber
extend Events::Subscribable
subscribes_to "leave.", "payroll.", "employee.", "billing."
def self.consume(event)
# ...
end
end

Trailing dot means prefix match: "leave." handles every leave event. Exact strings match exactly. Omit subscribes_to and the subscriber handles everything. We considered regex. Didn’t need it.

The Subscribers

Every event passes through a set of registered subscribers:

LogSubscriber - handles everything. Structured log line with event name, payload, severity.

SentrySubscriber - handles everything. Breadcrumbs on the Sentry scope. When a bug report comes in days later, the trail shows every event that fired.

PosthogSubscriber - analytics with a PII whitelist. Only IDs and status codes leave the system. No names, no emails, no salaries.

SlackSubscriber - formatted messages to team channels. Payroll events to #payroll, leave to #leave.

TodoSubscriber - auto-creates, updates, and completes tasks based on domain events. A payroll run moves through three events and the todo board reflects each stage without anyone touching it.

NotificationSubscriber - bridges events to the notifier system for email delivery.

Plus two domain-specific subscribers for billing and employee lifecycle side effects.

Notifiers

The notification subscriber doesn’t handle events directly. It routes them to notifiers - classes that declare which events they react to and who gets the email:

class Leave::RequestNotifier < Notifier::Base
subscribe_to "leave.request.submitted" => :submitted,
"leave.request.approved" => :approved,
"leave.request.rejected" => :rejected
def recipients
case current_action
when :submitted then pending_approvers
when :approved, :rejected then [source.employee]
end
end
def approved(**)
email { LeaveMailer.request_approved(source) }
end
end

subscribe_to is a hash: event name to method name. The notifier declares what it reacts to. The operation that approved the request has no idea this class exists.

The base class handles subject extraction (rehydrating the domain object from the payload), tenant verification (checking company_id matches), and recipient iteration. Notifiers cover the whole platform. Adding a new notification is a subscribe_to entry and an action method. No operation changes. (Deep dive: Notifiers)

Idempotency

Background jobs retry. Network hiccups cause duplicate dispatches. Three layers prevent double processing:

Layer 1 - Dispatch job. Redis SETNX with a 24-hour TTL keyed on the event’s UUID v7. If the dispatch job retries, the second attempt skips.

Layer 2 - Subscriber. Same pattern, scoped to "PosthogSubscriber:{idempotency_key}". Even if dispatch somehow runs twice, PostHog sees the event once.

Layer 3 - Notifier. Scoped to "Leave::RequestNotifier:{idempotency_key}". The email sends once.

Three different Redis keys for the same event. Each layer guarantees its own exactly-once independently.

Circuit Breakers

PostHog and Slack are external services. They go down. When they do:

def self.consume(event)
return unless claim_event!(event)
return if circuit_open?
POSTHOG_CLIENT.capture(...)
reset_circuit!
rescue => e
record_failure!
end

Five consecutive failures open the circuit. Skip for 60 seconds. One test request. Success closes it. Three lines of integration for any subscriber that talks to an external service.

Two Queues

Not all events are equal:

queue_as do
event_name.start_with?(*CRITICAL_PREFIXES) ? :events_critical : :events
end

Leave and payroll notifications go to :events_critical. Analytics and logging go to :events. A surge of import events doesn’t delay someone’s leave approval email.

What It Costs

It’s more code than direct mailer calls. The abstraction has a learning curve - new developers need to understand the emit-schema-subscribe chain before they can trace a side effect. Debugging means following an event through Sidekiq, through the bus, through the subscriber, sometimes through the notifier registry.

The event bus is also synchronously invisible. emit_async returns immediately. If you need to know whether the email sent, you’re looking at Sidekiq logs, not the operation’s return value.

What It Gets Us

Adding a new side effect to any operation in the system means writing a subscriber (or adding a line to an existing one) and registering it. The operation that triggered the event is never opened. Never tested again. Never deployed again.

When we added PostHog analytics across the entire platform, it was one subscriber class. Zero changes to existing operations. When we added Slack notifications, same story. When we built an auto-managing task board driven by domain events, the operations that emit those events were never touched.

Events across payroll, leave, billing, employee lifecycle, auth, attendance, compensation, and more. Subscribers handle logging, error tracking, analytics, Slack, todos, and email. The operations that emit them have no idea any of this exists.

Operations emit. Subscribers react. Nobody coordinates.


This is the condensed version. For the full deep dive, start from Part 1: Why We Built an Event System - a 7-part series covering schemas, subscribers, notifiers, lessons learned, and a complete end-to-end trace.