The redb.Route expression language, part 2: from a string to IL, registry calls, and a gate that checks routes before they run
The first part made the general case for the redb.Route expression language. It covered one grammar for conditions, values and templates, compilation into a delegate instead of interpretation per message, one truthiness rule, and the places where expressions live: patterns and endpoint addresses. This part goes underneath. It shows what exactly happens to a string such as header.a > 10, how a template decides where a placeholder ends, how a route reaches your own services and the environment, what the language refuses to do when the text comes from data rather than from a developer, and what the package gate tells you about a route file before it is ever deployed.
A note on where the results come from. The public guide to the language, EXPRESSIONS.md, marks its example tables for execution, and a test (ExpressionsGuideExamplesTests) reads the guide itself and runs every marked example, more than 130 of them, against the engine on every test run. When the engine changes, either the guide changes with it or the build fails. Every result below that comes from those tables is what the engine returns, not what it is meant to return. The few results that are not in the guide (the sandbox, prefix resolvers, the gate output) come from runs against the current code, and I say so where they appear.
All examples run on one exchange:
header.a = 42 header.b = 3 header.neg = -7 header.price = 2.5
header.s = "Hello World" header.flag = true header.off = false
header.Content-Type = "text/xml" header.empty = "" header.O'Brien = "obrien"
header.user = { Name = "Ann", Age = 30, Active = true, Manager = { Name = "Bob", Age = 51 } }
header.list = [5, 1, 9] header.when = 2026-08-28 10:30 UTC
property.count = 7 property.items = ["p", "q", "r"] property.nums = [1, 2, 3, 4]
property.cfg = { enabled = true, limit = 5 } property.counter = 10
body = "payload" exception.Message = "boom"
registry: beans "pricing" and "pricing-rules"; "limits.max", the number 100
1. From a string to a delegate
Most integration frameworks treat an expression string as something to interpret: every message walks the text, looks up functions by name and converts values on the way. redb.Route treats it as source code. The route is built once, and the expressions in it are compiled once into delegates of the shape Func<IExchange, object?>. Each message only calls a delegate.
Here is the path a bare expression takes.
- Tokenizer. The text is split into tokens: names, numbers, string literals, operators, parentheses. Whitespace between tokens disappears here, which is why
header.a>10andheader.a > 10are the same expression and a condition broken across lines in an XML attribute still reads correctly. - Parser. A recursive descent parser builds a syntax tree. The precedence levels are the familiar ones, from the loosest to the tightest: ternary,
??, logic, comparison, addition and subtraction, multiplication and division, unary operators, and finally primary forms such as names, literals, calls and parentheses. The parser takes the whole text: a token left over after a complete expression is an error with its position, not something quietly dropped. - Expression tree. Each node of the syntax tree is translated into
System.Linq.Expressions, the same expression trees that LINQ providers and Entity Framework build. A header read becomes a dictionary access andupper(...)a method call.ANDandORbecomeAndAlsoandOrElseover the truthiness conversion, and??becomesCoalesce, so their right side runs only when the left one does not decide.XORneeds both sides and evaluates both. - Compile.
Expression.Lambda<Func<IExchange, object?>>(...).Compile()turns the tree into IL. The result is an ordinary .NET delegate.
This is the only engine. Every value expression, condition and placeholder goes through this one pipeline, so a form that works in one position works in the others with the same meaning and the same errors.
Short-circuiting is not a detail. It is visible in the guide's executed rows, where the right side calls a bean that does not exist:
| Expression | Result |
|---|---|
header.flag OR bean:nobody.Check() |
True |
header.off AND bean:nobody.Check() |
False |
header.a ?? bean:nobody.Check() |
42 |
None of the three ever calls nobody. A guard on the left protects an expensive or fallible call on the right, as in C#.
Templates take a slightly different road. A scanner first finds the holes in the text. A format suffix such as ${header.price:F2} is split off first (section 2). A placeholder that is the whole string, such as ${header.a}, has direct shortcuts: a header, a property, the body, a single jpath/xpath call and the Camel spellings ${jsonpath:...}, ${xpath:...} and ${file:...} compile straight to an accessor and skip the parser entirely. Anything richer goes through the same tree as a bare expression. A mixed template, with text around its placeholders, becomes a list of literal segments plus compiled holes, joined at run time.
Caching. Compiled delegates are kept in a process-wide cache keyed by the expression text. Two routes with the same condition share one compilation. The cache has no automatic eviction; ExpressionResolver.ClearAllCaches() clears it by hand.
I will not give a speed figure in this article. No clean measurement exists yet that I would publish, and a number from a busy development machine says nothing. What can be said precisely is what is compiled: everything. There is no interpreted mode to fall back to and no "compiled variant" to enable. Parsing happens when the route is built, and if the text does not parse, the route does not start: .Filter("header.a >") throws ExpressionCompilationException at Start().
Bare text: a word is a word, arithmetic must be whole
What should application/json mean in an expression attribute? Apache Camel reads a text without operators as the text itself, and so does redb.Route: hello, plain text and key=value are literals, and a name such as header.a or body is a lookup. A text that contains an arithmetic operator (+ - * /) outside quotes is arithmetic, and it must be whole: it parses to its end, and every bare word in it must be a value. If it is not, the error names the place:
1 + → ends where a value is expected
header.a+b → 'b' at position 10 is not a value
application/json → 'application' at position 1 is not a value
Write such text in quotes, 'application/json'. The same rule catches dates: a bare 2026-08-28 is arithmetic and gives 1990, so a date is quoted or read from a header. The point of the rule is that a typo never turns into a plausible value. A half-parsed expression is an error at load, not a wrong number on a message.
Increments write into the exchange
The language has ++ and --, prefix and postfix, on headers and properties. They are not decoration: they change the exchange.
| Expression | Returns | Afterwards |
|---|---|---|
header.a++ |
42 |
header.a = 43 |
++header.a |
43 |
header.a = 43 |
header.a-- |
42 |
header.a = 41 |
--header.a |
41 |
header.a = 41 |
property.counter++ |
10 |
property.counter = 11 |
++property.counter |
11 |
property.counter = 11 |
Prefix returns the new value, postfix the old one, as in C#. An increment of a missing header or property starts from zero. A value that is there but is not a number is an error naming it, and the header is left as it was:
header.x++: the value 'abc' (String) is not a number.
The increment works inside a template too. In a route builder:
.SetProperty("attempt", Constant(0))
.LoopWhile("property.attempt < 3")
.Log("attempt ${property.attempt++}")
.To("direct://step")
.EndLoop()
LoopWhile repeats the body while the condition holds; the increment in the log template advances the counter, so the log prints attempt 0, attempt 1, attempt 2 and the body runs three times. (Constant is a helper of RouteBuilder; outside one, write new ConstantExpression(0).)
Under the hood an increment compiles into a read, a computation and a write back into In.Headers or Properties. That is a plain read-modify-write, not an atomic operation. It is correct because one exchange is processed by one flow at a time; it is not a tool for counters shared between threads.
Literals do not depend on the server's locale
Number literals are parsed with the invariant culture. max(2.5, 1) means the same thing on an en-US build server and on a ru-RU production host, where the decimal separator is a comma. The same holds on the way out: a number or a date rendered into a template is 2.5 and ISO 8601 on every machine. The boolean words in the truthiness rule (true/false, yes/no, on/off and the rest) are English only, for the same reason: the locale of the person who wrote the route never changes what it means.
2. Typed templates
The first part mentioned that a whole placeholder keeps its type. This deserves a closer look, because it removes a whole class of conversion code.
| Template | Result | Type |
|---|---|---|
${header.a} |
42 |
int |
${header.a + 1} |
43 |
number |
${header.a > 10} |
True |
bool |
${header.price} |
2.5 |
number, rendered invariantly |
a=${header.a}, b=${header.b}, sum=${header.a + header.b} |
a=42, b=3, sum=45 |
string |
[${header.missing}] |
[] |
string |
SetHeader("n", Expr("${header.a}")) stores the int 42, not the text "42". A Kafka key, an SQL parameter or a header read by the next step with getHeader<int> receives a number, not a string that someone must parse again. As soon as there is text around a placeholder, the template is a string, which is what a template with text should be. A missing value renders as nothing, so [${header.missing}] is [] rather than [null].
A format suffix
When a number must be text of a fixed shape, a placeholder takes a standard .NET format after a colon:
| Template | Result |
|---|---|
${header.price:F2} |
2.50 |
${header.a:D5} |
00042 |
${header.price * 4:F1} |
10.0 |
${value:F2} is format(value, 'F2') with the invariant culture: one letter and up to two digits (F2, N0, D8). The part before the colon is a path or a call, so a namespace such as env:X is never mistaken for a format, and neither is the colon of a ternary. A custom format such as yyyy-MM-dd goes through format(...) or dateformat(...).
3. Where a placeholder ends: braces, quoting, escape
A template looks simple until someone writes a brace inside a string literal. The rules are short and every one of them is executed by the guide's tests:
| Template | Result |
|---|---|
${concat('a', '}', 'b')} |
a}b |
{${header.a}} |
{42} |
${} |
${} |
${header.O'Brien} |
obrien |
echo \${HOME} for ${header.s} |
echo ${HOME} for Hello World |
text ${body |
error: unclosed '${' at position 6 of "text ${body": the placeholder has no closing '}'. |
A placeholder starts at ${ and ends at the } that balances it. A } inside a quoted literal belongs to the literal, so ${concat('a', '}', 'b')} is one placeholder. A plain { opens nothing, so the outer braces of {${header.a}} stay text; that matters for JSON written as a template. An empty ${} is not a placeholder and stays as written. A quote inside a header name is part of the name, so ${header.O'Brien} reads the header O'Brien without escaping. A quote that opens a string literal, on the other hand, must close:
header.s == 'abc → unterminated string literal starting at position 13 within the expression "header.s == 'abc".
A literal ${ is written \${. It opens nothing and renders as ${, which is what a shell command or another template engine's syntax inside a route template needs.
The last row is the important one. A ${ that nothing closes is an error with its position, raised where the template is compiled: when the route is built in C#, when a route file is loaded, and earlier still by the package gate (section 9). It is never silently rendered as text.
A placeholder may contain another placeholder in exactly one place: as an argument of a bean: call (section 5). Everywhere else a ${ inside an expression is an error naming its position.
4. Reading and comparing: the cases that usually hurt
Names with dots and dashes
Header names in integration are rarely C# identifiers: Content-Type, JMSCorrelationID, x.request.id. The language resolves them with one rule: the literal name comes first. header.dotted.key is the header named dotted.key if one exists, otherwise the member key of the header dotted. Inside a template the same rule keeps a dashed name readable: ${header.Content-Type} gives text/xml.
In a bare expression the dash would be a minus, so header.Content-Type there would be a subtraction. Instead of computing a meaningless difference, the engine stops with an error that tells you what to write:
'header.Content-Type' at position 1 reads as a subtraction: a name with a dash is written ${header.Content-Type}
No quoted indexer syntax had to be invented for this.
Objects, collections, dictionaries
An object reads the same wherever it lies: in a header, in a property or as the body. Members are found by name, case-insensitive, public fields included. A dictionary answers both .key and ['key']. header.user.Manager.Name gives Bob, header.list[1] gives 1, property.cfg.enabled gives True. Attachments of the In message have their own root: attachments.size and attachments[0].fileName.
Member access is null-safe by default: a member of a missing value is null, not an exception, so there is no separate ?. to write. The exception of a failed exchange is a root like the others: bare exception.Message reads the message, the same as ${exception.Message}.
Methods of the value itself
A method of the object in a header or the body can be called in every position, conditions included:
| Where | Text | Result |
|---|---|---|
| condition | header.s.length() > 10 |
True |
| template | ${header.s.toUpper()} |
HELLO WORLD |
| value | header.list.Count |
3 (a property, no parentheses) |
A method argument keeps its dots, so header.list.contains(5.0) and header.names.contains('a.b') pass the whole argument. A null target gives null. Which methods exist depends on the value, and the value is known only when the message arrives, so a failed call is reported on the message, as an ExpressionEvaluationException that names the call and its position:
header.o.Boom() at position 1 threw InvalidOperationException: boom
'Nope' is not a method of String at position 1 (in header.s.Nope()).
A method that throws never turns into an empty value that flows on downstream.
Comparing: exact, ignoring case, in a range
| Condition | Result |
|---|---|
header.s == 'hello world' |
False |
header.s =~ 'hello world' |
True |
header.a =~ '42' |
True |
header.a range '1..42' |
True |
header.price range '2.5..3' |
True |
header.a not range '1..10' |
True |
header.missing range '1..10' |
False |
== on two strings is exact, case included. =~ is Apache Camel's equals-ignoring-case: two strings compare ignoring case, ordinally, with no culture involved; any other pair compares as ==. Numbers of different types are equal when their values are: 3 == 3.0 and 3 == '3' are true, 3 == 5.0 is false.
range 'from..to' is true for a number within both ends, inclusive, as in Camel, and not range is its opposite. The right side is a quoted literal read when the expression is compiled, so a malformed range ('1-10') or one whose start is above its end ('10..1') is an error at load and in the gate, not a filter that silently lets nothing through. A missing value is in no range. A text that is not a number is an error naming it:
header.s range '1..10': the value 'Hello World' (String) is not a number.
range is an operator only after an operand; elsewhere it is a name, so header.range reads the header called range.
JSON and XML in the body
jpath follows RFC 9535 through JsonPath.Net: .name, ['name'], [0], slices, [*], .., filters such as [?(@.price < 10)] and the standard functions. xpath reads XML. Inside ${...} a path may be written bare:
${jpath($.order.id)} → 7
id=${jpath($.order.id)} total=${jpath($.order.total)} → id=7 total=150.5
${xpath(/order/id)} → 7
${jsonpath:$.order.id} → 7
${xpath:/order/id} → 7
The last two lines are Apache Camel's spellings: ${jsonpath:path} and ${xpath:path} mean jpath('path') and xpath('path'). Each spelling fills its own ${...}, the path runs to the closing brace, and text may stand around the placeholder. A route copied from Camel keeps working.
In a bare expression and in a condition the path must be quoted, jpath('$.order.id'), because $ and / are operators there. Unquoted, the expression does not compile, and again it fails when the route is built, not on a message.
File names: `$
A route that handles files usually needs the file's name: to archive it, to name a reply, to log it. Camel's file language is there: ${file:key} fills its own placeholder, the key runs to the closing brace, and it reads the headers a file consumer sets on the message. Text may stand around it, as the second row shows. With redbFile.Name = "orders/a.xml" and redbFile.NameOnly = "a":
| Template | Result |
|---|---|
${file:name} |
orders/a.xml |
archive/${file:name.noext}.done |
archive/a.done |
| Key | Header |
|---|---|
name, onlyname |
redbFile.Name |
name.noext, onlyname.noext |
redbFile.NameOnly |
ext, name.ext |
redbFile.Extension, without the dot (xml) |
parent |
redbFile.Parent |
path, absolute.path |
redbFile.AbsolutePath |
relative.path |
redbFile.RelativePath |
length, size |
redbFile.Length |
modified |
redbFile.LastModified |
A message that did not come from a file has no such headers, and the key is empty. A key not in the table is an error when the route is built, and the error lists the keys. The file consumers' own options (moveTo, preMove, moveFailed, doneFileName, idempotentKey, and S3's doneFileName) take the same keys through the same table and give the same values, read from the file being handled. There a key matches ignoring case, as these options always did; in a template it matches exactly, as in Camel.
5. Reaching outside the message: bean:, ref:, env:
bean:, calling your own code
Sooner or later a route needs a decision that the language should not express: a loyalty check, a price calculation, a rule that lives in a service. The language calls a method of an object registered in the route context, in any position: a condition, a value or a template.
The examples use a registry with two beans, pricing and pricing-rules, each with Gross(int qty, double price), IsGold(string name), Badge(string name, int points) and Touch(string).
| Expression | Result |
|---|---|
bean:pricing.Gross(header.b, header.price) |
7.5 |
${bean:pricing.Gross(${header.b}, ${header.price})} |
7.5 |
bean:pricing-rules.Badge(header.user.Name, 7) |
Ann:7 |
bean:pricing.Gross(header.b, 2) - 1 |
5 |
bean:pricing.Touch('x') |
null |
| Condition | Result |
|---|---|
bean:pricing.IsGold(header.user.Name) |
True |
header.flag && bean:pricing.IsGold('Bob') |
False |
| Template | Result |
|---|---|
total ${bean:pricing.Gross(header.b, 2)} EUR |
total 6 EUR |
A bean call is a full participant of the expression: its result can be compared, combined with &&, used in arithmetic. In a route file it looks like this:
<filter expr="bean:loyalty.IsGold(header.customer)">
<to uri="direct://gold"/>
</filter>
The rules, all of them visible in the tables above:
- The name is the registry name without
#and may contain dashes (pricing-rules). - The method is required.
bean:pricingalone is an error.bean:pricing.Methodwithout parentheses calls it with no arguments, and a dash after it is a minus:bean:svc.Count-1callsCountand subtracts one. - Arguments are expressions of the language. Inside a
${bean:...}placeholder an argument may be wrapped in${...}and means the same as written bare; this is the Camel form, and it works. In a bare expression write the arguments bare. A${there makes the whole text a template (it renders instead of calling), and the gate warns about exactly this mistake. - Binding goes by method name (any case) and number of arguments. A parameter of type
IExchange,IMessageorCancellationTokenis filled in by the binding and does not count. Each argument converts to its parameter's type. - The result keeps its type. A
voidmethod givesnull, which reads as false or empty, as in Apache Camel. - Resolution is lazy and per context. The bean is looked up on the call's first evaluation in a route context and the bound method is kept for that context. The compiled delegate is shared through the text cache, but each context resolves its own bean, so two contexts with different
pricingobjects never see each other's. A missing bean or method is an error naming the call. - The colon touches both sides.
bean:is a call only when the colon touchesbeanand the name. In a ternary whose branch is a variable namedbean, keep the spaces:flag ? bean : other.
Synchronous only, and why
An expression is evaluated synchronously: a template is rendered into a string right now, a value is computed right now. A method that returns Task, Task<T>, ValueTask or ValueTask<T> is therefore refused, with advice to call it as a step instead: <bean ref="#name" method="..."/> in markup or .Bean("#name", "...") in C#. The same rule holds in Apache Camel's Simple language. Blocking on a task inside an expression would hide a thread stall in a place nobody looks for one, so the language does not offer it.
Method() in C#, and where Task<bool> is awaited
Method(reference, spec) is the typed form of the same call, the counterpart of Camel's method():
From("direct://orders")
.Filter(Method("#loyalty", "IsGold(${header.customer})")) // a condition
.SetBody(Method("#pricing", "Gross(${header.qty}, 2.5)")) // a value, its type kept
.EndFilter()
.Validate(Method("#rules", "IsAllowed(${header.customer})"), "not allowed");
There is one place where asynchronous code is welcome: a condition. Conditions in redb.Route are predicates that the engine awaits (MatchesAsync), so in Filter, When and Validate a Method() whose target returns Task<bool> is awaited properly. In a value position the call is synchronous, as the bean: function is. Validate also accepts any expression and reads it as a condition, the way Filter does: Validate(Expr("header.a > 0")).
ref:, the registry object itself
Sometimes a route needs the registered object, not a call on it: a limit, a settings object, a value to pass to another method. ref:name is the object registered under name, as Camel's ${ref:name}:
| Expression | Result |
|---|---|
ref:limits.max |
100 |
ref:limits.max - header.b |
97 |
${ref:limits.max} |
100 |
header.a < ref:limits.max (condition) |
True |
max ${ref:limits.max} (template) |
max 100 |
The name is the whole run of letters, digits, _, . and - after the colon, so ref:limits.max is the object registered as limits.max, not a member of limits. Put a space before a minus: ref:limit-1 is the name limit-1, ref:limit - 1 subtracts. ref: reads nothing from the object itself. A whole placeholder ${ref:name} keeps the object, so a header set from it holds the instance; with text around it the template writes its ToString(). To use a member, pass the object to a method: bean:fmt.Describe(ref:chat.settings). A missing name is an error naming it, the lookup happens once per route context, and the colon needs to touch both sides, as with bean:.
env:, the process environment
env:NAME is the OS environment variable NAME, as Camel's ${env:NAME}: ${env:BILLING_TOKEN} in a template, env:REGION == 'eu' in a condition. A variable that is not set is null and renders empty. It is read on every evaluation.
For a value that may come from configuration or from the environment there is a second mechanism that is not part of the expression language: {{BILLING_TOKEN}} and {{key:default}} configuration placeholders, resolved before the route runs in endpoint URIs, enabled=, bean values and value= of <setHeader>, <setProperty> and <setBody>. They read the host's IConfiguration first (environment, appsettings.json, secrets, in the host's order), then the context properties, and the OS environment only when the context has no IConfiguration. A key with no value and no default is an error naming the key.
6. The comparison trap, and who catches it
One mistake deserves its own section because every Camel user makes it on day one:
.Filter("${header.kind} == 'order'") // always true
.Filter("header.kind == 'order'") // what was meant
The first line is a template. It renders to text such as refund == 'order', the text is not empty, and a non-empty string is true. In Camel's Simple this form is the normal way to write a predicate; in redb.Route a string with ${...} is a string, and a comparison is written whole, either bare or entirely inside the braces. The language is consistent here, but consistency does not help someone who types from muscle memory. So the package gate warns about it, at the attribute, for == and for the Camel operators =~ and range alike:
warning: <file>(line,col): expr="${header.kind} == 'order'" compares OUTSIDE ${…}: a condition holding a placeholder is rendered to text first, and non-empty text is true whatever it says. Write the comparison without the braces (header.kind == 'order') or entirely inside them.
It also warns about the related forms: a bean: call with ${...} arguments outside a placeholder, and a ref: name outside ${...} in a text that has placeholders (section 9).
7. Templates are data: the sandbox
redb.Route has a second kind of template: payload templates of the redb.Route.Templates package, {{ ... }} documents that render a message body. Their text often comes from configuration, a database or a partner, not from a developer. Data must not run code.
So a payload template renders inside a sandbox (ExpressionSandbox). Inside it objects expose their properties and fields, built-in functions work, and the built-in string helpers work. expr('x') evaluates x exactly as ${x} would in a route template, but under the template's sandbox. A method call that would run code is refused, and so are the ways out of the message: bean:, ref: and env:. A run against the current code, with a body object whose method Pwn() sets a flag:
{{ body.Name }} → ann
{{ expr('upper(body.Name)') }} → ANN
{{ expr('body.Pwn()') }} → ExpressionSandboxViolationException:
Method 'Pwn' on Probe cannot be called here: the expression runs in a sandbox
(a template argument or expr()), where objects expose their members but never code.
(the flag stayed false: the method did not run)
{{ expr('bean:rules.IsGold(body.Name)') }} → error: Method 'IsGold' on TemplateGoldRules cannot be called here: ...
{{ expr('ref:rules') }} → error: 'ref:rules' cannot be read here: the expression runs in a sandbox
(a template argument or expr()), which reads the message and its values,
not the registry or the process environment.
{{ expr('env:PATH') }} → error: 'env:PATH' cannot be read here: ... (the same sentence)
The sandbox covers the whole render: the template's arguments and every expr(...) in it. A payload template can therefore neither call your services nor read the process environment, whoever wrote it. Route expressions written by a developer in C# or in a route file are code and are not sandboxed; payload templates are data and are.
8. Your own roots: prefix resolvers
header., property., body and exception. are the built-in roots. A module can add its own, so that a route reads domain data as naturally as a header:
context.RegisterPrefixResolver("order.", (exchange, path) => orders.Read(exchange, path));
A run with a resolver that returns 150.5m for total and true for vip:
| Where | Text | Result |
|---|---|---|
| whole placeholder | ${order.total} |
150.5, a decimal |
| mixed template | total=${order.total} |
total=150.5 |
| condition | order.vip |
True |
| condition | order.total > 100 |
True |
A resolver works in every position, and a whole placeholder keeps the type it returns. The order of lookup is fixed: built-in roots first, then the resolvers of the exchange's route context, then the process-wide ones (ExpressionResolver.RegisterPrefixResolver). The run confirms it: a resolver registered for header. does not shadow the real header. A resolver registered on a route context lives and dies with that context, so a module that is unloaded leaves nothing behind in the process. Registration is done in code, typically in the module's context initialisation; there is no markup element for it.
9. The gate: errors with line and column before deployment
Compiling at Start() already means a broken expression never meets live traffic. A route file can be checked earlier still. The package gate (redb-route-xml check, and pack, which runs the same checks and builds only a clean package) compiles every expression of every route file, a bare one and each ${...}, exactly as the engine compiles it. An expression the engine would refuse is an error at its attribute, with line and column, in the engine's words. For every bean: call and ref: name it checks that a <bean> of the package declares it, and with --bin it loads the module's assemblies and checks every bean: call against the declared bean's type.
Here is real output of the check on test packages (the project path is shortened):
error: routes/main.route.xml(5,14): expr= unclosed '${' at position 7 of "total ${header.amount": the placeholder has no closing '}'.
error: routes/main.route.xml(6,25): expr= a bean: call names its method: bean:rules.Method(...) (position 1 within the expression).
warning: routes/main.route.xml: '#pricing' is not declared by any <bean> of this package — either module code registers it at startup (fine), or it is a dangling reference.
error: routes/main.route.xml(8,13): expr= bean: '#rules' has no public method 'Badge' taking 1 argument(s) (IExchange, IMessage and CancellationToken parameters are supplied by the binding and do not count).
error: routes/main.route.xml(9,13): expr= bean:rules.CheckAsync returns Task`1: a bean: call in an expression is synchronous. Call an asynchronous method as a step: <bean ref="#rules" method="CheckAsync(...)"/> or .Bean("#rules", "CheckAsync(...)").
warning: routes/main.route.xml(10,13): expr= holds a bean: call outside ${...} with ${...} arguments: the text is a template and renders instead of calling (in a condition it is always true). Write the arguments bare, bean:svc.Check(header.x), or the whole call as one placeholder, ${bean:svc.Check(${header.x})}.
warning: <file>(line,col): expr= holds a ref: name outside ${...} in a text with ${...}: the text is a template and renders ref:name as text instead of reading the object (in a condition it is always true). Write the whole expression bare, ref:limit > header.x, or as one placeholder, ${ref:limit > header.x}.
error: <file>(line,col): expr= Error compiling expression 'header.b == 3 garbage' using AST: unexpected 'garbage' at position 15 within the expression: the expression ends before it.
warning: <file>(line,col): expr= "header.b 3" reads as the text itself, not as an expression: '3' at position 10 does not continue it; in a condition the text is always true.
Every message says what is wrong and how to write it instead. Note the difference between errors and warnings. A bean that no <bean> of the package declares is only a warning, because module code may legitimately register it at startup; the gate cannot know, so it says both possibilities. A method with the wrong number of arguments, or one that returns a task, is an error, because no runtime situation makes it right. Text after a complete expression is an error, here and when the route is built. header.b 3 is only a warning: by the rule for bare text it is a valid literal, but it starts like a lookup, and the gate says what it will actually be.
The editor side, to be precise about it: the redb Route XML extension for VS Code validates the markup of a route file against its schema while you type (elements, attributes, required values). Expression attributes are plain strings to the schema, so expression errors come from the gate and from route loading, not from underlines in the editor. The next release of the extension adds completion of bean names after bean: and ${bean:, taken from the file's own <bean> declarations and from the module's context.xml.
10. Where the language is stronger, and why
Each point below rests on something shown above.
- Compiled is the only mode. There is no interpreter path to fall into and no separate compiled dialect with its own restrictions. Any valid expression, including nested functions and registry calls, becomes IL when the route is built.
- Errors come before traffic, twice. The gate reports a broken expression at the attribute with line and column before a package is built. If something slips past,
Start()throws before the first message. A broken condition never becomes a quiet "no". - A typo is an error, not a value. Half-parsed arithmetic, text left after a complete expression, an unclosed quote, a dashed name written bare, a malformed range, an increment of a non-number, and a method that throws or does not exist all stop with a message that names the place. None of them quietly produces a plausible result.
- Bean calls are checked against real types. With
--binthe gate verifies that the method exists, takes that many arguments, and is synchronous. A typo in a method name is a build error, not a production incident. - Values keep their types. A whole placeholder returns an
int, abool, adecimal, a registry object, whatever the expression or the resolver produced. Downstream steps receive data, not text to parse again. - The meaning does not depend on the machine. Literals are parsed and values rendered with the invariant culture, format suffixes included;
=~compares ordinally; the truthiness words are English only. - Guards work as in C#.
AND,ORand??short-circuit, so a check on the left protects a call on the right. - One truthiness rule. A condition, an operand of
AND, andlogical()read a value the same way, everywhere in the library. - Increments change the exchange.
property.attempt++is a counter, not a computed value that someone has to store back. - Data never runs code. Payload templates render in a sandbox where method calls, registry access and the environment are refused with a clear exception.
- Extensions are scoped. A prefix resolver registered on a route context leaves with that context.
- The documentation cannot drift. Every marked example in the guide runs on every test run. Changes to how templates are scanned were checked against a snapshot of the 956 distinct templates found in the redb.Route tree, and every changed line was reviewed before the change landed.
11. Compared with Apache Camel's Simple
The resemblance to Simple is deliberate, and someone coming from Camel will recognise almost every form. The differences, form by form:
| Camel Simple | redb.Route |
|---|---|
${header.x}, ${exchangeProperty.x}, ${body} |
${header.x}, ${property.x}, ${body} |
${header.x} == 'a' as a predicate |
header.x == 'a'; ${...} around an operand renders text first |
${bean:name.method(${body})}, ${bean:name?method=m} |
${bean:name.method(${body})} or bare bean:name.method(body); the ?method= form belongs to the bean: endpoint URI |
method(ref, "name") |
Method("#ref", "name(args)") |
${ref:x} |
${ref:x} or ref:x; the whole run is the name |
${header.x} =~ 'abc' |
header.x =~ 'abc': two strings ignore case, any other pair is == |
${header.n} range '100..199', not range |
header.n range '100..199', not range; the literal is checked at load |
${env:X} |
${env:X} or env:X; {{X}} reads configuration first |
${jsonpath:$.x}, ${xpath:/x} |
the same, or jpath('$.x') / xpath('/x') |
${file:name}, ${file:name.noext}, ${file:size} |
the same, over the redbFile.* headers |
${date:now:yyyyMMdd} |
dateformat(now(), 'yyyyMMdd') |
escaping \${ and $${ |
\${; $${ is a dollar sign followed by a placeholder |
What Simple has and redb.Route does not have today, stated plainly: the ?. operator, which has nothing to do here because member access is null-safe by default, and the ${date:...} spelling, whose work dateformat(now(), ...) does.
12. Known limits
| What | Today |
|---|---|
jpath($.x) / xpath(/x) bare and unquoted |
does not compile; quote the path or write it inside ${...} |
round(2.5) |
2: rounding half to even, as .NET does |
a backslash meant as text right before a placeholder (\\${header.x}) |
has no spelling |
| a method call on a value that throws or does not exist | an error on the message, not at build: the value's type is known only then |
The list is short because the mistakes that usually pass silently (trailing text, a bare dashed name, a non-numeric increment, a throwing method) are errors with a position here. The rows above are what remains.
What to take away
The first part argued that expression strings are worth having at all. This one shows what stands behind them. A string is parsed by a real tokenizer and parser into a LINQ expression tree and compiled once, with AND, OR and ?? short-circuiting as in C#. A half-written expression is an error with a position, not a value. A placeholder ends exactly where its braces balance, and \${ writes one literally. A bean call binds against real types and is checked before deployment, and ref: and env: reach the registry and the environment the way Camel users expect. Data templates cannot run code or read the environment. A module can add its own roots without leaving traces in the process. Every result in the guide is executed by a test.
The guide itself, with every table and rule from this article, is EXPRESSIONS.md in the repository. The language ships inside redb.Route on NuGet.
If this was useful — a ⭐ on GitHub helps others find it.
More of my writing: redbase.app/articles, and on dev.to.