Getting Started
Run your first durable workflow in an AdonisJS app — install, configure, register a workflow, and start a run. Zero infrastructure with the in-process transport and in-memory store.
This guide gets a workflow running in an AdonisJS app in a few minutes — no broker, no database, using the in-process transport and an in-memory store. Swap those for the queue transport and the Lucid store when you're ready for production.
1. Install & configure
node ace add @adonis-agora/durableadd installs the package and runs its configure hook, which:
- registers
@adonis-agora/durable/durable_providerand@adonis-agora/durable/dashboard_providerinadonisrc.ts; - registers the Ace commands (
@adonis-agora/durable/commands) — see the CLI; - registers the Assembler
inithooks that generate the typedapp/workflows/app/stepsbarrels — see codegen; - adds the
#workflows/*and#steps/*subpath imports to yourpackage.json; - publishes
config/durable.ts,config/durable_dashboard.tsand the Lucid migrations for the optionallucidstore anddbtransport.
The published config/durable.ts defaults to the in-process memory transport + the in-memory store, so the engine works with zero extra infrastructure. Drivers are selected by name from the transports / stores maps — each lazily imports its optional peer (@adonisjs/queue, @adonisjs/lucid, bullmq) only when chosen:
import { defineConfig, transports } from '@adonis-agora/durable'
export default defineConfig({
transport: 'memory',
transports: {
memory: transports.memory(),
// 'event-emitter': transports.eventEmitter(), // single-process production, no infra
// queue: transports.queue({ connection: 'redis' }), // names a config/queue.ts adapter
// db: transports.db(),
// bullmq: transports.bullmq({ connection: { host: '127.0.0.1', port: 6379 } }),
},
// store: 'lucid',
// stores: { lucid: stores.lucid() },
// leaseMs: 30_000,
})memory is test-only
The memory transport drives a dispatch straight into its handler and exists for tests and this tutorial. For a single-process production app with no external infrastructure, select transports.eventEmitter(); for several processes, pick a cross-process transport and the Lucid store.
The provider binds a singleton WorkflowEngine built from this config. Import it anywhere — a controller, a service, a start file — with import engine from '@adonis-agora/durable/services/main'. On a store-less tenant pod there is no engine; the same module exports runGateway, the role-agnostic read/control/start surface.
2. Define a step (optional)
ctx.step(refOrName, input) is the durable step primitive — always dispatched, routed to a handler by name. Author the handler as a @Step class under app/steps, or with defineStep for a typed ref. Schemas are optional — add input/output from any Standard Schema library (zod, valibot, arktype, …) to validate at the serve boundary; the handler's input type is then inferred from the schema:
import { defineStep } from '@adonis-agora/durable'
import { z } from 'zod' // or valibot, arktype, … — any Standard Schema
export const chargeCard = defineStep(
'payments:charge-card',
async (input) => ({ chargeId: `ch_${input.orderId}` }),
{
input: z.object({ orderId: z.number().int(), amountCents: z.number().int() }),
output: z.object({ chargeId: z.string() }),
retries: 3,
},
)3. Define the workflow
A workflow is a class under app/workflows/ that extends BaseWorkflow and declares its identity with a
static workflow = { name, version }. Its run(ctx, input) method is the deterministic body. The durable
provider scans app/workflows/ at boot and registers every exported workflow class automatically — no manual
engine.register(...), no preload to wire:
import { BaseWorkflow } from '@adonis-agora/durable'
import type { WorkflowCtx } from '@adonis-agora/durable'
import { chargeCard } from '#steps/charge'
export default class CheckoutWorkflow extends BaseWorkflow {
static workflow = { name: 'checkout', version: '1' }
async run(ctx: WorkflowCtx, order: { id: number; total: number }) {
await ctx.localStep('reserveStock', async () => ({ reserved: true }))
const charge = await ctx.step(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.localStep('ship', async () => ({ shipped: true }))
return { status: 'shipped', chargeId: charge.chargeId }
}
}Lower-level alternative. You can still register by hand from a preload file with
engine.register('checkout', '1', async (ctx, order) => { … }) — the body is the same deterministic
(ctx, input) => …. Auto-discovery is the recommended path for same-runtime TypeScript workflows; see
app/workflows.
4. Serve the step handler
A step is dispatched to a handler served under its name. A @Step class or defineStep(...) under app/steps is discovered and served automatically at boot — nothing else to wire. The low-level escape hatch is transport.handle(name, fn), which registers a handler directly on a worker-side transport.
For real cross-process handlers, configure the queue transport in config/durable.ts and run a worker process that serves the same step names. The workflow code never changes.
5. Start a run
A controller kicks off the workflow with the class's dispatch static — CheckoutWorkflow.dispatch(input) enqueues a top-level run and returns { runId } immediately, so the HTTP handler never blocks on workflow logic (no engine to inject for the happy path). Validate the request with VineJS, persist the order, then dispatch the run keyed by a stable runId. A worker executes the body (by default, the same instance, asynchronously), runs the steps, then suspends on the approval signal; an approval webhook resumes it:
import vine from '@vinejs/vine'
export const createCheckoutValidator = vine.compile(
vine.object({
total: vine.number().positive(),
})
)import type { HttpContext } from '@adonisjs/core/http'
import engine from '@adonis-agora/durable/services/main'
import CheckoutWorkflow from '#workflows/checkout_workflow'
import Order from '#models/order'
import { createCheckoutValidator } from '#validators/checkout'
export default class CheckoutController {
// the engine is only needed to signal the run; dispatch resolves it for you
async store({ request, response }: HttpContext) {
const { total } = await request.validateUsing(createCheckoutValidator)
const order = await Order.create({ total, status: 'pending' })
// Enqueue the run and respond now — a worker runs the body.
const { runId } = await CheckoutWorkflow.dispatch(
{ id: order.id, total: order.total },
{ runId: `checkout:${order.id}` }
)
return response.accepted({ runId })
}
// Later, from your approval webhook — this completes & ships the run.
async approve({ params, response }: HttpContext) {
await engine.signal(`approve:${params.id}`, { approved: true })
return response.noContent()
}
}Wire the routes in start/routes.ts:
import router from '@adonisjs/core/services/router'
const CheckoutController = () => import('#controllers/checkout_controller')
router.post('checkout', [CheckoutController, 'store'])
router.post('checkout/:id/approve', [CheckoutController, 'approve'])CheckoutWorkflow.dispatch(input, { runId }) is the ergonomic equivalent of
engine.start(CheckoutWorkflow, input, runId) (a random UUID when you omit runId), and .start(input) equals that
followed by await engine.waitForRun(runId, { terminal: true }), returning the run's output. The runId acts as the
idempotency key, so dispatching the same one twice is a no-op. Called inside a running workflow, the same statics
start a child instead (.start → ctx.child, .dispatch → ctx.startChild) — see
child workflows.
Need the outcome inline? CheckoutWorkflow.start(input) blocks the caller until the run reaches a terminal state (completed/failed/cancelled/dead) — it keeps waiting through sleep/waitForSignal suspensions — and returns the workflow's output:
const output = await CheckoutWorkflow.start({ id: order.id, total: order.total }) // resolves when the run is terminalengine.waitForRun(runId) without { terminal: true } resolves earlier, once the run settles — a terminal state or suspended — and returns the full RunResult (status, output, error). Pass { timeoutMs } to bound the wait.
.start blocks the calling context until the run settles — great for scripts and short runs, but avoid it on a
hot HTTP path for a long-running workflow. There, .dispatch and respond immediately, then read the outcome
later (a webhook, a poll, or engine.waitForRun(runId)).
That's it — a durable workflow whose dispatched step runs 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.
- State stores — persist to Postgres with Lucid.
- The CLI — run a worker, list runs, retry from the terminal.
- Testing — drive workflows deterministically in Japa/Vitest.
- Observability — the dashboard, OTel, metrics, and Telescope.
Durable
Durable workflows for AdonisJS — write a workflow as plain code; every step is checkpointed, so it survives crashes and deploys. Steps can run across processes, with a built-in control plane.
Patterns
A cookbook of common durable-workflow shapes — human approval, webhook confirmation, batch fan-out, cron digests, booking sagas, rate-limited APIs, and long-lived pollers — each a short, self-contained recipe with a link to the full page.