Sub-Workflows & Composition
Compose workflow runs hierarchically — a parent step delegates to a child workflow.
Compose workflow runs hierarchically — a parent step delegates to a child workflow.
A sub-workflow step delegates to a fully independent child workflow run. The
parent node goes RUNNING while the child executes and resolves when the
child reaches a terminal state.
Build the child as an ordinary Workflow (do not submit it) and pass it
to .subWorkflow() on the parent:
// Child workflow — never submitted directly.
Workflow processChunk = Workflow.named("process-chunk")
.step("validate", validateTask, 10)
.step("transform", transformTask, 20, "validate")
.step("store", storeTask, 30, "transform");
// Parent workflow.
Workflow pipeline = Workflow.named("full-pipeline")
.step("prepare", prepareTask, 1)
.subWorkflow("process", processChunk, "prepare")
.step("finish", finishTask, 2, "process");
WorkflowRun run = queue.submitWorkflow(pipeline);
try (Worker worker = queue.worker()
.handle(prepareTask, p -> p)
.handle(validateTask, p -> p)
.handle(transformTask, p -> p)
.handle(storeTask, p -> p)
.handle(finishTask, p -> p)
.trackWorkflows(pipeline) // required — the tracker submits the child
.start()) {
WorkflowStatus status = run.await(Duration.ofSeconds(30));
status.state; // COMPLETED | FAILED
}prepare completes, the tracker reaches the process node and
submits the child as a linked run — a separate entry in storage with its
own nodes and state.process node status is RUNNING while the child is in flight.COMPLETED; finish is
enqueued normally.FAILED; finish and any
other downstream nodes are SKIPPED; the parent run ends FAILED.The child is a first-class run with its own run id, node statuses, and lifecycle — it can be inspected independently through the dashboard and status queries.
The child crosses the submit boundary as data, so anything that lives only in process memory is rejected when the parent is submitted:
submitWorkflow throws.condition (a predicate is code, not data) —
submitWorkflow throws.Use string conditions (onSuccess() / onFailure() / always()) inside
child workflows instead.
Child workflows may themselves contain sub-workflow steps. There is no enforced depth limit, but deeply nested hierarchies make observability harder — prefer fan-out for homogeneous parallel work and sub-workflows for distinct named pipelines.
A workflow's entry step must be a plain step — a deferred kind (sub-workflow,
gate, fan-out, or conditioned step) only runs once a predecessor settles — so
give each sub-workflow step an after:
Workflow inner = Workflow.named("inner").step("a", taskA, 1);
Workflow middle = Workflow.named("middle")
.step("x", taskX, 1)
.subWorkflow("inner", inner, "x");
Workflow outer = Workflow.named("outer")
.step("init", initTask, 1)
.subWorkflow("middle", middle, "init");
queue.submitWorkflow(outer);Pass the child Workflow object itself — never call submitWorkflow on it.
Submitting the child directly starts an immediate standalone run unlinked
from the parent.
If the child run fails, the parent node is marked FAILED and the parent run
ends FAILED. The child's own node statuses are preserved on the child run.
This mirrors how a failed step propagates through the DAG — downstream steps
are skipped.
To handle child failures without extra fallout, branch after the sub-workflow
node: place an onFailure() step after it and an onSuccess() step for the
happy path. See Conditions.