Topologies
Run durable as a single process, split the control plane from the workers, or spread store-less "thin" pods per tenant — one config field, no code changes to your workflows.
By default @adonis-agora/durable runs as a single process: one engine that owns the store, dispatches work, and executes the step bodies itself. That is all most apps ever need. When you want to scale the executor pool independently, isolate tenants, or run a store-less worker pod that never touches your database, you select a different topology — a single role field in config/durable.ts. Your workflows and steps do not change.
Every topology runs the same durable engine over the same wire. Splitting the control plane from the workers does not change execution semantics: each ctx.step is still dispatched and checkpointed. The only thing that moves is where the code that executes the bodies lives.
The three roles
| Role | Owns the store | Executes step bodies | Serves HTTP / dashboard | Read/control surface |
|---|---|---|---|---|
standalone (default) | ✅ | ✅ (embedded worker) | optional | direct (store) |
control-plane | ✅ | ❌ | optional | direct (store) |
tenant (worker) | ❌ | ✅ | ❌ | proxied over the wire |
tenant (api/dashboard) | ❌ | ❌ | ✅ | proxied over the wire |
standalone— a control plane plus an embedded worker in one process. This is the behavior of a config written before roles existed: omitroleand you get it. Zero infrastructure with the in-process transport and in-memory store; swap in a broker transport and the Lucid store for production (see Choosing a transport). On a transport that carries the store-less protocol (bullmq,queue,db) it also answerstenantpods, exactly like acontrol-plane.control-plane— a pure coordinator. It owns the store and does everything except run your bodies: dispatch, crash recovery, timers, retention, and answering read/control requests from thin pods. Bodies execute on separatetenantworker pods.tenant— a store-less thin pod. It never owns a store; every read, control action, and run-start round-trips over the wire to the control plane. Whether a tenant pod is a worker or an api/dashboard pod is decided by the entrypoint you launch, not extra config (see Roles & config).
Dispatch and execution are separate
Every role dispatches over the transport — that never changes. What changes is who is subscribed to the queue a dispatch lands on. Two routing tokens have to agree:
- Dispatch side. A step is routed to
<name>@<tenant>, where the tenant comes from the run's namespace (or the step's ownpartition, which wins). Adefault/unset namespace routes to the bare<name>. - Consume side. A worker subscribes
<name>@<partition>for every handler it serves, where the suffix is its transport'spartition(transports.bullmq({ partition }),transports.queue({ partition })ortransports.db({ partition })) — bare when unset. On atenantpod the transport defaults to the pod's required top-levelpartition, which the provider also gives the gateway and the worker descriptor, so all three agree (see Roles & config).
A process executes a step body when, and only when, those two tokens match. There is no fallback: nothing "runs it anyway" because no worker showed up — the step stays pending (or the run parks blocked when the step requires capabilities no live worker advertises). See Tenancy for how a run's namespace is chosen.
Two consequences of the roles above:
standaloneservesapp/stepsitself (the embedded worker);control-planedoes not — the provider skips step registration for it, so its steps only run where a worker consumes the matching token.- A
control-planestill runs the workflow bodies — the deterministic coordinator code inapp/workflowsthat decides which step to dispatch next. Unless you setrunDispatcher, the role usesNOOP_RUN_DISPATCHER, soengine.start()never runs a body inline on the calling (e.g. HTTP) process; the run is leftpendingfor thedurable:workpoll loop, which replays the body and dispatches eachctx.stepto the workers. A standalone web pod gets the same behaviour withdrive: false. A control plane with nonamespaceis the operator and drives every pool's runs; a standalone engine with nonamespacedrives only the'default'pool. See where a run actually executes.
When to reach for each
Reach for a split topology when you have a concrete driver — not by default. standalone is simpler to operate and is the right answer until one of these bites.
- Scale the executor pool independently. CPU-heavy steps shouldn't compete with the coordinator.
control-plane+ Ntenantworkers lets you scale the workers without touching the coordinator. - Isolation & security. A
tenantworker pod has no database credentials and no store binding at all — a compromised worker cannot read or corrupt durable state. Isolation here is a structural fact, enforced at compile time (atenantconfig cannot name a store — see Roles & config), not a runtime check. - Per-tenant routing. Route each tenant's runs to its own pool of pods with a
<name>@<tenant>queue suffix, sharing one control plane and one store. See Tenancy for thenamespacevspartitionaxes and the isolation boundary. - Polyglot / thin workers. Because the
bullmqtransport's wire is byte-compatible with the aviary (nestjs-durable) engine, a Python worker or a NestJS worker can execute your workflows' steps against the same Adonis control plane. See Cross-ecosystem interop.
What splits, and what doesn't
The seam is the control plane ↔ worker boundary on the transport:
- The control plane owns the store and the authoritative run state. It dispatches step/workflow tasks onto per-handler queues and consumes their results.
- A worker pulls a task, runs the body through the shared step handler, and publishes the result. It holds no durable state.
- A store-less api/dashboard pod never executes anything; it turns every read/control/start into a wire request answered by the control plane's
RunRequestResponder.
Three bundled transports carry that seam for store-less pods: bullmq (Redis, and the wire NestJS and Python workers speak), queue (any @adonisjs/queue adapter) and db (the control plane's own database, for pods that can reach it). See Roles & config for choosing between them.
Your application code — BaseWorkflow subclasses in app/workflows, @Step handlers in app/steps — is identical across every role. The dashboard, routes, and services/main reads all go through one RunGateway interface that is either backed directly by the store or proxied over the wire, so nothing above that line can tell whether a store is present.
Next steps
- Roles & config — the role-discriminated
config/durable.ts, the worker vs api entrypoints, and layered tenant authentication. - Tenancy — what
namespaceandpartitionpartition, and the boundary that keeps a store-less pod to its own runs. - Handshake & capability negotiation — how a mixed fleet stays version-safe: descriptors, negotiation, capability-aware routing, and blocked runs.
- Cross-ecosystem interop — run Adonis, NestJS, and Python workers on one control plane.
Telescope
Surface workflow runs and steps inside Telescope with durableTelescopeExtension() — a Workflows dashboard with golden-signal health, current-state gauges, recent failures and a per-worker table, alongside your app's requests, queries, and jobs.
Roles & config
The role-discriminated config/durable.ts, the worker vs api entrypoints for a store-less pod, and layered tenant authentication — with store-less isolation enforced at compile time.