Heximon Logo
Recipes

Background Jobs

Move work off the request path with a typed IntegrationEvent producer, an IntegrationEventHandler consumer, an in-process MemoryIntegrationEventTransport default, and an optional transactional-outbox tier for at-least-once delivery.

Sending a welcome email, regenerating a report, calling a slow third-party API — none of that should make the user wait for their 201. This recipe moves that work off the request path: an endpoint publishes a typed integration event and returns immediately, and a separate handler does the slow work later, when the transport delivers it.

By the end you'll have POST /users that responds in milliseconds and a WelcomeEmailHandler that runs the email send afterward. The full app is the ladder's L08 — Queue.

1. Define the typed producer event

The producer and consumer share one statically-checked contract: a payload interface and a typed event class. The event name lives as the first type argument of IntegrationEvent<"name", Payload> — the compiler reads it at build time to derive the dispatch channel and wire the publisher. Define both in one file, centralized so the consumer imports the payload type directly.

src/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> {}

The event class is the DI token. The controller injects it by class identity; the compiler wires its publish method over the bound IntegrationEventTransport. There is no type-map interface to maintain and no string channel to keep in sync — the literal "user.signed-up" in the type argument is the single source of truth for both the producer and the consumer.

2. List the producer in the module

Declare the typed event under the integration namespace's events key. That is the list the compiler scans to discover producers — not providers.

src/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] },
}) {}

The in-process MemoryIntegrationEventTransport is the default — assembled by the host at boot, bound automatically. There is no transport boilerplate, no QueueModule, no boot seam to call.

3. Publish from the endpoint

The controller receives the typed event as a constructor parameter — class identity is the token, the compiler injects it. Calling await this.userSignedUp.publish(data) hands the event off to the transport; the request responds 201 right away.

src/users/signup.controller.ts
import { uuid } from "@heximon/runtime";
import type { Controller, Post } from "@heximon/http";
import { UserSignedUp } from "./events";

interface SignupBody {
  readonly name: string;
  readonly email: string;
}

interface SignupResult {
  readonly id: string;
  readonly email: string;
}

export class SignupController implements Controller<"/users"> {
  private readonly userSignedUp: UserSignedUp;

  public constructor(userSignedUp: UserSignedUp) {
    this.userSignedUp = userSignedUp;
  }

  public async signUp(action: Post<"/">): Promise<SignupResult> {
    const body = (await action.request.json()) as SignupBody;
    const userId = uuid.v7();

    // Publish the integration event; the welcome email is sent when the in-process transport
    // delivers it on the prefixed channel and the WelcomeEmailHandler runs.
    await this.userSignedUp.publish({
      userId,
      name: body.name,
      email: body.email,
    });

    action.response.status = 201;
    return { id: userId, email: body.email };
  }
}

publish is fire-and-forget at the business level — it resolves once the work is handed off to the transport. On Node the dispatch rides the request frame's waitUntil, so the response goes out before the email is ever sent.

4. Handle the work in the background

The consumer is one class per event: class X implements IntegrationEventHandler<"user.signed-up", Payload>. Heximon discovers it from that base — no decorator — and runs its handle when the transport delivers the event. Its constructor declares dependencies the normal way, so the email transport arrives by class identity.

src/mail/welcome-email.handler.ts
import type { IntegrationEventHandler } from "@heximon/integration";
import { EmailMessage } from "@heximon/notifications/email";
import type { UserSignedUpPayload } from "../users/events";
import { CaptureEmailTransport } from "./capture-email-transport";

type SignedUpPayload = UserSignedUpPayload;

export class WelcomeEmailHandler implements IntegrationEventHandler<
  "user.signed-up",
  SignedUpPayload
> {
  private readonly transport: CaptureEmailTransport;

  public constructor(transport: CaptureEmailTransport) {
    this.transport = transport;
  }

  public async handle(payload: SignedUpPayload): Promise<void> {
    const message = new EmailMessage({
      from: { name: "Heximon Example", email: "noreply@example.com" },
      to: { name: payload.name, email: payload.email },
      subject: "Welcome aboard!",
      content: [
        "<p>Hi {{ name }},</p>",
        "<p>Thanks for signing up. Your account id is <code>{{ userId }}</code>.</p>",
        "<p>— The Heximon team</p>",
      ].join("\n"),
      placeholders: {
        name: payload.name,
        userId: payload.userId,
      },
    });

    await this.transport.send(message);
  }
}

The event name in the IntegrationEventHandler<type, Payload> type argument must be a string literal — Heximon reads it at build time to route the event here, so it has to be a constant the compiler can see.

5. Wire the consumer module

Declare the handler under the integration namespace's eventHandlers key — that is the list Heximon scans to find it. Its plain dependency, the email transport, stays in providers.

src/mail/mail.module.ts
import { Module } from "@heximon/runtime";
import { CaptureEmailTransport } from "./capture-email-transport";
import { MailController } from "./mail.controller";
import { WelcomeEmailHandler } from "./welcome-email.handler";

export class MailModule extends Module({
  providers: [CaptureEmailTransport],
  http: { controllers: [MailController] },
  integration: { eventHandlers: [WelcomeEmailHandler] },
  exports: [CaptureEmailTransport],
}) {}

CaptureEmailTransport is a test stand-in that records what it "sends" so you can inspect it. Because it extends EmailTransport, going to production is a one-line change — provide ResendTransport here instead, and the handler is untouched.

6. Run it

terminal
curl -s -X POST localhost:3010/users \
  -H 'content-type: application/json' \
  -d '{"name":"Alice","email":"alice@example.com"}'
# → 201 immediately; the welcome email is sent when the transport delivers the event, off the request path

Transactional outbox option

The recipe above is fire-and-forget: publish hands the event to the in-process transport and the handler runs asynchronously. That works well for low-stakes notifications, but it has a gap — if the process crashes after POST /users commits the user but before the transport delivers the event, the "user.signed-up" event is lost.

The transactional outbox closes that gap: the integration event is written to an outbox table inside the same database transaction as the aggregate save. After the commit, a relay drains the outbox row and dispatches it to the handler. A crash after the commit replays the rows on boot; a crash before the commit discards the buffer with the rolled-back write — no ghost deliveries.

This is the pattern in the gap's reliable cross-service example. The atomic write-and-publish pattern looks like this in the controller:

src/orders/orders.controller.ts
await this.repository.runInTransaction(async () => {
  await this.repository.save(order);             // aggregate write
  await this.orderPlaced.publish({               // buffered → outbox row on onBeforeCommit
    orderId: order.id,
    customer: order.customer,
    totalCents: order.totalCents,
  });
});
// after the commit the relay schedules; after dispatch the handler runs in the notifications context

The OrderPlaced typed producer is the same pattern as UserSignedUp — an IntegrationEvent<"order.placed", Payload> class the controller injects and calls publish on. The only difference is the bound transport: this module provides an OutboxIntegrationEventTransport satisfier instead of relying on the memory default.

To opt in, provide four thin app-local subclasses of the shipped integration + drizzle + queue pieces in a shared outbox module. Because each constructor is authored in your source, the compiler reads the parameter types as the dependency declaration and injects them directly — no useFactory:

src/outbox/outbox.ts
import { Context, TransactionContext } from "@heximon/runtime";
import { OutboxIntegrationEventTransport, OutboxRelay, OutboxStore } from "@heximon/integration";
import { DrizzleOutboxStore } from "@heximon/drizzle/core";
import { IntegrationEventTransport, QueueChannelDispatcher } from "@heximon/queue";
import { MemoryIntegrationEventTransport } from "@heximon/queue/memory";
import { Database } from "../database/database";
import { outbox } from "../database/schema";

// Store: the OutboxStore port backed by the app's `outbox` table.
export class AppOutboxStore extends DrizzleOutboxStore implements OutboxStore {
  public constructor(database: Database) { super(database, outbox); }
}

// The relay's in-process delivery terminal (bound separately since the producer binds
// its own IntegrationEventTransport — the outbox one — which suppresses the memory default).
export class AppRelayTransport extends MemoryIntegrationEventTransport {
  public constructor(dispatcher: QueueChannelDispatcher) { super(dispatcher); }
}

// Relay: drains pending rows after commit via AppRelayTransport.
export class AppOutboxRelay extends OutboxRelay {
  public constructor(store: AppOutboxStore, target: AppRelayTransport, context: Context) {
    super(store, target, context);
  }
}

// Producer transport: buffers each publish, appends to outbox on onBeforeCommit, schedules relay
// on onCommit. implements IntegrationEventTransport so the OrderPlaced producer resolves it.
export class AppOutboxTransport
  extends OutboxIntegrationEventTransport
  implements IntegrationEventTransport
{
  public constructor(
    database: Database,
    store: AppOutboxStore,
    relay: AppOutboxRelay,
    context: Context,
    transactionContext: TransactionContext,
  ) {
    super(database, store, relay, context, transactionContext);
  }
}

Then list them all in a shared OutboxModule's providers — every constructor above is authored in app source, so the compiler reads its parameter types directly and injects it, bare, no useFactory needed even for AppOutboxRelay. OutboxRelay's OnBootstrap hook (the crash-recovery boot replay) is inherited, not redeclared, and still runs automatically. A bare TransactionContext provider (its constructor is already a plain DI token — just the app Context) and a useExisting alias round it out: both AppOutboxTransport and AppRelayTransport satisfy the abstract IntegrationEventTransport, so the alias makes every producer's injection resolve to the outbox transport, never the relay's terminal.

That disambiguation is also why AppOutboxRelay's own constructor asks for AppRelayTransport by its concrete class instead of the abstract IntegrationEventTransport — this module binds that abstract token to AppOutboxTransport, so injecting the exact terminal class sidesteps the ambiguity instead of tripping over the same alias:

src/outbox/outbox.module.ts
import { Module, TransactionContext } from "@heximon/runtime";
import { IntegrationEventTransport } from "@heximon/queue";
import { DatabaseModule } from "../database/database.module";
import { AppOutboxRelay, AppOutboxStore, AppOutboxTransport, AppRelayTransport } from "./outbox";

export class OutboxModule extends Module({
  imports: [DatabaseModule],
  providers: [
    AppOutboxStore,
    AppRelayTransport,
    AppOutboxRelay,
    AppOutboxTransport,
    { provide: IntegrationEventTransport, useExisting: AppOutboxTransport },
    TransactionContext,
  ],
  exports: [AppOutboxStore, AppRelayTransport, AppOutboxRelay, AppOutboxTransport, IntegrationEventTransport],
}) {}

The producer module imports OutboxModule and lists its own typed event under integration: { events } — no other change is needed; the producer's publish now routes through the shared outbox transport:

src/orders/orders.module.ts
import { Module } from "@heximon/runtime";
import { DatabaseModule } from "../database/database.module";
import { OrderPlaced } from "./events";
import { OrderRepository } from "./order.repository";
import { OrdersController } from "./orders.controller";
import { OutboxModule } from "../outbox/outbox.module";

export class OrdersModule extends Module({
  imports: [DatabaseModule, OutboxModule],
  providers: [OrderRepository],
  http: { controllers: [OrdersController] },
  integration: { events: [OrderPlaced] },
  exports: [OrderRepository],
}) {}

Delivery is at-least-once (a crash between dispatch and row deletion can re-deliver), so handlers should be idempotent — dedup on the event id or the aggregate id before acting.

Swap to Cloudflare Queue in production

The handler is bound to the event name, not to a transport, so the only thing that changes is the transport the producer publishes through. Bind CloudflareQueueIntegrationEventTransport (@heximon/queue/cloudflare) instead of relying on the in-process memory default, and let CloudflareQueueConsumer drain each batch in the worker's queue(batch, env, ctx) hook onto the same handlers you already wired.

The split is the point: your business logic — the typed event, the handler, the email — is identical in dev and production, so you develop with zero infrastructure and deploy without rewriting the work.

See also

  • Queue — the base transport, channel dispatch, and the memory and Cloudflare adapters in depth.
  • Events — decouple features in-process with the event bus when the work stays inside one request and one context, with no queue.
  • Domain-Driven Design — model the aggregates and domain events that integration events are published from.
  • the ladder's L08 — Queue — the full runnable app this recipe is drawn from: the sign-up endpoint, the welcome-email handler, a swappable email transport, and a test that drives POST /users → transport delivery → captured email end-to-end.
  • the gap's reliable cross-service example — the reliable integration tier end to end: an order controller that saves the aggregate and publishes the typed event in one transaction, an outbox relay that drains and dispatches after the commit, and a test proving that a rolled-back order delivers no event.
Copyright © 2026