Flows and Scripts
Stem supports two workflow models. Both are durable. They differ in where the execution plan lives.
The distinction
| Model | Source of truth | Best for |
|---|---|---|
Flow | Declared steps | Explicit orchestration, clearer admin views, fixed step order |
WorkflowScript | The Dart code in run(...) | Branching, loops, and function-style workflow authoring |
The confusing part is that both models expose step-like metadata. The difference is that for script workflows those are checkpoints, not the plan itself.
- Flow: the runtime advances through the declared step list.
- WorkflowScript: the runtime re-enters
run(...)and durable boundaries are created whenscript.step(...)executes. - Script checkpoints exist for replay boundaries, manifests, dashboards, and tooling.
Flow example
class ApprovalsFlow {
static final flow = Flow<String>(
name: 'approvals.flow',
build: (flow) {
flow.step('draft', (ctx) async {
final draft = ctx.requiredParamJson<ApprovalDraft>(
'draft',
decode: ApprovalDraft.fromJson,
);
return draft.documentId;
});
flow.step('manager-review', (ctx) async {
final resume = ctx.waitForEventValueJson<ApprovalDecision>(
'approvals.manager',
decode: ApprovalDecision.fromJson,
);
if (resume == null) {
return null;
}
return resume.approvedBy;
});
flow.step('finalize', (ctx) async {
final approvedBy = ctx.previousValue<String>();
return 'approved-by:$approvedBy';
});
},
);
static final ref = flow.refJson<ApprovalDraft>();
}
Future<void> registerFlow(StemWorkflowApp workflowApp) async {
workflowApp.registerFlows([ApprovalsFlow.flow]);
}
Future<void> registerWorkflowDefinition(StemWorkflowApp workflowApp) async {
workflowApp.registerWorkflows([ApprovalsFlow.flow.definition]);
}
Manual flows can also derive a typed workflow ref from the definition:
final approvalsRef = approvalsFlow.ref<Map<String, Object?>>(
encodeParams: (draft) => <String, Object?>{'draft': draft},
);
When a flow has no start params, start directly from the flow itself with
flow.start(...) or flow.startAndWait(...). Keep
flow.ref0().asRef.buildStart(params: ()) for the rarer cases where you want to assemble
or adjust overrides before dispatch.
Use ref0() only when another API specifically needs a NoArgsWorkflowRef.
Use Flow when:
- the sequence of durable actions should be obvious from the definition
- each step maps cleanly to one business stage
- your operators care about a stable, declared step list
Script example
final retryScript = WorkflowScript(
name: 'billing.retry-script',
run: (script) async {
final chargeId = await script.step<String>('charge', (ctx) async {
final resume = ctx.waitForEventValueJson<ChargePrepared>(
'billing.charge.prepared',
decode: ChargePrepared.fromJson,
);
if (resume == null) {
return 'pending';
}
return resume.chargeId;
});
final receipt = await script.step<String>('confirm', (ctx) async {
ctx.idempotencyKey('confirm-$chargeId');
return 'receipt-$chargeId';
});
return receipt;
},
);
final retryDefinition = retryScript.definition;
Future<void> registerScript(StemWorkflowApp workflowApp) async {
workflowApp.registerScripts([retryScript]);
}
Manual scripts support the same pattern:
final retryRef = retryScript.ref<Map<String, Object?>>(
encodeParams: (params) => params,
);
When a script has no start params, start directly from the script itself with
retryScript.start(...) or retryScript.startAndWait(...). Keep
retryScript.ref0().asRef.buildStart(params: ()) for the rarer cases where you want to
assemble or adjust overrides before dispatch. Use ref0() only when another API
specifically needs a NoArgsWorkflowRef.
Use WorkflowScript when:
- you want normal Dart control flow to define the run
- the workflow has branching or repeated patterns
- you want a more function-like authoring model
Contexts in each model
- flow steps receive
FlowContext - script runs may receive
WorkflowScriptContext - script checkpoints may receive
WorkflowScriptStepContext
The full injection and parameter rules are documented in Context and Serialization.