Why should we do it?
REST-based element templates are reviewed in PRs, hand-edited, and copy-pasted into Modeler. They are the source of truth, not a build artifact.
The pain point: a generator producing a multi-operation REST template today emits one set of hidden fields (method, headers, operationPath, queryParameters, request body) per operation, each gated by condition: {property: operationId, equals: "<op>"}. For a 5-operation API, that's 30+ conditional properties — and most of them are byte-identical duplicates that could be one property with a wider condition. A PR reviewer has no syntactic signal that the four equals: "search"|"autocomplete"|"feed"|"recommendations" properties are equivalent and could be collapsed; they have to mentally do the merge themselves to verify correctness.
Once a hand edit lands on top of generator output, the boundary between "generated boilerplate" and "human intent" is invisible to the next reviewer.
Two further reasons in favor:
1. Concrete win on real templates. A prototype against a 5-operation real-world REST spec took a generated template from 42 → 23 properties (−45%) by running the merge and totalize passes proposed below. Verified semantically equivalent by applying the template via element-templates-cli and cycling all operations — identical <zeebe:input> and <zeebe:taskHeader> output before and after.
2. Each pass is small. Every pass takes a template and returns a template; inputs are bounded JSON. Testable with static fixtures. The validator (#7152) already provides a Rule / Finding / ReportPrinter framework we can reuse, so the only new code is the pass logic itself.
What should we do?
Add an optimizer module — sibling to the validator from #7152 — that runs a small set of passes over a template, each rewriting it into a smaller semantically-equivalent form. The validator's Rule / Finding / ReportPrinter infrastructure can be reused for the per-pass interfaces and dry-run reporting.
Key concepts
An element template property has, at minimum, an id, a binding (which <zeebe:input> it produces), a value (literal or FEEL expression), a condition (when the property is active), and presentation fields (label, description, feel, constraints) that affect how Modeler renders it.
Two terms used below:
- discriminator — the property that conditional fields key off (in REST-connector output today, this is
operationId).
- merged property — the single property that results from collapsing several conditional properties whose only difference was their condition's
equals / oneOf value.
Proposed passes
| Pass |
What it does |
merge-by-identity |
Group properties by (binding, value, presentation). If a group's members all condition on the same discriminator and differ only in their equals / oneOf value, replace the group with one property whose condition is oneOf: [union of their values]. The merged property's id is also chosen at this point — the source members' ids are merge-artifact names (autocomplete_query_locale, alphabetical first by accident), so strip the per-operation prefix to a neutral form (query_locale) while the merge context is still in scope. |
totalize |
When a oneOf covers every choice of its discriminator, drop the condition entirely — the property becomes unconditional. |
strength-reduce |
Singleton oneOf: [x] → equals: x. Cosmetic; matches what the generator emits naturally. |
reorder |
Stable canonical order (group, then hidden-vs-visible, then id). Makes regeneration diffs reviewable. |
A lint pass at the end is desirable but not new work — several validator rules from #7152 (condition-value-in-choices, condition-target-exists, empty-group, …) already catch what an optimizer's lint step would. Run them as a post-optimization gate.
Worked example
Before — four near-identical properties from a locale query parameter shared across search, autocomplete, feed, recommendations:
{ "id": "search_query_locale", "value": "string", "binding": {...},
"condition": { "property": "operationId", "equals": "search" } }
{ "id": "autocomplete_query_locale", "value": "string", "binding": {...},
"condition": { "property": "operationId", "equals": "autocomplete" } }
{ "id": "feed_query_locale", "value": "string", "binding": {...},
"condition": { "property": "operationId", "equals": "feed" } }
{ "id": "recommendations_query_locale", "value": "string", "binding": {...},
"condition": { "property": "operationId", "equals": "recommendations" } }
After merge-by-identity (which also picks the neutral id):
{ "id": "query_locale", "value": "string", "binding": {...},
"condition": { "property": "operationId",
"oneOf": ["search", "autocomplete", "feed", "recommendations"] } }
If the oneOf had grown to cover every value in the operationId choices, totalize would drop the condition altogether.
Why should we do it?
REST-based element templates are reviewed in PRs, hand-edited, and copy-pasted into Modeler. They are the source of truth, not a build artifact.
The pain point: a generator producing a multi-operation REST template today emits one set of hidden fields (
method,headers,operationPath,queryParameters, request body) per operation, each gated bycondition: {property: operationId, equals: "<op>"}. For a 5-operation API, that's 30+ conditional properties — and most of them are byte-identical duplicates that could be one property with a wider condition. A PR reviewer has no syntactic signal that the fourequals: "search"|"autocomplete"|"feed"|"recommendations"properties are equivalent and could be collapsed; they have to mentally do the merge themselves to verify correctness.Once a hand edit lands on top of generator output, the boundary between "generated boilerplate" and "human intent" is invisible to the next reviewer.
Two further reasons in favor:
1. Concrete win on real templates. A prototype against a 5-operation real-world REST spec took a generated template from 42 → 23 properties (−45%) by running the merge and totalize passes proposed below. Verified semantically equivalent by applying the template via
element-templates-cliand cycling all operations — identical<zeebe:input>and<zeebe:taskHeader>output before and after.2. Each pass is small. Every pass takes a template and returns a template; inputs are bounded JSON. Testable with static fixtures. The validator (#7152) already provides a
Rule/Finding/ReportPrinterframework we can reuse, so the only new code is the pass logic itself.What should we do?
Add an optimizer module — sibling to the validator from #7152 — that runs a small set of passes over a template, each rewriting it into a smaller semantically-equivalent form. The validator's
Rule/Finding/ReportPrinterinfrastructure can be reused for the per-pass interfaces and dry-run reporting.Key concepts
An element template property has, at minimum, an
id, abinding(which<zeebe:input>it produces), avalue(literal or FEEL expression), acondition(when the property is active), and presentation fields (label,description,feel,constraints) that affect how Modeler renders it.Two terms used below:
operationId).equals/oneOfvalue.Proposed passes
merge-by-identity(binding, value, presentation). If a group's members all condition on the same discriminator and differ only in theirequals/oneOfvalue, replace the group with one property whose condition isoneOf: [union of their values]. The merged property'sidis also chosen at this point — the source members' ids are merge-artifact names (autocomplete_query_locale, alphabetical first by accident), so strip the per-operation prefix to a neutral form (query_locale) while the merge context is still in scope.totalizeoneOfcovers every choice of its discriminator, drop the condition entirely — the property becomes unconditional.strength-reduceoneOf: [x]→equals: x. Cosmetic; matches what the generator emits naturally.reorderid). Makes regeneration diffs reviewable.A lint pass at the end is desirable but not new work — several validator rules from #7152 (
condition-value-in-choices,condition-target-exists,empty-group, …) already catch what an optimizer's lint step would. Run them as a post-optimization gate.Worked example
Before — four near-identical properties from a
localequery parameter shared acrosssearch,autocomplete,feed,recommendations:{ "id": "search_query_locale", "value": "string", "binding": {...}, "condition": { "property": "operationId", "equals": "search" } } { "id": "autocomplete_query_locale", "value": "string", "binding": {...}, "condition": { "property": "operationId", "equals": "autocomplete" } } { "id": "feed_query_locale", "value": "string", "binding": {...}, "condition": { "property": "operationId", "equals": "feed" } } { "id": "recommendations_query_locale", "value": "string", "binding": {...}, "condition": { "property": "operationId", "equals": "recommendations" } }After
merge-by-identity(which also picks the neutralid):{ "id": "query_locale", "value": "string", "binding": {...}, "condition": { "property": "operationId", "oneOf": ["search", "autocomplete", "feed", "recommendations"] } }If the
oneOfhad grown to cover every value in theoperationIdchoices,totalizewould drop theconditionaltogether.