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 endendFifteen 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 endendActiveRecord 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 endendemit_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" raiseendThe 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)endMiddleware 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_v7endThis 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.