Reactive Choreography
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 path — POST /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 path — POST /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.
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> {}
| Event | Emitted by | Consumed by | Carries |
|---|---|---|---|
order.placed | Orders | Inventory | orderId, failPayment |
inventory.reserved | Inventory | Orders, Payment | orderId, failPayment |
payment.processed | Payment | Orders | orderId |
payment.failed | Payment | Inventory | orderId |
reservation.released | Inventory | Payment | orderId |
order.cancelled | Payment | Orders | orderId |
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:
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:
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:
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
eventIddedup on top of each handler's own guard. - Integration Events — the
IntegrationEventproducer base and theIntegrationEventHandlerconsumer base. - Saga Orchestration — when choreography needs a clock: a process-state
AggregateRoot+DurableTimerdeadlines +TimeoutHandlercompensation. - Workflow Compensation — the complete, runnable three-context app with happy and compensation paths and an in-process e2e test.
Reliable Integration Events
Transactional outbox pattern for at-least-once integration event delivery — OutboxStore, OutboxIntegrationEventTransport, OutboxRelay, fire-and-forget tradeoff, the fire-and-forget compiler warning and its suppressDiagnostics opt-out.
Build a Production CRUD API
The full best-practice CRUD chain — a shared Contract + typed self-client, QuerySchema/PaginatedResponseSchema pagination, CQRS command/query handlers, and a Drizzle aggregate repository behind an EntityQuery mapper.