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?
| Surface | Affected |
|---|---|
| Generated Kotlin/Java/C# Process API | yes — see the Process API and shared definition APIs |
Built-in rules, the BpmnValidator API and assertions | two new mandatory rules — reserved-element-name and shared-definition-collision; BpmnRules.all() now returns List<ValidationRule> |
| Gradle / Maven task configuration | yes 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 JSON | yes — new format |
| Custom validation rules that inspect the model type | yes — 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):
Flowis renamed toFlowNodes, the BPMN term for the elements it holds, so it no longer clashes withkotlinx.coroutines.flow.Flowin coroutine code. ReplaceFlow.withFlowNodes.and update imports.FlowNodesis flat. Every element, whatever its subprocess depth, is a direct child ofFlowNodes. A subprocess opens its interior viastartEvents.- Nodes carry their data.
jobType,Variables,calledProcesswithInputs/Outputs,timer,message/signal/error/escalation,attachedToandisInterrupting, plusnameon 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'svariableslists the variables it declares:allof them, those it reads (inputs) and those it writes (outputs). TheElements,Variables,CallActivitiesandTimerssections are gone. - Sequence flows are typed.
then()becamenext/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 —flowfor the usual single one,flowswhen several lead to the same element — each aSequenceFlow<Target>withid,name,conditionExpression,isDefaultandtarget; a boundary event appears asAttachedBoundaryEvent<Target>. Kotlin, Java and C# share this shape. TheProcessPath/PathWalksteps take these successors, reach the next node throughtargetand record the walked sequence flow inflowIdswhenever it is unambiguous. Boundary events implementBoundaryEvent<Host>. - Registries are shared.
Messages,Errors,Signals,EscalationsandServiceTasksare no longer nested in the Process API but generated once per run — see shared definition APIs.ServiceTasks.Xremains the constant for@JobWorker(type = …), and a node'sjobType,message,signal,errorandescalationrefer to these shared constants rather than repeating the value. - Element type, event type, timer type and engine are enums.
elementTypeis aBpmnElementTypethat names the shape only (BOUNDARY_EVENT); an event's definition moves toeventType: BpmnEventTypeon the newEventmarker, whichBoundaryEventextends.timer.typeis aTimerType. Replace string comparisons:elementType == "TIMER_BOUNDARY_EVENT"becomeselementType == BpmnElementType.BOUNDARY_EVENT && (node as Event).eventType == BpmnEventType.TIMER. C# gets the same enums in PascalCase insideRuntime,IEvent, and aRuntime.BpmnEngine ProcessEngineinstead of a string. - Java nodes are singletons. Like Kotlin
objects and C#Instance, every Java node is a singleton behindINSTANCEwith a private constructor; accessors return that instance, soFlowNodes.x() == FlowNodes.x(). Replace any directnew Flow.X()withFlowNodes.X.INSTANCEorFlowNodes.x(). Classes holding only constants (FlowNodes,ServiceTasks,Messages, …) can no longer be instantiated, and nodes print asELEMENT_TYPE(id). - C# (experimental) gets the full API.
FlowNodesis generated, with the runtime types inlined into each file as a nestedRuntimeclass. - Raw names are compile-time constants. The shared
ProcessVariablesfile carries every variable name of the run once as a rawString(ProcessVariables.X), which each node's typedVariablesrefer to, and every node hasELEMENT_ID— usable in annotations andwhen/switch(#116).
Migration table
| 5.x | 6.0 Kotlin | 6.0 Java | 6.0 C# |
|---|---|---|---|
Elements.X | FlowNodes.X.id | FlowNodes.x().getId() | FlowNodes.X.Instance.Id (no longer const) |
Variables.Node.V | FlowNodes.Node.Variables.V | FlowNodes.Node.Variables.V | FlowNodes.Node.Instance.Variables.V |
Variables.Node.V.value (raw name) | ProcessVariables.V | ProcessVariables.V | ProcessVariables.V |
CallActivities.Node.PROCESS_ID | FlowNodes.Node.calledProcess | FlowNodes.node().getCalledProcess() | FlowNodes.Node.Instance.CalledProcess |
CallActivities.Node.Inputs.M / .Outputs.M | FlowNodes.Node.Inputs.M / .Outputs.M | same | FlowNodes.Node.Instance.Inputs.M / .Outputs.M |
Timers.T / .type / .timerValue | FlowNodes.T.timer.type / .timerValue | FlowNodes.t().getTimer().getType() | FlowNodes.T.Instance.Timer.Type / .TimerValue |
Flow.Sub.Inner / Flow.sub().inner() | FlowNodes.Inner | FlowNodes.inner() | FlowNodes.Inner |
OrderProcessApi.Variants.V.Flow.X | VOrderProcessApi.FlowNodes.X | VOrderProcessApi.FlowNodes.x() | VOrderProcessApi.FlowNodes.X.Instance (its own API) |
Flow.X.name (named nodes only) | name: String? on every node | getName() | Name |
4.x Flows.FLOW_X / Relations | FlowNodes.Source.next.<target>.flow | FlowNodes.source().getNext().<target>().getFlow() | FlowNodes.Source.Instance.Next.<Target>.Flow |
| — | FlowNodes.X.jobType, .message, .error, .attachedTo, .isInterrupting | FlowNodes.x().getJobType(), .getMessage(), .getError(), .getAttachedTo(), .isInterrupting() | JobType, Message, Error, AttachedTo, IsInterrupting |
Two changes are not mechanical:
- C# constants became instance properties.
Elements.XandTimers.X.Valuewereconst stringand could sit in aswitchlabel or an attribute;FlowNodes.X.Instance.Id.Valuecannot.ServiceTasks.Xstaysconst— use it in@JobWorker(type = …)/[JobType(…)], since a job type can be shared by several processes. A node'sjobType/JobTypeis an interface member and no compile-time constant. - Element ids must not shadow the API. The flat
FlowNodesplaces every element next to the holders and runtime types, so an element whose generated name isFlowNodes,Next,Start,SequenceFlows,Variables,Instance,ElementId, a facet interface (TimerEvent,ITimerEvent, …), a C# node member (Id,Name,Message,Timer, …) — or whose accessor would behashCode,toString,wait, … — is rejected by the new mandatoryreserved-element-namerule (also inBpmnRules.all()). The same id declared at the root and inside a subprocess is now reported bycollision-detection, sinceFlowNodescan 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
propertiesslot 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/errorswere keyed by the event's ID, not thebpmn:Messageroot element, so one message used by three events appeared three times and could not be resolved by reference.engineSpecificPropertiescould hold primitives only, sozeebe:taskHeadersor 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:
{
"$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
// 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.
// Before
{ "elementType": "MESSAGE_START_EVENT" }
// After
{
"type": "startEvent",
"eventDefinitions": [{ "type": "message", "messageRef": "Message_FormSubmitted" }]
}To find message start events:
// 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.
// 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.
// Before — reconstruct nesting from a flat list
const children = nodes.filter(n => n.parentId === subProcessId)
// After
const children = subProcess.flowNodesWalking every node, at any depth:
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.
// 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
// 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
// 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
// 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
| Before | After |
|---|---|
processId | process.id |
flowNodes | process.flowNodes |
displayName | name |
elementType | type + eventDefinitions[] |
incoming / outgoing (node IDs) | incoming / outgoing (sequence-flow IDs) |
parentId | containment — flowNodes on the parent scope |
attachedElements | boundaryEventRefs |
interrupting | cancelActivity (boundary) / isInterrupting (event sub-process start) |
properties.implementationValue | implementation.* |
properties.engineSpecificProperties.correlationKey | definitions.messages[].correlationKey |
engineSpecificProperties | engineAttributes + extensions |
variables: ["x"] | variables: [{ name, direction, expression }] |
messages / signals / errors | definitions.*, keyed by root-element ID |
errors[].code | definitions.errors[].errorCode |
compensations | eventDefinitions[{ "type": "compensation" }] on the node |
sequenceFlows[].isDefault | unchanged, 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:
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=draft2020Custom 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:
// Before
val detected = (context.model as? BpmnModel)?.detectedEngine
if (context.model is MergedBpmnModel) { /* ... */ }
// After
val detected = context.model.detectedEngineRules 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:
// Before
context.model.messages
context.model.signals
// After
context.model.definitions.messages
context.model.definitions.signalsProcessModel.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:
// 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.
// 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_MAILTo migrate:
- Replace
<X>ProcessApi.ServiceTasks/.Messages/.Signals/.Errors/.Escalationswith the top-levelServiceTasks,Messages,Signals,Errors,Escalationsand import them from yourpackagePath. - 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). - 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. - The runtime types are renamed to
BpmnErrorDefinitionandBpmnEscalationDefinition, so they no longer clash with the engines'BpmnError(Camunda 7, Operaton, Spring Zeebe) in the same class. - Use one
packagePathper 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'sServiceTasks,Messages, … and remove each other's Process APIs as stale. Generate related BPMN files in one run, or give each run its own package. - Stale generated files are removed. Before writing, the generator deletes the files in
packagePaththat carry the// Generated by bpmn-to-codeheader 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-implementationif your model has several. - A merged process reports its real
isExecutable. Merged models hardcodedtrue. Only the API-generation path filters non-executable processes, and it does so before merging, so a process whose variants were all markedisExecutable="false"was published as executable in the JSON. - Root elements sharing a name are kept apart. Two
bpmn:Messageelements 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.
<zeebe:property name="variantName" value="corporate" />| 5.x | 6.x |
|---|---|
OrderProcessApi.Variants.Default.Flow.X | OrderProcessApi.FlowNodes.X (model without a variantName) |
OrderProcessApi.Variants.Corporate.Flow.X | CorporateOrderProcessApi.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:
- Remove
enableVariantsfrom the Gradle task or the Maven configuration; the setting no longer exists. - Remove the
variantNamefrom the model that should keep the plain API name, typically the default one. The other models keep theirs. - Replace
OrderProcessApi.FlowVariants.Default.withOrderProcessApi.FlowNodes., andOrderProcessApi.FlowVariants.Corporate.withCorporateOrderProcessApi.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:
| Case | 5.x | 6.0 |
|---|---|---|
| Reading a value | task.baseDir | task.baseDir.get() |
| Kotlin DSL on Gradle older than 8.2 | baseDir = "…" | baseDir.set("…") |
Compiled Kotlin (buildSrc/*.kt, own plugins) | task.baseDir = "…" | task.baseDir.set("…") |
| Java | task.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:
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.