Decouple Features with Events
You want one feature to react to another without the two knowing about each other: placing an order should send a confirmation email and record a metric, but the order code shouldn't import any of that.
Events give you that seam — a publisher emits, subscribers react, and the only thing they share is the event's shape. Here you'll build a shop module that emits an order event and a separate notifications module that reacts to it, so that removing the subscriber is a legitimate no-op, not a compile error.
Pick the right tier
Heximon's three event tiers differ in scope (who can react), delivery (sync vs. queued), and failure policy (does a failed reaction roll back the originating work). Get the choice wrong and a metrics outage rolls back a paid order, or a critical email silently disappears.
| Tier | Bind a handler with | Scope & delivery | Pick it when… |
|---|---|---|---|
| In-process (serial) | implements EventHandler<SomeEvent> | same process, in the request | a handler must succeed for the originating work to count — its throw should roll the operation back |
| In-process (notification) | implements NotificationHandler<SomeEvent> | same process, after the serial bucket settles | the reaction is a side effect (metrics, projections) that must never roll back the originating work |
| Domain | implements DomainEventHandler<SomeEvent> | same process, flushed after a save | the event is recorded on an aggregate and should fire only once the write commits |
| Integration | implements IntegrationEventHandler<"type", Payload> | another context, drained from a queue | the reaction crosses a service boundary, or must survive the request that triggered it |
Stay in-process until the work must outlive the request — then reach for the integration tier and a queue. This recipe builds the in-process tier end to end, then shows where the domain and integration tiers take over.
1. Declare the event
An in-process event extends BaseEvent — its class identity is what handlers bind to, so there are no
string keys to keep in sync, and the constructor parameters are the typed payload. Extending BaseEvent
is what lets the publisher emit(new OrderPlacedEvent(...)) with no cast.
(A DDD app extends DomainEvent instead, which itself extends BaseEvent, to record the event on an
aggregate — see Model a DDD aggregate.)
import { BaseEvent } from "@heximon/events";
export interface OrderLineItem {
readonly productId: string;
readonly quantity: number;
}
export class OrderPlacedEvent extends BaseEvent {
public 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}`;
}
}
2. Write the handlers
A handler binds to the event by implementsing one of two bases, both declaring handle(event). The event
class is the type argument — implements EventHandler<OrderPlacedEvent> — which the compiler reads from
the type position, so the class is type-only here.
A serial EventHandler runs in declaration order, and a throw propagates back to the publisher — what you
want when the confirmation email is part of the order succeeding:
import type { EventHandler } from "@heximon/events";
import { OrderPlacedEvent } from "../shop/order-events"; // the bound event class is the type argument
import { EventLog } from "./event-log";
export class ConfirmationEmailHandler implements EventHandler<OrderPlacedEvent> {
public 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()}`,
);
}
}
A NotificationHandler runs concurrently after the serial bucket settles, and its failure is
absorbed — so a metrics outage can never undo a placed order:
import type { NotificationHandler } from "@heximon/events";
import type { OrderPlacedEvent } from "../shop/order-events";
import { EventLog } from "./event-log";
export class OrderMetricsHandler implements NotificationHandler<OrderPlacedEvent> {
public 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 ${totalUnits} unit(s)`);
}
}
The base you bind to is the policy, so there is no flag to get wrong.
3. Publish from the controller
The publisher injects EventBus (provided for you, by class identity) and emits, so the shop has no idea
who's listening. Use emitAsync to await the fan-out, so a serial handler's throw surfaces here on the
request's success path. Because OrderPlacedEvent extends BaseEvent, the live instance emits with no
cast — the base carries the dispatch stamp at the type level.
import { EventBus } from "@heximon/events";
import type { Controller, Post } from "@heximon/http";
import { type OrderLineItem, OrderPlacedEvent } from "./order-events";
interface PlaceOrderBody {
readonly customerEmail: string;
readonly lineItems: readonly OrderLineItem[];
}
export class ShopController implements Controller<"/shop"> {
public 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 = `order-${Date.now().toString(36)}`;
// Fan out to every handler bound to OrderPlacedEvent; emitAsync awaits them all.
await this.eventBus.emitAsync(new OrderPlacedEvent(orderId, body.customerEmail, body.lineItems));
return { orderId, status: "placed" };
}
}
Want the response to return before the handlers finish? eventBus.emit(...) is the fire-and-forget
variant — the handlers run to completion, but the reply doesn't block on them.
4. Wire the two modules
Register handlers under the events namespace — serial ones under handlers, parallel-absorb ones under
notificationHandlers, never in providers. That placement is what marks them as subscribers rather than
plain injectables; their dependencies (the shared EventLog) do go in providers.
import { Module } from "@heximon/runtime";
import { ConfirmationEmailHandler } from "./confirmation-email.handler";
import { EventLog } from "./event-log";
import { OrderMetricsHandler } from "./order-metrics.handler";
export class NotificationsModule extends Module({
providers: [EventLog],
events: {
handlers: [ConfirmationEmailHandler], // serial — a throw rolls back the publisher
notificationHandlers: [OrderMetricsHandler], // parallel-absorb — failures are swallowed
},
exports: [EventLog],
}) {}
The shop module owns only the publisher and never imports the notifications module:
import { Module } from "@heximon/runtime";
import { ShopController } from "./shop.controller";
export class ShopModule extends Module({
http: { controllers: [ShopController] },
}) {}
The root module composes both, and the bus is the only seam between them. Drop NotificationsModule and
the shop happily emits into the void — no compile error, because nothing imports across the boundary:
import { Module } from "@heximon/runtime";
import { NotificationsModule } from "./notifications/notifications.module";
import { ShopModule } from "./shop/shop.module";
export class AppModule extends Module({
imports: [ShopModule, NotificationsModule],
}) {}
5. Register the plugin and run it
Add the events compiler plugin next to 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()],
});
vite.config.ts itself stays unchanged — the plugin takes no arguments:
import heximon from "@heximon/build/vite";
import { defineConfig } from "vite-plus";
export default defineConfig({ plugins: [heximon()] });
Then place an order and watch the fan-out:
curl -s -X POST localhost:3000/shop/orders \
-H 'content-type: application/json' \
-d '{"customerEmail":"alice@example.com","lineItems":[{"productId":"p1","quantity":2}]}'
# → { "orderId": "...", "status": "placed" }
# the confirmation email (serial) ran before the response; the metrics handler fanned out too
Cross a boundary with the integration tier
When a reaction must outlive the request — a welcome email after sign-up, a downstream service that
consumes your events — move it to the integration tier. You declare a typed IntegrationEvent producer
class, inject it by class identity, and call publish(data).
The compiler wires the publisher over the bound IntegrationEventTransport (the in-process
MemoryIntegrationEventTransport default, assembled by the host at boot — zero boilerplate). A handler
class per consumer drains the channel.
The producer class carries the event's name and payload as type arguments, so a wrong name or payload shape is a compile error — there is no separate shared map or string key to keep in sync:
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 controller injects the typed event by class identity and publishes — await publish(data) hands the
work off to the transport and resolves, so the sign-up responds 201 without waiting on the email:
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();
await this.userSignedUp.publish({ userId, name: body.name, email: body.email });
action.response.status = 201;
return { id: userId, email: body.email };
}
}
List the producer under integration: { events } in its owning module — the compiler wires its
publisher and makes it injectable. No transport boilerplate is needed in the simple case:
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 consumer implements IntegrationEventHandler<"user.signed-up", Payload>; its handle runs later, off the
request path, when the transport delivers the event on its channel:
import type { IntegrationEventHandler } from "@heximon/integration";
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> {
// ...compose and send the welcome email through the injected transport
}
}
Integration handlers are declared under integration: { eventHandlers }. For transactional delivery
— where the integration event must commit atomically with the aggregate write and survive crashes —
swap in OutboxIntegrationEventTransport (the @heximon/integration transactional-outbox transport
satisfier) by providing it as the IntegrationEventTransport binding in your module.
The in-process memory default applies when no transport is explicitly bound. Wiring that end to end is its own recipe: Background jobs.
See also
- Background jobs — the integration tier and a typed queue, drained in memory for dev and durable in production.
- Model a DDD aggregate — record domain events on an aggregate and flush them
with
DomainEventsafter the write commits. - Event-driven architecture — the full bus contract: the tiers, delivery policies,
and the string-keyed
EventMapfor dynamically-named events. - the ladder's L03 — Events — a publisher with no handler imports, serial and parallel-absorb subscribers, and a test asserting exactly which handlers ran, in which order.
- the ladder's L08 — Queue — the integration tier end to end: sign-up emits, the queue drains, and a welcome email is sent off the request path.
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.
Build a DDD Aggregate
Model an Order aggregate root with branded ids, value objects, invariants, DomainEvent recording, DomainEvents.emitFrom flushing, and a cross-context DomainEventHandler.