Aviary
Cluster

Topologies

Run durable in one process, split the control plane from the workers, or spread store-less thin workers per tenant — the same engine over the same wire, selected by which options a process is given.

By default @dudousxd/nestjs-durable runs everything in one process: an operator that owns the store, dispatches work, and executes @Workflow/@Step bodies. That is all most apps ever need. When you want to scale the executor pool independently, isolate tenants, or run a store-less worker that never touches your database, you split that process in two. 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 dispatched ctx.step is still checkpointed. The only thing that moves is where the code that executes the bodies lives.

What a process is, is what you give it

There is no role: 'worker' switch that turns execution off. A NestJS process's part in the cluster falls out of three independent facts about it:

FactDecides
store set?Whether it owns durable state — and so whether it can drive, recover, and answer reads directly.
drive: false?Whether it runs the store's loops (poll pending, recover crashed, resume timers, sweep timeouts, prune retention).
Does it register @Workflow/@Step providers, and on which partition?Whether bodies execute in this process, and for whose runs.

The topology preset names the first two explicitly and validates them. The third is your module graph plus one routing rule, and it is worth being precise about, because "the control plane doesn't execute" is only true in some shapes.

Dispatch and execution are separate

Every shape dispatches over the transport — that part never changes. What changes is who is subscribed to the queue a dispatch lands on. Two 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 own partition, which wins). A default/unset namespace routes to the bare <name>.
  • Consume side. A process subscribes <name>@<tenant> for every handler it declares, where the tenant is its own partition — bare when unset.

A process executes a body when, and only when, those two tokens match. There is no fallback: nothing "runs it anyway" because no worker showed up.

That gives the two control-plane shapes different answers:

  • A global control plane ({ role: 'control-plane' }, no tenant) subscribes the bare tokens. A tenant's run dispatches to <name>@acme, so it never sees that task — the tenant's workers do. It is, for tenant work, exactly the pure coordinator you asked for. What it does execute is default-namespace work, which is usually the point: something has to.
  • A tenant-scoped control plane ({ role: 'control-plane', tenant: 'acme' }) is deliberately different: the preset maps tenant onto partition too, so the node subscribes the very tokens it dispatches to. Otherwise it would dispatch into queues nothing in that process is listening on. Here it does compete with any other acme worker for the same tasks — by design.

So topology: { role: 'control-plane' } is not a switch that turns execution off. If a process declares @Step providers on a transport that can serve them (BullMQ, event-emitter), it starts consumers for them — and whether that matters depends on whether its partition lines up with what it dispatches. To keep bodies off the box entirely, keep the handlers out of its module.

The shapes

ShapeOwns the storeDrives runsExecutes bodiesRead/control surface
Single process (default)✅ inlinedirect (store)
Control planeonly what it declares, on its own partitiondirect (store)
Thin worker✅ off the brokerproxied over the wire
Thin api / dashboardproxied over the wire
Read replica (drive: false)direct (store)
  • Single process (default){ store, transport }. Owns everything and runs @Workflow bodies on the inline fast path. Zero infrastructure with the event-emitter transport; swap in BullMQ and an ORM store for production. Add a connection and the same process keeps the store but moves execution onto a co-located BullMQ consumer.
  • Control plane{ store, transport } in a module that declares no handlers. It dispatches, recovers crashes, fires timers, prunes retention, and answers read/control requests from thin pods. Bodies execute on the workers.
  • Thin worker{ connection }, no store. It consumes its partition's queues, executes the bodies it declares, and publishes results. It holds no durable state and needs no database credentials.
  • Thin api / dashboard — the same store-less config in an HTTP app that declares no handlers. Every read and control action round-trips to the control plane over the transport.
  • Read replica{ store, transport, drive: false }. Mounts the store and the dashboard for reads but leaves every driving loop to another instance. Make sure a driving operator is actually running somewhere, or nothing ticks.
Single deployment: one process holds the store, engine, and workersApplicationengine · store · workersnamespace: defaultStoreRunGateway (direct reads)Transport(Redis)tasks
Single process: one box owns the store, the engine and the handlers. The transport can be your database or Redis.
Control plane (operator) plus tenant workers, wired over the transportControl plane · operatorengine · store · dashboardnamespace: — (drives all)sees every tenant’s runsTransport(Redis)Tenant · blueDURABLE_TENANT=blue · no storeworkers → handler@blueProxyRunGatewayTenant · greenDURABLE_TENANT=green · no storeworkers → handler@greenProxyRunGatewaycontrol + readsrun@bluerun@green
Control plane plus thin workers: the store stays on one side of the transport, execution on the other.

When to reach for a split

Reach for a split topology when you have a concrete driver — not by default. The single-process operator 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. A control plane plus N thin workers scales the workers without touching the coordinator.
  • Isolation & security. A thin worker has no store and no database credentials — a compromised worker cannot read or corrupt durable state.
  • Per-tenant routing. Route each tenant's runs to its own pool with a <name>@<tenant> queue suffix, sharing one control plane and store. See Tenancy for the namespace vs partition axes and the isolation boundary.
  • Polyglot workers. Because the wire is byte-compatible, a Python worker (the durable-worker client) or an Adonis worker (@adonis-agora/durable) can execute your workflows' steps against the same control plane. See Cross-ecosystem interop.
A Node operator orchestrating a Python tenant worker over one transportOperator · Nodeengine · storeorchestrates pipelinenamespace: defaultTransport(Redis)Tenant · Pythondurable-worker (no store)runs handler@… stepssame queue namesstart-rundispatch · reply
A Node control plane and a Python worker share one transport and one queue-naming scheme.

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; a worker pulls a task, runs the body, and publishes the result, holding no durable state. Your @Workflow and @Step classes are identical across every shape — the dashboard, controllers, and reads all go through one RunGateway that is either store-backed or proxied over the wire, so nothing above that line can tell whether a store is present.

Next steps

  • Roles & config — the topology preset on forRoot, the RunGateway surface every shape shares, and layered tenant authentication.
  • Tenancy — what namespace and partition actually partition, and the boundary that keeps a tenant 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 NestJS, Adonis, and Python workers on one control plane.

On this page