Architecture¶
azure-functions-durable-graph compiles graph-shaped workflows into Azure Functions
applications that run on Durable Functions without violating orchestrator determinism.
Design principles¶
- Keep the orchestrator deterministic — all user logic runs in activities.
- Use a manifest as a stable, versioned intermediate representation.
- Separate graph definition from runtime execution.
- Favor typed state contracts over ad-hoc dict passing.
- Avoid global mutable configuration.
Mental model
The manifest is compiled once at startup. The orchestrator reads it to decide what activities to call, but never executes user code directly.
High-level flow¶
sequenceDiagram
participant Client as HTTP Client
participant HTTP as HTTP Endpoints
participant DF as Durable Orchestrator
participant ACT as Activities
participant REG as GraphRegistry
Client->>HTTP: POST /api/graphs/{name}/runs
HTTP->>DF: start_new(orchestrator, input)
loop For each node
DF->>ACT: execute_node(graph, node, state)
ACT->>REG: lookup handler, validate state
REG-->>ACT: updated state
ACT-->>DF: state dict
DF->>ACT: resolve_route(graph, node, state)
ACT->>REG: lookup route handler
REG-->>ACT: RouteDecision
ACT-->>DF: decision dict
alt complete
DF-->>Client: final state
else wait_for_event
DF->>DF: wait_for_external_event
DF->>ACT: apply_event(handler, state, payload)
ACT-->>DF: updated state
else next
Note over DF: continue loop
end
end
Runtime rule¶
The orchestrator may only:
- read the manifest
- read orchestration input
- set custom status
- call activities
- wait for external events
- decide loop termination from activity outputs
The orchestrator may not:
- call LLMs
- call tools
- inspect wall-clock time
- perform network I/O
- run arbitrary route logic
Execution loop¶
state = initial_state
node = entrypoint
while True:
state = call execute_node(graph_name, node, state)
decision = call resolve_route(graph_name, node, state)
if decision.action == "complete":
return state
if decision.action == "wait_for_event":
event_payload = wait_for_external_event(decision.event_name)
state = call apply_event(graph_name, decision.event_name, state, event_payload)
node = decision.resume_node
continue
node = decision.next_node
Module responsibilities¶
app.py (runtime entry point)¶
Owns:
DurableGraphAppclass- HTTP endpoint registration (start run, get status, send event, cancel, health, openapi)
- Durable Functions orchestrator definition
- Activity definitions (execute_node, resolve_route, apply_event)
Not responsible for:
- graph definition or manifest building
- state validation logic
manifest.py (graph definition)¶
Owns:
ManifestBuilderfluent APIGraphManifestdata structureGraphRegistrationdata classNodeDefinitionmodel- Topology hash computation
registry.py (handler dispatch)¶
Owns:
GraphRegistryfor storing and looking up registrationsexecute_node— state validation, handler invocation, state mergeresolve_route— route handler invocation and decision normalizationapply_event— event handler invocation and state merge
contracts.py (data types)¶
Owns:
RouteActionenumRouteDecisionmodel with factory methodsOrchestrationInput,NodeExecutionRequest,RouteResolutionRequest,EventApplyRequestRunStatusEnvelope
Module boundaries¶
flowchart LR
APP["app.py\nDurableGraphApp"]
MAN["manifest.py\nManifestBuilder"]
REG["registry.py\nGraphRegistry"]
CON["contracts.py\nRouteDecision"]
APP -- "registers" --> REG
APP -- "reads manifest from" --> REG
MAN -- "builds" --> REG
REG -- "uses" --> CON
APP -- "uses" --> CON
What this package owns¶
| Concern | Description |
|---|---|
| Graph compilation | Compile node/edge definitions into a versioned manifest |
| Runtime orchestration | Deterministic Durable Functions orchestrator loop |
| State management | Pydantic v2 state validation and merge semantics |
| HTTP API | Start, poll, event, cancel, health, and OpenAPI endpoints |
| Handler dispatch | Node, route, and event handler invocation |
What this package does not own¶
- LLM client management or prompt engineering
- Authentication and authorization
- Business/domain logic inside node handlers
- Data persistence beyond Durable Functions state
Why a manifest exists¶
A manifest gives the runtime a stable, versioned intermediate representation:
- graph name and version
- manifest-derived hash for change detection
- state model identity
- entrypoint and node definitions
- handler name references
- event handler registrations
This makes safe deployment and debugging easier than trying to infer graph structure at runtime from arbitrary objects.
Versioning shape¶
The hash is derived from a canonical JSON representation of the graph topology. Breaking graph changes should eventually deploy side-by-side with older manifests still available for in-flight instances.
Storage stance¶
Durable state is the execution source of truth. Long-term memory belongs in pluggable providers.
Invariants and guarantees¶
ManifestBuilder.build()requires an entrypoint referencing a registered node.- Terminal nodes cannot define
next_nodeorroute_handler_name. - The orchestrator never calls user code directly.
- State merging is shallow for dict returns, full replacement for BaseModel returns.
- Route handlers can return
RouteDecision,str,dict, orNone.
Anti-patterns to avoid¶
| Anti-pattern | Why it causes problems |
|---|---|
| Calling LLMs inside the orchestrator | Breaks Durable Functions replay determinism |
| Skipping state model validation | State drift across nodes becomes hard to debug |
| Using mutable global state in handlers | Activity instances may run on different workers |
| Deep nesting of graph nodes | Increases orchestration history size and replay cost |