Saga Orchestration
Reactive choreography handles multi-step flows well when every step either succeeds or is compensated by a downstream event. One pattern it cannot express without external help is a deadline: "if payment is not confirmed within 30 seconds, cancel the reservation and the order." That requires a clock that survives process restarts.
Heximon's @heximon/saga package provides that clock as DurableTimer — a one-shot deadline that commits to a
database row inside the same transaction as the saga state write, fires when it elapses, and delivers its payload
as an ordinary integration event.
SagaTimer is the typed injectable veneer you author against: it encapsulates the identity and channel
computation, so your handlers never hand-build a WakeupRequest or recompute a channel string. No setAlarm,
no step.sleep, no external scheduler.
The mental model
The saga pattern names its two ingredients:
- A process-state aggregate — an
AggregateRootwhoseidis the correlation key (e.g. theorderId). It holds the current step of the orchestration (placed,awaiting-payment,completed,cancelled) and enforces that transitions happen in the only legal order. Each transition is version-guarded: a re-delivered integration event that finds the aggregate already past its expected step throws, and the retry loop absorbs the throw as a duplicate. - A
SagaTimersubclass — one per deadline slot. Declare it with aslotfield; list it undersaga: { timers }, and inject it into any handler that needs to arm or cancel that deadline. Callarm(correlationKey, payload, after)inside a transaction and the timer row commits atomically with the saga write.cancel(correlationKey)removes it the same way.
When the deadline elapses the payload is re-emitted on the"saga.timeout.<slot>"channel and the boundTimeoutHandler<YourTimer>receives it through the same"queue:channel"fan-out integration events use. The slot is declared once — theslotfield, which the compiler reads off the discovered timer and the runtime uses to arm/cancel — so there is no string-slot argument to keep in sync. - A
TimeoutHandler<YourTimer>— the consumer side. Linked to the timer by class identity (TimeoutHandler<OrderPaymentDeadline>), not a string literal slot. The payload type is inferred from the timer, and the dispatch channel is computed by the compiler from the bound timer'sslotfield. The stale-timeout guard in theTimeoutHandleris the same idempotency guard every integration-event handler uses: load the aggregate, check its step, return early if the transition no longer applies.
SagaTimer delegates to the lower-level DurableTimer abstract port under the hood — inject DurableTimer
directly only if you need a custom arming shape; standard saga authoring stays on SagaTimer.
Payloads are persisted with devalue, so a Date / Map / Set /
BigInt fires with the type it was armed with; a non-plain payload (a class instance, a function) throws at arm
time rather than degrading silently — arm with plain data.
Declare the process aggregate
An OrderSaga aggregate owns the orchestration state. Its id is the orderId — the same value used as the
timer's correlationKey, so the deadline is addressed by the order it guards:
import type { Branded } from "@heximon/primitives";
import { ConflictError } from "@heximon/runtime/errors";
import { AggregateRoot } from "@heximon/domain";
export type OrderSagaId = Branded<string, "OrderSagaId">;
export type OrderSagaStep = "placed" | "awaiting-payment" | "completed" | "cancelled";
interface OrderSagaProps extends Record<string, unknown> {
step: OrderSagaStep;
}
export class OrderSaga extends AggregateRoot<OrderSagaProps, OrderSagaId> {
public static start(id: OrderSagaId): OrderSaga {
return new OrderSaga(id, { step: "placed" });
}
public get step(): OrderSagaStep {
return this.props.step;
}
public isAwaitingPayment(): boolean {
return this.props.step === "awaiting-payment";
}
public awaitPayment(): void {
this.assertStep("placed", "awaitPayment");
this.props.step = "awaiting-payment";
}
public complete(): void {
this.assertStep("awaiting-payment", "complete");
this.props.step = "completed";
}
public cancel(): void {
this.assertStep("awaiting-payment", "cancel");
this.props.step = "cancelled";
}
private assertStep(expected: OrderSagaStep, transition: string): void {
if (this.props.step !== expected) {
throw new ConflictError(
`OrderSaga ${this.id}: cannot ${transition} from step "${this.props.step}" ` +
`(expected "${expected}")`,
);
}
}
}
The assertStep guard makes every transition idempotent under at-least-once delivery — a duplicate event throws
inside the transaction, rolls back, and the relay absorbs the throw.
Declare a SagaTimer
Declare one concrete SagaTimer subclass per deadline slot. It encapsulates the slot and the derived
"saga.timeout.<slot>" channel, so the slot is declared in exactly one place — the slot field, which the
compiler reads off the discovered timer and the runtime uses to arm / cancel.
List it under saga: { timers } (a discovered concept that is also injectable). No constructor is needed: the
compiler recovers the inherited DurableTimer dependency from the SagaTimer base.
import { TimeSpan } from "@heximon/primitives";
import { SagaTimer } from "@heximon/saga";
export interface PaymentDeadlinePayload {
readonly orderId: string;
}
export class OrderPaymentDeadline extends SagaTimer<PaymentDeadlinePayload> {
public static readonly window: TimeSpan = new TimeSpan(50, "ms");
protected readonly sagaName = "OrderSaga";
protected readonly slot = "payment";
}
Two declared fields identify the deadline: slot (the channel suffix the compiler reads to derive
"saga.timeout.payment") and sagaName (which namespaces the timer in the durable store). Keep sagaNamestable across class renames so a rename never orphans timers already armed under the old name.
TimeSpan (from @heximon/primitives) accepts units "ms", "s", "m", "h", "d", "w" — no constructor
is needed on the timer class itself; the compiler recovers the inherited DurableTimer dependency.
Arm a deadline
A saga step handler injects the OrderPaymentDeadline by class identity. Inside its runInTransaction body it
calls arm(correlationKey, payload, after) — sync-void and transactional: the timer row commits atomically with
the saga state write. A rollback discards both.
import type { IntegrationEventHandler } from "@heximon/integration";
import { type InventoryReservedPayload, PaymentRequested } from "../events";
import { OrderPaymentDeadline } from "./order-payment-deadline";
import { type OrderSagaId } from "./order-saga.entity";
import { OrderSagaRepository } from "./order-saga.repository";
export class InventoryReservedSagaHandler implements IntegrationEventHandler<
"inventory.reserved",
InventoryReservedPayload
> {
public constructor(
private readonly repository: OrderSagaRepository,
private readonly deadline: OrderPaymentDeadline,
private readonly paymentRequested: PaymentRequested,
) {}
public handle(payload: InventoryReservedPayload): Promise<void> {
return this.repository.runInTransaction(async () => {
const saga = await this.repository.getById(payload.orderId as OrderSagaId);
if (saga === undefined) {
return;
}
saga.awaitPayment();
await this.repository.save(saga);
// Sync-void: the timer row commits atomically with the saga write.
this.deadline.arm(saga.id, { orderId: saga.id }, OrderPaymentDeadline.window);
await this.paymentRequested.publish({
orderId: saga.id,
abandonPayment: payload.abandonPayment,
});
});
}
}
Cancel on success
When payment is confirmed the handler cancels the armed deadline inside the same transaction as the completed
saga write, so the deadline can never fire after payment:
import type { IntegrationEventHandler } from "@heximon/integration";
import type { PaymentProcessedPayload } from "../events";
import { OrderPaymentDeadline } from "./order-payment-deadline";
import { type OrderSagaId } from "./order-saga.entity";
import { OrderSagaRepository } from "./order-saga.repository";
export class PaymentProcessedSagaHandler implements IntegrationEventHandler<
"payment.processed",
PaymentProcessedPayload
> {
public constructor(
private readonly repository: OrderSagaRepository,
private readonly deadline: OrderPaymentDeadline,
) {}
public handle(payload: PaymentProcessedPayload): Promise<void> {
return this.repository.runInTransaction(async () => {
const saga = await this.repository.getById(payload.orderId as OrderSagaId);
if (saga === undefined) {
return;
}
saga.complete();
await this.repository.save(saga);
// Sync-void: the timer row is removed atomically with the `completed` state.
this.deadline.cancel(saga.id);
});
}
}
cancel(correlationKey) — just the saga instance id; sagaName and slot are already on the SagaTimer
subclass.
Handle a fired timeout
A TimeoutHandler<Timer> is the consumer side of the deadline — the type argument is the SagaTimer subclass,
not a string slot. The compiler reads that timer's slot = "payment" field and registers this handler under the
derived "saga.timeout.payment" channel; the payload type is inferred from the timer via SagaTimerPayload<Timer>.
When a deadline fires, its payload is re-emitted on that channel and this handler receives it through the shared
"queue:channel" dispatch. The stale-timeout guard protects against a re-delivered fired timer:
import { TimeoutHandler } from "@heximon/saga";
import { OrderCancelled, ReservationReleased } from "../events";
import { OrderPaymentDeadline, type PaymentDeadlinePayload } from "./order-payment-deadline";
import { type OrderSagaId } from "./order-saga.entity";
import { OrderSagaRepository } from "./order-saga.repository";
export class PaymentTimeoutHandler extends TimeoutHandler<OrderPaymentDeadline> {
public constructor(
private readonly repository: OrderSagaRepository,
private readonly reservationReleased: ReservationReleased,
private readonly orderCancelled: OrderCancelled,
) {
super();
}
public override handle(payload: PaymentDeadlinePayload): Promise<void> {
return this.repository.runInTransaction(async () => {
const saga = await this.repository.getById(payload.orderId as OrderSagaId);
if (saga === undefined) {
return;
}
// Stale-timeout guard: if the saga already advanced past `awaiting-payment`
// (payment arrived and cancelled the deadline, or compensation already ran),
// the fired timer is a no-op.
if (!saga.isAwaitingPayment()) {
return;
}
saga.cancel();
await this.repository.save(saga);
await this.reservationReleased.publish({ orderId: saga.id });
await this.orderCancelled.publish({ orderId: saga.id });
});
}
}
PaymentDeadlinePayload is imported from the timer file, keeping the payload type co-located with the slot it
belongs to.
Wire the durable timer store
DurableTimer is an abstract DI token — its satisfier is not listed in providers. The SagaPlugin emits the
NodeDurableTimer as a core provider automatically once it discovers timeout handlers. You only need to provide
the DurableTimerStore satisfier and declare the durable_timers table it reads.
Declare the table by spreading durableTimerColumns(dialect), with the (saga_name, correlation_key, slot)
triple as the composite primary key (what makes arm an upsert):
import { primaryKey, sqliteTable } from "drizzle-orm/sqlite-core";
import { durableTimerColumns } from "@heximon/saga/node";
export const durableTimers = sqliteTable(
"durable_timers",
{ ...durableTimerColumns("sqlite") }, // or "pg" / "mysql" with the matching table builder
(columns) => [primaryKey({ columns: [columns.sagaName, columns.correlationKey, columns.slot] })],
);
Bind the one-hop store satisfier:
import { DurableTimerStore } from "@heximon/saga";
import { DrizzleDurableTimerStore } from "@heximon/saga/node";
import { Database } from "../database/database";
import { durableTimers } from "./durable-timers-table";
export class AppDurableTimerStore extends DrizzleDurableTimerStore implements DurableTimerStore {
public constructor(database: Database) {
super(database, durableTimers);
}
}
Both extends DrizzleDurableTimerStore and implements DurableTimerStore are required: extends provides the
implementation; implements makes the class satisfy the abstract token.
Wire the module
List each typed IntegrationEvent producer under integration: { events }, the SagaTimer subclass under
saga: { timers }, and the TimeoutHandler subclass under saga: { timeouts }. AppDurableTimerStore and a
Database must be reachable in the same module's DI scope. DurableTimer itself is NOT listed in providers —
SagaPlugin emits it as a core provider:
import { Module } from "@heximon/runtime";
import {
OrderCancelled,
OrderPlaced,
PaymentRequested,
ReservationReleased,
} from "../events";
import { DatabaseModule } from "../database/database.module";
import { OutboxModule } from "../outbox/outbox.module";
import { AppDurableTimerStore } from "./durable-timer-store";
import { InventoryReservedSagaHandler } from "./inventory-reserved.handler";
import { OrderPaymentDeadline } from "./order-payment-deadline";
import { OrderSagaRepository } from "./order-saga.repository";
import { OrdersController } from "./orders.controller";
import { PaymentProcessedSagaHandler } from "./payment-processed.handler";
import { PaymentTimeoutHandler } from "./payment-timeout.handler";
export class SagaModule extends Module({
imports: [DatabaseModule, OutboxModule],
providers: [OrderSagaRepository, AppDurableTimerStore],
http: { controllers: [OrdersController] },
integration: { events: [OrderPlaced, PaymentRequested, ReservationReleased, OrderCancelled],
eventHandlers: [InventoryReservedSagaHandler, PaymentProcessedSagaHandler],
},
saga: {
timers: [OrderPaymentDeadline],
timeouts: [PaymentTimeoutHandler],
},
exports: [OrderSagaRepository],
}) {}
Register SagaPlugin in heximon.config.ts after QueuePlugin (the saga plugin contributes its handlers into
the queue dispatch table, so QueuePlugin must be listed first):
import { defineHeximonConfig } from "@heximon/build";
import { IntegrationEventsPlugin } from "@heximon/integration/compiler";
import { HttpPlugin } from "@heximon/http/compiler";
import { QueuePlugin } from "@heximon/queue/compiler";
import { SagaPlugin } from "@heximon/saga/compiler";
export default defineHeximonConfig({
plugins: [new HttpPlugin(), new QueuePlugin(), new IntegrationEventsPlugin(), new SagaPlugin()],
});
SagaTimerStoreUnbound — a build error fires when timeout handlers are declared but no concrete
DurableTimerStore satisfier AND a Database satisfier are both reachable from the timeout handlers' owning
module's DI scope. Both must be present: AppDurableTimerStore (in providers) plus a Database reachable
via imports or providers.This check is node-only — the edge tier needs no store or database (the Durable Object owns the timer
state), so it is not raised on an edge build.Migrations
The durable_timers table is the one declared above (spreading durableTimerColumns(dialect) from
@heximon/saga/node). There is no framework-shipped migration — generate it from your declared schema with
drizzle-kit, the same as your domain tables.
Run it on Cloudflare
The DurableTimer port your saga step injects is the same on every platform — only the satisfier the compiler
bakes in differs per build target. On an edge build for Cloudflare, SagaPlugin swaps in a shipped
TimerRoom Durable Object (one alarm per (sagaName, correlationKey, slot) triple) instead of the Node poller
— no durable_timers table, no DurableTimerStore.
See Deploy to Cloudflare for the wrangler wiring and the Durable Object alarm mechanics.
When to use a saga
A saga trades choreography's simplicity for a deadline and a queryable process state — see the decision table in the Architecture overview for how it stacks up against plain choreography and workflows.
See also
- Reactive Choreography — the pattern this builds on; start here if you don't need a deadline.
- Reliable Integration Events — the transactional outbox that makes each step durable.
- Integration Events — the
IntegrationEventHandlertier a saga's step and timeout handlers are built on. - Domain-Driven Design —
AggregateRootandRepository, the process-state aggregate a saga persists. - Deploy to Cloudflare — the
TimerRoomDurable Object leg andwrangler.jsonwiring for the edge tier. - Saga orchestration — the complete, runnable three-context app; its test suite proves both the happy path (deadline cancelled) and the compensation path (deadline fires) with an in-process e2e.
Integration Events
Cross-context and cross-service events with IntegrationEvent producers, IntegrationEventHandler consumers, and the transactional outbox (OutboxStore, OutboxRelay, OutboxIntegrationEventTransport).
Durable Workflows
Workflow, WorkflowFactory, WorkflowStep, NodeWorkflowEngine — a linear imperative procedure that memoizes each step and survives restarts, with step.do durable checkpoints and step.sleep durable suspend/resume.