Building Graphs
For developers defining workflows. This is the graph-authoring API: the tasks, transitions, conditions and reducers that decide what runs when. Writing the task bodies themselves is Writing Tasks.
A workflow.Graph is the definition of a workflow. You build one in code and hand it to the engine via
your host’s LoadGraph. This guide covers the full graph-authoring API.
Tasks
A graph is a set of tasks connected by transitions. Bind a task with SetURL, giving a task name
(its identity in the graph) and a URL (the address your host’s ExecuteTask resolves to reach it):
g := workflow.NewGraph("Checkout")
g.SetURL("Reserve", "inventory.reserve")
g.SetURL("Charge", "billing.charge")
g.SetURL("Ship", "fulfillment.ship")NewGraph takes only the graph’s display name. The key you pass to Create and Run, the workflow
URL, is whatever your host’s LoadGraph understands; it is not stored on the graph. SetURL creates the task if new and updates its
URL if it already exists. The task name and URL can differ, which lets the same task URL appear at several
positions under different names. The engine passes the URL to your executor; your executor decides what that
address means (a function key, an HTTP endpoint, a message topic).
By convention, graph and task names are PascalCase (Reserve, Charge) — they are
graph-topology identifiers, distinct from the lowercased URLs they dispatch to and from the camelCase
state fields tasks read and write (lineItems, loopsLeft). Keeping the three namespaces visually
distinct makes graphs easier to read; the engine itself imposes no casing, and compares every name and URL
exactly, case included. A graph name, a task name and a workflow URL must not be empty or begin or end with
whitespace, and END is the terminal, not a task.
The entry point defaults to the first task bound. Override it with g.SetEntryPoint("Reserve").
workflow.End is the sentinel terminal target — a transition to it ends the flow.
Transitions
Transitions are the edges. After a task completes, the engine evaluates its outgoing transitions to decide what runs next.
Unconditional
g.AddTransition("Reserve", "Charge") // Reserve always -> Charge
g.AddTransition("Charge", "Ship") // Charge always -> Ship
g.AddTransition("Ship", workflow.End) // Ship ends the flowgraph LR
classDef task fill:#32a7c1,color:#f4f2ef,stroke:#32a7c1
classDef term fill:#e5f4f3,color:#434343,stroke:#32a7c1
linkStyle default stroke:#32a7c1
t0["Reserve"]:::task
t1["Charge"]:::task
t2["Ship"]:::task
t0 --> t1
t1 --> t2
_start(( )):::term --> t0
t2 --> _end(( )):::termConditional (when)
AddTransitionWhen fires only if a boolean expression over the merged state is true. Expressions use
boolexp syntax — infix comparisons and boolean operators over
state fields:
// Each check opts in on its own condition; every one that holds runs, in parallel.
g.AddTransitionWhen("Intake", "CreditCheck", "amount >= 1000")
g.AddTransitionWhen("Intake", "FraudCheck", "international == true")
g.AddTransition("CreditCheck", "Decide")
g.AddTransition("FraudCheck", "Decide")
g.SetFanIn("Decide")graph LR
classDef task fill:#32a7c1,color:#f4f2ef,stroke:#32a7c1
classDef term fill:#e5f4f3,color:#434343,stroke:#32a7c1
linkStyle default stroke:#32a7c1
t0["Intake"]:::task
t0_when{"when"}:::term
t1["CreditCheck"]:::task
t2["FraudCheck"]:::task
t3["Decide"]:::task
t3_reduce(("reduce")):::term
t3_reduce --> t3
t1 --> t3_reduce
t2 --> t3_reduce
t0 --> t0_when
t0_when -->|"amount #gt;= 1000"| t1
t0_when -->|"international == true"| t2
_start(( )):::term --> t0Every when transition is evaluated independently: if several match, all of them fire in parallel —
that’s a fan-out (see Fan-Out & Subgraphs). A source with two or more when
transitions is a fan-out source even when its conditions happen to be mutually exclusive, so its branches
must converge on a SetFanIn task; if none match, the fan-in runs on the source’s own state, as it does for
an empty forEach array. A single when that does not match ends the flow at the source task. For an
either/or, where exactly one branch runs and no fan-in is needed, use switch below.
First-Match-Wins (switch)
When you want exactly one branch — a router — use AddTransitionSwitch. Switch transitions from the same
source are evaluated in registration order; only the first whose expression is true fires, the rest
are skipped:
g.AddTransitionSwitch("Router", "HandleHigh", "amount >= 10000")
g.AddTransitionSwitch("Router", "HandleMid", "amount >= 1000")
g.AddTransitionSwitch("Router", "HandleLow", "true") // catch-allgraph LR
classDef task fill:#32a7c1,color:#f4f2ef,stroke:#32a7c1
classDef term fill:#e5f4f3,color:#434343,stroke:#32a7c1
linkStyle default stroke:#32a7c1
t0["Router"]:::task
t0_switch{"switch"}:::term
t1["HandleHigh"]:::task
t2["HandleMid"]:::task
t3["HandleLow"]:::task
t0 --> t0_switch
t0_switch -->|"amount #gt;= 10000"| t1
t0_switch -->|"amount #gt;= 1000"| t2
t0_switch -->|"default"| t3
_start(( )):::term --> t0Because only one branch ever runs, switch branches need no fan-in. A task that uses switch transitions must declare every success-path outgoing edge as switch — the validator rejects mixing switch with when/plain/forEach from the same source. (Error and goto transitions are orthogonal and still allowed.)
Static Fan-Out (fanOut)
When several statically named tasks should run in parallel off one source, AddTransitionFanOut wires
an unconditional edge from the source to each destination — the convenience form of repeated
AddTransition calls:
// Verify, ScoreCredit, and FraudCheck all fire after Intake and run in parallel.
g.AddTransitionFanOut("Intake", "Verify", "ScoreCredit", "FraudCheck")graph LR
classDef task fill:#32a7c1,color:#f4f2ef,stroke:#32a7c1
classDef term fill:#e5f4f3,color:#434343,stroke:#32a7c1
linkStyle default stroke:#32a7c1
t0["Intake"]:::task
t1["Verify"]:::task
t2["ScoreCredit"]:::task
t3["FraudCheck"]:::task
t0 --> t1
t0 --> t2
t0 --> t3
_start(( )):::term --> t0It creates only the outgoing edges; if the branches rejoin, the join task still needs SetFanIn (and
usually a reducer). Its linear sibling is AddTransitionChain("A", "B", "C"), which wires consecutive
pairs (A -> B -> C). Use AddTransitionForEach below instead when the parallel branches are dynamic —
one instance of a single task per runtime array element.
Dynamic Fan-Out (forEach)
AddTransitionForEach iterates an array field in the state and spawns one instance of the target task per
element, each receiving its element under the as key:
// For each element of state["lineItems"], run "Process" with the element bound to state["item"].
g.AddTransitionForEach("Split", "Process", "lineItems", "item")graph LR
classDef task fill:#32a7c1,color:#f4f2ef,stroke:#32a7c1
classDef term fill:#e5f4f3,color:#434343,stroke:#32a7c1
linkStyle default stroke:#32a7c1
t0["Split"]:::task
t1["Process"]:::task
t0 -->|"for each"| t1
_start(( )):::term --> t0Each branch also gets <as>Index (its position) and <as>Count (the number of branches) injected. An empty
array spawns no branches but does not end the flow: it routes straight to the fan-in task, which runs on the
source task’s own state.
Explicit Jumps (goto)
A goto transition is taken only when a task calls flow.Goto(target) — never during normal evaluation.
Use it for loops and computed routing:
g.AddTransitionGoto("Evaluate", "Retry") // taken only if a task calls flow.Goto("Retry")
g.AddTransitionGoto("Evaluate", "Finish")graph LR
classDef task fill:#32a7c1,color:#f4f2ef,stroke:#32a7c1
classDef term fill:#e5f4f3,color:#434343,stroke:#32a7c1
linkStyle default stroke:#32a7c1
t0["Evaluate"]:::task
t1["Retry"]:::task
t2["Finish"]:::task
t0 -->|"goto"| t1
t0 -->|"goto"| t2
_start(( )):::term --> t0See flow.Goto.
Error Handling (onError)
When a task returns an ordinary error, the engine routes to an onError handler if one is declared,
before failing the flow:
g.AddTransitionOnError("Charge", "Refund") // any error from Charge -> Refundgraph LR
classDef task fill:#32a7c1,color:#f4f2ef,stroke:#32a7c1
classDef term fill:#e5f4f3,color:#434343,stroke:#32a7c1
linkStyle default stroke:#32a7c1
t0["Charge"]:::task
t1["Refund"]:::task
t0 -->|"onError"| t1
_start(( )):::term --> t0The error from Charge routes to Refund rather than failing the flow. The error is serialized into the
state field onErr (as a structured error the handler can read), the failed step is marked completed, and
the handler runs next. If no onError handler is declared, the flow fails. If the failing task was part
of a fan-out, its siblings keep running — the errored branch continues down its handler path and the
branches converge normally.
A task can have an onError transition alongside its other outgoing transitions, at most one per task.
The success path and the error path are simply separate transitions:
g.AddTransitionForEach("Plan", "Search", "queries", "query") // success: one branch per query
g.AddTransitionOnError("Plan", "Apologize") // failure: handle the errorgraph LR
classDef task fill:#32a7c1,color:#f4f2ef,stroke:#32a7c1
classDef term fill:#e5f4f3,color:#434343,stroke:#32a7c1
linkStyle default stroke:#32a7c1
t0["Plan"]:::task
t1["Search"]:::task
t2["Apologize"]:::task
t0 -->|"for each"| t1
t0 -->|"onError"| t2
_start(( )):::term --> t0An error voids the task’s changes. Anything the failing task wrote with f.Set in the run that returned
the error is discarded — the handler does not see it, and it never reaches the flow’s final state. (Same on
the unhandled path: a failing task’s writes are dropped there too.) What the step’s earlier runs wrote -
before a Retry, or before an Interrupt or Subgraph call the task was dispatched again from - is kept,
and the handler sees it. This mirrors Go’s own convention, and it is
forced by at-least-once execution: if a worker crashes mid-task, the task re-runs and recomputes
its changes, so a half-written attempt is not a fact you can build on.
Two ways to say something to your handler, both explicit:
- Put it in the error.
onErrcarries the message, status code, trace id, and any properties you attach. - Give an external side effect its own task. Then its success is recorded durably before anything downstream can fail — which is what lets the handler know the charge went through and needs refunding.
The engine routes on any error — it never inspects the error’s HTTP status or text. To handle a specific failure kind (e.g. a timeout), branch inside the task:
flow.Retryfor transient failures,flow.Gotofor computed recovery, or return the error and let theonErrorhandler deal with it.
Fan-In and Reducers
When parallel branches (from multiple when matches or a forEach) converge on a single task, that task
is a fan-in: the engine waits for all siblings, merges their changes field-by-field, and runs the
target once. Mark the convergence task and wire non-default merges:
g.AddTransitionForEach("Split", "Process", "lineItems", "item")
g.AddTransition("Process", "Summarize")
g.SetFanIn("Summarize") // the branches converge here
g.SetReducer("results", workflow.ReducerAppend) // each branch appends its result
g.SetReducer("total", workflow.ReducerAdd) // each branch adds its subtotalgraph LR
classDef task fill:#32a7c1,color:#f4f2ef,stroke:#32a7c1
classDef term fill:#e5f4f3,color:#434343,stroke:#32a7c1
linkStyle default stroke:#32a7c1
t0["Split"]:::task
t1["Process"]:::task
t2["Summarize"]:::task
t2_reduce(("reduce")):::term
t2_reduce --> t2
t0 -->|"for each"| t1
t1 --> t2_reduce
_start(( )):::term --> t0A field with no reducer uses replace (last write wins). A task writes only its contribution to a
reducer-managed field, wherever it runs, and the reducer combines it at every hand-off, not only at a fan-in.
See Writing Tasks and
Fan-Out & Subgraphs.
Field Names
A state field name is an ASCII letter or underscore, followed by ASCII letters, digits, underscores or hyphens:
lineItems, line_item and line-item-2 are valid; 2nd, -x, order.items and naïve are not.
workflow.IsValidFieldName applies the rule.
Validaterejects a graph whoseforEach,asorSetReducernames a field any other way.Create,Run,ContinueandForkrefuse caller state holding such a name with a 400.- A task writing one panics, which takes the graph’s
onErrortransition or fails the step (see Writing tasks).
A when expression reads names of letters, digits and underscores only, so a field it compares must not contain a
hyphen.
Validation
Call g.Validate() to check structural integrity before use — unreachable tasks, dangling transition
targets, inconsistent fan-out siblings, illegal mixes (switch with non-switch), a reducer that is not one
of the Reducer constants, and so on. The engine also
validates at create time. Validate early in tests:
err := g.Validate()
if err != nil {
t.Fatalf("invalid graph: %v", err)
}Inspection
Read-only inspectors include Name, EntryPoint, Tasks, Transitions, Reducers, URLOf,
ErrorTransition, IsFanIn, and IsFanOutSource. Graphs marshal to and from JSON
(MarshalJSON/UnmarshalJSON), and a flow keeps the graph it was created with.
Next: Writing Tasks.