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, registers @adonis-agora/durable/durable_provider in adonisrc.ts, and publishes config/durable.ts.
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) only when chosen:
import { defineConfig, transports } from '@adonis-agora/durable'
export default defineConfig({
transport: 'memory',
transports: {
memory: transports.memory(),
// queue: transports.queue({ connection: 'redis' }), // names a config/queue.ts adapter
// db: transports.db(),
},
// store: 'lucid',
// stores: { lucid: stores.lucid() },
// leaseMs: 30_000,
})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'. (The container binding, app.container.make(WorkflowEngine), is the lower-level path.)
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 zod input/output to validate at the serve boundary:
import { defineStep } from '@adonis-agora/durable'
import { z } from 'zod'
export const chargeCard = defineStep(
'payments:charge-card',
async (input: { orderId: number; amountCents: number }) => ({ 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 { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import { WorkflowEngine } from '@adonis-agora/durable'
import CheckoutWorkflow from '#workflows/checkout_workflow'
import Order from '#models/order'
import { createCheckoutValidator } from '#validators/checkout'
@inject()
export default class CheckoutController {
// the engine is only needed to signal the run; dispatch resolves it for you
constructor(private engine: WorkflowEngine) {}
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 this.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 resolving the engine and calling
engine.start('checkout', input, runId), and .start(input) equals that followed by
await engine.waitForRun(runId). The runId acts as the idempotency key, so dispatching the same one twice is a
no-op. Prefer injecting the engine directly? engine.start(CheckoutWorkflow, input, runId) is the lower-level form.
Need the outcome inline? CheckoutWorkflow.start(input) blocks the caller until the run settles — a terminal state (completed/failed/cancelled/dead) or suspended — and returns the run's result:
const result = await CheckoutWorkflow.start({ id: order.id, total: order.total }) // resolves when the run settles.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.
- Observability — the control plane, OTel, 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.
Durability & replay
How checkpoint-and-replay makes a workflow survive crashes — and the one rule it imposes. The workflow body must be deterministic; all side effects live in steps.