Agora
Authoring

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 order

This 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.registername, 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:

app/workflows/order_workflow.ts
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.charge is a @Step-decorated method, so ctx.step infers 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 run reads 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>.tsapp/workflows/<name>_workflow.ts, make:jobmake: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.

On this page