
"A route in .route.xml runs on the same engine as C#: completion in VS Code, package checks inside dotnet build, a graph editor and hot module swap.
An integration route outlives the code around it. A partner moves to another SFTP folder, an analyst asks for one more branch for a new file type, support wants to know which path last night's rejected request took. These are questions about the route, and answering them through a C# project, a build and a redeploy costs more than the question does.
In 4.0 redb.Route gained a second way to write a route: XML. A .route.xml file goes into a package, the package into a folder of a running service, and less than half a second later the first message travels the new route. XML here is not a separate engine and not a simplified builder "for analysts". The loader turns markup into the same DSL calls you would write in C#, and the definition tree comes out identical. For every example in the repository that is pinned by tests: the markup, the C# generated from it and the tree the loader builds all agree.
Around the format grew the things it was meant for: a schema the editor resolves by namespace, package checks that run inside dotnet build, a graph editor in VS Code, packages that contribute their own elements, and modules with no assembly at all that a worker swaps while it runs. Everything below is shown on a working example.
Where the example comes from
The serial numbers module came out of a GitHub discussion. A reader described a system of four Quartz jobs with status tables between them and asked how that maps onto redb.Route. The answer became SerialNumbersDemo: two partners (one over SFTP, one over AS2), SQL Server, objects in redb, a quota report on a schedule, and packaging into a .tpkg for Tsak. Its routes are written in C#.
Next to it lives the XML twin, SerialNumbers.Xml. Domain, services and database are shared; only the way the flow is described changed. That makes a fair comparison: one integration, two spellings, one engine.
One route, two spellings
The heart of the module is a serial number request. The file is validated against an XSD and unmarshalled into an object, the decision is taken inside a transaction, the response is queued, and only after the commit does the route log what has happened. In C#:
From(RouteUris.SerialNumberRequest)
.RouteId("serial-number-request")
.MessageHistory()
.DoTry()
.ValidateXsd(XmlSchemas.SerialNumberRequest)
.Unmarshal<SerialNumberRequestXml>("application/xml")
.DoCatch<ValidationException>()
.ProcessWithRedb(IntakeRecorder.RecordInvalidAsync)
.Log("${header.serials.partner}: ${header.serials.fileName} violates the schema", LogLevel.Warning)
.Stop()
.EndTryCatch()
.Transacted()
.ProcessWithRedb(SerialRequestService.RegisterAsync)
.Choice()
.When(e => DecisionOf(e) == MessageStatuses.Accepted)
.ProcessWithRedb(SerialRequestService.AllocateAsync)
.ProcessWithRedb(SerialRequestService.QueueResponseAsync)
.When(e => DecisionOf(e) == MessageStatuses.Rejected)
.ProcessWithRedb(SerialRequestService.RejectAsync)
.ProcessWithRedb(SerialRequestService.QueueResponseAsync)
.EndChoice()
.EndTransaction()
// ...logs after the commit
The same route in markup:
<route id="serial-number-request" description="A serial number request: validate, decide, respond">
<from uri="direct://serial-number-request"/>
<messageHistory/>
<tryCatch>
<try>
<validateXsd file="SerialNumberRequest.xsd"/>
<unmarshal format="application/xml"
target="SerialNumbers.Core.Integration.Xml.SerialNumberRequestXml, SerialNumbers.Core"/>
</try>
<catch exceptions="redb.Route.Validation.ValidationException">
<to uri="bean:#intake?method=RecordInvalid"/>
<log level="Warning">${header.serials.partner}: ${header.serials.fileName} violates the schema</log>
<stop/>
</catch>
</tryCatch>
<transaction>
<to uri="bean:#serial-requests?method=Register"/>
<choice>
<when expr="header.serials.decision == 'Accepted'">
<to uri="bean:#serial-requests?method=Allocate"/>
<to uri="bean:#serial-requests?method=QueueResponse"/>
</when>
<when expr="header.serials.decision == 'Rejected'">
<to uri="bean:#serial-requests?method=Reject"/>
<to uri="bean:#serial-requests?method=QueueResponse"/>
</when>
</choice>
</transaction>
<log level="Debug">${messageHistory()}</log>
</route>
The correspondence is line by line. DoTry and DoCatch became <tryCatch> with <try> and <catch> branches, Transacted() became <transaction>, predicate lambdas became expressions in an expr attribute. The transaction boundary is visible to the eye: everything inside <transaction> commits together, everything outside it is written after the commit.
A service call, ProcessWithRedb(SerialRequestService.RegisterAsync), became bean:#serial-requests?method=Register. The bean is a thin wrapper that takes redb from the exchange and hands the exchange to the same service:
public sealed class SerialRequestBeans
{
public Task Register(IExchange exchange, CancellationToken ct)
=> SerialRequestService.RegisterAsync(Redb(exchange), exchange, ct);
public Task Allocate(IExchange exchange, CancellationToken ct)
=> SerialRequestService.AllocateAsync(Redb(exchange), exchange, ct);
// Reject, QueueResponse: the same
internal static IRedbService Redb(IExchange exchange)
=> exchange.ServiceProvider?.GetService<IRedbService>()
?? throw new InvalidOperationException("The exchange carries no IRedbService.");
}
The redb instance is the one ProcessWithRedb would have received: one connection per exchange, inside the transaction the route opened. The demo has ten such wrappers, and that is all the code the markup needed on top of the existing module. Markup decides when a step runs, the service decides what happens.
Not a second engine, a second spelling
The property that matters most is easy to say and hard to overvalue: the markup executes nothing by itself. The loader reads the document and calls Filter(...), When(...), SetHeader(...) and the rest of the DSL exactly as C# code would. What follows comes for free:
- Everything the engine can do, the markup can do. Retries, transactions, message history, metrics, secret redaction in URIs, mocks in the test kit: all of it lives in the engine and behaves the same for both spellings.
-
A route converts back to C#.
redb-route-xml csharpprints aRouteBuilderclass. In the readable style, markup comments become code comments; in the machine style the code carries#linedirectives, so a debugger breakpoint lands on the line of the XML file. -
The diagram comes from the same source.
redb-route-xml mermaidbuilds a flowchart from the definition tree, so the picture in your documentation cannot drift from the running route. -
Migration goes one route at a time. Both spellings live in one context, address the same
direct:endpoints and beans, and there is no reason to convert everything at once.
For the examples in the repository this is pinned by tests: each one is loaded and compared against a reference description, and from each one C# is generated, compiled in a test project and checked to build the very same tree. The markup has 198 tests on three target frameworks, net8, net9 and net10.
A schema the editor picks up by itself
A markup document starts with a namespace:
<routes xmlns="urn:redb:route:1.0">
The VS Code extension hands the schema over for that namespace through an OASIS catalog, and from there Red Hat XML does the work: completion for elements and attributes, enum values offered, errors underlined. A file gets the schema from what its root says, under any name and in any folder. Somebody else's route.xml without our namespace is left alone.
The schema is not written by hand. It is generated from the same element registry the loader uses, so the editor knows exactly what the service will later accept: 77 elements including the ones packages contribute, and typed options of 48 schemes. That is every connector plus the engine's own schemes, the ones most routes start from: timer, direct, seda, vm. For sql: that means dataSource, placeholderStyle with its At, Colon and Question values, readOnly and the rest, each with its type and default. Options a connector marks as sensitive are shown as secrets.
The option catalog is built by reflection over connector assemblies. A connector gains an option, the catalog is regenerated, and the editor offers it without a line changing in the extension.
The graph editor: the text stays the truth
The second mode of the same extension opens a route as a graph: Open With, then redb Route Graph, or the button in the editor title. The graph is a projection of the text, not a second storage format. The file is never reserialized: every edit in the graph becomes a pinpoint replacement in the text, usually one line, and undoes with plain Ctrl+Z. Comments, indentation and attribute order stay as the author left them.
What it gives you:
-
Two orientations, one look. The route is drawn Mermaid-style: curved edges, diamonds for branchings, hexagons for conditions, brackets around
filterandtransaction. Top down it reads as a diagram; left to right it snakes, wrapping a long route onto a new row the way text wraps a line, with the carriage return drawn. Zoom is on buttons andCtrl+wheel, and both the layout and the zoom are remembered per file. -
A properties panel on click. Identifier and description first, enums and flags as dropdowns. An endpoint URI is broken out into the connector's own options: the ones you set on top, the rest below, secrets masked. A
{{sftp.host}}placeholder is edited as written, with the value it resolves to from configuration shown next to it. - Editing with the mouse. A step is inserted through the "+" between steps and a palette built from the same element registry. A step is dragged to a new place or into a bracket. Right click opens a menu: insert before, after or inside, reveal in text, delete. A step travels with its comments.
An endpoint: a short URI or a structure
A short address is written as in C#:
<to uri="kafka://orders?key=${header.tripId}"/>
A long one reads better as a structure. SQL text goes into the element's content without escaping, parameters become their own lines:
<to>
<sql dataSource="#main-db">
<![CDATA[ INSERT INTO auth_log(login, at) VALUES (:#login, :#at) ]]>
<param name="login" value="${header.login}"/>
<param name="at" value="${dateformat(now(), 'o')}"/>
</sql>
</to>
The loader assembles from that structure the same URI string a human would have typed, and one canonical form travels the pipeline: the endpoint cache, the statistics, mock patterns and secret redaction all see it. One rule keeps the format honest: no connector carries code written specially for XML. The structured form, option types, completion and validation are all derived from what the connector already has. A new connector gets markup support on the day it appears.
Factories, configuration and secrets
Objects the routes refer to are declared in the package's context.xml. Connection factories are the main kind: they already live in the context registry by name, and a URI points at them. So the factory is declared in markup, its settings arrive as placeholders from configuration, and not a single secret stays in the URI:
<bean name="main-db" type="redb.Route.Sql.Connection.SqlConnectionFactory, redb.Route.Sql">
<constructorArg>
<bean type="redb.Route.Sql.Connection.SqlConnectionOptions, redb.Route.Sql">
<property key="ConnectionString" value="{{db.main.connection}}"/>
</bean>
</constructorArg>
</bean>
{{key}} and {{key:default}} resolve through the configuration chain before the value is converted, so {{ldap.port:636}} honestly becomes a number. Every placeholder without a default lands in the package manifest as a required key: whoever deploys the module sees the list of settings to provide before anything starts.
Some factories hold an object rather than a string. For AS2 those are certificates: your own with its private key, and the partner's. There a property takes a nested bean, and factoryMethod builds the object with a static method instead of a constructor:
<bean name="globex" type="redb.Route.As2.As2ConnectionFactory, redb.Route.As2">
<property key="OurCertificate">
<bean type="System.Security.Cryptography.X509Certificates.X509CertificateLoader, System.Security.Cryptography.X509Certificates"
factoryMethod="LoadPkcs12FromFile">
<constructorArg value="{{As2.CertificateDirectory}}/hub.pfx"/>
<constructorArg value="{{As2.CertificatePassword}}"/>
</bean>
</property>
<property key="As2From" value="{{As2.Id}}"/>
<property key="As2To" value="GLOBEX"/>
<property key="Sign" value="true"/>
<property key="Encrypt" value="true"/>
</bean>
The AS2 partner is described in full: identifiers, the signing and encryption profile, the key material. The certificate password comes from the worker's configuration layer and never travels inside the package.
Packages bring their own elements
The element registry is open. A connector package can add its own elements to the markup, and the schema, the package check and the editor learn about them from its assembly. That is how <cache>, <rest>, <transformJson>, <payload> and the bridge to redb storage appeared: <redbGet>, <redbSave>, <redbQuery>, <redbDelete>, plus a <redb> block in the context.
Here is a route from the second demo, XmlDemo, which works with redb without a line of C#:
<route id="redbdemo-cycle" description="redb bridge: upsert one row by unique key, query it back">
<from uri="timer://redbdemo?period={{demo.period:5000}}"/>
<setBody expr='{"name":"xmldemo:heartbeat","value_unique":"xmldemo:heartbeat","properties":{"ProcessorName":"xmldemo","MessageKey":"heartbeat","Confirmed":true}}'/>
<redbSave type="redb.Route.RedbCore.Models.IdempotentEntryProps, redb.Route.Core" byUnique="true"/>
<redbQuery type="redb.Route.RedbCore.Models.IdempotentEntryProps, redb.Route.Core"
where="ProcessorName == 'xmldemo' AND MessageKey == 'heartbeat'"
orderBy="MessageKey" take="10"/>
<setBody expr="REDBDEMO total=${body.Count}"/>
<to uri="log://redbdemo"/>
</route>
<redbSave byUnique="true"> stores the object by a unique key: a row with that key is updated, no new one appears. <redbQuery> turns the where expression into a server-side redb query with ordering and a limit, rather than fetching everything and filtering in memory. An expression the database cannot run is rejected while the route loads, with a message that says why. The data schema is synchronised once, when the context starts:
<context xmlns="urn:redb:route:1.0">
<redb>
<syncScheme type="redb.Route.RedbCore.Models.IdempotentEntryProps, redb.Route.Core"/>
</redb>
</context>
The routes project
XML routes live in an ordinary .NET project with a layout convention. One command creates it, redb-route-xml new, and from there the package is built out of what sits in its places:
| Path | What is there |
|---|---|
routes/*.route.xml |
routes; load order comes from the manifest |
context.xml |
components, context beans, the one-off <onInit> pipeline |
resources/ |
XSD, XSLT and anything routes reference by file name |
config/{Name}.config.json |
module identity: context name and autostart |
config/context.sample.json |
a sample of the settings for whoever deploys it |
schema/ |
the schema for working without the extension, bound in .vscode
|
Configuration is layered. Only the module identity travels inside the package; settings come from the worker's shared configuration and its override layer, secrets from environment variables on the worker. The same .tpkg therefore goes to staging and to production without a rebuild: the environment changes, the package does not.
If a routes project references its own types (as SerialNumbers.Xml references the domain and the services), those are ordinary project and NuGet references. The build puts them in the output, and the package check sees them where the worker will.
Tests: the same test kit
An XML route is tested exactly as a C# one, with the same test kit and no test host of its own. Load the markup into a context, mock the external endpoint, send a message, assert. This is a test from the repository:
[Fact]
public async Task TheCanonicalTestKitFlow_WorksOverAnXmlRoute()
{
_context.AddXmlRoutesFromContent("""
<routes xmlns="urn:redb:route:1.0">
<route id="under-test">
<from uri="direct://tk-in"/>
<setHeader name="seen" value="true"/>
<to uri="kafka://orders"/>
</route>
</routes>
""");
_context.AdviceAllRoutes(a => a.MockEndpoints("kafka://*"));
await _context.Start();
var mock = _context.Mock("kafka://orders").ExpectMessageCount(1).ExpectHeader("seen", "true");
await _context.SendBody("direct://tk-in", "payload");
await mock.AssertIsSatisfiedAsync(TimeSpan.FromSeconds(2));
}
A route from a file loads the same way, with one line: AddXmlRoutes("routes/orders.route.xml").
WeaveById finds a step by its id attribute in the markup, so any step of an XML route can be replaced in a test without touching the file. A password from a URI never surfaces in mock names or test reports: redaction works on the same canonical URI string the running route uses.
Checks before you ship
Markup is edited by hand, so the format is built to surface a mistake as early as possible. The first line of defence is the loader: it collects all errors of a document into one list with line and column numbers instead of stopping at the first. A broken expression in a condition is caught at load, not on the first message.
The second line is the package check, redb-route-xml check and pack. It plugs straight into the build:
dotnet build -p:PackRouteOnBuild=true -p:Version=1.0.0
After the build the check runs against the fresh output and sees the real assemblies. What it verifies:
- the whole document against the schema, with the position of every error;
- registry references:
#main-dbwith no declaration in the package is named, while a SQL parameter:#loginis not a reference at all, the difference being where the hash sits; - beans against the real assemblies: the type exists,
<property>properties are writable, the method inbean:#x?method=Mis really there; - resources: the schema named by
validateXsd file=is in the package; - secrets written literally, such as
password=...in a URI; - required configuration keys, which go into the manifest.
Here is its answer to a typo in a bean method name and in an element name:
error: routes/serial-number-request.route.xml(22,10): The element 'catch' in namespace
'urn:redb:route:1.0' has invalid child element 'stopp' ...
error: routes/serial-number-request.route.xml: bean 'serial-requests':
type 'SerialNumbers.Xml.Beans.SerialRequestBeans' has no public method 'Regster'
(bean:#serial-requests?method=Regster).
And to the corrected file:
serial-numbers-xml 0.0.0: 1 artifact(s), 0 required config key(s) - ok
A mistake in the markup becomes a build error, and a package with an error is not produced. A dangling reference, a missing method and a forgotten schema file are found on a developer machine, not in the log of a production worker.
Separately the gate warns, where the decision belongs to the author: a step that will never run (anything after <stop/> in the same list), an undeclared #name that module code may register at startup, and a condition that compares outside a placeholder. The last one is worth spelling out. Written expr="${header.kind} == 'order'", the line is rendered to text first, and non-empty text is true whatever it says, so that branch wins on every message and does so silently. Written without the braces, header.kind == 'order', it compares. The gate names the line where the braces are one pair too many.
The tool also watches itself: built against a different version of redb.Route.Xml than the project's assemblies, it stops and names both versions.
A module with no assembly of its own
A package holding only .route.xml, context.xml, resources and configuration is a complete Tsak module. Drop the .tpkg into the modules folder of a running worker and it picks it up. This is the log for XmlDemo:
03:33:17.720 [INF] Registered module xmldemo v4.0.1
03:33:17.743 [INF] Created context xmldemo
03:33:17.881 [INF] XmlRouteModule xmldemo: loaded 1 artifact(s)
03:33:18.091 [INF] Exchange [redbdemo-cycle]: Body: REDBDEMO total=1
03:33:18.095 [INF] Context 'xmldemo' started successfully: all 1 endpoints operational
03:33:23.108 [INF] Exchange [redbdemo-cycle]: Body: REDBDEMO total=1
03:33:28.134 [INF] Exchange [redbdemo-cycle]: Body: REDBDEMO total=1
From registering the module to the first exchange: 0.37 seconds. The counter stays at one tick after tick, because saving by a unique key updates the same row. Delete the file from modules and the module unloads just as quietly:
03:34:27.821 [INF] Package xmldemo-4.0.1.tpkg removed from disk, unloading 1 modules
03:34:27.857 [INF] Removed context xmldemo
03:34:27.871 [INF] Module xmldemo unloaded: context stopped, ALC released
A module that does need code ships its assembly in the same package. The worker brings up the module's code first and the markup second, so objects the code put in the registry are visible to the markup by #name. That is how SerialNumbers.Xml works: its assembly holds the bean wrappers and the bootstrap that raises the demo database, while the whole flow is described in XML. How Tsak assembles services out of modules is covered in the article on microservices with Tsak.
Expressions: one language, errors at load
The expression language is the same one C# routes use, covered in a separate article on redb.Route expressions. Three things matter for XML.
Position decides meaning. In a condition (filter, when, validate) the string becomes a predicate; in a value (setBody, setHeader, toD) it becomes an evaluated object; in uri and id it stays a literal. You never have to ask whether something is an expression: the attribute it sits in answers.
Whitespace around operators does not matter, which is convenient in XML specifically. In an attribute value the greater-than sign needs no escaping while less-than does, so a comparison without spaces is also shorter:
<filter expr="header.amount>1000"/>
<filter expr="header.amount > 1000"/>
<filter expr="header.amount < 10"/>
Diagnostics read as expressions. messageHistory() returns the trail of an exchange step by step: as a table, as a single line log > choice > to(http), as a JSON array, or as numbers ('count', 'totalMs', 'slowestMs'). stats('otel:...') reads counters of the OpenTelemetry layer that endpoint statistics do not carry: throttle delays, circuitbreaker trips, messages dropped by filter. In markup that turns into conditions which used to need code:
<setHeader name="trail" expr="messageHistory('compact')"/>
<choice>
<when expr="messageHistory('slowestMs') > 500">
<log level="Warning">${routeId} slow: ${messageHistory()}</log>
</when>
</choice>
The trail is recorded when <messageHistory/> is in the route or the engine option is on. Where it was never enabled, the function answers with an empty string and a zero: a diagnostic log must not bring the route down.
What to know before you start
Topology is written out. In the C# demo the SFTP consumer and the delivery route are created in a loop over the partners in the database. The markup describes each partner as its own route, so the topology is in the file rather than computed at startup. With a dozen partners that change by deployment, that reads better. When partners are a table that changes at runtime, a C# builder in the same package builds those routes while the markup describes the shared part of the flow. Both spellings live in one context.
Code stays where code belongs. Business logic lives in services, the markup calls them through beans. When a service signature differs from (IExchange) or (IExchange, CancellationToken), a two-line wrapper sits between them, as above.
XML is added, it does not replace. Routes move to markup one at a time while the rest keep running in C#. If a route outgrows the markup, redb-route-xml csharp turns it into code, and from there it evolves as an ordinary RouteBuilder.
Try it
The tool installs from NuGet:
dotnet tool install -g redb.Route.Xml.CodeGen
It creates a routes project by the package convention, with the schema and the editor binding in place:
redb-route-xml new Orders --context orders
The project builds and packs with the check, as shown above. From there the .tpkg goes into a Tsak worker's modules, and a route turns into C# or into a diagram whenever you want:
dotnet build -p:PackRouteOnBuild=true -p:Version=1.0.0
redb-route-xml csharp routes/main.route.xml --namespace Orders.Routes
redb-route-xml mermaid routes/main.route.xml
The markup library is the redb.Route.Xml package, version 4.1.0; the VS Code extension ships as a .vsix attached to the redb-route release on GitHub. Both demos live in the demo folder of the same repository.
If this was useful — a ⭐ on GitHub helps others find it.
More of my writing: redbase.app/articles, and on dev.to.



Top comments (0)