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.
The way to author a workflow is a class under app/workflows/ that extends BaseWorkflow and
declares its identity with a static workflow = { name, version }, mirroring how
@adonisjs/queue authors a job per file under
app/jobs/. The durable provider scans app/workflows/ at boot and registers every exported workflow class on
the engine automatically — you never call engine.register(...) by hand.
Scaffold a workflow
node ace make:workflow orderThis emits app/workflows/order_workflow.ts:
import { BaseWorkflow } from '@adonis-agora/durable'
import type { WorkflowCtx } from '@adonis-agora/durable'
interface OrderInput {
// Define your workflow input here
}
export default class OrderWorkflow extends BaseWorkflow {
static workflow = { name: 'order', version: '1' }
async run(ctx: WorkflowCtx, input: OrderInput) {
// Compose ctx.step / ctx.localStep / ctx.child / ctx.sleep …
return input
}
}The class's run(ctx, input) is the workflow body — the same deterministic (ctx, input) => … that
engine.register takes, just attached to a class so a class
ref (OrderWorkflow.dispatch(input), ctx.child(OrderWorkflow)) carries its input/output types. The
static workflow object accepts the same options as engine.register — name, version, and
optional tags / onEvent / executionTimeout.
Auto-registration
At boot the provider scans the configured directory (default app/workflows), imports each module, and
registers every exported workflow class from its static workflow config — including the version,
tags, executionTimeout, onEvent and singleton from its options, plus any
colocated schedule.
Dependency injection in the constructor
Workflow classes are instantiated through the AdonisJS container, exactly like an @adonisjs/queue
job. Decorate the class with @inject() and declare what it needs as constructor parameters — the
container resolves them:
import { inject } from '@adonisjs/core'
import { BaseWorkflow } from '@adonis-agora/durable'
import type { WorkflowCtx } from '@adonis-agora/durable'
import BillingService from '#services/billing_service'
import NotificationService from '#services/notification_service'
interface OrderInput {
orderId: string
amount: number
}
@inject()
export default class OrderWorkflow extends BaseWorkflow {
static workflow = { name: 'order', version: '1' }
constructor(
private billing: BillingService,
private notifications: NotificationService,
) {
super()
}
async run(ctx: WorkflowCtx, input: OrderInput) {
const charge = await ctx.step(this.billing.charge, {
orderId: input.orderId,
amount: input.amount,
})
await ctx.step(this.notifications.orderConfirmed, {
orderId: input.orderId,
receiptUrl: charge.receiptUrl,
})
return { charged: charge.id }
}
}Two things follow from this, and they are the reason to prefer it over reaching for the container inside
run:
- The step reference is typed.
this.billing.chargeis a@Step-decorated method, soctx.stepinfers both its input and its output — no generic to write, and renaming the method is a compile error rather than a runtime "no handler for …". - The body stays a pure composition. Resolving services is construction, not workflow logic, so the
deterministic
runreads as the sequence of steps it is.
The instance is built once, at registration, and reused for every run — so treat the constructor as
wiring and keep per-run state in the workflow's input and step outputs, never on this.
A class with no constructor dependencies is unaffected: the container simply builds it with no arguments.
Injected services are for composing the workflow, not for doing its work. Calling this.billing.charge(...)
directly from run performs I/O outside a step, so it re-executes on every replay and is never checkpointed.
Always reach a service through ctx.step / ctx.localStep.
Running a workflow
The ergonomic way to run or compose a registered class is its context-aware statics, Wf.dispatch and
Wf.start:
// fire-and-forget a top-level run from a controller/service — returns { runId }
const { runId } = await OrderWorkflow.dispatch(input)
// block until the run settles and return its result (scripts / short runs)
const result = await OrderWorkflow.start(input)Both are context-aware: called inside a running workflow body they route to a child instead of the engine. See Child workflows for the full semantics table.
engine.start(OrderWorkflow, input, runId) remains the lower-level equivalent — resolve the engine from the
container and start by class (typed) or by registered name (engine.start('order', input, runId)).
Wf.dispatch / Wf.start resolve the engine for you.
Configure or disable discovery in config/durable.ts:
export default defineConfig({
workflowsPath: 'app/workflows', // default; set false to disable and register by hand
})engine.register(name, version, fn) remains the low-level escape hatch (cross-runtime workflows, dynamic
registration). The BaseWorkflow class + app/workflows convention is the preferred authoring path for
same-runtime TypeScript workflows.
From a queue job to a durable workflow
The convention is a deliberate parallel, so switching a @adonisjs/queue job to a durable workflow is a
mechanical move — app/jobs/<name>.ts → app/workflows/<name>_workflow.ts, make:job → make:workflow,
Job.execute() → Workflow.run(ctx, input), Job.dispatch() → Workflow.dispatch(input) — and you gain
durable checkpoints, replay, timers, signals and saga compensation on top.
Tenancy
The two isolation axes — namespace on the engine and partition on the transport — what each one actually partitions, and the boundary that keeps a store-less pod to its own runs.
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.