-
Notifications
You must be signed in to change notification settings - Fork 0
guides building a process in code
You do not need XML to get a process. Bpmn.Model is a plain object model with public constructors,
so a definition can be built in code and handed straight to the writer or the interpreter.
This is the path for generated processes, for test fixtures, and for tools that produce BPMN rather than consume it.
Start, one task, end.
using Bpmn.Model;
var process = new BpmnProcessDefinition(
ProcessId: "order-approval",
Name: "Order approval",
IsExecutable: true,
Elements:
[
new BpmnElement("start", BpmnElementTypes.StartEvent),
new BpmnElement("approve", BpmnElementTypes.UserTask, name: "Approve order", bindingRef: "task:approve"),
new BpmnElement("end", BpmnElementTypes.EndEvent)
],
SequenceFlows:
[
new BpmnSequenceFlow("f1", "start", "approve"),
new BpmnSequenceFlow("f2", "approve", "end")
]);
var definitions = new BpmnDefinitions(
Id: "definitions-1",
TargetNamespace: "https://example.com/processes",
Processes: [process]);Two things in there are load-bearing.
bindingRef is the work. It is an opaque host key naming the work the element runs. The
interpreter compares and echoes it; it never parses it. Gateways and plain start and end events have
no bindingRef because the interpreter handles them entirely on its own — they bind no work.
Ids are yours. elementId must be unique within its process, flowId within its flows, and
sourceRef / targetRef must name elements that exist. The constructors reject empty ids, but they
do not check that the graph hangs together; that is the graph builder's job. See
Hosting the interpreter.
A sequence flow is conditional when it carries a conditionOutcome. The flow is taken when the
source element's completed work reported that outcome name. The library evaluates no expressions:
outcomes are strings the host supplies on completion. See
Interpreter, not engine.
var elements = new List<BpmnElement>
{
new("start", BpmnElementTypes.StartEvent),
new("check", BpmnElementTypes.ServiceTask, name: "Check credit", bindingRef: "task:check-credit"),
new("gate", BpmnElementTypes.ExclusiveGateway, name: "Approved?", defaultFlowId: "to-reject"),
new("ship", BpmnElementTypes.ServiceTask, name: "Ship", bindingRef: "task:ship"),
new("reject", BpmnElementTypes.EndEvent, name: "Rejected"),
new("done", BpmnElementTypes.EndEvent, name: "Shipped")
};
var flows = new List<BpmnSequenceFlow>
{
new("f1", "start", "check"),
new("f2", "check", "gate"),
new("to-ship", "gate", "ship", conditionOutcome: "Approved"),
new("to-reject", "gate", "reject", isDefault: true),
new("f3", "ship", "done")
};defaultFlowId on the gateway and isDefault on the flow are two sides of the same statement, and
both are set. The default flow is taken only when no conditional flow matched.
Event semantics come from an element's EventDefinitions. The type strings are constants on
BpmnEventDefinitionTypes, and any extra data lives in the definition's Properties, keyed by the
constants on BpmnEventDefinitionProperties.
// A message start event.
var messageStart = new BpmnElement(
"start", BpmnElementTypes.StartEvent,
bindingRef: "listener:order-placed",
eventDefinitions:
[
new BpmnEventDefinition(
BpmnEventDefinitionTypes.Message,
new Dictionary<string, string> { [BpmnEventDefinitionProperties.Name] = "OrderPlaced" })
]);
// A timer start event with a recurring interval.
var timerStart = new BpmnElement(
"nightly", BpmnElementTypes.StartEvent,
bindingRef: "listener:nightly",
eventDefinitions:
[
new BpmnEventDefinition(
BpmnEventDefinitionTypes.Timer,
new Dictionary<string, string> { [BpmnEventDefinitionProperties.Interval] = "PT24H" })
]);
// A terminate end event: ends the process, discarding every remaining token.
var terminate = new BpmnElement(
"abort", BpmnElementTypes.EndEvent,
eventDefinitions: [new BpmnEventDefinition(BpmnEventDefinitionTypes.Terminate)]);Interval (an ISO-8601 duration) and Cron are mutually exclusive on a timer.
A boundary event is an element with an attachedToRef and a cancelActivity flag.
var task = new BpmnElement("call-customer", BpmnElementTypes.UserTask, bindingRef: "task:call");
// Interrupting: the timer fires, the task is torn down, the boundary path runs.
var timeout = new BpmnElement(
"timeout", BpmnElementTypes.BoundaryEvent,
attachedToRef: "call-customer",
cancelActivity: true,
bindingRef: "timer:2h",
eventDefinitions:
[
new BpmnEventDefinition(
BpmnEventDefinitionTypes.Timer,
new Dictionary<string, string> { [BpmnEventDefinitionProperties.Interval] = "PT2H" })
]);
// Non-interrupting: the reminder path runs alongside the still-running task.
var reminder = new BpmnElement(
"reminder", BpmnElementTypes.BoundaryEvent,
attachedToRef: "call-customer",
cancelActivity: false,
bindingRef: "timer:30m",
eventDefinitions:
[
new BpmnEventDefinition(
BpmnEventDefinitionTypes.Timer,
new Dictionary<string, string> { [BpmnEventDefinitionProperties.Interval] = "PT30M" })
]);cancelActivity: true is the BPMN default and is what the constructor assumes. Error boundary events
are always interrupting — BPMN fixes that, and it is not a choice you get to make. See
Errors and escalation.
Loop characteristics go on the host element, which must bind work:
var reviewEach = new BpmnElement(
"review", BpmnElementTypes.SubProcess,
bindingRef: "subprocess:review",
loopCharacteristics: new BpmnLoopCharacteristics(isSequential: false, cardinality: 3));Each instance gets a per-iteration frame seeding a zero-based loopIndex
(BpmnLoopCharacteristics.LoopIndexVariable). isSequential: false schedules all instances up front;
true runs one at a time.
Collection mode uses collectionVariable instead of cardinality, and seeds each instance's item
under itemVariable (default item). The named collection must be a declared container-scoped
variable of the process, or the graph build throws; the collection is read once at loop start and
snapshotted, so a later change to the variable does not resize a running loop.
var process = new BpmnProcessDefinition(
ProcessId: "review-all",
Variables: [new BpmnVariableDeclaration("documents", TypeHint: "any")],
Elements:
[
new BpmnElement("review", BpmnElementTypes.UserTask,
bindingRef: "task:review",
loopCharacteristics: new BpmnLoopCharacteristics(
isSequential: true,
collectionVariable: "documents",
itemVariable: "document"))
]);Exactly one of cardinality and collectionVariable may be set. loopIndex is reserved and cannot
be used as an item variable name. Collection mode needs the ScopeVariables host capability.
These are element flags rather than separate types.
// A compensation handler: bound work, no sequence flows, invoked only by compensation replay.
var undo = new BpmnElement(
"undo-charge", BpmnElementTypes.ServiceTask,
bindingRef: "task:refund",
isForCompensation: true);
// The boundary event that associates a host activity with that handler.
var compensateOn = new BpmnElement(
"charge-compensation", BpmnElementTypes.BoundaryEvent,
attachedToRef: "charge-card",
compensationHandlerElementId: "undo-charge",
eventDefinitions: [new BpmnEventDefinition(BpmnEventDefinitionTypes.Compensation)]);
// A transaction subprocess: may be cancelled from within by a cancel end event.
var booking = new BpmnElement(
"booking", BpmnElementTypes.SubProcess,
bindingRef: "subprocess:booking",
isTransaction: true);
// An event subprocess triggered by an error: no flows, activated by its start event.
var onFailure = new BpmnElement(
"on-failure", BpmnElementTypes.SubProcess,
bindingRef: "subprocess:on-failure",
triggeredByEvent: true);A transaction may not also carry loop characteristics. An event subprocess participates in no sequence flows, hosts no boundary event, and is neither a compensation handler nor a transaction.
An event subprocess whose start trigger is a message, signal or timer additionally needs a
listenerBindingRef: the work armed when the enclosing scope starts, so the trigger can be observed
while the scope runs. bindingRef binds the body; listenerBindingRef binds the listener. Error- and
escalation-triggered event subprocesses are dormant catchers and need no listener, so theirs stays
null.
A given binding ref is bound either as some element's BindingRef or as some element's
ListenerBindingRef — never both, never twice.
Variable declarations are container-scoped and carry a name, an optional host-interpreted type hint, and an optional default:
var process = new BpmnProcessDefinition(
ProcessId: "order-approval",
Variables:
[
new BpmnVariableDeclaration("orderId", TypeHint: "string"),
new BpmnVariableDeclaration("lineCount", TypeHint: "integer")
]);The semantics core reads only the name. What a type hint means is entirely the host's business.
Layout is optional — a definition with no diagram is valid and interprets fine. Add one when the output is meant for a human's modeling tool:
var plane = new BpmnPlane(
id: "plane-1",
bpmnElementRef: "order-approval",
shapes:
[
new BpmnShape("s-start", "start", new BpmnBounds(160, 100, BpmnLayoutDefaults.EventSize, BpmnLayoutDefaults.EventSize)),
new BpmnShape("s-approve", "approve", new BpmnBounds(260, 78, BpmnLayoutDefaults.TaskWidth, BpmnLayoutDefaults.TaskHeight)),
new BpmnShape("s-end", "end", new BpmnBounds(440, 100, BpmnLayoutDefaults.EventSize, BpmnLayoutDefaults.EventSize))
],
edges:
[
new BpmnEdge("e-f1", "f1", [new BpmnPoint(196, 118), new BpmnPoint(260, 118)]),
new BpmnEdge("e-f2", "f2", [new BpmnPoint(360, 118), new BpmnPoint(440, 118)])
]);
var withLayout = definitions with { Diagrams = [new BpmnDiagram("diagram-1", null, plane)] };BpmnLayoutDefaults carries the sizes the library itself uses when synthesizing layout, which makes
generated diagrams look consistent with imported ones.
BpmnDefinitions, BpmnProcessDefinition, and the DI types are records, so edits are with
expressions and the original is untouched:
var updated = definitions with
{
Processes = definitions.Processes
.Select(p => p.ProcessId == "order-approval"
? p with { Elements = [.. p.Elements, newElement] }
: p)
.ToList()
};BpmnElement, BpmnSequenceFlow, BpmnEventDefinition, BpmnPool, BpmnLane and
BpmnMessageFlow are immutable classes rather than records — replace them by construction.
using Bpmn.Interchange;
File.WriteAllText("generated.bpmn", new BpmnXmlWriter().Write(definitions));The writer synthesizes complete BPMN DI layout for a definition that carries none, so a code-generated process still opens in a modeling tool as something a human can read.
A definition built in code and a definition read from XML are the same type, so everything on Reading and writing BPMN XML applies to both.
- Supported BPMN constructs — what you can express.
- Simulating a process — run what you just built.
- Hosting the interpreter — turn a definition into an executable graph.
Overview
Guides
- Building a process in code
- Getting started
- Hosting the interpreter
- Reading and writing BPMN XML
- Simulating a process
Concepts
Reference
Decision records
- ADR 0001: BPMN semantics ship as a host-agnostic library
- ADR 0002: The host port is synchronous and command-returning
- ADR 0003: Faults are a verdict, not a command
- ADR 0004: Missing host capabilities refuse at graph-build time
- ADR 0005: The library owns a versioned payload format
- ADR 0006: Vendor extensions are retained as typed data
- Decision records