Getting Started
Run your first durable workflow in an existing NestJS app — install the module, write a workflow, register it, and start a run. Zero infrastructure with the event-emitter transport.
This guide gets a workflow running in an existing NestJS app in a few minutes — no broker, no database, using the in-process event-emitter transport and an in-memory store. Swap those for BullMQ and an ORM store when you're ready to go to production.
1. Install
pnpm add @dudousxd/nestjs-durable @dudousxd/nestjs-durable-core @dudousxd/nestjs-durable-transport-event-emitter @nestjs/event-emitter zodnpm i @dudousxd/nestjs-durable @dudousxd/nestjs-durable-core @dudousxd/nestjs-durable-transport-event-emitter @nestjs/event-emitter zodyarn add @dudousxd/nestjs-durable @dudousxd/nestjs-durable-core @dudousxd/nestjs-durable-transport-event-emitter @nestjs/event-emitter zod2. Define your steps
Steps are plain provider methods marked with @Step — there's one always-dispatched ctx.step,
and the engine routes each call to a handler by name, giving it a checkpoint and exactly-once
execution. Add an optional typed contract (zod input/output) and a retry policy where you want
them:
import { Step } from '@dudousxd/nestjs-durable';
import { Injectable } from '@nestjs/common';
import { z } from 'zod';
@Injectable()
export class PaymentsWorker {
@Step()
async reserveStock(order: { id: string; total: number }) {
return { reserved: true };
}
@Step({
name: 'payments.charge-card',
input: z.object({ orderId: z.string(), amountCents: z.number().int() }),
output: z.object({ chargeId: z.string() }),
retries: 3,
})
async chargeCard(input: { orderId: string; amountCents: number }) {
return { chargeId: `ch_${input.orderId}` };
}
@Step()
async ship(order: { id: string; total: number }) {
return { shipped: true };
}
}3. Write the workflow
import { Workflow } from '@dudousxd/nestjs-durable';
import type { WorkflowCtx } from '@dudousxd/nestjs-durable-core';
import { PaymentsWorker } from './payments.worker';
@Workflow({ name: 'checkout', version: '1' })
export class CheckoutWorkflow {
constructor(private readonly payments: PaymentsWorker) {}
async run(ctx: WorkflowCtx, order: { id: string; total: number }) {
await ctx.step(this.payments.reserveStock, order);
const charge = await ctx.step(this.payments.chargeCard, {
orderId: order.id,
amountCents: order.total,
});
const approval = await ctx.waitForSignal<{ approved: boolean }>(`approve:${order.id}`);
if (!approval.approved) return { status: 'rejected', chargeId: charge.chargeId };
await ctx.step(this.payments.ship, order);
return { status: 'shipped', chargeId: charge.chargeId };
}
}4. Register the module
import { DurableModule } from '@dudousxd/nestjs-durable';
import { InMemoryStateStore } from '@dudousxd/nestjs-durable-core';
import { EventEmitterTransport } from '@dudousxd/nestjs-durable-transport-event-emitter';
import { Module } from '@nestjs/common';
import { EventEmitter2, EventEmitterModule } from '@nestjs/event-emitter';
import { CheckoutWorkflow } from './checkout.workflow';
import { PaymentsWorker } from './payments.worker';
@Module({
imports: [
EventEmitterModule.forRoot(),
DurableModule.forRootAsync({
inject: [EventEmitter2],
useFactory: (emitter: EventEmitter2) => ({
store: new InMemoryStateStore(),
transport: new EventEmitterTransport(emitter),
}),
}),
],
providers: [CheckoutWorkflow, PaymentsWorker],
})
export class AppModule {}5. Start a run
Inject WorkflowService and start the workflow. start enqueues the run and returns
immediately with { runId, status: 'pending' } — the HTTP handler never blocks on workflow logic.
A worker executes the body (by default, the same instance, on a microtask), dispatches its steps,
then suspends on the approval signal; a webhook resumes it:
constructor(private readonly workflows: WorkflowService) {}
async checkout(order: Order) {
const { runId } = await this.workflows.start('checkout', order); // → { status: 'pending' }
return runId; // respond now; the worker runs the workflow
}
// later, from your approval webhook:
async approve(orderId: string) {
await this.workflows.signal(`approve:${orderId}`, { approved: true }); // → completes & ships
}Need the outcome inline? await this.workflows.waitForRun(runId) resolves once the run settles
— a terminal state (completed/failed/cancelled/dead) or suspended:
const { runId } = await this.workflows.start('checkout', order);
const result = await this.workflows.waitForRun(runId); // resolves when the run settlesWorkflowService or WorkflowEngine?
WorkflowService is a thin, NestJS-idiomatic facade — its start delegates to the core
WorkflowEngine, defaulting the runId and adding conveniences (signal, signalWithStart,
waitForRun, publishEvent). Prefer it in application code. WorkflowEngine is also injectable
and equivalent for starting runs — reach for it directly when you want the lower-level engine
surface (driving runPending, recovery, an explicit runId you supply). Both accept a workflow
class (fully typed) or a name string:
// via the service (recommended)
await this.workflows.start(CheckoutWorkflow, order);
// via the engine, with an explicit idempotency key
await this.engine.start(CheckoutWorkflow, order, uuidv7());That's it — a durable workflow whose steps run in-process, that pauses for human approval and survives restarts. Next:
- Durability & replay — the one rule the model imposes.
- Transports — move steps to another process or a Python worker.
- State stores — persist to Postgres with your ORM.
- Observability — the control plane, OTel and Telescope.
Durable
Durable workflows for NestJS — write a workflow as plain code; every step is checkpointed, so it survives crashes and deploys. Steps can run across apps and languages, with a built-in control plane.
Comparison
How nestjs-durable compares to Temporal, Inngest, and BullMQ — what the suspend-model library approach buys you, and when a dedicated orchestration cluster or a managed platform is the better call.