Heximon Logo
Capabilities

Events

Decouple features with events across three tiers — the in-process EventBus, EventHandler and NotificationHandler, and domain events on aggregates with DomainEvents.emitFrom.

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:

TierReachDeliveryWhen to use
In-processSame appSynchronous, awaitedDecouple features in one app — notifications, logging, side effects
DomainSame app, after a saveSynchronous, flushed on demandReact to state changes an aggregate recorded, within one bounded context
IntegrationAcross servicesAsync, queue-backedCommunicate 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

Register the events compiler plugin alongside the HTTP plugin in heximon.config.ts:

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.

order-events.ts
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.)

confirmation-email.handler.ts
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()}`,
    );
  }
}
The event class is the 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.

order-metrics.handler.ts
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.

audit-event.ts
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.

audit-trail.handler.ts
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.

shop.controller.ts
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.

trace-events.interceptor.ts
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.

notifications.module.ts
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.

order.events.ts
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.

order.entity.ts
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.

orders.controller.ts
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.

record-shipment.handler.ts
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.

shipping.module.ts
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 Order aggregate that records domain events on ship() and a shipping context that reacts to them across bounded contexts.
Copyright © 2026