Events
When one feature has to react to something another feature did — send a welcome email after a sign-up, record a metric when an order ships — the obvious move is to call straight across: the sign-up code invokes the mail service. Do that a few times and your modules are knotted together; the sign-up feature can't compile without the mail feature, and you can't test either alone. Events break that knot: a feature publishes what happened and walks away, and any number of features subscribe. The publisher never imports a subscriber — the only thing connecting them is the event's shape.
Heximon gives you three event tiers, each for a different reach. They build on one another, so pick the smallest one that does the job:
| Tier | Reach | Delivery | When to use |
|---|---|---|---|
| In-process | Same app | Synchronous, awaited | Decouple features in one app — notifications, logging, side effects |
| Domain | Same app, after a save | Synchronous, flushed on demand | React to state changes an aggregate recorded, within one bounded context |
| Integration | Across services | Async, queue-backed | Communicate between bounded contexts or separate services |
The tiers share one substrate, so promoting from one to the next is cheap. Domain events ride the same class-identity dispatch the in-process bus uses; integration events ride the same queue channels raw queue handlers use.
In-process events
The lowest tier is the EventBus: a publish/subscribe bus for decoupling features inside one running
app. It keys events two ways — by class identity (emit(new OrderPlacedEvent(...))) or by a string
name (emit("shop.order.audited", payload)) — and it's a core provider, so you inject it by class
identity with no module to import.
pnpm add @heximon/events
npm install @heximon/events
yarn add @heximon/events
bun add @heximon/events
Register the events compiler plugin alongside the HTTP plugin in heximon.config.ts:
import { defineHeximonConfig } from "@heximon/build";
import { EventEmitterPlugin } from "@heximon/events/compiler";
import { HttpPlugin } from "@heximon/http/compiler";
export default defineHeximonConfig({
plugins: [new HttpPlugin(), new EventEmitterPlugin()],
});
Define a class-tier event
A class-tier event is a plain class whose constructor parameters carry its payload. Its class identity
is the dispatch token — an emit(new OrderPlacedEvent(...)) routes to every handler bound to that
class. No string keys, no central registry: the class itself is the contract.
import { BaseEvent } from "@heximon/events";
export class OrderPlacedEvent extends BaseEvent {
constructor(
public readonly orderId: string,
public readonly customerEmail: string,
public readonly lineItems: readonly OrderLineItem[],
) {
super();
}
public describe(): string {
return `order ${this.orderId} (${this.lineItems.length} item(s)) for ${this.customerEmail}`;
}
}
Each event extends BaseEvent, the shipped class-tier base. The bus dispatches by reading
event.constructor["~eventId"], but TypeScript widens an instance's constructor to Function, so a
plain class wouldn't type-check as emittable — BaseEvent carries that stamp at the type level so
emit(new OrderPlacedEvent(...)) needs no cast. An app built on @heximon/domain gets
this for free — its DomainEvent base extends BaseEvent — which is exactly why domain events ride
this same class tier.
Handle a class-tier event serially
implements EventHandler<OrderPlacedEvent> binds a handler to the event class, and handle receives
the typed instance. Serial handlers run in declaration order, and a throw stops the rest — the
exception propagates back to the caller that emitted the event, rolling back the originating work. That
makes serial handlers the right home for side effects on a request's success path. (extends EventHandler<OrderPlacedEvent> binds identically — use it when you want your own base-class hierarchy on
top of a handler.)
import type { EventHandler } from "@heximon/events";
// The compiler reads the bound event class from the `EventHandler<OrderPlacedEvent>` type argument.
import { OrderPlacedEvent } from "../shop/order-events";
export class ConfirmationEmailHandler implements EventHandler<OrderPlacedEvent> {
constructor(private readonly eventLog: EventLog) {}
public handle(event: OrderPlacedEvent): void {
this.eventLog.record(
"confirmation-email",
event.orderId,
`Confirmation email sent to ${event.customerEmail} for ${event.describe()}`,
);
}
}
EventHandler<…> type argument. The compiler reads the binding from the type
position — implements EventHandler<OrderPlacedEvent> — and bakes the routing into generated wiring, so
both classes are type-only here. import type { EventHandler } keeps the marker out of the runtime module
graph, and import type { OrderPlacedEvent } is correct when the event class is used nowhere else as a
value; a plain import { OrderPlacedEvent } is equally fine.Handle a class-tier event best-effort
implements NotificationHandler<OrderPlacedEvent> binds the same way, but flips the execution policy.
Notification handlers run together, after the serial bucket, and their errors are collected rather
than thrown — one handler failing never rolls back the originating work, or stops its siblings. Reach
for these when a side effect must not be allowed to fail the request: metrics, projections,
fire-and-forget notifications.
import type { NotificationHandler } from "@heximon/events";
import type { OrderPlacedEvent } from "../shop/order-events";
export class OrderMetricsHandler implements NotificationHandler<OrderPlacedEvent> {
constructor(private readonly eventLog: EventLog) {}
public async handle(event: OrderPlacedEvent): Promise<void> {
const totalUnits = event.lineItems.reduce((sum, item) => sum + item.quantity, 0);
this.eventLog.record("order-metrics", event.orderId, `Recorded orders_placed metric (${totalUnits} unit(s))`);
}
}
That split — serial handlers can fail the request, notification handlers can't — is the whole design. A failed confirmation email should fail the order; a flaky metrics write should never touch it.
Subscribe by name instead
Some events have no single owning class — a cross-cutting audit record for any order action, say. For
those, key by a string name. Declare the name → payload mapping by augmenting the framework's
EventMap; that one augmentation types both the publisher and every subscriber.
export interface OrderAuditedEvent {
readonly orderId: string;
readonly action: string;
readonly recordedAt: string;
}
// Register the name → payload so both emit() and the handler type-check.
declare module "@heximon/events" {
interface EventMap {
"shop.order.audited": OrderAuditedEvent;
}
}
A string-tier handler subscribes by name, and its event parameter is typed straight from the EventMap.
String-tier handlers always run serially.
import type { EventHandler } from "@heximon/events";
import type { OrderAuditedEvent } from "../shop/audit-event";
export class AuditTrailHandler implements EventHandler<"shop.order.audited"> {
constructor(private readonly eventLog: EventLog) {}
public handle(event: OrderAuditedEvent): void {
this.eventLog.record("audit-trail", event.orderId, `Audit: order ${event.action} at ${event.recordedAt}`);
}
}
Publish from anywhere
The publisher injects EventBus by class identity — there's no module to add to imports — and emits
either tier. It knows nothing about the handlers reacting; the only coupling is the event shape.
import type { Controller, Post } from "@heximon/http";
import { EventBus } from "@heximon/events";
import { OrderPlacedEvent } from "./order-events";
export class ShopController implements Controller<"/shop"> {
constructor(private readonly eventBus: EventBus) {}
public async placeOrder(action: Post<"/orders">): Promise<{ orderId: string; status: "placed" }> {
const body = (await action.request.json()) as PlaceOrderBody;
const orderId = ShopController.nextOrderId();
// Class tier: fan out to every handler bound to OrderPlacedEvent; emitAsync awaits them.
await this.eventBus.emitAsync(new OrderPlacedEvent(orderId, body.customerEmail, body.lineItems));
// String tier: name + payload, typed against the augmented EventMap.
await this.eventBus.emitAsync("shop.order.audited", {
orderId,
action: "placed",
recordedAt: new Date().toISOString(),
});
return { orderId, status: "placed" };
}
}
emitAsync awaits the fan-out: the serial bucket runs first (a throw surfaces back here), then the
notification bucket — the right contract on a request's success path. There's also emit, which is
fire-and-forget: inside a request it still completes its handler work, but never blocks the response. No
cast is needed at the publish boundary because OrderPlacedEvent extends BaseEvent, which carries the
dispatch stamp at the type level; a DDD app emits through the DomainEvents facade, the next tier.
Intercept serial dispatch
EventInterceptor wraps the serial EventHandler chain — the right home for cross-cutting behavior
like tracing, logging, or short-circuiting dispatch. Scope it to one event class (a type argument) or make
it app-wide (no type argument). Only the serial EventHandler tier is intercepted — NotificationHandler
runs outside the chain, and string-tier handlers bypass it entirely.
import { EventClassName, EventInterceptor } from "@heximon/events";
import { EventLog } from "./event-log";
export class TraceEventsInterceptor extends EventInterceptor {
constructor(private readonly eventLog: EventLog) {
super();
}
public async intercept(event: object, next: () => Promise<void>): Promise<void> {
this.eventLog.record(
"trace-events",
TraceEventsInterceptor.orderIdOf(event),
`Traced dispatch of ${EventClassName.of(event)}`,
);
await next();
}
private static orderIdOf(event: object): string {
const record = event as Record<string, unknown>;
return typeof record["orderId"] === "string" ? record["orderId"] : "unknown";
}
}
event.constructor.name mangles under production minification — every vendor deploy target ships
minified — so tracing reads EventClassName.of(event) instead, the compiler-stamped name that survives it
(falling back to constructor.name only for an event with no class-tier handler, which is never stamped).
Wire the handlers
Handlers are declared under the module's events namespace — never in providers. Serial
EventHandlers go under handlers, best-effort NotificationHandlers under notificationHandlers,
and interceptors under interceptors. A handler left in providers is constructed but never wired
into dispatch, so it silently never fires. Plain injectables stay in providers.
import { Module } from "@heximon/runtime";
import { AuditTrailHandler } from "./audit-trail.handler";
import { ConfirmationEmailHandler } from "./confirmation-email.handler";
import { EventLog } from "./event-log";
import { OrderMetricsHandler } from "./order-metrics.handler";
import { TraceEventsInterceptor } from "./trace-events.interceptor";
export class NotificationsModule extends Module({
providers: [EventLog],
events: {
handlers: [ConfirmationEmailHandler, AuditTrailHandler],
notificationHandlers: [OrderMetricsHandler],
interceptors: [TraceEventsInterceptor],
},
exports: [EventLog],
}) {}
Domain events
Once you have aggregates (see Domain-Driven Design), the next tier up is domain events. The difference is when they fire: an aggregate records events as its state changes, you save it, and then you flush the recorded events. That ordering means a handler always runs against persisted state, never a half-applied change.
A DomainEvent is a plain class — same shape as a class-tier event, but its base already carries the
dispatch stamp, so there's no extra base to declare.
import { DomainEvent } from "@heximon/domain";
import type { OrderId } from "../order.entity";
export class OrderShippedDomainEvent extends DomainEvent {
public constructor(
public readonly orderId: OrderId,
public readonly shippedAt: Date,
) {
super();
}
}
Record events on the aggregate
The AggregateRoot records events with addDomainEvent(...) as its state
transitions. It buffers them and otherwise does nothing — the aggregate has no dependency on the
event bus, so recording stays decoupled from dispatch and the domain layer stays pure.
public ship(): void {
if (this.props.status === "Shipped") throw new CannotShipTwiceError();
const shippedAt = new Date();
this.props.status = "Shipped";
this.props.shippedAt = shippedAt;
this.addDomainEvent(new OrderShippedDomainEvent(this.id, shippedAt)); // buffered, not yet dispatched
}
Flush after the save
DomainEvents is the injectable facade — a core provider, injected by constructor parameter type with
no module import. After persisting the aggregate, drain its buffer with domainEvents.emitFrom(order).
That dispatches each recorded event serially, in order — a throw stops the remaining events. Under the
DB-backed command auto-wrap (which wraps execute + emitFrom in a single Database.runInTransaction) this
rolls the SQL write back atomically; a command whose module has no Database propagates the throw without a
rollback.
import { NotFoundError } from "@heximon/runtime/errors";
import { DomainEvents } from "@heximon/domain";
import type { Controller, Post } from "@heximon/http";
import type { OrderId } from "./order.entity";
import { OrderRepository } from "./order.repository";
export class OrdersController implements Controller<"/orders"> {
public constructor(
private readonly repository: OrderRepository,
private readonly domainEvents: DomainEvents, // core provider, injected by type
) {}
public async ship(action: Post<"/:id/ship">): Promise<{ id: string; status: string }> {
const id = action.request.pathParams.id as OrderId;
const order = await this.repository.getById(id);
if (!order) throw new NotFoundError(`Order ${id} not found`);
order.ship();
await this.repository.save(order);
await this.domainEvents.emitFrom(order); // drains the buffer + dispatches the recorded events
return { id: order.id, status: order.status };
}
}
Nothing dispatches until that explicit emitFrom — no implicit unit-of-work fires events behind your
back, which keeps the save-then-flush ordering visible and under your control. A second flush is a
harmless no-op, because draining the buffer clears it.
React to a domain event
A domain-event handler is class X implements DomainEventHandler<EventClass>. It's discovered through the
same class-identity tier as an in-process EventHandler and routed by the event class, and it runs
serially inside emitFrom.
import type { DomainEventHandler } from "@heximon/domain";
import { OrderShippedDomainEvent } from "../orders/domain-events/order.events";
import { ShipmentTracker } from "./shipment-tracker.service";
export class RecordShipmentHandler implements DomainEventHandler<OrderShippedDomainEvent> {
public constructor(private readonly tracker: ShipmentTracker) {}
public handle(event: OrderShippedDomainEvent): void {
this.tracker.record({ orderId: event.orderId, shippedAt: event.shippedAt.toISOString() });
}
}
Notice the handler consumes only the flat data the event carries — never the Order aggregate itself.
That's deliberate: the shipping context reacts without reaching into the orders context, so the two stay
decoupled. Declare the handler under the module's domain namespace, in the eventHandlers sub-key.
import { Module } from "@heximon/runtime";
import { RecordShipmentHandler } from "./record-shipment.handler";
import { ShipmentTracker } from "./shipment-tracker.service";
export class ShippingModule extends Module({
providers: [ShipmentTracker],
domain: { eventHandlers: [RecordShipmentHandler] },
exports: [ShipmentTracker],
}) {}
An app using domain events needs DomainEventsPlugin, which wires the DomainEvents facade. It requires
the events plugin, so the compiler pulls that in automatically — you only list DomainEventsPlugin.
Integration events
The two tiers above run in the same process and on the request path. When an event must reach another bounded context or another service — without blocking the request that produced it — promote to integration events. They're queue-backed: the producer publishes and returns, and the consumer runs later when the transport delivers.
A typed IntegrationEvent<"name", Payload> producer and an
IntegrationEventHandler<"name", Payload> consumer meet on one channel, with a shared payload type keeping
drift between them a compile error rather than a runtime surprise. That tier — the producer/consumer walkthrough,
the transactional outbox, and the four cross-service fan-out legs (per-service-queue split, Redis Streams
broadcast, SNS → SQS, and QStash push for freezing platforms) — has its own page:
Integration Events.
Choosing a tier
Start at the top of this list and move down only when you have to — each tier carries more machinery than the last:
- In-process for decoupling features inside one app: notifications, logging, fan-out side effects. No persistence, no infrastructure.
- Domain once you have aggregates and want to react to state changes after a save, within a single bounded context — same process, flushed explicitly.
- Integration only when an event genuinely has to cross a process or service boundary. It's the async, queue-backed tier, and it's the most you'll wire.
Promotion is cheap because the substrate is shared: a class-tier EventHandler becomes a
DomainEventHandler by re-export, and an integration handler reuses the same queue channels.
See also
- Domain-Driven Design — the aggregates that record the domain events you flush here, plus the value objects and errors around them.
- Integration Events — the queue-backed tier for a cross-context or cross-service event: typed producers and handlers, the transactional outbox, and cross-service fan-out.
- CQRS — flush an aggregate's events from a command handler after it saves, then react in-process or across services.
- Example L03 — events — a shop module that publishes both class and string tiers, and a fully decoupled notifications module of serial and best-effort handlers.
- L06 — DDD
— an
Orderaggregate that records domain events onship()and a shipping context that reacts to them across bounded contexts.
Overview
The capability map — every production concern (events, background jobs, cron, caching, storage, health, notifications, idempotency, security, tracing, OpenAPI, MCP, schema, branded ids) and the one config line each one adds.
Queue
Move slow work off the request path onto durable channels — the Queue producer port, QueueEventHandler consumers under queue { handlers }, per-handler retry and payload validation, MemoryQueue in dev, and a durable transport per platform.