Expressions in .NET routes: one language for conditions, values and templates, compiled to IL
Half of an integration route is small decisions: let the message through or not, where to send it, what to put in a header, what to name the topic. In code those are lambdas. Lots of lambdas. Each drags in e.In.Headers.TryGetValue, a cast, a null check, and six months later a twenty-step route reads like an exception handler rather than a description of a flow.
Apache Camel has the Simple language for this: a condition is written as a string, ${header.amount} > 1000, and the route looks like a route again. redb.Route brings the same idea to .NET, but it is built differently: one grammar for conditions, values and templates, compilation of the string into a delegate through expression trees, a single truthiness rule, and failure at route build time rather than on the first message. Below is what it looks like, what the language can do, and exactly where it parts ways with Simple.
Why strings at all, when there are lambdas
A lambda is precise, but it is opaque. Here is a filter as a lambda:
.Filter(e =>
e.In.Headers.TryGetValue("amount", out var a) && a is int amount && amount > 1000
&& e.In.Headers.TryGetValue("region", out var r) && r is string region && region == "eu")
And the same filter as a string:
.Filter("header.amount > 1000 AND header.region == 'eu'")
The difference is not just length. A string can live in configuration, show up on a dashboard, be read in a log, be diffed between route versions. A lambda exists only inside a compiled assembly. For integration code, which gets edited far more often than it gets written, that matters.
The price of strings in most frameworks is well known: interpretation on every message, parsing at runtime, errors that surface in production. The rest of this piece is about how that price was removed.
Three positions, one language
The first thing to know: the meaning of a string is decided by its position in the DSL, not by its content.
| Position | Example | What happens to the string |
|---|---|---|
| String | .SetHeader("note", "a>b") |
nothing, it is a literal |
| Template | .SetHeaderExpression("key", "${header.region}-${header.priority}") |
text as written, evaluation inside ${...} |
| Expression | .SetBody(Expr("upper(body)")) |
the whole string is evaluated |
| Condition | .Filter("header.amount > 1000") |
an expression whose result is read as yes or no |
A string in a string position is never parsed. Rabbit.Queue("q").Password("p@ss(1)") is a password, not a function call, and the framework does not try to guess. Guessing from content would be convenient right up until the first password with a > in it.
Inside ${...}, inside Expr() and in a condition the language is the same one. That is not a documentation promise, it is a test: a corpus of two hundred and some forms is run through all three positions and the columns must agree line by line. header.a > 10 in a filter, in ${header.a > 10} and in Expr("header.a > 10") is compiled by one parser into one tree.
What the language can do
Every example below was taken from a live run against one exchange: header.a = 42, header.b = 3, header.s = "Hello World", header.price = 2.5, header.user is an object with Name = "Ann", Age = 30, Manager, and the items property holds a list of three strings.
Reaching into the exchange
header.a → 42
header.user.Name → "Ann"
header.user.Manager.Name → "Bob"
header.list.Count → 3
header.list[1] → 1
property.cfg.enabled → true
property.items[0] → "p"
body.Age → 30
exception.Message → "boom" (inside ${} and in conditions)
header, property and body behave identically: properties, public fields, nested objects, collections, indexers, dictionaries, and member names are case-insensitive. If the message body is your own class, body.Order.Total reads without a cast.
Names containing dots or dashes follow a "literal name first" rule: ${header.Content-Type} finds the Content-Type header because such a header exists, and only if it does not is the text parsed as an expression. No quoted-indexer syntax was needed for this.
Arithmetic and increments
header.a + header.b → 45
header.a / 4 → 10.5
(header.a + header.b) * 2 → 90
header.price * 2 → 5
Increments change the value in the exchange, on headers and properties alike:
header.a++ → 42, the header is now 43
++header.a → 43
property.counter--
Handy in loops: .LoopWhile("property.attempt < 3") with ${property.attempt++} inside reads like ordinary code.
Comparisons and logic
header.a > 10 → true
header.a>10 → true (whitespace is not significant)
header.s == 'Hello World' → true
header.missing == null → true
header.a >= 42 AND header.b < 5 → true
header.a > 100 OR header.b < 5 → true
header.a > 10 XOR header.b > 10 → true
NOT header.flag → false
header.a > 10 && header.b < 5 → true (&& and || work too)
(header.a > 10) AND (header.b < 5) → true
header.s == 'a > b' → compares against the text; an operator inside quotes is not an operator
Whitespace around operators carries no meaning. header.a>10, header.a > 10, header.a\t>\t10 and a line break in the middle of a condition in an XML attribute are one expression. This is worth dwelling on, because Simple is the other way round: the Camel documentation requires spaces around the operator, and without them the condition does not parse. There is no such caveat here, and not because someone forgot to write it: a real lexer has no notion of "insignificant whitespace", it simply does not see it between tokens.
Ternary and ??
header.a > 10 ? 'big' : 'small' → "big"
header.flag ? header.a : header.b → 42
header.missing ?? 'default' → "default"
Strings
Twenty-three built-in functions plus ten methods in dot notation. Both forms work:
upper(header.s) → "HELLO WORLD"
concat(header.s, '!') → "Hello World!"
substring(header.s, 6) → "World"
replace(header.s, 'World', 'Route') → "Hello Route"
contains(header.s, 'World') → true
startswith(header.s, 'Hello') → true
length(header.s) → 11
header.s.toUpper() → "HELLO WORLD"
header.s.substring(0, 5) → "Hello"
header.s.replace('o', '0') → "Hell0 W0rld"
header.s.indexOf('W') → 6
header.s.Length → 11
A method not on the list is looked up by reflection on the CLR object. That is how header.user.Manager.Name and header.list.Count work without special support, and how your own class in the message body exposes all of its public members.
Collections, numbers, dates
count(property.items) → 3
sum(property.nums) → 10
avg(property.nums) → 2.5
min(property.nums) → 1
max(header.a, header.b) → 42
abs(header.neg) → 7
round(2.567, 2) → 2.57
dateformat(header.when, 'yyyy-MM-dd') → "2026-08-28"
dateadd(header.when, 1, 'day') → 2026-08-29 10:30
dateformat(dateadd(header.when, 1, 'day'), 'yyyy-MM-dd') → nesting works
JSON and XML straight from the body
${jpath($.order.id)} → "7"
${xpath(/order/id)} → "7"
id=${jpath($.order.id)} total=${jpath($.order.total)} → "id=7 total=150.5"
Paths are compiled once and cached like everything else. In a bare expression the path goes in quotes, jpath('$.order.id'), because $ and / are operators there.
Templates: interpolation that knows about types
A template is text with holes. Outside the holes it is a literal, inside it is the same language:
Hello, ${header.s}! → "Hello, Hello World!"
a=${header.a}, b=${header.b}, sum=${header.a + header.b} → "a=42, b=3, sum=45"
${property.cfg.limit > 3 ? 'over' : 'under'} → "over"
${count(property.items)} items → "3 items"
[${header.missing}] → "[]"
One detail that saves a lot of casts. If the whole string is a single placeholder, the result keeps its CLR type:
.SetHeaderExpression("n", "${header.a}") // the header holds int 42, not the string "42"
.SetHeaderExpression("big", "${header.a > 10}") // the header holds a bool
.SetHeaderExpression("total", "${header.amount * header.qty}") // a number
A mixed template, with text outside the holes, yields a string, as a template should.
Conditions: any expression plus one truthiness rule
Filter, When, LoopWhile and Validate need a bool. They get it from any expression through one rule that holds across the whole library:
| Value | Truthiness |
|---|---|
bool |
as is |
| number | != 0 |
| string | a boolean word true/false, 1/0, yes/no, on/off, otherwise non-empty means true |
null |
false |
| any object | true |
So bare references, templates and explicit coercion all work:
.Filter("header.flag") // a bool in the header
.Filter("property.cfg.enabled") // a value from a dictionary
.Filter("${header.vip}") // a template as a condition
.Filter("logical(header.count)") // explicit coercion by the same rule
.Filter("header.user.Age > 18 AND header.user.Active")
The rule is one for everything: the DSL boundary, the operands of AND/OR/NOT, and the logical() function all count the same way. That sounds obvious, but until recently this library had three such rules, and Filter("${header.zero}") let a message through where Filter("logical(header.zero)") dropped it. Now zero is false wherever it appears.
What actually happens to the string
This is the main difference from interpreted languages.
The string goes through a tokenizer and a parser and becomes an AST. The AST is compiled into System.Linq.Expressions, the .NET expression tree, and Expression.Compile() hands back a Func<IExchange, object?> delegate, which is real IL. Parsing happens once, when the route is built. On every message the already compiled delegate is invoked: no tokens, no strings, no reflection by function name.
Compiled delegates live in a ConcurrentDictionary isolated by context, so two routes with the same condition text share one compilation and different contexts do not step on each other.
Two properties follow from this. First, an error in a condition is a build error. .Filter("header.amount >") throws ExpressionCompilationException on Start(), before the first message enters the route. A condition never goes quiet and never turns into a constant "no" on live traffic.
Second, a condition is stored as a predicate, not as a delegate. Filter, When, Loop and Validate accept IPredicate, keep it in the definition, and on every message await MatchesAsync. A predicate that consults a database or a service does not block a thread. A Func<IExchange, bool>, if you did write a lambda, is wrapped into a predicate on the way in, not the other way round.
How it changes route code
An ordinary orders route with branching, enrichment and a dynamic destination:
From("direct://orders")
.Filter("header.amount > 1000 AND header.region == 'eu'")
.SetHeaderExpression("routeKey", "${header.region}-${header.priority}")
.SetHeaderExpression("total", "${header.amount * header.qty}")
.SetBody(Expr("upper(body)"))
.Choice()
.When("header.user.Age >= 18").To("direct://adult")
.When("${header.vip}").To("direct://vip")
.Otherwise().To("direct://default")
.EndChoice()
.LoopWhile("property.attempt < 3")
.SetPropertyExpression("attempt", "${property.attempt + 1}")
.To("http://flaky-service")
.EndLoop()
.Validate("header.amount > 0", "amount must be positive")
.ToD("kafka://orders-${header.region}");
Not a single TryGetValue, cast or null check. Every line reads as a statement about the flow. And all of it is compiled: header.amount > 1000 became a delegate before the first message, ${header.region}-${header.priority} became a concatenation of two calls, upper(body) became a method call.
When you need composition or something outside the language, predicates are built as objects:
.Filter(new HeaderExpression("amount").isGreaterThan(1000)
.and(new HeaderExpression("vip").isEqualTo(true)))
.Filter(myAsyncPredicate) // an IPredicate with a real MatchesAsync
.Filter(e => e.In.getHeader<int>("amount") > 1000) // the lambda has not gone anywhere
Eighteen ready-made predicates, from isBetween to regex and In, all combinable with and, or, not.
Where else expressions live: EIPs and endpoint addresses
The filter is the most visible consumer of the language, but not the biggest. The same engine sits behind every pattern that has to compute something from the message, and behind the addresses of consumers and producers.
EIP patterns
| Pattern | What is evaluated | Example |
|---|---|---|
| Content-Based Router | the branch | .When("header.type == 'vip'") |
| Message Filter | pass or drop | .Filter("header.amount > 1000") |
| Splitter | the collection to split | .Split(Expr("body.Items")) |
| Routing Slip | the list of addresses | .RoutingSlip("${header.steps}") |
| Dynamic Router / ToD | the address per message | .ToD("kafka://orders-${header.region}") |
| Loop | a condition or a count | .LoopWhile("property.attempt < 3"), .LoopExpression("${header.count}") |
| Validate | a condition | .Validate("header.amount > 0", "amount must be positive") |
| Message Translator | body, header, property | .SetBodyExpression("${upper(body)}"), .TransformExpression("concat(body, '!')") |
| Delayer | delay in milliseconds | .DelayExpression("${header.backoff}") |
| Throttler | limit per period, per message | .ThrottleExpression("header.tier == 'gold' ? 100 : 10", TimeSpan.FromSeconds(1)) |
| Log | the log line | .Log("Processing outbox row ${header.id}") |
Condition, value and template are compiled by one parser, so header.amount > 1000 in a filter and ${header.amount > 1000} in a log line are the same tree, not two parsers with two sets of rules.
Log deserves its own paragraph, because besides the short form there is a rich one: a scope that can carry several messages, each its own template, plus headers, properties and the route id printed structurally.
.Log(LogLevel.Information)
.Message("order ${header.orderId}: ${header.amount * header.qty} total")
.Message("${count(property.items)} items, first is ${property.items[0]}")
.Header("correlationId")
.Property("attempt")
.ShowRouteId()
.EndLog()
One entry lands in the log, shaped like [rId:orders] [p:attempt=2] [h:correlationId=…], followed by the two messages on their own lines, every ${...} in them evaluated by the same engine. .Log() without a level opens the same scope at Information.
Endpoint addresses: `$
A producer option containing ${...} is resolved per message. It is compiled once when the endpoint is built; after that a delegate is invoked. This works both in the URI string and in the fluent builder, because the builder produces the same URI.
Kafka: the partition key from a header.
.To("kafka://orders?key=${header.tripId}")
// the same, fluently
.To(Kafka.Topic("orders").Key("${header.customerId}").Build())
SQL: query parameters from the body and headers; the statement itself is static and parameterised.
From("direct://save")
.To("sql:INSERT INTO audit(message, status) VALUES(@message, @status)"
+ "?dataSource=#main"
+ "¶m.message=${body}"
+ "¶m.status=${header.mode}");
HTTP: path, host and port from the message.
.To("https://api.example.com/orders/${header.orderId}?method=PUT")
.To("https://${header.targetHost}:${header.targetPort}/api/${header.resource}?method=POST")
RabbitMQ, Firebase, files:
.To(Rabbit.Queue("orders").RoutingKey("${header.region}-key").Build())
.To("fstore://users?documentId=${header.userId}&merge=true")
.To("fbstorage://media-bucket?objectName=${header.fileName}&contentType=image/png")
.To(File.Write("out").FileName("${header.orderId}.json").Build())
Under the hood this is one place, EndpointOptions.ResolveOption, and fourteen producers use it, from Kafka and RabbitMQ to SMTP, MQTT, Redis, gRPC and Telegram. On top of that, typed DynamicValue<T> options in thirteen option sets cover the cases where the value is needed as a number or a bool rather than a string: a file name, a Firestore document, an S3 key, SQL parameters.
Two boundaries worth knowing. First: a string without ${} in an option is a literal; nobody parses password=p@ss>1. Second: a consumer that produces messages itself has no exchange at poll time, so there is nothing to apply ${header...} to. The SQL consumer's param.* values, for instance, are resolved without a message and take constants only. That is not a limitation of the engine but a property of the moment: the header arrives together with the message.
Compared with Simple from Apache Camel
Simple is a good language, and the resemblance here is deliberate: anyone coming from Camel recognises header., body, ${...}, the ternary, contains. The differences are structural, and they are worth naming plainly.
Where the operator goes. In Simple the left side of a comparison must be inside ${}, and the operator sits outside: ${header.bar} > 100. That is a rule from the documentation, not my paraphrase. In redb.Route the expression is either entirely bare, header.bar > 100, or entirely inside ${}. The form ${header.bar} > 100 is allowed too, but it means exactly what it says: a template that renders to the text "42 > 100", and a filter reads that non-empty string as true. That is not a defect; it is the consequence of a string being a string and an expression being an expression. If you want a comparison, write the comparison whole.
Whitespace. Simple requires spaces around operators. redb.Route does not. For a condition that arrived from an XML attribute with a line break in it, that is the difference between "works" and "does not parse".
Compilation. Simple is interpreted at runtime. A compiled variant, csimple, exists, but it needs either a maven plugin that generates sources at build time or jOOR for compilation at startup (which, per the documentation, does not work with fat-jar packaging in Spring Boot), it makes you write type hints like bodyAs(String), it does not support nested functions or the null-safe operator, and as of Camel 4.19 it is deprecated. In redb.Route compilation to IL is the only mode; it needs no plugins and no type hints, and nested functions work.
One language or two. In Simple a value and a condition are different constructs: ${...} for interpolation, ${...} OP value for a predicate. In redb.Route the grammar is one, and the position in the DSL decides how to read the result. Fewer rules to remember, fewer places where documentation and behaviour drift apart.
Fail-fast. A broken expression in redb.Route does not start the route. That is not a virtue of the language but a consequence of compiling at build time: to get a delegate you have to parse the whole string, and doing that at Start() is the natural moment.
What Simple has that this does not: the null-safe operator ?. and some of Simple's operators such as range and =~. The first is covered by ?? and by the fact that a missing member yields null rather than an exception; the second have not been needed in any of thirty-plus connectors.
How it is verified
An expression language is easy to talk about and hard to guarantee: a change in one place quietly shifts behaviour in another. So besides ordinary tests the language has a characterisation net. A corpus of two hundred and some forms, from header.a > 10 to dGVzdC1zZWNyZXQ== and black and white, is run through all three positions and compared against a recorded snapshot. Any change in behaviour is printed line by line: what it was, what it is now. The snapshot is updated only after every shift has been explained and written into the changelog.
It was this net that exposed the parser reading decimal literals with the machine's current culture: max(2.5, 1) compiled on en-US and failed on ru-RU. Literals are now parsed invariantly and pinned by a test under three cultures. The meaning of source text cannot depend on the server's locale.
What to take away
String conditions and templates are not a trade-off made for readability. Done right, they give code shorter than lambdas, configurable from outside, visible in logs, and compiled all the same: parsed once at build time, a delegate per message, failure before the first message rather than after.
One language across three positions, one truthiness rule, parity between header, property and body over every member of an object, predicates that are awaited. If you come from Camel the syntax is familiar, and there are no whitespace caveats or compiled-mode footnotes to remember.
The language ships inside redb.Route on NuGet; the full reference of forms with verified results lives in the repository next to the code.
If this was useful — a ⭐ on GitHub helps others find it.
More of my writing: redbase.app/articles, and on dev.to.