I’m currently implementing Camunda 8 with a customer (great project, learning a lot). Modeling and implementation are the visible parts of the work, and they usually go well. The pain starts when testers expect stability and the system changes under running instances.
Our testers don’t have a fixed schedule. They test when they can, and they expect that yesterday’s flow still works. At the same time, we have a strict divide between dev and ops, so ops needs a setup that stays boring and predictable.
This is a three part series about that last mile of a process. Part 1 is about compatibility and versioning. In this article, I focus on what compatibility means in process automation, how it breaks, and what rules keep models and runtimes stable while both keep changing.
In production, instances outlive deployments. Old and new versions run at the same time. That makes compatibility and versioning the real gate, not just whether the newest version works in dev.
How Compatibility Breaks
In Camunda, you always have two sides that need to stay compatible: the BPMN model plus its variable mappings, and the runtime code that reads and writes variables.
Model changes can break the runtime:
An input mapping is removed or renamed, and a worker stops receiving a required variable.
A mapping is changed from string to object, and deserialization fails at runtime.
An output mapping is added, that overwrites a variable with a different shape than the runtime expects.
Runtime changes can break the process:
A required inputs or output shape has changed. The model still deploys, but instances fail later.
Someone removes or renames an output variable and the next step in the model reads null.
This is the kind of breakage that is hard to spot early because it often compiles and deploys fine.
Forward And Backward Compatibility
When I worked with Kafka and Confluent’s schema registry we made compatibility direction explicit by validating schemas against a compatibility mode in the registry. If a schema change or payload violated the contract, it was rejected. That makes it hard to break clients by accident. In process automation, the same idea exists, but the boundary is not a network API. It is the mapping boundary between model and runtime.
Think of it like this: there is a hidden contract between the runtime and the engine. The runtime normally defines the input schema, while the process defines the output schema. But this might differ between organizations and their teams. The schema is the set of variables and their types.
There are generally two types of compatibility: Backward compatibility means a new runtime can read payloads of the old schema. Forward compatibility means an old runtime can read payloads with a new schema. These modes do not remove the need to ship changes on both sides. They only decide who can go first without breaking anything.
In practice, it boils down to rollout order:
If you change the model first, the payload changes first. The runtime has to be forward compatible so the old runtime can read the new payload.
If you change the runtime first, the schema changes first. The runtime has to be backward compatible so the new runtime can read the old payload.
Here are some general rules that apply for both modes:
Note: The change of optional fields is always allowed, since the runtime already has a mechanism to handle their absence.
As you can see, the rules are true for both inputs and outputs, but the direction flips between them. Model-first means you need forward compatibility for the input mapping (old runtime reads new inputs), but backward compatibility for the output mapping (new model reads old outputs). Runtime-first means you need backward compatibility for the input mapping (new runtime reads old inputs), but forward compatibility for the output mapping (old model reads new outputs).
Everything else is a breaking change, and that is where versioning starts.
When the Model Changes First
Think of this for a moment: you change a variable mapping. You either send a new field, remove an old field, rename something, or change a type. The engine will start sending that new payload right away, but your runtime may still be on the old version.
So the question is: can the old runtime still deserialize the new payload. If not, your process change is a breaking change.
A typical safe change from the model side is adding new fields. For example, a modeler extends a payment JSON input by a new field currency. The runtime ignores the unknown field and binds only what it knows, the model can be rolled out first.
In Java, this can look like binding only what you need and allowing unknown fields:
// worker with a stable contract, ignoring new variables
@JobWorker(fetchVariables = {"requestId", "amount"})
public void handle(@Variable String requestId, @Variable Double amount) {
// do payment logic here
}Or with a complex object, where additional fields are ignored:
@JsonIgnoreProperties(ignoreUnknown = true)
record PaymentRequest(String requestId, Double amount) {}When the Runtime Changes First
Now think of this: you deploy a new runtime. Right after deployment, the engine still sends the old payload from old models and running instances.
If you want old instances to keep working, the new runtime must still accept the old payload. That means new fields must be optional or have defaults.
record PaymentRequest(String requestId, String amount) {
String currency() { return "Euro"; }
}Concrete Compatibility Measures
The thought experiments are simplistic on purpose. They lead to a few practical rules that keep long runners safe.
On the model side:
You can add fields. You can remove fields only after the runtime ignores them.
Do not rename fields in place. Write both names for a while and migrate step by step.
Do not change types in place. If you need a new shape, add a new field and keep the old one for a transition period.
Do the opposite for output mappings or result expressions.
On the runtime side:
Do not add new required fields to the request. If you add fields, make them optional and add defaults.
Remove fields by ignoring them first (or stop deserializing them) before models remove them.
Do the opposite for outputs that are used in output mappings or result expressions.
Rules And Rollout For Breaking Changes
If a change is outside the compatibility rules above, it is a breaking change. Treat it like a rollout, not like a refactor.
The safe pattern is always the same:
Add new fields first and keep the old ones working.
Deploy the new runtime that can handle both shapes.
Migrate models and mappings step by step.
Remove the old fields only when no running instances depend on them.
This is also why we version job types and connector ids. It makes old and new behavior explicit and lets you run them side by side for a while.
class MyWorker {
@JobWorker(type = "com.example:worker:1")
public Map<String, Object> handleV1(final Map<String, Object> vars) {
// Accept the v1 contract and produce v1 outputs.
return Map.of("result", "ok");
}
@JobWorker(type = "com.example:worker:2")
public Map<String, Object> handleV2(final Map<String, Object> vars) {
// v2: new required fields, new outputs, or different behavior.
return Map.of("result", "ok", "resultVersion", 2);
}
}For outbound connectors, do the same. Keep the connector ID versioned and implement each version in a separate class so behavior stays explicit.
@OutboundConnector(
name = "com.example:payment-connector:1",
inputVariables = { "amount", "currency", "requestId" }
)
class PaymentConnectorV1 {
// Execute v1 request and response mapping.
}
@OutboundConnector(
name = "com.example:payment-connector:2",
inputVariables = { "amount", "currency", "requestId", "customerId" }
)
class PaymentConnectorV2 {
// Execute v2 request and response mapping.
}Common patterns:
Version job types for breaking worker changes (example: com.example:payment-worker:1).
Version webhook payloads (new schema version, or a new endpoint).
Version connector templates and keep old templates available until old instances are gone.
The goal is boring transitions. You accept two shapes for a while, then remove the old one when the system is clean.
What’s Next?
This is where manual deployment usually fails. Someone deploys a new worker, or a modeler publishes a mapping change, and you only learn that it was incompatible once incidents show up. Modelers also can’t see what the runtime actually binds and validates, so a mapping can look harmless and still break deserialization.
That is why compatibility rules are not enough on their own. You need automation that checks model and runtime changes against each other. And yes, you can use the run feature in the web modeler, to verify if you break the current runtime, but since I’m a developer, I tend to automate things.
We do not have a Schema Registry for BPMN mappings, so process tests are the closest thing we have to compatibility validation. That is what I cover in part 2.



