Heximon Logo
Recipes

Reactive Choreography

A multi-step saga that compensates itself, with no central coordinator — each bounded context reacts to integration events over the transactional outbox and emits the next, with at-least-once delivery and per-handler idempotency.

A multi-step business flow — place an order, reserve inventory, take payment — must either complete fully or undo itself fully: a failed payment should release the reservation and cancel the order. A saga orchestrator does this with a central coordinator that holds the state and issues commands.

Reactive choreography does it with no coordinator: each context reacts to the events it cares about and emits the next one. The flow organizes itself, and the only thing connecting the contexts is the shape of the events they exchange.

This recipe builds the three-context flow in the gap's workflow compensation example. It stands on the transactional outbox — that is what makes each step durable.

The flow, first

Two paths, decided by whether payment succeeds. Each step reacts to one event and emits the next; the indentation shows that causation. Every emit rides the outbox (see the next section).

Happy pathPOST /orders/place { "failPayment": false }:

Orders      save Order(pending)      ──▶ order.placed
└─ Inventory   save Reservation       ──▶ inventory.reserved
   └─ Payment     save Payment(ok)     ──▶ payment.processed
      └─ Orders      mark Order(paid)            ✓ done

Compensation pathPOST /orders/place { "failPayment": true }:

Orders      save Order(pending)      ──▶ order.placed
└─ Inventory   save Reservation       ──▶ inventory.reserved
   └─ Payment     save Payment(failed) ──▶ payment.failed
      └─ Inventory    release Reservation ──▶ reservation.released
         └─ Payment      (no write)        ──▶ order.cancelled
            └─ Orders       mark Order(cancelled)  ✓ done

No context imports another. The failPayment flag rides the payloads from order.placed down to the payment step, where it deterministically picks the branch — so a test can drive both paths with no real gateway.

The only coupling: one shared event file

Because the contexts never call each other, the event classes and their payload interfaces are the entire contract. They live in one statically-checked file every producer and consumer imports — inject an unknown event class, or one with the wrong payload, and it is a compile error.

src/events.ts
import { IntegrationEvent } from "@heximon/integration";

export interface OrderPlacedPayload {
  readonly orderId: string;
  readonly failPayment: boolean;
}
export interface InventoryReservedPayload {
  readonly orderId: string;
  readonly failPayment: boolean;
}
export interface PaymentProcessedPayload { readonly orderId: string; }
export interface PaymentFailedPayload    { readonly orderId: string; }
export interface ReservationReleasedPayload { readonly orderId: string; }
export interface OrderCancelledPayload   { readonly orderId: string; }

export class OrderPlaced      extends IntegrationEvent<"order.placed",         OrderPlacedPayload>      {}
export class InventoryReserved extends IntegrationEvent<"inventory.reserved",  InventoryReservedPayload> {}
export class PaymentProcessed extends IntegrationEvent<"payment.processed",    PaymentProcessedPayload> {}
export class PaymentFailed    extends IntegrationEvent<"payment.failed",       PaymentFailedPayload>    {}
export class ReservationReleased extends IntegrationEvent<"reservation.released", ReservationReleasedPayload> {}
export class OrderCancelled   extends IntegrationEvent<"order.cancelled",      OrderCancelledPayload>   {}
EventEmitted byConsumed byCarries
order.placedOrdersInventoryorderId, failPayment
inventory.reservedInventoryOrders, PaymentorderId, failPayment
payment.processedPaymentOrdersorderId
payment.failedPaymentInventoryorderId
reservation.releasedInventoryPaymentorderId
order.cancelledPaymentOrdersorderId

orderId threads the correlation through every hop, so each handler finds its own aggregate without a coordinator. Note inventory.reserved has two consumers — the transport fans an event out to every IntegrationEventHandler registered for that type.

Each step is an atomic write-and-emit

Every step has the same shape: react → guard → write the aggregate and emit the next event, atomically. Here is the inventory context reacting to order.placed:

src/inventory/order-placed.handler.ts
import { uuid } from "@heximon/runtime";
import type { IntegrationEventHandler } from "@heximon/integration";
import { InventoryReserved, type OrderPlacedPayload } from "../events";
import { InventoryReservation, type ReservationId } from "./inventory-reservation.entity";
import { InventoryReservationRepository } from "./inventory-reservation.repository";

export class OrderPlacedHandler implements IntegrationEventHandler<
  "order.placed",
  OrderPlacedPayload
> {
  public constructor(
    private readonly repository: InventoryReservationRepository,
    private readonly inventoryReserved: InventoryReserved,
  ) {}

  public handle(payload: OrderPlacedPayload): Promise<void> {
    return this.repository.runInTransaction(async () => {
      // Idempotency guard: skip if a reservation already exists for this order.
      const existing = await this.repository.getByOrderId(payload.orderId);

      if (existing !== undefined) {
        return;
      }

      const reservation = InventoryReservation.create(uuid.v7() as ReservationId, payload.orderId);

      // 1) The aggregate write.
      await this.repository.save(reservation);

      // 2) The integration event — buffered now, appended to the outbox on this transaction's commit
      //    hook, so the event is durable iff the reservation aggregate write is.
      await this.inventoryReserved.publish({
        orderId: payload.orderId,
        failPayment: payload.failPayment,
      });
    });
  }
}

The typed producer InventoryReserved is a class that extends IntegrationEvent<"inventory.reserved", InventoryReservedPayload> — injected by class identity, listed under integration: { events } in the inventory module. The load-bearing part is the transaction body:

inventoryReserved.publish routes through the bound IntegrationEventTransport. When the outbox transport is bound, publish buffers the event — the outbox row is appended on the transaction's onBeforeCommit hook, inside the same transaction as the aggregate. So the aggregate row and the event row commit together: a rollback drops both; a crash after commit keeps both.

The next step can't run unless this step durably happened, and this step can't durably happen without enqueuing the next one. That is the transactional-outbox guarantee — covered in the outbox recipe.

At-least-once → every handler is idempotent

The relay re-delivers an event if the process crashes after dispatching it but before deleting its outbox row. So each writing handler checks for the work it would do before doing it:

if (await this.repository.getByOrderId(payload.orderId)) return;
// a reservation already exists → a previous delivery wrote the correct state → skip

The orders-context handlers check the order's status instead (already paid? skip). An emit-only handler (like the one that turns reservation.released into order.cancelled) needs no guard of its own, because its consumer has one. For framework-level dedup that rejects re-deliveries before the handler runs, see Idempotency.

Wiring: one module per context

Each bounded context is a module; they share the outbox through imports. A module that emits events lists each typed producer class under integration: { events } — the compiler wires its publisher over the bound IntegrationEventTransport and makes it injectable by class identity. No satisfier class, no boot seam:

src/inventory/inventory.module.ts
import { Module } from "@heximon/runtime";
import { DatabaseModule } from "../database/database.module";
import { InventoryReserved, ReservationReleased } from "../events";
import { OutboxModule } from "../outbox/outbox.module";
import { InventoryReservationRepository } from "./inventory-reservation.repository";
import { OrderPlacedHandler } from "./order-placed.handler";
import { PaymentFailedHandler } from "./payment-failed.handler";

export class InventoryModule extends Module({
  imports: [DatabaseModule, OutboxModule],
  providers: [InventoryReservationRepository],
  integration: { events: [InventoryReserved, ReservationReleased],
    eventHandlers: [OrderPlacedHandler, PaymentFailedHandler],
  },
}) {}

The in-process MemoryIntegrationEventTransport is the default — assembled by the host at boot with no app boilerplate. Upgrading a context to at-least-once, transactional delivery means binding the OutboxIntegrationEventTransport satisfier in that module's providers (see the outbox recipe); every other context that only receives events keeps the memory default and needs no change.

Verify both paths in-process

The example's e2e test boots the app in-process with createTestApp and drives both flows through an in-process HTTP client (createTestClient). On Node there is no platform waitUntil sink, so the relay drain is awaited at the request frame's teardown — by the time POST /orders/place returns the whole chain has already run, and a follow-up GET reflects the terminal status with no polling or sleeps:

test/workflow-compensation.test.ts
const app = await createTestApp();   // boots a fresh isolated app (reads heximon.config.ts)
const client = createTestClient(app);

// happy path → paid
let res = await request("POST", "/orders/place", { failPayment: false });
let order = await request("GET", `/orders/${(await res.json()).id}`);
expect((await order.json()).status).toBe("paid");

// compensation path → cancelled
res = await request("POST", "/orders/place", { failPayment: true });
order = await request("GET", `/orders/${(await res.json()).id}`);
expect((await order.json()).status).toBe("cancelled");

(The full harness — createTestApp() reads the example's heximon.config.ts for HttpPlugin + QueuePlugin + IntegrationEventsPlugin, and createTestClient drives the booted app — is in the example's test/workflow-compensation.test.ts.)

See also

  • Reliable Integration Events — the transactional outbox this builds on.
  • Idempotency — framework-level eventId dedup on top of each handler's own guard.
  • Integration Events — the IntegrationEvent producer base and the IntegrationEventHandler consumer base.
  • Saga Orchestration — when choreography needs a clock: a process-state AggregateRoot + DurableTimer deadlines + TimeoutHandler compensation.
  • Workflow Compensation — the complete, runnable three-context app with happy and compensation paths and an in-process e2e test.
Copyright © 2026