Heximon Logo
Architecture

Integration Events

Cross-context and cross-service events with IntegrationEvent producers, IntegrationEventHandler consumers, and the transactional outbox (OutboxStore, OutboxRelay, OutboxIntegrationEventTransport).

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.

users/events.ts
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.

users/signup.controller.ts
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:

users/users.module.ts
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.)

mail/welcome-email.handler.ts
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:

mail/mail.module.ts
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],
}) {}
No transport boilerplate.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

heximon.config.ts
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/integrationOutboxStore, 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:

orders/outbox-relay.ts
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.)

orders/app.module.ts
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:

orders/orders.controller.ts (excerpt)
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:

database.module.ts
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 deploysorders 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.

Picking and wiring a fan-out leg is a deploy-topology concern — see Cross-Service Events for the full comparison and the config for each transport, and Reliable Integration Events for composing the outbox with cross-service delivery in one relay pass.

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-up and 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.
Copyright © 2026