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:
| Fact | Decides |
|---|---|
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 ownpartition, which wins). Adefault/unset namespace routes to the bare<name>. - Consume side. A process subscribes
<name>@<tenant>for every handler it declares, where the tenant is its ownpartition— 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' }, notenant) 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 mapstenantontopartitiontoo, 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 otheracmeworker 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
| Shape | Owns the store | Drives runs | Executes bodies | Read/control surface |
|---|---|---|---|---|
| Single process (default) | ✅ | ✅ | ✅ inline | direct (store) |
| Control plane | ✅ | ✅ | only what it declares, on its own partition | direct (store) |
| Thin worker | ❌ | ❌ | ✅ off the broker | proxied over the wire |
| Thin api / dashboard | ❌ | ❌ | ❌ | proxied over the wire |
Read replica (drive: false) | ✅ | ❌ | ❌ | direct (store) |
- Single process (default) —
{ store, transport }. Owns everything and runs@Workflowbodies on the inline fast path. Zero infrastructure with the event-emitter transport; swap in BullMQ and an ORM store for production. Add aconnectionand 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 }, nostore. 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.
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 thenamespacevspartitionaxes and the isolation boundary. - Polyglot workers. Because the wire is byte-compatible, a Python worker (the
durable-workerclient) or an Adonis worker (@adonis-agora/durable) can execute your workflows' steps against the same 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; 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
topologypreset onforRoot, theRunGatewaysurface every shape shares, and layered tenant authentication. - Tenancy — what
namespaceandpartitionactually 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.
Diagnostics channel
Bridge every durable engine lifecycle event onto the node diagnostics-channel bus, so any @OnDiagnostic subscriber sees runs and steps alongside your app's other channels — additive to OTel and Telescope.
Roles & config
The topology preset on DurableModule.forRoot, the RunGateway surface every shape shares, and layered tenant authentication for a store-less fleet.