Integration Events
When an event has to reach another bounded context or another deployed service — without blocking the
request that produced it — reach for an integration event. The producer publishes and returns immediately; a
consumer runs later, off the request path, wherever the Queue transport delivers it. The
model is one class, one event: declare a typed producer for each cross-context fact, inject it by class
identity, and call publish(data). A shared payload type keeps producer and consumer in lockstep — drift
between them is a compile error, not a runtime surprise.
Declare the producer
A typed integration event is a plain class that extends IntegrationEvent<"name", Payload>. The string-literal
first type argument is both the event name and the dispatch key; the second argument is the payload the producer
and consumer share.
import { IntegrationEvent } from "@heximon/integration";
export interface UserSignedUpPayload {
readonly userId: string;
readonly name: string;
readonly email: string;
}
export class UserSignedUp extends IntegrationEvent<"user.signed-up", UserSignedUpPayload> {}
Publish the event
The controller injects the typed event class by class identity — no facade, no string token, no boot seam. The
compiler reads the class from the constructor signature and wires its publisher over the bound
IntegrationEventTransport (the in-process MemoryIntegrationEventTransport by default, assembled at boot with
no app boilerplate). await publish(data) hands the payload off; the request returns right away.
import { uuid } from "@heximon/runtime";
import type { Controller, Post } from "@heximon/http";
import { UserSignedUp } from "./events";
export class SignupController implements Controller<"/users"> {
private readonly userSignedUp: UserSignedUp;
public constructor(userSignedUp: UserSignedUp) {
this.userSignedUp = userSignedUp;
}
public async signUp(action: Post<"/">): Promise<{ id: string; email: string }> {
const body = (await action.request.json()) as { name: string; email: string };
const userId = uuid.v7();
// Publish the integration event; the welcome email is sent when the transport delivers it.
await this.userSignedUp.publish({ userId, name: body.name, email: body.email });
action.response.status = 201;
return { id: userId, email: body.email };
}
}
List the typed event class under the module's integration namespace, in the events sub-key:
import { Module } from "@heximon/runtime";
import { UserSignedUp } from "./events";
import { SignupController } from "./signup.controller";
export class UsersModule extends Module({
http: { controllers: [SignupController] },
integration: { events: [UserSignedUp] },
}) {}
Consume an integration event
A consumer is class X implements IntegrationEventHandler<"type", Payload> — its own discovery concept,
not EventHandler, because it rides the queue rather than the in-process bus. The string-literal name matches
the producer's first type argument; that shared name is how they meet on the channel. When the transport
delivers, handle runs. (extends IntegrationEventHandler<...> binds identically — use it when you want
your own base-class hierarchy on top of a handler.)
import type { IntegrationEventHandler } from "@heximon/integration";
import type { UserSignedUpPayload } from "../users/events";
import { CaptureEmailTransport } from "./capture-email-transport";
export class WelcomeEmailHandler implements IntegrationEventHandler<"user.signed-up", UserSignedUpPayload> {
private readonly transport: CaptureEmailTransport;
public constructor(transport: CaptureEmailTransport) {
this.transport = transport;
}
public async handle(payload: UserSignedUpPayload): Promise<void> {
// compose + send the welcome email through the injected transport
}
}
Declare it under the module's integration namespace, in the eventHandlers sub-key:
import { Module } from "@heximon/runtime";
import { CaptureEmailTransport } from "./capture-email-transport";
import { WelcomeEmailHandler } from "./welcome-email.handler";
export class MailModule extends Module({
providers: [CaptureEmailTransport],
integration: { eventHandlers: [WelcomeEmailHandler] },
exports: [CaptureEmailTransport],
}) {}
MemoryIntegrationEventTransport is the default: it fans out to
IntegrationEventHandlers over the shared queue channel table and is bound automatically at boot — the app
declares no producer seam, no boot wiring. IntegrationEventsPlugin (from @heximon/integration/compiler)
requiresQueuePlugin, so listing the integration plugin auto-pulls the queue plugin — the reverse
doesn't hold: a bare QueuePlugin never activates the integration tier.Wire the plugin
import { defineHeximonConfig } from "@heximon/build";
import { IntegrationEventsPlugin } from "@heximon/integration/compiler";
import { HttpPlugin } from "@heximon/http/compiler";
export default defineHeximonConfig({
plugins: [new HttpPlugin(), new IntegrationEventsPlugin()],
});
Transactional outbox
The default transport is fire-and-forget: a process crash between the aggregate write and the dispatch loses the event. The transactional outbox closes that gap by persisting each event to a durable outbox table in the same transaction as the aggregate write, delivering after the commit, and replaying pending rows on boot — so delivery survives a crash and a rollback discards the buffered event instead of leaking it.
The tier ships as three classes from @heximon/integration — OutboxStore, OutboxRelay, and
OutboxIntegrationEventTransport (an IntegrationEventTransport satisfier) — plus the Drizzle-backed
DrizzleOutboxStore from @heximon/drizzle/core. OutboxStore and OutboxIntegrationEventTransport bind as a
bare provider or a useFactory on their own token (a useFactory only where construction needs a non-DI
argument, like the app's outbox table). OutboxRelay is the one piece that needs a thin exported local
subclass instead — and the reason is a disambiguation, not a formality.
OutboxRelay's own constructor types its delivery target as the abstract IntegrationEventTransport — the
same port a typed event's publish() resolves through. Your module also binds
{ provide: IntegrationEventTransport, useClass: OutboxIntegrationEventTransport } for the producer side, and
exact-token resolution wins before the compiler's satisfier scan. Bind OutboxRelay bare (or via useFactory)
against that same abstract type, and its delivery target silently resolves to the very producer transport that
writes into the outbox — not an error, a loop: every drained row gets re-buffered into the outbox instead of
reaching an IntegrationEventHandler.
The fix types target concretely, at the actual delivery terminal, in a thin subclass:
import type { Context } from "@heximon/runtime";
import { OutboxRelay, type OutboxStore } from "@heximon/integration";
import { MemoryIntegrationEventTransport } from "@heximon/queue/memory";
/**
* A thin `OutboxRelay` subclass so the compiler reads its constructor directly. Types `target` concretely as
* `MemoryIntegrationEventTransport`: a bare abstract `IntegrationEventTransport` param would instead exact-match
* the producer transport bound below.
*/
export class AppOutboxRelay extends OutboxRelay {
public constructor(store: OutboxStore, target: MemoryIntegrationEventTransport, context: Context) {
super(store, target, context);
}
}
Because the constructor is authored in your own source, list AppOutboxRelay bare — no useFactory, no
eager flag. OutboxRelay implements OnBootstrap for its crash-recovery replay, and unlike an opaque
useFactory value, a subclass's inherited lifecycle hooks are auto-recovered. (Any local bare provider must be
an exported class — the compiler imports it by name.)
import { Module, TransactionContext } from "@heximon/runtime";
import { OutboxIntegrationEventTransport, OutboxStore } from "@heximon/integration";
import { DrizzleOutboxStore } from "@heximon/drizzle/core";
import { IntegrationEventTransport } from "@heximon/queue";
import { MemoryIntegrationEventTransport } from "@heximon/queue/memory";
import { AppOutboxRelay } from "./outbox-relay";
import { OrderPlaced } from "./order.events";
import { OrdersController } from "./orders.controller";
import { Database } from "./shared-database";
import { outbox } from "./outbox-table";
export class AppModule extends Module({
providers: [
Database,
{
provide: OutboxStore,
useFactory: (database: Database) => new DrizzleOutboxStore(database, outbox),
},
// The relay's terminal — a distinct concrete token from the producer transport, so the relay delivers to
// local handlers with no self-delivery loop on `IntegrationEventTransport`.
MemoryIntegrationEventTransport,
AppOutboxRelay,
{
provide: IntegrationEventTransport,
useClass: OutboxIntegrationEventTransport,
},
TransactionContext,
],
integration: { events: [OrderPlaced] },
http: { controllers: [OrdersController] },
}) {}
Inside a command handler or controller, wrap the aggregate write and the publish call in one transaction. The
publish buffers the record; the outbox row appends on the transaction's onBeforeCommit hook — atomic with the
aggregate write. A rollback discards the buffer, so no order means no event:
await this.database.runInTransaction(async () => {
await this.orderPlaced.publish({ orderId });
});
Delivery is at-least-once: the relay re-dispatches a row on a later drain if handle threw. Pair with a
QueueIdempotencyInterceptor (from @heximon/queue/idempotency) if your handlers must deduplicate. The relay
also replays pending rows on boot — the OnBootstrap hook fires whether or not a request arrives — so rows left
by a crashed process are delivered after restart. The gap's reliable cross-service example
has this exact wiring plus the rollback proof (place an order that fails, and no event is ever delivered).
Swap the relay's terminal per platform
The relay's target is the delivery surface each drained row is handed to — swapping platforms means retyping
AppOutboxRelay's target parameter to the platform's own transport (the same disambiguation as above, now
aimed at a durable queue instead of the in-process default) and binding that transport in its place. On
Cloudflare Workers, bind CloudflareQueueIntegrationEventTransport (@heximon/queue/cloudflare) — it sends
each drained row onto the app's own Cloudflare Queue, and that same worker's queue(batch, env, ctx) export
drains it later in a separate invocation with the platform's own retry/backoff/DLQ:
import type { Context } from "@heximon/runtime";
import { Platform } from "@heximon/runtime";
import { OutboxRelay, type OutboxStore } from "@heximon/integration";
import { CloudflareQueueIntegrationEventTransport } from "@heximon/queue/cloudflare";
/**
* A thin `OutboxRelay` subclass so the compiler reads its constructor directly. Types `target` concretely as
* {@link CloudflareQueueIntegrationEventTransport} — a bare abstract `IntegrationEventTransport` param would
* instead exact-match the producer transport bound below.
*/
export class AppOutboxRelay extends OutboxRelay {
public constructor(
store: OutboxStore,
target: CloudflareQueueIntegrationEventTransport,
context: Context,
) {
super(store, target, context);
}
}
// two entries in the module's `providers` array:
{
provide: CloudflareQueueIntegrationEventTransport,
useFactory: (): CloudflareQueueIntegrationEventTransport =>
new CloudflareQueueIntegrationEventTransport(Platform.binding("OUTBOX_QUEUE")),
},
AppOutboxRelay,
On Vercel Functions, bind VercelQueueIntegrationEventTransport (@heximon/queue/vercel) — the same durable
pattern for a freezing platform, delivering onto the app's own Vercel Queue and drained by a separately generated
functions/queues/consumer.func. See Queue
for the full table of durable transports, including the Cloudflare Queues binding this relay uses.
Fan out across services
Everything above lives in one deploy. When the producer and the subscribers are separate deploys —
orders publishes order.placed, a separately deployed billing service reacts — the promise stays the same
shape: the producer calls publish(data) once, and every interested service gets its own delivery, however
many subscribers exist or whichever broker sits between them. Which transport carries that delivery (a
per-service queue split, a native broadcast over Redis/SNS, a Cloudflare Durable Object router, or a push broker
for a freezing function) is a crossService.transport choice in heximon.config.ts — the producer's publish()
call and the consumer's IntegrationEventHandler never change.
See also
- Domain-Driven Design — the aggregates whose domain events are the usual source of an integration event, and the bounded contexts that talk to each other only through this tier.
- Events — the in-process and domain tiers below this one; start there for a side effect that stays inside one deploy.
- Queue — the transport integration events ride:
IntegrationEventTransport, the consumer stack, and the durable Drizzle/SQS/Upstash transports the outbox and cross-service legs build on. - Cross-Service Events — the fan-out legs for reaching a separately deployed subscriber: per-service-queue split, native broadcast, and push delivery for freezing platforms.
- Reliable Integration Events — the transactional-outbox recipe end to end, with the rollback proof.
- Example L08 — queue — a sign-up
controller that publishes
user.signed-upand a welcome-email handler that consumes it off the in-memory transport. - The gap's reliable cross-service example — the transactional outbox composed with cross-service fan-out in one relay pass, and the crash-rollback proof.
Repository Pattern
A collection-like persistence boundary with save, getById, getAll, and a zero-codegen EntityQuery, swappable behind an abstract Repository token, with optimistic-concurrency ConcurrencyError.
Saga Orchestration
SagaTimer, TimeoutHandler, DurableTimer, DurableTimerStore, NodeDurableTimer, DrizzleDurableTimerStore, durableTimerColumns, SagaPlugin — durable one-shot deadlines that commit atomically with saga state.