Child workflows
Compose workflows by calling other workflows — await a child's result with ctx.child, kick one off fire-and-forget with ctx.startChild, or fan out N children of the same workflow and wait for all with ctx.all. Pass the workflow class for a typed input and result, or a name string for a cross-runtime child.
A workflow can run another workflow as a child — to break a big process into reusable pieces, to fan out work, or to hand part of the job to a workflow in another service or language. Children are durable runs in their own right: their own history, retries, and dashboard entry.
There are two ways to call one, depending on whether the parent needs the result.
1@Workflow({ name: 'onboard', version: '1' })2export class OnboardWorkflow {3 constructor(4 private readonly accounts: AccountsService,5 private readonly email: EmailService,6 ) {}7 8 async run(ctx: WorkflowCtx, user: User) {9 const account = await ctx.step(this.accounts.create, user);10 11 // run a child workflow and await its result:12 const kyc = await ctx.child(KycWorkflow, { userId: account.id });13 14 await ctx.step(this.email.welcome, { user, kyc });15 return { verified: kyc.passed };16 }17}ctx.child — run a child and await its result
ctx.child(workflow, input) starts the child once and suspends the parent — zero compute — until
the child reaches a terminal state, then resumes the parent with the child's output. If the child
fails, the call throws in the parent (so a child failure fails the parent unless you catch it).
@Workflow({ name: 'checkout', version: '1' })
export class CheckoutWorkflow {
constructor(private readonly payments: PaymentsService) {}
async run(ctx: WorkflowCtx, order: Order) {
const payment = await ctx.step(this.payments.charge, order);
// Hand shipping to its own workflow and wait for the tracking number.
const shipment = await ctx.child(ShippingWorkflow, { orderId: order.id });
return { paymentId: payment.id, tracking: shipment.tracking };
}
}Because the parent suspends rather than blocking, a child that takes hours (or itself sleeps, waits on a signal, or spawns its own children) costs the parent nothing while it runs.
Class ref or string
Pass the child's class (ctx.child(ShippingWorkflow, input)) and the call is fully typed: the
input is checked against the child's run, and the result is the child's return type — no manual
type parameter, and renaming the workflow can't silently break the caller.
const shipment = await ctx.child(ShippingWorkflow, { orderId: order.id });
// ^? the return type of ShippingWorkflow.run, inferred — and { orderId } is type-checkedPass a string name when there's no class to import — most importantly a cross-runtime child, e.g. a workflow implemented in Python. There you annotate the result yourself:
const result = await ctx.child<EnrichResult>('python-enrich', { recordId });Both forms take an optional third childId argument; it defaults to a deterministic id derived from
the parent run and the call position, so it's stable across replay.
ctx.startChild — fire-and-forget
ctx.startChild(workflow, input) dispatches a child and returns its run id immediately — the
parent keeps running instead of suspending. Use it for side work the parent doesn't need to wait on:
@Workflow({ name: 'publish-post', version: '1' })
export class PublishPostWorkflow {
constructor(private readonly posts: PostsService) {}
async run(ctx: WorkflowCtx, post: Post) {
await ctx.step(this.posts.publish, post);
// Kick off indexing + notifications; don't make publishing wait on them.
await ctx.startChild(ReindexSearchWorkflow, { postId: post.id });
await ctx.startChild(NotifyFollowersWorkflow, { postId: post.id });
return { published: true };
}
}The dispatch is checkpointed (replay-safe — it won't re-fire on resume) and idempotent by child id.
1@Workflow({ name: 'publish-post', version: '1' })2export class PublishPostWorkflow {3 constructor(private readonly posts: PostsService) {}4 5 async run(ctx: WorkflowCtx, post: Post) {6 await ctx.step(this.posts.publish, post);7 8 // fire-and-forget — don't make publishing wait on indexing:9 await ctx.startChild(ReindexSearchWorkflow, { postId: post.id });10 11 return { published: true };12 }13}⋯6 async run(ctx: WorkflowCtx, input: { postId: string }) {7 const doc = await ctx.step(this.search.reindex, input);8 await ctx.step(this.search.warmCache, doc);9 }⋯ctx.all — fan out N children of the same workflow, wait for all
The common scatter-gather shape — process a batch by running the same workflow once per item, then
wait for every result — has a built-in primitive: ctx.all(workflow, inputs, opts?). It dispatches one
child per entry in inputs concurrently, suspends the parent — zero compute — until every child
reaches a terminal state, then resumes with their outputs in input order:
async run(ctx: WorkflowCtx, batch: Batch) {
const results = await ctx.all(ProcessItemWorkflow, batch.items);
return results;
}Each child gets a stable, group-scoped id (<runId>.all.<firstSeq>.<i>), and the dashboard renders the
whole fan-out as one group instead of a sequential chain (the running children share a parallelGroup
tag). Empty inputs returns [] with no side effects.
Here it is live — scatter, run concurrently, join on all results:
ctx.all: the parent suspends (amber, zero compute) while N children — each a full durable run, labelled #i — execute concurrently and join as they settle, stamping a per-child ✓/✗ under the join. Toggle a failing child: the others still finish green, and the join resolves as a GatherError naming exactly which index failed.Handling failures — mode and GatherError
By default (mode: 'waitAll'), ctx.all waits for every child to finish, then throws an aggregate
GatherError if any failed — one throw naming every failing child, instead of the first one masking
the rest:
import { GatherError } from '@dudousxd/nestjs-durable-core';
try {
const results = await ctx.all(ProcessItemWorkflow, batch.items);
return results;
} catch (err) {
if (err instanceof GatherError) {
// err.failures: { index: number; id: string; error: string }[]
this.logger.warn(`${err.failures.length} item(s) failed: ${err.message}`);
}
throw err;
}Pass { mode: 'failFast' } to throw the moment a failed child is seen, instead of waiting for the
stragglers — useful when one bad item means the batch is a lost cause and there's no point waiting out
the slowest sibling. The surviving siblings are cancelled (best-effort — a child mid-step observes
the cancellation at its next checkpoint), so no orphaned work keeps burning workers after the batch is
already doomed.
const results = await ctx.all(ProcessItemWorkflow, batch.items, { mode: 'failFast' });ctx.all is the wait-all/fan-out counterpart to ctx.child — reach for it whenever every item runs
the same workflow.
Manual scatter-gather: startChild + child, for the flexible cases
ctx.all covers "same workflow, dispatch-then-join-immediately". When you need something it doesn't
give you — a different workflow per item, custom per-item child ids, or to do other work between
starting the children and joining them (ctx.all suspends as one call from dispatch straight through
to the join) — fall back to the manual pattern it's built on: because startChild returns the child id
and the start is idempotent by that id, you can fan out with startChild and later join with
ctx.child using the same id — the child runs exactly once, and the second call just attaches to
it:
async run(ctx: WorkflowCtx, batch: Batch) {
// 1. Start every item's workflow without waiting — they run concurrently. (Here every item uses the
// same workflow, but each call could just as well target a different one per item.)
const ids = await Promise.all(
batch.items.map((item) => ctx.startChild(ProcessItemWorkflow, item, `item:${item.id}`)),
);
// 2. ...do other work while they run — ctx.all can't interleave work here, since it suspends for
// the whole dispatch-to-join span in one call.
// 3. Join: await each by the same id (no re-dispatch — they're already running).
const results = await Promise.all(
batch.items.map((item, i) => ctx.child(ProcessItemWorkflow, item, ids[i])),
);
return results;
}Class-first alternative — ChildWorkflow.execute / .start
A workflow that extends DurableWorkflow can be called child-style from its own class, no ctx
threading: inside a workflow body, ShippingWorkflow.execute(input) IS
ctx.child(ShippingWorkflow, input) (awaited, parent-linked) and ShippingWorkflow.start(input) IS
ctx.startChild (fire-and-forget) — same checkpointing, same replay safety, detected via the
ambient workflow context. See
Class-first starts.
Inside a body, though, prefer the ctx.* forms as the default — ctx keeps every history operation
visible at the call site, and it's the only form that reaches a child by name (a cross-runtime
workflow) or ctx.all. Save the statics for deep helpers where threading ctx would be noise; see
Which to reach for. Outside a workflow, the
opposite holds — the statics are the clean way to start a run without injecting WorkflowService.
When to use which
ctx.child | ctx.startChild | ctx.all | |
|---|---|---|---|
| Parent waits for the result | ✅ suspends until the child finishes | ❌ returns the id immediately | ✅ suspends until every child finishes |
| Child failure affects the parent | ✅ throws in the parent | ❌ independent (inspect/retry the child run) | ✅ throws an aggregate GatherError |
| Typical use | a sub-task whose output you need | side work; the dispatch half of scatter-gather | fan out N items through the same workflow |
All three establish the same durable parent→child relationship, so a child started any way is a normal run you can inspect, retry, or cancel from the dashboard.
Authoring
Everything you compose a real workflow out of — child workflows, durable entities, events, queries and updates, webhooks and external tasks, versioning, and scheduling.
Durable entities
Keyed, long-lived virtual objects — @Entity + @On declare per-key state and its operations; ctx.callEntity/signalEntity or EntityService drive them, serialized per key, exactly once.