Schemas, Emitters, and the Shape of an Event

How we define events with a 15-line DSL, auto-register them, and serialize payloads safely. The building blocks of our event system.

An event system is only as useful as the events it carries. If payloads are unstructured hashes that drift over time, you end up debugging what an event used to look like versus what it looks like now.

We solved this with schemas. Every event in the system has a class that defines its name, its payload shape, and how to extract data from domain objects.

What an Event Looks Like

This is the schema that fires when a leave request gets approved:

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

Fifteen lines. Three declarations: a dot-notation name, a block that extracts data from the domain object, and the parameters it accepts.

event_name "leave.request.approved" sets the wire name and auto-registers the schema in a central registry as :leave_request_approved. No manual wiring. Define the class, it registers itself.

from_subject receives the domain object - a Leave::Request - and returns a hash of the data worth capturing. IDs, codes, dates. Not the whole object.

params declares what the emitter can pass alongside the subject. required :actor means someone must pass the approver. optional :comments means they can.

Serialization Opinions

The schema base class is opinionated about what goes into a payload:

def serialize_value(value, depth = 0)
case value
when ActiveRecord::Base then value.id
when Time, DateTime then value.iso8601
when Exception then { class: value.class.name, message: truncate_string(value.message) }
when String then truncate_string(value)
else value
end
end

ActiveRecord objects become IDs. Not serialized records, not JSON blobs - just the integer. This prevents circular references, avoids leaking sensitive attributes, and keeps payloads small enough for Sidekiq arguments.

Times become ISO8601 strings. Exceptions become {class, message} with backtraces intentionally excluded to prevent information disclosure. Strings get truncated at 10,000 characters.

These aren’t configurable per-event. Global rules. Every event in the system follows the same serialization contract, which means every downstream consumer can make the same assumptions.

The Emitter

Operations opt into event emission with a single include:

module Payroll
class ApproveRun
include Events::Emitter
def call
# ... business logic ...
emit_async :payroll_processed, @payroll_run
end
end
end

emit_async looks up the schema by key, instantiates it with the subject and params, extracts the payload, and enqueues a Sidekiq job to dispatch it. Four things, one call.

There’s also emit for synchronous dispatch, used when the event must be processed before moving on:

rescue => e
emit :payroll_failed, @payroll_run, error: e, step: "approval"
raise
end

The failure event has to be dispatched before the exception propagates. Async would be too late - the Sentry breadcrumb needs to be in place before the error report fires.

Context Comes Free

Every event automatically carries request context: user_id, company_id, request_id, IP address, browser info, device type. The emitter doesn’t pass any of this explicitly:

def current_event_context
current_context = {
user_id: Current.user&.id,
company_id: Current.company&.id
}.compact
Events::Context.current.merge(current_context)
end

Middleware captures request metadata on every request. The emitter merges in fresh Current values at emit time. This matters because async events run in background jobs where Current.user doesn’t exist. The context is captured in the request and travels with the event payload into Sidekiq.

Idempotency Key

Every event gets a UUID v7 on creation:

def initialize(subject, **params)
@subject = subject
@params = params
@idempotency_key = SecureRandom.uuid_v7
end

This key travels with the payload and prevents duplicate processing downstream. Sidekiq retries a failed job? The idempotency key ensures subscribers don’t process it twice. The same key, checked at three different layers - but that’s a later post.

Every Schema, One Pattern

Schemas exist across every domain: payroll, leave, billing, employee lifecycle, auth, attendance, compensation, holidays, imports, payments. Every one follows the same structure. New events take about two minutes to define.

The schema is the contract. When this thing happens, the data looks like this. Everything downstream - subscribers, notifiers, analytics - trusts that contract.

Events exist. They carry structured, validated payloads. But nothing happens until something listens.