Child workflows
Compose workflows by calling other workflows — await a child's result with Inner.start (or ctx.child), kick one off fire-and-forget with Inner.dispatch (or ctx.startChild), or fan out over a list with ctx.all. The same statics work at the top level, context-aware.
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 that runs elsewhere. 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.
Inner.start / Inner.dispatch — the ergonomic form
When the child is a BaseWorkflow class you can import, its statics are
the shortest way to compose — and they are context-aware, so the same call does the right thing inside a
workflow versus outside one:
export default class CheckoutWorkflow extends BaseWorkflow {
static workflow = { name: 'checkout', version: '1' }
async run(ctx: WorkflowCtx, order: Order) {
const kyc = await KycWorkflow.start({ userId: order.userId }) // linked child — awaits the result
await AuditWorkflow.dispatch({ orderId: order.id }) // fire-and-forget child
return { verified: kyc.passed }
}
}Inner.start(input, opts?)= "I want the result." Inside a running workflow it creates a linked child (the parent suspends until the child settles, then resumes with its result) — exactlyctx.child(Inner, input).Inner.dispatch(input, opts?)= fire-and-forget. Inside a running workflow it kicks off a child and returns{ runId }while the parent keeps going — exactlyctx.startChild(Inner, input).
The same two statics also run a top-level run from a controller/service (Wf.dispatch returns { runId };
Wf.start blocks until the run settles and returns its result). One consistent rule — .start = "give me the
result", .dispatch = "fire and forget" — in both contexts:
| Static call | OUTSIDE a workflow (controller / service / script) | INSIDE a running workflow body |
|---|---|---|
Wf.start(input, opts?) | engine.start + waits for the run to settle → returns the result | linked child (ctx.child) — parent suspends until it settles, returns the child's result |
Wf.dispatch(input, opts?) | enqueues a top-level run → fire-and-forget, returns { runId } | fire-and-forget child (ctx.startChild) → returns { runId }, parent keeps going |
Why the same call is context-aware
Inside a running body it is required to route through the child path — calling the engine directly from a
workflow would break determinism. The static detects the ambient run and does this for you, so Inner.start /
Inner.dispatch are always safe to call from either place. ctx.child / ctx.startChild are the underlying
primitives and stay available.
opts.runId — outside vs inside
opts is { runId? } plus the usual start options (priority, namespace, …). Outside a workflow, runId
is auto-generated when omitted, and acts as an idempotency key when you provide one. Inside a workflow the
child id is derived deterministically from the call position (replay-stable) when omitted — do not pass a
random one, as that would break replay. Pass an explicit stable id only when you deliberately want to join a
specific child (see scatter-gather).
The rest of this page documents ctx.child / ctx.startChild — the primitives the statics delegate to. Use
them directly when you only have the child's name string (no class to import), or when the calling code is
already an engine.register(...) body.
ctx.child — run a child and await its result
ctx.child(workflow, input, childId?) 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).
engine.register('checkout', '1', async (ctx, order: Order) => {
const payment = await ctx.localStep('charge', () => payments.charge(order))
// Hand shipping to its own workflow and wait for the tracking number.
const shipment = await ctx.child<{ tracking: string }>('shipping', { 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.
Name or class ref
Pass the child's registered name string (the portable form). When you have a typed workflow class to import, pass the class and the call is fully typed — the input is checked against the child's run and the result is its return type:
const shipment = await ctx.child(ShippingWorkflow, { orderId: order.id })
// ^? the return type of ShippingWorkflow.run, inferred — and { orderId } is type-checkedBoth 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.
1@Workflow({ name: 'onboard', version: '1' })2export default class OnboardWorkflow {3 constructor(4 private accounts: AccountSteps,5 private email: EmailSteps,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.startChild — fire-and-forget
ctx.startChild(workflow, input, childId?) 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:
engine.register('publish-post', '1', async (ctx, post: Post) => {
await ctx.localStep('publish', () => posts.publish(post))
// Kick off indexing + notifications; don't make publishing wait on them.
await ctx.startChild('reindex-search', { postId: post.id })
await ctx.startChild('notify-followers', { 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 default class PublishPostWorkflow {3 constructor(private posts: PostSteps) {}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 over one workflow
Running the same child workflow across a list of inputs is common enough to have its own primitive.
ctx.all(workflow, inputs, opts?) starts one child per input, suspends the parent while they run
concurrently, and resumes with their outputs in input order:
engine.register('batch', '1', async (ctx, batch: Batch) => {
const results = await ctx.all(ProcessItemWorkflow, batch.items)
// ^? the return type of ProcessItemWorkflow.run, as an array
return { processed: results.length }
})Pass a registered name string instead of the class when you have no class to import, with the output type
as a generic: ctx.all<ItemResult>('process-item', batch.items).
The child ids are derived from the parent and the position the fan-out occupies — <parentRunId>.all.<seq>.<i>
— so they are stable across replay without you inventing them. Every sibling is also tagged with the same
parallel group, which is what lets the dashboard render one fan-out as
a single unit rather than N unrelated children. An empty inputs array returns [] immediately and starts
nothing.
waitAll vs failFast
opts.mode decides what a failing sibling does to the rest:
const results = await ctx.all(ProcessItemWorkflow, batch.items, { mode: 'failFast' })| Mode | Behaviour |
|---|---|
waitAll (default) | Every child runs to a terminal state. If any failed, the call then throws — nothing is cancelled, so a partial batch is fully recorded and inspectable. |
failFast | The first observed failure cancels every sibling that has not completed, then throws. |
Either way the error is a GatherError carrying which items failed:
import { GatherError } from '@adonis-agora/durable'
try {
await ctx.all(ProcessItemWorkflow, batch.items)
} catch (error) {
if (error instanceof GatherError) {
// error.failures: [{ index, id, error }, ...]
const failed = error.failures.map((f) => batch.items[f.index].id)
await ctx.step(reportPartialBatch, { batchId: batch.id, failed })
}
throw error
}Choose waitAll when each item is independent and you want the whole picture — a nightly import where three
bad rows should not hide the other nine hundred. Choose failFast when the items are facets of one operation
and finishing the rest is wasted work.
failFast cancellation is best-effort and carries no saga compensation: it is a plain cancel, and a
sibling in the middle of a step only observes it at its next checkpoint. If a partially-run child needs to be
undone, give the child its own compensations and cancel it with
{ compensate: true } yourself instead.
Fanning out by hand
ctx.all covers one workflow over N inputs. When the fan-out is heterogeneous — different workflows, or work
interleaved between starting and joining — build it from startChild + child. Because startChild returns
the child id and the start is idempotent by that id, you can fan out first and join later with the same
id: the child runs exactly once and the second call just attaches to it.
engine.register('batch', '1', async (ctx, batch: Batch) => {
// 1. Start every item's workflow without waiting — they run concurrently.
const ids = await Promise.all(
batch.items.map((item) => ctx.startChild('process-item', item, `item:${item.id}`)),
)
// 2. ...do other work while they run...
// 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('process-item', item, ids[i])),
)
return 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.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 a GatherError |
| Typical use | one sub-task whose output you need | side work; heterogeneous fan-out | the same workflow over a list of inputs |
Inner.start(input) and Inner.dispatch(input) are the ergonomic equivalents of ctx.child(Inner, input) and
ctx.startChild(Inner, input) — same behavior, they just resolve the ambient ctx for you.
Both establish the same durable parent→child relationship, so a child started either way is a normal run you can inspect, retry, or cancel from the dashboard. Read the children of a run programmatically with engine.getRunChildren(parentRunId).
Continue-as-new
A workflow that would otherwise loop forever (a long-lived entity, a poll loop) should not accumulate unbounded history. ctx.continueAsNew(input?) completes the current run and starts a fresh one — same workflow, new input, a clean history — keyed runId~N:
engine.register('counter', '1', async (ctx, input: { n: number }) => {
await ctx.localStep(`work-${input.n}`, async () => input.n)
if (input.n < 3) await ctx.continueAsNew({ n: input.n + 1 }) // → runId~1, runId~2, …
return `done at ${input.n}`
})Each generation completes cleanly, so the run never grows without bound — the durable equivalent of tail recursion.
The handoff itself is crash-safe: the continuation run is persisted before the parent's terminal write, so a crash in the gap between "generation N completed" and "generation N+1 started" cannot lose the chain — on recovery the already-persisted continuation is simply driven forward. The continuation also inherits the parent run's namespace, so a chain pinned to a worker pool stays in it across generations.
app/workflows & make:workflow
The class-based authoring convention — a BaseWorkflow subclass per file under app/workflows with a static workflow config, auto-registered at boot, scaffolded by make:workflow. The parallel to @adonisjs/queue's app/jobs and make:job.
Durable entities
Keyed virtual actors — engine.registerEntity defines a named entity whose handlers run serialized per key over durable state (exactly once). Drive it with engine.signalEntity / ctx.callEntity, read it with engine.getEntityState. The durable answer to a per-user counter or per-account balance without DB locks.