Skip to content

v6.0.0 Migration Guide — node-centric Process API, shared definition APIs and BPMN-aligned JSON ​

v6.0.0 reshapes the generated Process API and replaces the generated JSON format. The code API becomes a typed projection of the process model: every element is a node of FlowNodes that carries its own data, sequence flows become typed edges, and C# generates the same shape as Kotlin and Java. Job types, messages, signals, errors and escalations move out of the Process API into shared files generated once per run. The JSON export follows the BPMN 2.0 vocabulary.

Am I affected? ​

SurfaceAffected
Generated Kotlin/Java/C# Process APIyes — see the Process API and shared definition APIs
Built-in rules, the BpmnValidator API and assertionstwo new mandatory rules — reserved-element-name and shared-definition-collision; BpmnRules.all() now returns List<ValidationRule>
Gradle / Maven task configurationyes if several files share a processId — give them a variantName; one packagePath per generation run. Gradle build logic that reads task properties or sets them outside a build script — see lazy Gradle task properties
Generated JSONyes — new format
Custom validation rules that inspect the model typeyes — see below

The generated Process API ​

The 5.x API had two organising principles side by side: Elements, Variables, CallActivities and Timers were per-element trees, each re-deriving the element names, while Flow was the only section that modelled the process shape — and it dropped every sequence flow. 6.0 keeps one tree (ADR 022):

  • Flow is renamed to FlowNodes, the BPMN term for the elements it holds, so it no longer clashes with kotlinx.coroutines.flow.Flow in coroutine code. Replace Flow. with FlowNodes. and update imports.
  • FlowNodes is flat. Every element, whatever its subprocess depth, is a direct child of FlowNodes. A subprocess opens its interior via startEvents.
  • Nodes carry their data. jobType, Variables, calledProcess with Inputs / Outputs, timer, message / signal / error / escalation, attachedTo and isInterrupting, plus name on every node. Each facet implements a runtime interface — HasJobType, CallActivity, TimerEvent, HasMessage, SignalEvent, ErrorEvent, EscalationEvent, HasVariables (C#: Runtime.ITimerEvent, …) — so generic code can filter nodes by facet: FlowNodes.all.filterIsInstance<TimerEvent>(). Kotlin names fixed values UPPER_SNAKE and interface members camelCase; Java exposes facets and navigation via getters (getTimer(), getJobType(), getNext(), getStartEvents()). A node's variables lists the variables it declares: all of them, those it reads (inputs) and those it writes (outputs). The Elements, Variables, CallActivities and Timers sections are gone.
  • Sequence flows are typed. then() became next / Next, and each of its entries is a typed successor named after the element it leads to: SequenceFlows<Target> holds the sequence flow(s) leading there — flow for the usual single one, flows when several lead to the same element — each a SequenceFlow<Target> with id, name, conditionExpression, isDefault and target; a boundary event appears as AttachedBoundaryEvent<Target>. Kotlin, Java and C# share this shape. The ProcessPath / PathWalk steps take these successors, reach the next node through target and record the walked sequence flow in flowIds whenever it is unambiguous. Boundary events implement BoundaryEvent<Host>.
  • Registries are shared. Messages, Errors, Signals, Escalations and ServiceTasks are no longer nested in the Process API but generated once per run — see shared definition APIs. ServiceTasks.X remains the constant for @JobWorker(type = …), and a node's jobType, message, signal, error and escalation refer to these shared constants rather than repeating the value.
  • Element type, event type, timer type and engine are enums. elementType is a BpmnElementType that names the shape only (BOUNDARY_EVENT); an event's definition moves to eventType: BpmnEventType on the new Event marker, which BoundaryEvent extends. timer.type is a TimerType. Replace string comparisons: elementType == "TIMER_BOUNDARY_EVENT" becomes elementType == BpmnElementType.BOUNDARY_EVENT && (node as Event).eventType == BpmnEventType.TIMER. C# gets the same enums in PascalCase inside Runtime, IEvent, and a Runtime.BpmnEngine ProcessEngine instead of a string.
  • Java nodes are singletons. Like Kotlin objects and C# Instance, every Java node is a singleton behind INSTANCE with a private constructor; accessors return that instance, so FlowNodes.x() == FlowNodes.x(). Replace any direct new Flow.X() with FlowNodes.X.INSTANCE or FlowNodes.x(). Classes holding only constants (FlowNodes, ServiceTasks, Messages, …) can no longer be instantiated, and nodes print as ELEMENT_TYPE(id).
  • C# (experimental) gets the full API. FlowNodes is generated, with the runtime types inlined into each file as a nested Runtime class.
  • Raw names are compile-time constants. The shared ProcessVariables file carries every variable name of the run once as a raw String (ProcessVariables.X), which each node's typed Variables refer to, and every node has ELEMENT_ID — usable in annotations and when / switch (#116).

Migration table ​

5.x6.0 Kotlin6.0 Java6.0 C#
Elements.XFlowNodes.X.idFlowNodes.x().getId()FlowNodes.X.Instance.Id (no longer const)
Variables.Node.VFlowNodes.Node.Variables.VFlowNodes.Node.Variables.VFlowNodes.Node.Instance.Variables.V
Variables.Node.V.value (raw name)ProcessVariables.VProcessVariables.VProcessVariables.V
CallActivities.Node.PROCESS_IDFlowNodes.Node.calledProcessFlowNodes.node().getCalledProcess()FlowNodes.Node.Instance.CalledProcess
CallActivities.Node.Inputs.M / .Outputs.MFlowNodes.Node.Inputs.M / .Outputs.MsameFlowNodes.Node.Instance.Inputs.M / .Outputs.M
Timers.T / .type / .timerValueFlowNodes.T.timer.type / .timerValueFlowNodes.t().getTimer().getType()FlowNodes.T.Instance.Timer.Type / .TimerValue
Flow.Sub.Inner / Flow.sub().inner()FlowNodes.InnerFlowNodes.inner()FlowNodes.Inner
OrderProcessApi.Variants.V.Flow.XVOrderProcessApi.FlowNodes.XVOrderProcessApi.FlowNodes.x()VOrderProcessApi.FlowNodes.X.Instance (its own API)
Flow.X.name (named nodes only)name: String? on every nodegetName()Name
4.x Flows.FLOW_X / RelationsFlowNodes.Source.next.<target>.flowFlowNodes.source().getNext().<target>().getFlow()FlowNodes.Source.Instance.Next.<Target>.Flow
—FlowNodes.X.jobType, .message, .error, .attachedTo, .isInterruptingFlowNodes.x().getJobType(), .getMessage(), .getError(), .getAttachedTo(), .isInterrupting()JobType, Message, Error, AttachedTo, IsInterrupting

Two changes are not mechanical:

  • C# constants became instance properties. Elements.X and Timers.X.Value were const string and could sit in a switch label or an attribute; FlowNodes.X.Instance.Id.Value cannot. ServiceTasks.X stays const — use it in @JobWorker(type = …) / [JobType(…)], since a job type can be shared by several processes. A node's jobType / JobType is an interface member and no compile-time constant.
  • Element ids must not shadow the API. The flat FlowNodes places every element next to the holders and runtime types, so an element whose generated name is FlowNodes, Next, Start, SequenceFlows, Variables, Instance, ElementId, a facet interface (TimerEvent, ITimerEvent, …), a C# node member (Id, Name, Message, Timer, …) — or whose accessor would be hashCode, toString, wait, … — is rejected by the new mandatory reserved-element-name rule (also in BpmnRules.all()). The same id declared at the root and inside a subprocess is now reported by collision-detection, since FlowNodes can emit it once.

The migrate-to-bpmn-to-code-apis skill rewrites 5.x references to the 6.0 shape.

Two earlier 6.0 changes to the code API are covered by the table as well: the standalone Flows and Compensations sections were dropped (#85) and Relations was reshaped into Flow with then() / start() (#97).

Why the format changed ​

The JSON export was still marked beta, and locking it down as a stable contract meant fixing what could not be expressed in it first (#58):

  • A node carried a single properties slot holding exactly one facet. A multi-instance service task with an I/O mapping was not representable — which is why #73 and #74 could not ship against the old shape.
  • Only one event definition survived per event, though BPMN allows several.
  • messages / signals / errors were keyed by the event's ID, not the bpmn:Message root element, so one message used by three events appeared three times and could not be resolved by reference.
  • engineSpecificProperties could hold primitives only, so zeebe:taskHeaders or connector configuration were silently flattened to strings.
  • Sequence flows had no scope, so a flow inside a sub-process was indistinguishable from a top-level one.

The new format follows OMG BPMN 2.0 and the bpmn-moddle vocabulary, so it stays stable as BPMN features are added. See ADR 018.

Detecting the version ​

Every file now declares its schema and format version:

json
{
    "$schema": "https://miragon.github.io/bpmn-to-code/schema/process-model/2.0.json",
    "formatVersion": "2.0"
}

Consumers that must handle both can branch on formatVersion — the old format has no such field.

What changed ​

The process moved under process ​

json
// Before
{ "processId": "newsletterSubscription", "flowNodes": [], "sequenceFlows": [] }

// After
{ "process": { "id": "newsletterSubscription", "flowNodes": [], "sequenceFlows": [] } }

process also gained name, isExecutable and engine, which the old format discarded.

elementType → type + eventDefinitions ​

The flattened <SUBTYPE>_<SHAPE> vocabulary is gone. type is now the BPMN element name, and the event's triggers are a list — so an event with two triggers no longer loses one.

json
// Before
{ "elementType": "MESSAGE_START_EVENT" }

// After
{
    "type": "startEvent",
    "eventDefinitions": [{ "type": "message", "messageRef": "Message_FormSubmitted" }]
}

To find message start events:

js
// Before
nodes.filter(n => n.elementType === "MESSAGE_START_EVENT")

// After
nodes.filter(n => n.type === "startEvent"
    && n.eventDefinitions?.some(d => d.type === "message"))

Relations point at sequence flows, not nodes ​

incoming / outgoing now hold sequence-flow IDs, matching bpmn:FlowNode.incoming / .outgoing. The old node-to-node adjacency lost which flow was taken, so conditions and default branches could not be attributed.

js
// Before — outgoing held node ids
const nextNodeIds = node.outgoing

// After — resolve through the flow
const nextNodeIds = node.outgoing
    .map(id => scope.sequenceFlows.find(f => f.id === id))
    .map(flow => flow.targetRef)

The owning gateway or activity additionally carries default, as BPMN defines it; the flow keeps isDefault.

Nesting is real containment ​

parentId and attachedElements are gone. A sub-process owns its children and its own sequence flows; an activity lists its boundary events in boundaryEventRefs.

js
// Before — reconstruct nesting from a flat list
const children = nodes.filter(n => n.parentId === subProcessId)

// After
const children = subProcess.flowNodes

Walking every node, at any depth:

js
function allNodes(scope) {
    return (scope.flowNodes ?? []).flatMap(n => [n, ...allNodes(n)])
}

Root elements are a resolvable registry ​

messages, signals, errors and escalations moved into definitions and are keyed by their ownbpmn:Definitions ID. Nodes point at them via messageRef / signalRef / errorRef / escalationRef.

json
// Before — keyed by the event node, duplicated per usage
"messages": [{ "id": "StartEvent_SubmitRegistrationForm", "name": "Message_FormSubmitted" }]

// After — one entry, referenced from every event that uses it
"definitions": {
    "messages": [{ "id": "Message_FormSubmitted", "name": "Message_FormSubmitted" }]
}

errors[].code is now errorCode. The Zeebe zeebe:subscription correlation key moved from the event to definitions.messages[].correlationKey, because BPMN declares it on the message element itself.

The top-level compensations list is gone; a compensation is an event definition on its node, and its activityRef now points at the compensated activity — previously it repeated the event's own ID, which was a bug.

properties split into typed facets ​

json
// Before
"properties": { "type": "ServiceTask", "implementationValue": "newsletter.sendWelcomeMail" }

// After
"implementation": { "type": "jobWorker", "jobType": "newsletter.sendWelcomeMail" }

The one-of slot became independent optional facets — implementation, ioMapping, multiInstance, calledElement, variables — each present only where BPMN allows it. This is what makes #73 and #74 representable.

Variables carry direction and expression ​

json
// Before
"variables": ["subscriptionId"]

// After
"variables": [{ "name": "subscriptionId", "direction": "OUTPUT", "expression": "=subscriptionId" }]

This brings the JSON in line with the code API, which has split Inputs / Outputs since ADR 015.

Engine data is namespaced and structured ​

json
// Before — flat, primitives only, no provenance
"engineSpecificProperties": { "asyncBefore": true }

// After
"engineAttributes": { "camunda:asyncBefore": true },
"extensions": [
    {
        "$type": "zeebe:taskHeaders",
        "children": [
            { "$type": "zeebe:header", "attributes": { "key": "priority", "value": "high" } }
        ]
    }
]

extensions mirrors bpmn:extensionElements and nests arbitrarily, so structured engine configuration survives intact. Note that engineAttributes reports every foreign-namespace attribute verbatim, so Camunda defaults such as camunda:exclusive="true" now appear where the old format omitted them.

Field reference ​

BeforeAfter
processIdprocess.id
flowNodesprocess.flowNodes
displayNamename
elementTypetype + eventDefinitions[]
incoming / outgoing (node IDs)incoming / outgoing (sequence-flow IDs)
parentIdcontainment — flowNodes on the parent scope
attachedElementsboundaryEventRefs
interruptingcancelActivity (boundary) / isInterrupting (event sub-process start)
properties.implementationValueimplementation.*
properties.engineSpecificProperties.correlationKeydefinitions.messages[].correlationKey
engineSpecificPropertiesengineAttributes + extensions
variables: ["x"]variables: [{ name, direction, expression }]
messages / signals / errorsdefinitions.*, keyed by root-element ID
errors[].codedefinitions.errors[].errorCode
compensationseventDefinitions[{ "type": "compensation" }] on the node
sequenceFlows[].isDefaultunchanged, plus default on the source gateway or activity
—$schema, formatVersion
—process.name, process.isExecutable, process.engine
—multiInstance (#73)
—ioMapping (#74)

Validating your consumer ​

The schema is published and closed, so you can check your assumptions against it rather than against a sample file:

bash
npx ajv-cli validate \
  -s https://miragon.github.io/bpmn-to-code/schema/process-model/2.0.json \
  -d "src/main/resources/bpmn-json/*.json" \
  --spec=draft2020

Custom validation rules: one model type instead of three ​

BpmnModel and MergedBpmnModel are gone. There is now a single ProcessModel data class, and it always holds the content of one BPMN file — nothing is merged any more:

kotlin
// Before
val detected = (context.model as? BpmnModel)?.detectedEngine
if (context.model is MergedBpmnModel) { /* ... */ }

// After
val detected = context.model.detectedEngine

Rules that only read context.model.processId, .flowNodes, .allFlowNodes or the derived projections need no change.

The four root-element registries moved behind definitions, matching the JSON:

kotlin
// Before
context.model.messages
context.model.signals

// After
context.model.definitions.messages
context.model.definitions.signals

ProcessModel.engine is now ProcessModel.detectedEngine — the engine read from the file's namespaces. The engine that code is generated for lives on BpmnModelApi.targetEngine. The two were previously both called engine, which made the distinction easy to get wrong.

ModelMergerService is gone, and so is merging: ProcessModel.normalized() deduplicates and sorts one model.

SingleModelValidationRule.phase and ValidationPhase are gone with it; drop the override from your rules. Single-model rules run first; cross-model rules run once no single-model rule reports an error.

Flow nodes, sequence flows, call activities and timers no longer implement VariableMapping; read their fields directly. ProcessModel.compensations is gone — a compensation is an event definition on its node:

kotlin
// Before
node.getRawName()
callActivity.getValue()
val (type, expression) = timer.getValue() // type was the string "Cycle"
model.compensations

// After
node.id
callActivity.calledElement
val isCycle = timer.type == TimerType.CYCLE // expression: timer.expression
node.eventDefinitions.filterIsInstance<EventDefinitionInstance.Compensation>()

New and optional: a rule can build its findings with violation(processId = …, elementId = …, message = …), which fills in the rule's id and severity.

Unreferenced root elements now surface ​

A BPMN file may declare a bpmn:Message, bpmn:Signal, bpmn:Error or bpmn:Escalation that no element references — usually left behind when the event that used it was deleted. Earlier versions silently dropped those declarations. They are now kept, because the model mirrors the file, and a new unreferenced-root-element rule (severity WARN, part of BpmnRules.all()) reports them:

WARN  unreferenced-root-element  Message 'Message_36dkcng' is declared but no element references it.
      It still produces a constant in the generated API — remove it from the BPMN file if it is left
      over from an earlier version of the model.

What this means for you: if one of your models declares such an element, its generated API gains one constant. Nothing is removed and no existing constant changes. To get the old output back, delete the declaration from the BPMN file — which is what the warning is asking you to do.

Across this project's own fixtures the effect was 4 of 16 generated files, each gaining a single constant.

Shared definition APIs ​

Job types, message names, signal names, errors and escalations identify something the engine resolves across processes. They used to be nested in every Process API, so two processes using the same job type got two constants for one value. They are now generated once per run, each kind in its own file next to the Process APIs — see Shared definitions and ADR 021.

kotlin
// before
@JobWorker(type = NewsletterSubscriptionProcessApi.ServiceTasks.NEWSLETTER_SEND_CONFIRMATION_MAIL)
client.newPublishMessageCommand().messageName(NewsletterSubscriptionProcessApi.Messages.MESSAGE_FORM_SUBMITTED.value)
throw NewsletterSubscriptionProcessApi.Errors.ERROR_INVALID_MAIL

// after — top-level objects in the configured packagePath
@JobWorker(type = ServiceTasks.NEWSLETTER_SEND_CONFIRMATION_MAIL)
client.newPublishMessageCommand().messageName(Messages.FORM_SUBMITTED.value)
throw Errors.INVALID_MAIL

To migrate:

  1. Replace <X>ProcessApi.ServiceTasks / .Messages / .Signals / .Errors / .Escalations with the top-level ServiceTasks, Messages, Signals, Errors, Escalations and import them from your packagePath.
  2. Constants no longer repeat their kind. A leading prefix matching the holder is dropped (Messages.MESSAGE_FORM_SUBMITTED → Messages.FORM_SUBMITTED, Errors.ERROR_INVALID_MAIL → Errors.INVALID_MAIL; C#: Messages.MessageFormSubmitted → Messages.FormSubmitted). The prefix stays if nothing valid would remain (Message, Signal_1).
  3. Errors and escalations with the same name need the same code. The engine matches them by code, so the same name with another code is a different error — previously two such errors silently collapsed into one constant. The constant is named after the error only, and two errors or escalations with the same name but different codes now fail shared-definition-collision; give them distinct names.
  4. The runtime types are renamed to BpmnErrorDefinition and BpmnEscalationDefinition, so they no longer clash with the engines' BpmnError (Camunda 7, Operaton, Spring Zeebe) in the same class.
  5. Use one packagePath per generation run. The shared files are named after their kind, so two Gradle tasks or Maven executions generating into the same package overwrite each other's ServiceTasks, Messages, … and remove each other's Process APIs as stale. Generate related BPMN files in one run, or give each run its own package.
  6. Stale generated files are removed. Before writing, the generator deletes the files in packagePath that carry the // Generated by bpmn-to-code header and that it no longer produces, so the old sources disappear on the first 6.0.0 run. Hand-written files are never touched — a generated file you edited by hand is, as long as it keeps the header.

Constant-name collisions of these identifiers are now checked across all processes of a run: two different job types such as order.created and order-created both become ORDER_CREATED, which fails generation via the new mandatory shared-definition-collision rule (also available as BpmnRules.SHARED_DEFINITION_COLLISION and part of BpmnRules.all(), which therefore now returns List<ValidationRule>).

Also in 6.0.0 ​

Two behaviour changes outside the JSON format, both fixing under-reporting in validation:

  • Every unimplemented service task is now reported. Service tasks were deduplicated by their implementation reference — empty for an unconfigured task — so N service tasks without an implementation produced a single violation naming one element. Expect more violations from missing-service-task-implementation if your model has several.
  • A merged process reports its real isExecutable. Merged models hardcoded true. Only the API-generation path filters non-executable processes, and it does so before merging, so a process whose variants were all marked isExecutable="false" was published as executable in the JSON.
  • Root elements sharing a name are kept apart. Two bpmn:Message elements with the same name and distinct IDs — what a modeller gets by typing the same name twice — previously collapsed into one, leaving the other node's reference dangling. Both are kept now; the generated API still emits one constant per name.

Terminate, conditional and link events also report a precise eventType in the code API (END_EVENT with BpmnEventType.TERMINATE), and send tasks now count as message throwers in UncaughtMessageThrowRule.

Files sharing a process id each get their own API ​

Several BPMN files sharing a processId used to be merged into one API with a Variants section. Every BPMN file is now generated as a Process API of its own, and generation fails, naming the conflicting files, when two of them would end up under one name. A variantName tells them apart: the extension property on the process that named a variant before now leads the name of the API. It lives in the model, so it works the same in Gradle, Maven and the web UI.

xml
<zeebe:property name="variantName" value="corporate" />
5.x6.x
OrderProcessApi.Variants.Default.Flow.XOrderProcessApi.FlowNodes.X (model without a variantName)
OrderProcessApi.Variants.Corporate.Flow.XCorporateOrderProcessApi.FlowNodes.X (variantName="corporate")

Validation accepts files sharing a processId and validates each on its own. See Several files, one process id for the details.

A variantName always shows in the API name

A file that carries a variantName is generated as <Variant><ProcessId>ProcessApi, whether or not another file shares its processId. Remove the property from every model that should keep its plain API name.

Coming from 6.0 or 6.1

These two releases still merged such files, behind enableVariants = true, into FlowVariants.<Variant>. To move on:

  1. Remove enableVariants from the Gradle task or the Maven configuration; the setting no longer exists.
  2. Remove the variantName from the model that should keep the plain API name, typically the default one. The other models keep theirs.
  3. Replace OrderProcessApi.FlowVariants.Default. with OrderProcessApi.FlowNodes., and OrderProcessApi.FlowVariants.Corporate. with CorporateOrderProcessApi.FlowNodes..

Custom validation rules lose ProcessModel.isMerged, .variants, ProcessModel.mergeByProcessId and the phase of a rule. The JSON export writes one file per BPMN file and no variants array.

Gradle task properties are lazy ​

The properties of generateBpmnModelApi, generateBpmnModelJson and validateBpmnModels are now Gradle Property / SetProperty types instead of plain fields, so they accept providers (filePattern = providers.gradleProperty("bpmnPattern")). Assignments in build scripts keep working unchanged — baseDir = "…" in the Groovy DSL and in the Kotlin DSL on Gradle 8.2 or newer. Only these cases need a change:

Case5.x6.0
Reading a valuetask.baseDirtask.baseDir.get()
Kotlin DSL on Gradle older than 8.2baseDir = "…"baseDir.set("…")
Compiled Kotlin (buildSrc/*.kt, own plugins)task.baseDir = "…"task.baseDir.set("…")
Javatask.setBaseDir("…")task.getBaseDir().set("…")

Convention plugins compiled against 5.x need a recompile.

Gradle generation tasks are incremental ​

generateBpmnModelApi and generateBpmnModelJson are skipped as UP-TO-DATE while their configuration and the BPMN files they read are unchanged (#173); force a run with --rerun. Relative baseDir and outputFolderPath values now resolve against the project directory.

Breaking if you generate into the build directory. The generated directory is now a task output (cacheable, usable as a source directory). Gradle fails tasks that read it without depending on the generation, such as sourcesJar or linters, with "uses this output of task ':generateBpmnModelApi' without declaring an explicit or implicit dependency". Generate into a directory of your own such as build/generated/bpmn (Gradle's compile tasks write below build/generated themselves) and replace srcDir("build/generated/bpmn") plus dependsOn with:

kotlin
srcDir(tasks.named("generateBpmnModelApi", GenerateBpmnModelsTask::class).map { it.outputFolderPath.get() })

Builds generating into a source folder such as src/main/kotlin need no change. See Generate as part of the build.

New: C# output (experimental) ​

Experimental

C# support is experimental. It may change in any release and may be reworked or removed if it doesn't work out. Feedback welcome.

outputLanguage = CSHARP generates .cs files with the same API as Kotlin and Java — the shared definitions plus the typed FlowNodes navigation — so .NET workers talking to a JVM-hosted engine stop hardcoding BPMN strings. The runtime types the nodes need are inlined into every file as a nested Runtime class, so the generated file has no dependencies at all. See C# specifics and ADR 022.

See the full release notes.