Workflow Versioning
In-flight workflows replay from event history when their declaring code changes. The engine already tolerates additive changes—new steps, re-ordering, removing a step—because step lookup is name-based. What it cannot detect on its own is a semantic change: a step whose body now talks to a different service, or a branching change in workflow logic.
Two complementary mechanisms address this:
-
Definition-level versions (
@Workflow(workflowVersion = "…")or the programmaticWorkflowCustomization.workflowVersion(…)setter) for whole-body rewrites. Register a new definition with a bumped semver string and the runtime starts new instances on the highest registered version while replaying in-flight instances on the version they were started under (read from the started event’sMessageType.version()). -
ctx.migrateVersion(changeId, newVersion)primitive for additive changes within a single definition. Bumps the workflow’s running version mid-flight; in-flight executions that have already passed the call’s position stay on the legacy branch via the downstream-steps guard.
Pick the mechanism by change shape: small additive change → ctx.migrateVersion; whole-body rewrite →
copy-paste the class and bump @Workflow(workflowVersion=…). Both are documented below.
Definition-level versions
@Workflow(
idProperty = "orderId",
startOnEventClass = OrderPlacedEvent.class,
workflowName = "OrderWorkflow",
workflowVersion = "0.0.2"
)
public void execute(SimpleWorkflowContext ctx) { /* ... */ }
-
workflowVersionis optional. The default isorg.axonframework.messaging.core.MessageType.DEFAULT_VERSION("0.0.1"), matching Axon Framework 5’s native event-versioning convention. -
The value must be a parseable semver string (
MAJOR.MINOR.PATCHwith an optional pre-release suffix). Invalid strings are rejected at configuration time viaVersion.validate(…). -
Every event the workflow emits carries this version on its
MessageType.version(), which is AF5’s built-in event-version field, not a separate metadata key. -
The same value is reachable from inside the workflow body via
ctx.workflowVersion()(Java) /ctx.workflowVersion(Kotlin).
Starting new instances
When multiple definitions share the same (workflowName, startOnEventName, idProperty) triple but
differ by version, new starts route as follows:
-
No prior instance for the derived workflow id: start at the highest registered version.
-
Existing instance for the derived workflow id, at any version: rejected as a duplicate; a warning is logged. Workflow ids MUST be unique, so the version never becomes part of the id. The existing instance keeps replaying on the definition matching its recorded version. For two parallel instances, use a different
idPropertyvalue.
Dispatching the body of an existing instance
SimpleWorkflowExecution.resolveVersionedDefinition decides which workflow body to invoke on
every execute(…) of an existing instance. The lookup runs in this order:
-
exact-match-start-config:state.workflowVersion()equals the start-time configuration’sworkflowVersion(). The start-time definition is used directly. -
exact-match-sibling: the registry has a sibling definition with the same workflow name and the same version as recorded state. The sibling’s definition is used. -
closest-sibling: no exact-version sibling exists (typical after a mid-flightctx.migrateVersion(…)bump to a value that was not statically registered). The runtime picks the registered definition whoseworkflowVersion()is the highest version less than or equal to recorded state, and dispatches to it. -
closest-higher-sibling: no registered version is⇐recorded state. The runtime picks the registered definition whoseworkflowVersion()is the lowest version strictly greater than recorded state. Covers the "annotation bumped past the in-flight version" case: a workflow started with@Workflow(workflowVersion = "0.0.1")keeps replaying after the developer bumps the annotation to"0.0.2"and the older definition is no longer registered.
For example, with [v1.0.0, v2.0.0] registered and an instance whose state is "1.0.1" (after a
ctx.migrateVersion("foo", "1.0.1") bump inside the v1 body), step 3 picks v1.0.0. It never
jumps across the major boundary to v2.0.0. Symmetric for a v2 instance bumped to "2.0.1": it
stays on v2.0.0. With [v0.0.2] registered and state "0.0.1", step 4 picks v0.0.2.
When all four passes fail (no definition is registered for this workflow name at all), routing falls back to the start-time definition and logs at WARN. The drift safety net then pauses the workflow if step names diverge in incompatible ways.
Every routing decision is logged at INFO with the full set of registered definitions:
Workflow OrderWorkflow (order-1) routing: state='1.0.1' definitions=[1.0.0, 2.0.0]
-> target='1.0.0' [closest-sibling]
(no exact-version sibling registered; using highest registered version <= recorded state)
The decision tag is one of exact-match-start-config, exact-match-sibling, closest-sibling,
closest-higher-sibling, legacy-fallback (unparseable semver), registry-missing, or
no-match-fallback.
Worked examples
What each column means:
-
state: the version recorded on this workflow’s event history (the started event’s
MessageType.version(), possibly bumped bymigrateVersioncalls inside the body). -
registered: all workflow definitions currently registered for this workflow name.
-
start: the engine’s default pick before routing thinks about it (typically the highest registered version for this workflow name).
-
target: what routing actually chose after looking at
state. This is the body the workflow will run. -
tier: the routing rule that fired, matching the decision tag in INFO/WARN logs.
| state | registered | start | → target | tier |
|---|---|---|---|---|
|
|
|
|
exact-match-start-config: state == start, use it. |
|
|
|
|
exact-match-sibling: registry has a sibling exactly at state, use it. |
|
|
|
|
closest-sibling: no exact match; pick the closest registered version below state. |
|
|
|
|
closest-higher-sibling: nothing registered ≤ state; pick the closest above. |
|
|
|
|
closest-higher-sibling: pick the lowest above state, not the highest. |
|
|
|
|
closest-higher-sibling: closest above wins even across a major boundary. |
|
|
|
|
closest-sibling: one definition registered, state drifted past it via |
|
|
|
|
legacy-fallback: state isn’t parseable semver; fall back to the start config. |
Programmatic registration (no annotation)
For users wiring workflows manually rather than via the annotation scanner, the version is set on the
WorkflowCustomization returned by WorkflowModule.configure(…).customized(…):
WorkflowModule.defaults("OrderWorkflow", SimpleWorkflowContext.class)
.workflowContextFactory(c -> new SimpleWorkflowContextFactory())
.definition(d -> d.declarative(c -> orderWorkflow::execute)
.workflowName("OrderWorkflow")
.on(EventConditions.fromType(OrderPlacedEvent.class))
.customized((c, w) -> w.workflowVersion("0.0.2")
.workflowIdProvider(...)));
The annotation and programmatic paths thread the same String version field through
SimpleWorkflowConfiguration; behaviour is identical.
The ctx.migrateVersion(changeId, newVersion) primitive
Use this for additive changes within a single definition that don’t warrant a whole-body copy-paste.
The ctx.migrateVersion(changeId, newVersion) primitive lets developers explicitly fork the workflow body
when behavior diverges. New workflows record a version marker into the event store and adopt the new
branch; workflows that have already executed past the call’s position under old code stay on the
legacy branch.
Why versioning?
Without an explicit fork mechanism, three classes of code change can silently corrupt in-flight workflows:
-
Renamed step—replay can no longer match the new name against the old event and the step re-executes, producing duplicate side effects.
-
Changed step semantics without renaming—replay returns the old cached result to new code that expects different output.
-
Branching change in workflow logic—events replay correctly but the workflow control flow diverges between old and new executions.
ctx.migrateVersion(changeId, newVersion) addresses the second and third classes. The first remains a
forbidden migration; see Forbidden migrations.
The ctx.migrateVersion(changeId, newVersion) contract
@Workflow(idProperty = "orderId", startOnEventClass = OrderPlacedEvent.class, workflowVersion = "0.0.1")
public void execute(SimpleWorkflowContext ctx) {
ctx.awaitExecute("reserveStock", Boolean.class, InventoryService::reserveStock);
if (ctx.migrateVersion("payment-redesign", "0.0.2")) {
ctx.awaitExecute("processPayment", PaymentService::processV2);
} else {
ctx.awaitExecute("chargePayment", PaymentService::chargeV1);
}
}
Returns a boolean. true iff the workflow has committed to (or is past) newVersion for this
changeId; false if it stays on the legacy branch. Internally, WorkflowState.effectiveVersionFor(changeId)
obtains this from the recorded migration step when one exists, otherwise from state.workflowDefinitionId().version()
(driven by the started event’s MessageType.version()).
Versions are semver strings, compared by Version. Downgrades
(requested < current, no recorded step) raise IllegalArgumentException. Same-version requests
return true without publishing.
No event is emitted at workflow start. A migration step is recorded only when a workflow runs the
call live, the requested version is strictly greater than the current, no step for the same
changeId already exists, and the downstream-steps guard does not fire. Event history stays
uncluttered: only workflows that actively migrated to a higher version carry migration steps.
The wire-level event name derives from the changeId. For the example above the marker event is
named Payment-redesign.Versioned in the event store, with MessageType.version() = "0.0.2" on the
envelope itself and metadata {eventKind: VERSION_MARKER, versionChangeId: "payment-redesign",
version: "0.0.2"}. The changeId is the business-meaningful description of what changed; it
surfaces directly in the event log.
The downstream-steps guard
A naive version() implementation would break workflows: if a workflow has already executed steps
A, B, C under old code and a developer later inserts ctx.migrateVersion("x", "0.0.2") between A and B, the
naive implementation would reach the version call with no marker in history and fork onto the v=2
branch—even though B and C already ran.
The actual implementation has a correctness invariant: version() returns the workflow’s current version unchanged (and emits nothing)
if state contains any terminal step that the current invocation has not yet referenced. Those
untouched-but-present steps prove old code already executed past this code point, so we must stay on
the legacy branch.
The mechanism: the workflow body runs from the top on every invocation (live or replay). The runtime
tracks a per-invocation set of "step names the body has referenced so far." When version() runs,
it compares this set against state.workflowStepNames()—anything in state but not yet referenced
is "downstream" of the current code position. Steps below this point in the new code that are
already in history can only have come from old code that ran past this point.
Worked example A—old workflow, version() inserted later
History: A.started, A.completed, B.started, B.completed, C.started, C.completed, workflow.completed.
New code:
step A; // line 1
if (ctx.migrateVersion("x", "0.0.2")) { // line 2—newly added
step D; // line 3
} else {
step B; step C; // line 4
}
| Moment | Body line | referencedStepNames |
state.workflowStepNames() |
What happens |
|---|---|---|---|---|
0 |
(replay catch-up complete) |
|
|
All steps terminal |
1 |
line 1: |
|
|
A cached → record reference, return cached |
2 |
line 2: |
|
|
state ∖ ref = |
3 |
line 4: |
|
|
B cached → returns cached |
4 |
line 4: |
|
|
C cached → returns cached |
5 |
body returns |
|
|
Workflow stayed on legacy branch ✓ |
Worked example B—brand-new workflow, same code
| Moment | Body line | referencedStepNames |
state.workflowStepNames() |
What happens |
|---|---|---|---|---|
0 |
start |
|
|
Empty state |
1 |
line 1: |
|
|
A executes live |
2 |
line 2: |
|
|
state ∖ ref = |
3 |
line 3: |
|
|
D executes live |
4 |
body returns |
|
step names: |
Workflow forked onto new branch ✓ |
Worked example C—workflow stopped at the boundary
History: A.started, A.completed only (the workflow was awaiting B when the deployment happened).
Same new code as A.
| Moment | Body line | referencedStepNames |
state.workflowStepNames() |
What happens |
|---|---|---|---|---|
0 |
(replay catch-up) |
|
|
Only A terminal |
1 |
line 1: |
|
|
A cached → record, return cached |
2 |
line 2: |
|
|
state ∖ ref = |
3 |
line 3: |
|
step names: |
D executes live—legacy B never runs |
Workflows that haven’t passed the version call yet adopt the new version on the next live run.
Mandatory rules
-
Call
ctx.migrateVersion(changeId, newVersion)at most once perchangeIdper workflow body. Multiple calls within one invocation could see differentreferencedStepNamessnapshots and return inconsistent values. -
Never call
version()inside a loop or combinator branch—the call must be reached deterministically and exactly once per body invocation. -
changeIdstrings are durable identifiers. Once shipped, they should not be renamed; the event log keys versions bychangeId, so renaming detaches new code from existing markers.
Three-phase cleanup lifecycle
// Phase 1: introduce the change. Both branches live.
if (ctx.migrateVersion("payment-redesign", "0.0.2")) { newer(); } else { old(); }
// Phase 2: once all v1 workflows have drained, delete the old branch.
// The migrate call stays. New workflows still record the step.
ctx.migrateVersion("payment-redesign", "0.0.2");
newer();
// Phase 3: once no further versioning is anticipated, delete the call.
// Orphan migration steps in old event logs are inert—Axon's replay tolerates them.
newer();
Phase 3 is safe. The engine’s replay is tolerant of orphan events: events whose metadata refers
to constructs the current code never reads are silently absorbed by
EventSourcedWorkflowState.evolve(…). This is a deliberate design: history is the source of
truth, and event metadata that no longer matches a code construct is inert, not a corruption.
Allowed migrations
| Migration | Safe? | Notes |
|---|---|---|
Branch on |
✅ |
The intended primary use case |
Append a new step after the last existing step |
✅ |
Replay finds existing steps cached; the new step runs live |
Narrow the result type of a step in a backwards-compatible way |
✅ |
Provided old result values still satisfy the new type |
Restructure sequential steps into |
✅ |
Combinators don’t change the per-step name-based lookup |
Forbidden migrations
| Migration | Reason | Remedy |
|---|---|---|
Rename a step ( |
Old |
Define a new workflow type with a new workflow name, not a version branch. |
Change the payload shape returned by a step under the same name |
Replay returns the old payload shape to new code that expects the new shape. |
Same—new workflow type. |
Remove a step that had side effects (for example a payment charge) |
History carries the side-effecting event but new code never reads it; data drifts between state and code. |
Same—new workflow type. |
Combinator restructuring
ctx.migrateVersion correctly handles changes that add a branch (sequential → version-gated sequential
or sequential → version-gated parallel) because name-based step lookup re-finds terminal steps
regardless of combinator wrapping.
It does not make safe the renames, payload-shape changes, or side-effect removals listed above. The boundary is: the combinator structure may change around steps, but the step names and their result shapes must stay stable.
Kotlin DSL
The Kotlin DSL exposes the primitive on Kontext:
@Workflow(idProperty = "orderId", startOnEventClass = OrderPlacedEvent::class)
fun Kontext.execute() {
awaitExecute<Boolean>("reserveStock") { InventoryService.reserveStock() }
if (migrateVersion("payment-redesign", "0.0.2")) {
awaitExecute<Boolean>("processPayment") { PaymentService.processV2() }
} else {
awaitExecute<Boolean>("chargePayment") { PaymentService.chargeV1() }
}
}
All rules (current-version default, single-call-per-changeId, downstream-steps guard, downgrade rejection) apply identically to the Kotlin DSL.
Multi-versioned workflows
Independent `changeId`s are tracked separately:
boolean payV2 = ctx.migrateVersion("payment-redesign", "0.0.2");
boolean shipV2 = ctx.migrateVersion("shipping-redesign", "0.0.3");
// Each changeId is its own slot in state.versions—neither call affects the other.
Drift detection (safety net for non-versioned changes)
The ctx.migrateVersion(changeId, newVersion) primitive protects workflows that opt in to versioning.
For workflows where the developer changed the code without using ctx.migrateVersion(), the engine has a
replay-drift guard that fires from every event-emitting primitive and pauses the workflow
non-terminally instead of silently corrupting it.
What the guard detects
The same signal the version primitive uses: at the moment an event-emitting primitive is about to publish its first event for a new step, if state contains any terminal step that the current invocation has not yet referenced, the workflow has already executed past this code point under old code. Publishing a new event here would diverge from history.
Example: workflow ran step1; step2 (both terminal in history). Developer inserts a new step
step1_1 between them—without versioning—and ships. On replay the body references
{step1, step1_1} but state holds {step1, step2}. step2’s side effects already landed; the new
code now takes a different path and pretends step2 doesn’t exist. The guard catches this exact
moment.
What the guard covers
Every event-emitting primitive:
-
ctx.awaitExecute(…)/ctx.execute(…)—before publishingSTARTED -
ctx.awaitWaitFor(…)/ctx.waitFor(…)—before publishingSTARTED -
ctx.awaitModifyPayload(…)/ctx.modifyPayload(…)—before publishingCOMPLETED -
ctx.fail(…)/ctx.cancel(…)/ctx.cancelStep(…)—before publishing the terminal workflow / step events. This is the case most easily missed: a developer inserting an early termination has every reason to assume it "just stops the workflow", but for an in-flight workflow that already completed steps under old code, the new terminate corrupts the historical trajectory.
ctx.migrateVersion(…) itself is the deliberate exception—it already runs the same check but its
response is to return the workflow’s current version unchanged and stay on the old branch, rather
than throw. That’s the entire purpose of the primitive.
Pause, don’t fail
When the guard fires, it throws WorkflowReplayDriftException. The exception is caught by the
workflow execution loop with a dedicated handler that:
-
Logs a warning with the workflow id, the primitive that drifted, and the orphan step names
-
Does not publish a
failedWorkfloworcancelledWorkflowevent -
Leaves the workflow in its current (non-terminal) state
The workflow is effectively paused, not failed. Each subsequent replay hits the same drift, logs the same warning, stays paused. State is preserved verbatim across every paused replay.
Recovery
The developer has two options to recover a paused workflow:
-
Revert the offending code change. The next replay will run cleanly and the workflow continues from where it was.
-
Wrap the change in
ctx.migrateVersion(changeId, newVersion). The next replay will hit the version primitive’s downstream-steps guard, which returns the workflow’s current version unchanged and routes the workflow back onto the old branch—same recovery, just expressed in code rather than reverted.
No state surgery, no manual event-store edits, no special workflow-administration tooling.
What’s in the log
WARN Workflow order-abc-123 paused due to replay drift: Workflow order-abc-123 cannot run
execute("step1_1") because history contains terminal steps the current code does not
reference: [step2]. This means old code ran past this point with steps the new code skips.
Either revert the change or wrap the new code in ctx.migrateVersion("<changeId>", "x.y.z") so old
workflows stay on the legacy branch. Revert the code change or wrap it in ctx.migrateVersion()
and replay.
Telemetry-friendly accessors are available on the exception:
workflowId(), primitive(), aboutToExecute(), orphans().
Limitations
The guard is deterministic for deterministic workflow code—the same payload + same history
will always produce the same body path on every replay. If a workflow body is non-deterministic
(for example branches on Math.random() or external mutable state), the guard can produce different
results across replays for the same workflow—but that’s already a workflow contract violation
the engine cannot fix.
The guard cannot detect every form of code drift. Specifically:
-
Step renames (
"ship"→"shipment"): the new code awaits a step name that never appears in history. The wait will time out, not produce drift. -
Payload-shape changes for the same step name: history returns the cached step result; new code reads the old shape. The guard cannot see semantic mismatches.
For both of these, the recommended remedy is documented in Forbidden migrations: define a new workflow type with a new workflow name.