Why We Built an Event System (And Why You Probably Shouldn't)

A payroll approval triggers six side effects. We needed them out of the operation. Here's the problem that led us to event-driven architecture.

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

Six side effects from one operation. And that’s a simple one.

The Naive Version

The tempting approach is to put everything in the operation:

module Payroll
class ApproveRun
def call
ActiveRecord::Base.transaction do
@payroll_run.update!(approved_at: Time.current, approved_by_user: @user)
@payroll_run.approve_and_process
@payroll_run.calculate_totals!
end
# Now the real mess begins
PayslipNotifier.new(@payroll_run).deliver
SlackClient.notify("#payroll", "Payroll approved for #{@payroll_run.period_display}")
POSTHOG_CLIENT.capture(distinct_id: @user.id, event: "payroll.processed")
Todos::CreateForFinance.call(payroll_run: @payroll_run)
AuditLog.record(:payroll_approved, subject: @payroll_run, actor: @user)
end
end
end

This works until it doesn’t.

Want to add a new notification channel? Edit the operation. Slack is down? The whole approval fails - a payroll notification shouldn’t take down payroll. Need to test the approval logic in isolation? Good luck mocking five external services. Another team wants to react to payroll approvals? They need to modify your code.

The operation knows too much. It knows what happened (payroll approved) and everything that should happen because of it (emails, Slack, analytics, todos, audit). Those are fundamentally different concerns, and they change at different rates for different reasons.

The Insight

The side effects don’t belong to the operation. They belong to the event. The operation’s job ends when the payroll is approved and the database reflects that. Everything else is a reaction to the fact that it happened.

Separate the thing that happened from the things that happen because of it.

module Payroll
class ApproveRun
include Events::Emitter
def call
# ... approve the payroll ...
emit_async :payroll_processed, @payroll_run
Result.new(success?: true, errors: [])
end
end
end

One line. The operation says “this happened” and moves on. Who reacts to that event, how, and when? Not the operation’s problem.

Why Not Use What Exists?

Rails ships with ActiveSupport::Notifications. There’s ActiveSupport::EventStore in Rails 8. We looked at both. Our requirements didn’t fit:

Async-first. Most reactions shouldn’t happen in the request cycle. Sending emails, hitting Slack, tracking analytics - these belong in background jobs, not blocking the user while PostHog acknowledges a request. We needed events to cross the sync/async boundary cleanly.

Multi-tenant context. Every event needs to carry company_id, user_id, request_id, and request metadata. When a background job processes an event minutes later, Current.user is nil, the request is long gone. That context has to be captured at emit time and travel with the payload.

Idempotency. Background jobs retry. Events can be dispatched twice. We needed a guarantee that each event is processed exactly once per subscriber, even under failure conditions. ActiveSupport::Notifications has no opinion on this.

Schema validation. With 93 events across 13 domains, we needed a way to define what an event looks like, what payload it carries, and catch drift early. Not at runtime in production - at definition time.

None of this is exotic. But the combination meant we’d be fighting the built-in tools rather than using them.

The Bet

We built a lightweight event bus. Not an event-sourcing system. Not CQRS. Not a message broker. A simple pattern: operations emit named events with structured payloads, and subscribers react independently.

The system has grown to 93 events across payroll, leave, billing, employee lifecycle, auth, attendance, and more. Eight subscribers handle logging, error tracking, analytics, Slack notifications, todo creation, and email delivery. The operations that emit them have no idea any of this exists.

Operations do their job. Events carry the news. Subscribers react. Nobody coordinates.

In the next post, we’ll look at how events are defined - the schema DSL that makes 93 events manageable.