ADR 022: Process API as a Typed Projection of the Process Model
Status
Accepted — supersedes the section layout of ADR 003 (its naming rules still apply) and ADR 020; builds on ADR 021.
Context
Up to 5.x the generated Process API followed two organising principles at once. Elements, Variables, CallActivities and Timers were per-element trees that each re-derived the element naming, while Flow (#97) was the only section modelling the process shape. The registries (Messages, Errors, Signals, Escalations, ServiceTasks) were a third kind: lists keyed by a shared identity rather than by element.
Sequence flows were lost entirely on the code side: the navigation graph reduced each flow to its target node, so id, label, conditionExpression and isDefault existed only in the JSON export. A test could not ask "which condition guards this edge" without parsing JSON.
C# emitted constants only (ADR 020), because Flow needed runtime types that existed as a JVM artifact and nothing else.
Decision
The generated code becomes the typed projection of the JSON v2 model: everything JSON hangs on a flowNode hangs on a node of Flow; everything under definitions stays a shared registry.
Flowis flat. Every element, whatever its subprocess depth, is a direct child ofFlow, addressed by the camelCase form of its id. A subprocess exposes its interior's start events viastartEvents. Uniqueness is guaranteed model-wide by the mandatorycollision-detectionrule, which now also rejects the same id declared in two scopes.Nodes carry their facets, mirroring the sealed
FlowNodeDefinitionhierarchy:id/elementType/nameon all;jobTypeon tasks and events with an implementation;Variables;calledProcesswithInputs/Outputson call activities;timer;message/signal/error/escalation;attachedTo,isInterruptingandBoundaryEvent<Host>on boundary events. Each value facet implements a runtime interface (HasJobType,TimerEvent, …), so generic code can filter nodes by facet. TheElements,Variables,CallActivitiesandTimerssections are removed.Outgoing sequence flows are named after the element they lead to. Each node with outgoing flows exposes
outgoingFlows/OutgoingFlowswith oneto<Element>entry per target: aSequenceFlow<Target>(id, name, conditionExpression, isDefault, target), or a list of them when several flows lead to the same element — the name stays stable and no flow is lost. Flow ids are not used for names: modelers rarely rename them (Flow_1csfyyz), whereas element names already exist and are unique.next/Nextstays the element-level view (flows and boundary attachments collapsed by target), so existingProcessPath/PathWalksteps are unchanged; the additionalviastep walks a chosen sequence flow and records it inflowIds. Labels are not used either: they are not identifiers and are the most volatile part of a model.Update (2026-10):
Nexthas a third kind of entry. A compensation boundary event lists the handler it is associated with as anAssociatedCompensationHandler<Target>, and thethrowingCompensationstep records that handler without leaving the node that threw the compensation (#188).Registries stay shared where BPMN itself models a shared identity: root elements (
Messages,Errors,Signals,Escalations) and job types (ServiceTasks, oneconstper distinct type, the canonical argument for@JobWorker) are the shared definition files of ADR 021. A node refers to that shared constant instead of repeating the value (jobType = ServiceTasks.X,message: MessageName = Messages.X), so a value exists once per run and a node shows where it comes from. Only a value no root element declares — an unresolved reference — is written on the node itself.C# reaches parity by inlining its runtime. The runtime types are emitted into every generated file as a nested
Runtimeclass, so the file still has no dependencies and two files never clash. Nodes are sealed singletons (Flow.X.Instance) navigated by instance, because static members cannot chain;JobTypeis anIHasJobTypeproperty, andServiceTasks.Xis theconstfor attributes. This reverses ADR 020's rejection of inlining: with the type set this small and no NuGet pipeline, a package would cost more than the duplication.Member names follow JSON v2 (
conditionExpression,isInterrupting,isDefault); references that hold the resolved value drop theRefsuffix (attachedTo,calledProcess).One reserved-name rule. An element whose generated name would shadow a holder (
Flow,Next,Instance, …), a runtime type or ajava.lang.Objectmethod breaks compilation in at least one language, so the mandatoryreserved-element-namerule rejects it explicitly rather than each language renaming silently. C#-only CS0542 cases (a facet named like its node) keep the existing_suffix.
Consequences
Positive
- One tree instead of five parallel ones; a node's data is where its id is.
- Conditions and default markers are readable and assertable in code, in all three languages.
- C# ships the full API, without a runtime package to publish and version.
- Collision detection mirrors the generated scopes exactly: model-wide for nodes, run-wide for the shared registries (process variables included), per node for call-activity mappings.
Negative
- Breaking for 5.x consumers:
Elements.X→Flow.X.id,Variables.Node.V→Flow.Node.Variables.V,CallActivities.Node.*→Flow.Node.*,Timers.T→Flow.T.timer, nested interior nodes → flat. C# consumers loseconst stringelement ids (Flow.X.Instance.Id.Valueis an instance property). - Longer generated files, C# in particular (the runtime block repeats per file).
- Per-file C# runtime types are unrelated across processes; generic .NET tooling needs its own abstraction.
Alternatives Considered
- Keep the sections and add
Flowsonly. Leaves the duplication in place and every element named three times. - Name outgoing flows after their flow id (the first 6.0 draft). Consistent, but unreadable with the modeler's default ids, and a condition could only be found by knowing its flow id.
- One holder mixing successors and sequence flows. Autocomplete mixes both kinds, and a flow picked in
then { … }does not compile. Nextreturning transitions instead of elements (SequenceFlow/BoundaryAttachment, named after the target). BPMN-faithful, but Java and C# need.getTarget()/.Targeton every chained step,then().xstops beingFlow.X, and several flows to one target need a third type or a renaming special case. Evaluated against eight user personas together with the options above; the target-namedOutgoingFlowsnext to an unchangedNextwon clearly.- Label-based names. Prettier for
Yes/No, but needs a sanitiser, breaks on relabelling and is non-local (adding a secondNorenames the first). - C# NuGet runtime. The clean long-term answer; deferred because publishing and versioning it is a larger effort than the generator, and inlining is reversible.