Workflows
ProThe Pro grouping layer over dependencies: author a typed multi-job DAG as one named unit with parallel fan-out, conditional gates, saga compensation, child splices, typed output, atomic enqueue, derived status, and whole-graph cancel.
A Workflow is the BackWave Pro way to author, enqueue, watch, and cancel a multi-job dependency graph as a single named unit. Each node in the graph is a Job, each edge is a Dependency between two of those Jobs, and the whole thing is created in one atomic operation, carries one derived status, and cancels as a group. Underneath, a Workflow is still nothing more than static Dependency edges. It does not add a new execution capability. It adds a strongly-typed builder, a name, a sortable id, a graph view, and group-level lifecycle operations on top of the Dependencies you would otherwise wire by hand. A Workflow groups Dependencies. It is not a durable-execution engine, and the section near the end of this page draws that line precisely.
Workflows ship in the BackWave Pro package. Referencing the package and calling AddBackWavePro once at startup is the whole boundary: the Workflow methods light up as extensions on the same BackWaveClient, BackWaveMonitor, and BackWaveOperator you already use for ordinary Jobs.
services
.AddBackWave(bw => bw
.UseStore(new PostgresJobStore(connectionString))
.UseJobs(BackWaveJobs.Module)
.AddWorkerGroup(new WorkerGroupOptions { Name = "default" }))
.AddBackWavePro(builder.Configuration["BackWave:ProLicense"]);BackWave Pro is free for organizations under one million dollars in annual revenue, and the license argument defaults to null for that case. The licensing page covers the model in full; see Licensing and Pricing.
Authoring a Workflow#
You build a Workflow with a strongly-typed fluent builder. A step is an ordinary [Job] payload record that additionally wears the IWorkflowStep marker, and you chain steps by their .NET type, so a mistyped or renamed step is a compile error instead of a runtime one. client.Workflow(name) opens the builder, each .Then(...) adds a step that depends on the current frontier, and .EnqueueAsync() validates and writes the whole graph.
var workflowId = await client.Workflow("order-fulfillment")
.Then(new ValidateOrder(orderId))
.Then(new ChargePayment(orderId)) // runs after validate
.Then(new ReserveInventory(orderId), after: [typeof(ValidateOrder)]) // also after validate
.Then(new PackShipment(orderId), after: [typeof(ChargePayment), typeof(ReserveInventory)]) // fan-in
.Then(new NotifyBuyer(orderId)) // runs after pack
.EnqueueAsync();The optional name passed to Workflow is a human-readable label for the graph view. It is not unique and the trivial "run B after A" case needs none. A linear .Then(step) depends on the frontier, the previously-added step, and becomes the new frontier. A step that fans in over several upstreams names them by type with after: [typeof(StepA), typeof(StepB)]. Because steps are referenced by type, fan-in over a repeated type is disambiguated by giving the repeats a name: and referencing them through a WorkflowStepRef.
The Workflows API reference is the exhaustive signature list. Conceptually, the builder composes a graph out of a small set of shapes.
Parallel fan-out and fan-in#
Parallel(...) fans out one branch per argument from the current frontier, then makes the set of all branch tips the new frontier. There is no synthetic join node: the next step that names the tips is the join, and a plain .Then after a Parallel fans in over every tip. A branch is either a single step (WorkflowBranch.Step) or a chained sub-builder (WorkflowBranch.Do).
.Then(new PriceOrder(orderRef, cents))
.Parallel(
WorkflowBranch.Step(new ReserveStock(orderRef, itemCount)),
WorkflowBranch.Do(b => b.Then(new NotifyWarehouse(orderRef)).Then(new ConfirmPick(orderRef))))
.Then(new AuthorizeCharge(orderRef, cents)) // fans in over BOTH branch tipsTyped output between steps#
A step declares the output type it produces on its own contract, via IWorkflowStep<TOut>. It emits with ctx.SetOutput<TStep, TOut>(value), and a downstream step pulls an ancestor's output with ctx.Output<TStep, TOut>(), type-checked against the producer's declaration. Data flows down the graph by pull, never push; nothing is injected into a reader's args. Absence is normal: an ancestor that failed, was cancelled, or emitted nothing resolves to a clean HasOutput == false, never a throw.
Conditional gates#
.If<...>(then:, otherwise:) adds a runtime conditional. Both arms are enqueued up front and a gate step is inserted after the frontier. At run time the gate reads the already-decided output of the ancestor its predicate observes, evaluates a named IWorkflowGate type, and cancels the arm that was not taken (its whole subtree); the taken arm proceeds. The graph is never reshaped: a node that was always in the graph is cancelled, so the below-boundary shape is fixed at build time. The predicate is a registered gate type rather than a lambda because it runs on whatever worker later claims the gate step, so it must survive enqueue as payload bytes. Register each gate with AddWorkflowGate.
Converging past a conditional needs DependencyMode.OnAnyTerminal, because the not-taken arm terminates cancelled and a join over both arms must release on any terminal state, not only success. This behavior has one surprising consequence that is important enough to have its own section below.
Saga compensation#
.WithCompensation(...) attaches a first-class saga-style undo guarding the current frontier. The compensation is an ordinary step wired to run once the protected work is terminal, whatever the outcome. Its handler reads the protected step's already-decided state and decides: undo if the protected work failed, no-op if it succeeded. So the compensation always runs but usually no-ops, and there is deliberately no on-failure release mode to model. Calling WithCompensation again for earlier steps builds a reverse-order saga: later-protected work undoes first.
.Then(new AuthorizeCharge(orderRef, cents))
.WithCompensation(new RefundCharge(orderRef)) // no-ops on success, refunds on failureSplicing a child workflow#
.ThenWorkflow<TChild, TSeed>(seed) splices a reusable IWorkflow<TSeed> definition into the current graph as an inline subgraph. The child's build runs at construction; its steps are grafted onto the same flat graph rooted at the frontier, and its leaves become the new frontier. There is no nested identity: one flat graph, one workflow row, one derived status, one retention unit. The child's TSeed is build-time only; the spliced child shares the parent's Workflow Input, so read the seed's values into the child's step payloads at build time.
Workflow Input: an immutable seed#
A workflow can be started with an immutable, set-once Workflow Input seed. It is baked into every member's payload at enqueue and read back inside any handler with ctx.Input<TInput>(). It shapes the graph at build time and supplies constant data to step payloads. It is never mutated and is not shared accumulating state; read an upstream step's result through Job Output, never through the seed. A seed type opts in by wearing the IWorkflowInput marker. One sharp edge: appended members carry no seed, so a ctx.Input<T>() in an appended step throws at runtime; give appended steps their constant data through their own payload.
Typed output codecs and seed codecs are auto-wired from your app's own JsonSerializerContext; list the output DTO and the seed in a context and BackWave sources the serializer for you. A workflow output type or seed listed in no context is a build error (BW0007) with a one-click fix, so a missing serializer is caught at compile time, not at run time.
Enqueuing the whole graph atomically#
.EnqueueAsync() writes the entire graph in one transaction. Every member Job and the Workflow record commit together or not at all. There is never a half-built Workflow or a dangling edge in the store: either the whole graph persists or nothing does. The method returns the Workflow's id.
You can co-commit the whole graph with your own database writes by passing a DbTransaction. The Workflow then commits or rolls back together with the business rows on that same transaction, the same Transactional Enqueue guarantee extended to a whole graph. If the storage adapter does not support transactional enqueue, supplying a transaction throws NotSupportedException.
await using var transaction = await db.Database.BeginTransactionAsync();
db.Orders.Add(order);
await db.SaveChangesAsync();
var workflowId = await client.Workflow("checkout")
.Then(new ChargeCard(order.Id))
.Then(new SendReceipt(order.Id))
.EnqueueAsync(transaction: transaction.GetDbTransaction());
await transaction.CommitAsync();The store rejects a Workflow as a single unit, and a rejection inserts nothing. Rejection reasons surface as an exception and cover a duplicate Workflow id, a missing append target, a duplicate member id, a member whose gating parent is not part of the same Workflow, an empty graph, an oversized payload, an over-length wire name, and too many parents on a node. The containment rule behind one of those reasons is worth stating directly: every gating parent of a member must belong to the same Workflow, and a Job belongs to at most one Workflow.
Workflow status#
A Workflow has one status, and it is always derived from the current states of its member Jobs. It is never stored on its own. Reading a Workflow recomputes the status from the member set, so it always reflects where the members actually are. The status is a WorkflowStatus with four values, resolved by first-match-wins precedence: Running, then Failed, then Cancelled, then Succeeded.
| Status | When it applies |
|---|---|
| Running | At least one member is still non-terminal. |
| Failed | All members are terminal and at least one is Dead-Lettered or Quarantined. Failure dominates: one failed member makes the whole Workflow Failed even when its siblings Succeeded. |
| Cancelled | All members are terminal, none failed, and at least one is Cancelled. An operator cancel produces no failures, so it reads here rather than as Failed. |
| Succeeded | Every member Succeeded. The empty case also reads as Succeeded. |
Because status is a pure function of the member set, it can legitimately move backward from Succeeded to Running when you append new live work to a drained Workflow. The terminal member states that feed this projection (Succeeded, Dead-Lettered, Quarantined, Cancelled) come from the Job Lifecycle.
The conditional status footgun#
This is the single most surprising behavior in the workflow surface, so hold it firmly: a healthy conditional (.If) workflow derives a Cancelled status even when every step that actually ran Succeeded. The gate cancels the not-taken arm, those members reach the terminal cancelled state, and a cancelled member with no failed member makes the whole-workflow rollup Cancelled (never Failed). This is expected and healthy for any conditional workflow, not a sign anything went wrong.
The consequence is a rule: for a workflow that contains a conditional, read per-step state from the member graph, not the whole-workflow rollup, to tell a healthy conditional run from a genuinely aborted one. A rollup of Cancelled means "at least one member was cancelled," which for a conditional is always true by construction. The claim "Cancelled means the run was aborted" is simply false for a conditional workflow, and so is "Succeeded means every branch ran."
Two read methods on BackWaveMonitor expose Workflows. ListWorkflowsAsync returns every Workflow oldest first, each as a WorkflowSnapshot carrying the id, optional Name, creation time, derived status, member count, and a restart lineage pointer. GetWorkflowAsync returns one Workflow's full graph as a WorkflowView with the members as Job snapshots and the structural WorkflowEdge list, or null when no Workflow has that id.
var view = await monitor.GetWorkflowAsync(workflowId);
if (view is not null)
{
// For a conditional workflow, inspect member states, not just view.Status:
foreach (var m in view.Members)
Console.WriteLine($" {m.WireName}: {m.State}");
}A WorkflowEdge is a fixed structural parent-to-child edge. Unlike the live gating edges that resolve away as parents finish, these stay in place for the Workflow's whole life, so the graph view is always complete.
Growing a Workflow#
A Workflow grows by appending, never by rewriting. client.WorkflowAppend(workflowId) returns a builder whose added steps become new members of an existing Workflow. New steps depend on the frontier, on other new steps by type, or on existing members by their Guid through the afterExisting: argument.
await client.WorkflowAppend(workflowId)
.Then(new NotifyOps(orderId), afterExisting: [chargeJobId])
.EnqueueAsync();Appending only adds Jobs. The existing members and their Dependencies are never touched. The target Workflow must already exist or the enqueue is rejected. Appending live work to a Workflow that had already drained reopens its derived status to Running, which is the same projection rule described above. Remember that appended members carry no Workflow Input.
Cancelling a Workflow#
CancelWorkflowAsync on BackWaveOperator cancels a Workflow as a group. It is a one-time snapshot, not a standing rule: it cancels each member that is running or pending at the moment of the call, and a member that starts after the call returns is unaffected. Already-terminal members are left untouched.
var result = await operator.CancelWorkflowAsync(workflowId, actor: "ops-oncall");Each per-member cancel is recorded against the actor you pass, for audit. A member that is currently leased cancels cooperatively through its CancellationToken, driven by the heartbeat, exactly like a single-Job cancel. Threads are never killed. Because a cancel produces no failed members, the Workflow's derived status reads as Cancelled rather than Failed. See Execution Guarantee for the cooperative cancellation contract.
The call returns a WorkflowCancelResult reporting whether the Workflow was found, how many pending members were cancelled outright, and how many running members were asked to stop cooperatively. There is no Workflow-level pause.
Recovering a failed Workflow#
Recovery is a full redo, not a resume. BackWave records nothing inside a handler and cannot resume a graph part-way, so to re-run a failed workflow you build the same graph again with the typed builder and enqueue it. That re-runs every step from the start, including the steps that already Succeeded, so any non-idempotent step runs again and idempotency stays the handler's responsibility. For a shape-preserving redo that carries a lineage pointer, Build() returns a WorkflowDefinition and WorkflowDefinition.RestartAsNew() mints a fresh definition with new ids and a RestartedFrom pointer back to the original, which the monitor surfaces on the snapshot and view.
What a Workflow deliberately does not do#
A Workflow groups static Dependency edges. It is not a durable-execution engine. Here is what that rules out.
- No replay. Nothing records or replays the steps inside a handler.
- No mid-handler resume. Recovery is graph-level, re-running whole nodes, never step-level.
- No durable waits, signals, or timers. A workflow never suspends a member pending an external event, and there is no
.Delayor.WaitForstep. Conditional branching does exist as a build-time gate step (.If), but it decides on data already produced by the graph; it is not a durable wait. - No result-driven graph reshaping. Reading a parent's Job Output can decide which pre-declared arm of an
.Ifruns, but output can never add, skip, or reorder nodes. The graph shape is fixed at build time. - Handlers stay opaque and at-least-once. A Workflow inherits the same execution guarantee as any Job: a node may run more than once, and idempotency is the handler's job. Cancellation is cooperative.
- Growth is append-only. You can add new nodes, whose own edges are fixed at their enqueue, but you can never rewrite an existing node's Dependencies.
A useful framing: a Workflow is about as dynamic as a dependency graph in River or Hangfire, plus first-class saga compensation and conditional gates, and deliberately not a Temporal-style durable workflow. The Execution Guarantee page covers the at-least-once, opaque-handler contract that every member carries.
Delays, waits, and versioning without new primitives#
The three things people most often reach for and do not find have honest, in-model alternatives that need no durable-execution machinery.
- Delay a step. There is no
.Delay. For a fixed floor ("cannot run before 9am"), enqueue with a future due time. For a completion-anchored delay ("an hour after the charge settles"), end the upstream handler by enqueuing the next step atnow + delay; that appends a new job rather than pausing one, so no worker is held and it survives a restart. - Wait for an event. There is no
.WaitFor. Either poll from a step, where the waiter checks its condition and, if unmet, re-enqueues itself with a future due time on a backoff, or let the event drive: the out-of-band event handler enqueues the continuation step directly. Both are just ordinary future-due jobs. - Version a definition. A definition carries no version field. Name your definition types by convention (
CheckoutWorkflowV2); already-running instances keep the shape they were enqueued with. When you rename or remove a step, keep the old handler registered until every in-flight instance referencing it has drained. A job whose step has no registered handler is quarantined for inspection, loud and visible, never silently dropped, so getting the drain wrong is a parked job you can requeue, not data loss.
Limits#
The numeric bounds that apply to a Workflow are the same store bounds that apply to any Job, not Workflow-specific limits.
| Limit or behavior | Value |
|---|---|
| Max parents (fan-in) per node | 16 |
| Max Job payload | 65,536 bytes |
| Max Job Output blob | 65,536 bytes, rejected rather than truncated when over |
| Max wire-name length | 128 characters |
| Minimum Workflow size | At least one node; an empty graph is rejected |
| Workflow name | Optional and non-unique |
| Workflow membership | At most one Workflow per Job |
| Atomicity | All members plus the Workflow record in one transaction; a partial write never persists |
| Cancel scope | A one-time snapshot of currently-running and pending members, not a standing rule |
| Workflow-level pause | None |
Members are retained as a unit until the whole Workflow drains, meaning every member reaches a terminal state, after which the normal per-Job retention window begins. The shared numeric bounds are listed in full under Limits and Defaults.
Where to go next#
- Dependencies: the layer below a Workflow, where edges, release modes, fan-in countdown, and Job Output are defined.
- Execution Guarantee: the at-least-once contract and cooperative cancellation that every member inherits.
- Job Lifecycle: the terminal member states (Succeeded, Dead-Lettered, Quarantined, Cancelled) that drive a Workflow's derived status.
- Build a Workflow: a guided walkthrough from builder to enqueue.
- Testing Workflows: asserting on a Workflow's shape and derived status.
- Workflows API: the full method and type reference.
Found a problem on this page? Report an issue