Feel Expressions Strategy
BPMN usa FEEL para conditions:
=amount > 100 and customer.tier == "premium". Camunda usa feel-scala (huge, ~50K LOC). MVP options: (1) build subset propio, (2) integrate feel-scala como library, (3) use JSONata (JSON-native), (4) CEL (Google's Common Expression Language). Recomendación: CEL — production-ready, multiple language SDKs (Go, Java, TS, Python), Postgres extension available, more pragmatic que FEEL para code-first teams. Pero NO is 100% BPMN-compliant. Trade-off documentado.
El problema¶
BPMN spec define FEEL (Friendly Enough Expression Language) para:
- Gateway conditions: =amount > 100
- Input mappings: =customer.email
- Output mappings: =response.body.orderId
- Timer durations: =duration("PT" + waitMinutes + "M")
- DMN decision tables: ranges, comparisons
Camunda implementa FEEL completo via feel-scala (~50K LOC, Scala language).
MVP necesita evaluar expressions. ¿Qué hacer?
FEEL — qué es exactamente¶
FEEL es spec del DMN standard (Decision Model and Notation). Features:
# Arithmetic
amount * 1.1
# Comparisons
amount > 100 and amount < 1000
# String operations
upper case("hello") = "HELLO"
string length(name)
# Date/time
date("2025-01-01")
duration("P1Y2M")
date and time("2025-01-01T12:00:00")
# Collections
[1, 2, 3, 4, 5][item > 2] # filter
sum([1, 2, 3])
count(orders)
# Context (struct access)
customer.address.city
# Conditionals
if score > 80 then "A" else if score > 60 then "B" else "C"
# Boxed expressions (DMN)
{
amount: 100,
taxed: amount * 1.1
}
# Function definitions
function(x, y) x + y
Complejidad significativa. Feature-rich pero NO popular fuera de BPMN/DMN world.
Opciones consideradas¶
Opción 1: Build subset propio (FEEL-lite)¶
Implementar parser + evaluator para subset común:
# Soportar:
# - Literals: numbers, strings, booleans
# - Variables: customer.email
# - Arithmetic: + - * /
# - Comparisons: < > <= >= == !=
# - Logical: and or not
# - Collections: [1,2,3], .length
# - Conditionals: if-then-else
# NO soportar:
# - Date/time complex (use ISO strings)
# - DMN-specific syntax
# - Custom functions
# - Boxed expressions
Pros: - Full control - Performance optimizable - No vendor lock-in - Minimal dependencies
Cons: - ~3-6 meses de eng (parser + AST + evaluator + tests) - Inevitablemente incomplete vs feel-scala - Users que migran de Camunda hit edge cases - Maintenance permanente
LOC estimado: ~3-5K (parser, AST, evaluator, error handling, tests).
Opción 2: Integrate feel-scala como library¶
Run feel-scala como subprocess o JVM library:
Pros: - 100% Camunda compatibility - Feature complete - Maintained upstream
Cons: - JVM dependency (huge — 100MB+ JRE) - Scala interop (engine en Go/TS = boundary crossing) - Performance overhead (subprocess RPC ~1ms+) - Vendor lock-in to Camunda's implementation - License (Apache 2.0, OK)
Implementation: - Wrap feel-scala como REST microservice - Engine calls via HTTP per expression evaluation - Cache compiled expressions
Opción 3: Use JSONata¶
JSONata es expression language JSON-native:
Pros: - Lightweight (~100KB) - JSON-native (perfect fit con variables) - Multiple language SDKs (JS, Python, Go, Rust) - Active community - Functional syntax
Cons: - NOT FEEL — users migrating from Camunda need conversion - BPMN spec compliance: rejected - Less features que FEEL
Opción 4: CEL (Common Expression Language)¶
CEL es de Google, production-grade:
amount > 100 && amount < 1000
customer.tier == "premium" && order.items.size() > 0
timestamp(joinDate) + duration("365d") > now
Pros:
- Production-grade (used in Istio, Kubernetes, GCP IAM)
- Multiple SDKs official (Go, Java, C++, with Python/TS community)
- Predictable evaluation (no Turing-complete)
- Type checking
- Postgres extension available (postgres-cel)
- Better tooling than FEEL
Cons: - NOT BPMN spec compliant - Migration de Camunda BPMN requires conversion - Less features for DMN specifically
Opción 5: Múltiples engines pluggable¶
Soportar varios:
<zeebe:taskDefinition type="my-task" />
<bpmn:conditionExpression language="feel">=amount > 100</bpmn:conditionExpression>
<bpmn:conditionExpression language="cel">amount > 100</bpmn:conditionExpression>
<bpmn:conditionExpression language="jsonata">$.amount > 100</bpmn:conditionExpression>
User elige por expression.
Pros: - Flexible - Migration de Camunda trivial (FEEL preserved)
Cons: - Multiple parsers/evaluators a mantener - Complexity de testing - User confusion (cuál usar?)
Decisión recomendada: CEL principal + FEEL-lite compatibility shim¶
Estrategia detallada¶
Phase 1: CEL como expression engine principal¶
Use CEL para nuevos workflows del MVP:
<bpmn:conditionExpression>
<![CDATA[
expressionLanguage="cel"
expression="amount > 100 && customer.tier == 'premium'"
]]>
</bpmn:conditionExpression>
Implementation:
- Go: github.com/google/cel-go
- TypeScript: @cel-eval/runtime
- Python: celpy
Cero código propio de parser/evaluator.
Phase 1: FEEL-lite shim para Camunda compatibility¶
Para BPMN files importados de Camunda, soportar subset común de FEEL:
class FEELLite:
"""Subset de FEEL that covers ~80% of real-world Camunda usage."""
SUPPORTED = {
# Operators
'+', '-', '*', '/',
'<', '>', '<=', '>=', '==', '!=',
'and', 'or', 'not',
# Literals
'number', 'string', 'boolean', 'null',
# Path access
'context.path.access',
# Conditionals
'if/then/else',
# Collections (basic)
'[a, b, c]',
'.length',
'.contains(x)',
}
NOT_SUPPORTED = {
# Reject these with clear error message
'date()', 'time()', 'duration()', # use ISO strings instead
'function(...)', # no user-defined functions
'boxed expressions', # DMN-specific
'every', 'some', # collection quantifiers
'instance of',
}
Implementation: ~2K LOC parser + evaluator. Suficiente para 80% de Camunda BPMN files migrated.
Phase 2: feel-scala integration (opcional)¶
Si demand real for full FEEL compatibility:
# Engine config
feel:
engine: subprocess
binary: /opt/feel-scala/bin/feel-evaluator
fallback_to_feel_lite: true
Subprocess approach (no JVM in main process): - Engine sends expression + variables via stdin - feel-scala evaluator responds via stdout - Cache compiled expressions
Performance cost: ~1-5ms per expression. Acceptable for non-hot-path.
CEL — los detalles¶
Setup en engine (Go example)¶
import "github.com/google/cel-go/cel"
// One-time: compile expression
env, _ := cel.NewEnv(
cel.Variable("amount", cel.IntType),
cel.Variable("customer", cel.MapType(cel.StringType, cel.AnyType)),
)
ast, issues := env.Compile("amount > 100 && customer.tier == 'premium'")
if issues.Err() != nil {
return invalidExpressionError(issues.Err())
}
program, _ := env.Program(ast)
// Many times: evaluate with variables
result, _, _ := program.Eval(map[string]interface{}{
"amount": 150,
"customer": map[string]interface{}{"tier": "premium"},
})
// result.Value() == true
CEL features for BPMN¶
// Gateway condition
amount > 100 && customer.tier == "premium"
// Input mapping
customer.email
// Output mapping
{
"orderId": response.id,
"totalAmount": response.amount * 1.1
}
// Timer duration
duration("PT" + string(waitMinutes) + "M")
// Collection operations
orderItems.filter(i, i.quantity > 0).map(i, i.price * i.quantity).sum()
// String operations
customer.name.upperAscii()
customer.email.contains("@example.com")
// Conditionals
score > 80 ? "A" : (score > 60 ? "B" : "C")
CEL covers vast majority of BPMN expression needs.
Variable injection¶
Engine injects process variables como CEL bindings:
# Process instance variables
variables = {
"customer": {"email": "alice@x.com", "tier": "premium"},
"amount": 150,
"items": [...]
}
# Build CEL context from variables
program.eval(variables)
Process variables naturally JSON → CEL maps trivially.
Migration de Camunda BPMN¶
User migrates Camunda BPMN file con FEEL expressions:
<!-- Camunda original -->
<bpmn:conditionExpression>=amount > 100</bpmn:conditionExpression>
<!-- After import to MVP -->
<bpmn:conditionExpression expressionLanguage="feel-lite">amount > 100</bpmn:conditionExpression>
Tool de conversion:
mvp-cli bpmn convert --from=camunda-feel --to=cel input.bpmn
# Tries best-effort FEEL → CEL conversion
# Reports unsupported expressions for manual fix
Common conversions:
- =amount > 100 → CEL: amount > 100
- =customer.email → CEL: customer.email
- =count(items) → CEL: items.size()
- =substring(name, 1, 5) → CEL: name.substring(0, 5)
Some won't convert cleanly:
- =date and time(...) → manual: use ISO strings
- DMN-specific syntax → manual review
Performance comparison¶
| Expression engine | Compile | Evaluate | Memory |
|---|---|---|---|
| CEL | ~1ms | ~5μs cached | Low |
| JSONata | ~2ms | ~20μs | Low |
| FEEL-lite custom | ~1ms | ~10μs | Low |
| feel-scala subprocess | ~50ms | ~1-5ms | High (JVM) |
CEL es clearly el best performance + features balance.
Caching expressions¶
Engine caches compiled expressions (parse once, evaluate many):
class ExpressionCache:
def __init__(self):
self.cache = LRUCache(maxsize=10000)
async def evaluate(self, expression: str, variables: dict):
if expression not in self.cache:
program = compile_cel(expression)
self.cache[expression] = program
program = self.cache[expression]
return program.eval(variables)
Compile cost amortized across many evaluations. Hot expressions effectively free.
Error handling¶
Bad expressions emit:
class ExpressionEvaluationError(Exception):
pass
# When engine encounters bad expression at runtime:
try:
result = await evaluator.evaluate(expression, variables)
except ExpressionEvaluationError as e:
# Create incident (per ADR-007 pattern)
await emit_incident(
type='EXTRACT_VALUE_ERROR',
message=f"Failed to evaluate expression: {e}",
element_instance_key=element_instance.key
)
# Element execution paused, requires admin resolution
Sigue concepts/incident-management pattern.
Documentation for users¶
# Expressions en el MVP Engine
Workflows usan CEL (Common Expression Language) por default.
## Syntax básica
\`\`\`cel
amount > 100 // comparison
customer.tier == "premium" // path access
items.size() > 0 // collection
amount > 100 && verified // logical
order.status in ["new", "paid"] // membership
\`\`\`
## Migrating from Camunda FEEL
Use `mvp-cli bpmn convert` para auto-conversion. Most FEEL expressions
convert directly. See [migration guide].
## Reference
Full CEL spec: https://github.com/google/cel-spec
Trade-offs documentados¶
Pro de CEL approach:¶
- Production-grade engine (used at Google scale)
- Multiple language SDKs
- Predictable evaluation
- Performance excellente
- Cero build de parser
Con de CEL approach:¶
- NOT 100% Camunda BPMN compliant
- Migration requires conversion tool
- Some FEEL features lost (DMN-specific especialmente)
- Less ecosystem que FEEL en BPMN world
ADR implication¶
This decision should become ADR-022: Use CEL for expressions + FEEL-lite for Camunda compatibility.
Add to ADRs index when finalized.
Links¶
- concepts/dmn-integration — DMN context
- adrs/adr-001-bpmn-as-workflow-language — BPMN decision
- CEL Spec
- cel-go
- feel-scala (reference)
- JSONata