<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: rinat kozin</title>
    <description>The latest articles on DEV Community by rinat kozin (@rinat_kozin).</description>
    <link>https://dev.to/rinat_kozin</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3929984%2F9dcd2f94-0b1b-4161-9693-3e8c3f6ce385.jpg</url>
      <title>DEV Community: rinat kozin</title>
      <link>https://dev.to/rinat_kozin</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rinat_kozin"/>
    <language>en</language>
    <item>
      <title>redb Route XML for VS Code: projects from templates, markup, a route graph and Mermaid diagrams</title>
      <dc:creator>rinat kozin</dc:creator>
      <pubDate>Thu, 05 Nov 2026 00:01:16 +0000</pubDate>
      <link>https://dev.to/rinat_kozin/redb-route-xml-for-vs-code-projects-from-templates-markup-a-route-graph-and-mermaid-diagrams-hj7</link>
      <guid>https://dev.to/rinat_kozin/redb-route-xml-for-vs-code-projects-from-templates-markup-a-route-graph-and-mermaid-diagrams-hj7</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkfdlsk3mslkuljiq4hhj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkfdlsk3mslkuljiq4hhj.png" alt="redb.Route VSCode" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The VS Code extension on the Marketplace: a project from a template, schema completion, bean hints, a route tree and graph, Mermaid, the way to a Tsak worker.&lt;/p&gt;

&lt;p&gt;An integration route in redb.Route can be written in C# or in markup: a &lt;code&gt;.route.xml&lt;/code&gt; file goes into a package, the package into the folder of a Tsak worker, and the route starts working. The format needs no editor; it is plain XML that the service loads as it is. But writing it by hand from memory is awkward: which options a connector has, what an element is called, which bean to refer to, where a message goes when no branch matches.&lt;/p&gt;

&lt;p&gt;That is what the &lt;strong&gt;redb Route XML&lt;/strong&gt; extension for VS Code is for. Since late October it has been on the Visual Studio Marketplace, current version 0.2.25. Below is the whole path with it: from an empty folder and a project from a template, through markup, completion and the graph, to a diagram for the docs and a module on a worker.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you get
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A start in a minute.&lt;/strong&gt; A working module with routes, checks and a stand is created from a page, without knowing the &lt;code&gt;dotnet new&lt;/code&gt; commands or hunting for the right template package.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Errors show while you type.&lt;/strong&gt; A wrong element, an option of the wrong connector or the wrong side of an endpoint is underlined at once, not in the worker's log after deployment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No connector options to memorize.&lt;/strong&gt; Kafka alone has more than forty options; the editor offers them with their types and allowed values, exactly what the service of the current release accepts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A route others can read too.&lt;/strong&gt; The graph shows the transaction boundary, the branches and the path of a message that matched no condition. Walking a flow through with an analyst, a tester or support is easier with it than with the text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clean changes in git.&lt;/strong&gt; The graph changes only the place it edits, so a change made with the mouse looks like an ordinary short diff in review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docs that do not drift from the code.&lt;/strong&gt; The Mermaid diagram is built from the same route that runs on the worker and is refreshed with one command.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No cost.&lt;/strong&gt; The extension is licensed under Apache 2.0, and the schema, tree and graph work offline.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What the extension does
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;New Project:&lt;/strong&gt; a page that creates a project from the redb templates and installs the template packages itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema:&lt;/strong&gt; completion of elements, attributes and the options of every connector, validation as you type.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skeletons:&lt;/strong&gt; a route, a choice and an error-handling block in one insert.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bean references:&lt;/strong&gt; a list of declared objects on &lt;code&gt;#&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route tree:&lt;/strong&gt; every route in the workspace with a jump to its line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Graph:&lt;/strong&gt; the route as a diagram you edit with the mouse that stays text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mermaid:&lt;/strong&gt; a route diagram for a README or a wiki.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;

&lt;p&gt;In the &lt;strong&gt;Extensions&lt;/strong&gt; view search for &lt;code&gt;redb Route XML&lt;/code&gt; and click &lt;strong&gt;Install&lt;/strong&gt;, or from a terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;code &lt;span class="nt"&gt;--install-extension&lt;/span&gt; redb.redb-route-xml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F710pr3kg2mtmh5fyj1c3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F710pr3kg2mtmh5fyj1c3.png" alt="redb Route XML in the extension list" width="799" height="127"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;VS Code installs &lt;a href="https://marketplace.visualstudio.com/items?itemName=redhat.vscode-xml" rel="noopener noreferrer"&gt;Red Hat XML&lt;/a&gt; along with it: that extension does the completion and validation, and ours hands it the schema. VS Code 1.85 or newer is required.&lt;/p&gt;

&lt;p&gt;For a machine without Marketplace access, and for VSCodium and Cursor, the same &lt;code&gt;redb-route-xml-0.2.25.vsix&lt;/code&gt; is attached to the &lt;a href="https://github.com/redbase-app/redb-route/releases" rel="noopener noreferrer"&gt;redb-route releases on GitHub&lt;/a&gt;: &lt;strong&gt;Extensions&lt;/strong&gt;, the &lt;code&gt;...&lt;/code&gt; menu, &lt;strong&gt;Install from VSIX&lt;/strong&gt;. Offline, install the Red Hat XML &lt;code&gt;.vsix&lt;/code&gt; first the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  A new project without the command line
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;redb: New Project&lt;/strong&gt; from the Command Palette opens a page that creates a project from the redb templates through &lt;code&gt;dotnet new&lt;/code&gt;. The top shows what is on the machine: the .NET SDKs and two template packages, &lt;code&gt;redb.Templates&lt;/code&gt; and &lt;code&gt;redb.Tsak.Templates&lt;/code&gt;. A missing package gets an &lt;strong&gt;Install&lt;/strong&gt; button:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd9f5e2m60zsh5bjtmxod.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd9f5e2m60zsh5bjtmxod.png" alt="Template packages not installed" width="800" height="225"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When nuget.org has a newer version of a package, the button turns into &lt;strong&gt;Update&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Every template is a card, in three groups.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;XML route modules&lt;/strong&gt;, twenty &lt;code&gt;redb-route-*&lt;/code&gt; templates. Each creates a Tsak module with routes in markup and the same scenario in C# next to it, in &lt;code&gt;routes/*.cs&lt;/code&gt;: handy for comparing two ways of writing one flow. Among them: files (a local folder, FTP, SFTP), a REST API, an SQL outbox and batch inserts, redb objects, Kafka, RabbitMQ, AMQP 1.0, MQTT, S3, LDAP, mail, AS2 and AS4, a Quartz schedule, an XSD check with XSLT, scatter-gather and a set of integration patterns without a broker.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh2z36te1p0596e4y8fou.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh2z36te1p0596e4y8fou.png" alt="Template cards and the form" width="800" height="587"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tsak hosts:&lt;/strong&gt; a worker that loads modules from its folder, reloads them on the fly, and offers a management API and a scheduler.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Applications&lt;/strong&gt; on redb: a console app, Blazor and Razor Pages sites, Blazor WebAssembly with a redb.Route REST API, a Blazor BFF, an integration worker, an application on redb.Identity, an LLM chat.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F39pwhnmmqwc8hvi1sd4t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F39pwhnmmqwc8hvi1sd4t.png" alt="Application templates" width="800" height="497"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On the right is the form with the chosen template's parameters: project name, target framework, database, state store, folder. A framework with no SDK on this machine is marked, and a folder that already holds files is refused.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Create&lt;/strong&gt; runs &lt;code&gt;dotnet new&lt;/code&gt;, and when the project carries a local tool manifest, &lt;code&gt;dotnet tool restore&lt;/code&gt; as well. The output goes to the page and to the &lt;strong&gt;redb Route&lt;/strong&gt; output channel, and a running step can be cancelled. At the end the page offers to open the project in this window, in a new one, or to add it to the workspace.&lt;/p&gt;

&lt;p&gt;The cards and the form are built from the templates themselves. A new template in the redb packages shows up on the page without an extension update.&lt;/p&gt;

&lt;h2&gt;
  
  
  The route project
&lt;/h2&gt;

&lt;p&gt;From here the example uses the &lt;strong&gt;redb.Route XML module: files&lt;/strong&gt; template (&lt;code&gt;redb-route-file&lt;/code&gt;). It needs no server at all: an inbox folder, an XSD check, folders for processed and failed files, a receipt per file. From a terminal it is the same command the page runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet new &lt;span class="nb"&gt;install &lt;/span&gt;redb.Templates
dotnet new redb-route-file &lt;span class="nt"&gt;-n&lt;/span&gt; Orders
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Orders/
  .config/dotnet-tools.json        the redb-route-xml local tool
  context.xml                      the context document: beans, C# routes
  routes/files.route.xml           two XML routes
  routes/CsvFilesRoute.cs          the same pattern for CSV in C#
  resources/order.xsd              the order schema
  config/orders.config.json        context name and auto start, shipped in the package
  config/context.sample.json       a deployment sample of the settings
  deploy/docker-compose.files.yml  the module on a Tsak worker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The layout is a convention of the format. Routes live in &lt;code&gt;routes/&lt;/code&gt;, the resources they refer to by name in &lt;code&gt;resources/&lt;/code&gt;. &lt;code&gt;context.xml&lt;/code&gt; loads before the routes, so the routes see its beans. The package carries only the module's identity, &lt;code&gt;orders.config.json&lt;/code&gt;, while settings come from the worker's configuration: the same package goes to staging and production without a rebuild.&lt;/p&gt;

&lt;h2&gt;
  
  
  Markup by example
&lt;/h2&gt;

&lt;p&gt;Here is the template's second route, the one that processes incoming files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;routes&lt;/span&gt; &lt;span class="na"&gt;xmlns=&lt;/span&gt;&lt;span class="s"&gt;"urn:redb:route:1.0"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;route&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"orders-files-in"&lt;/span&gt; &lt;span class="na"&gt;description=&lt;/span&gt;&lt;span class="s"&gt;"Processes the files of the inbox"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;from&lt;/span&gt; &lt;span class="na"&gt;uri=&lt;/span&gt;&lt;span class="s"&gt;"file://{{files.inbox:inbox}}?antInclude=*.xml&amp;amp;amp;delay=5000&amp;amp;amp;moveTo=done&amp;amp;amp;moveFailed=error"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;convertBody&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"System.String"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;validateXsd&lt;/span&gt; &lt;span class="na"&gt;file=&lt;/span&gt;&lt;span class="s"&gt;"resources/order.xsd"&lt;/span&gt; &lt;span class="na"&gt;throwOnFailure=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;log&lt;/span&gt; &lt;span class="na"&gt;level=&lt;/span&gt;&lt;span class="s"&gt;"Information"&lt;/span&gt; &lt;span class="na"&gt;showRouteId=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;message&amp;gt;&lt;/span&gt;processed ${header.redbFile.NameOnly}: ${body}&lt;span class="nt"&gt;&amp;lt;/message&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/log&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;setBody&lt;/span&gt; &lt;span class="na"&gt;expr=&lt;/span&gt;&lt;span class="s"&gt;"received ${header.redbFile.NameOnly}"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;to&lt;/span&gt; &lt;span class="na"&gt;uri=&lt;/span&gt;&lt;span class="s"&gt;"file://{{files.outbox:outbox}}?fileName=${header.redbFile.NameOnly}.receipt"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/route&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/routes&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five things are enough to read any route:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;&amp;lt;from&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;to&amp;gt;&lt;/code&gt;&lt;/strong&gt; are the input and the output. The URI works as in C# routes: the connector scheme, a path, options. A long endpoint can be written as a structure, for example &lt;code&gt;&amp;lt;to&amp;gt;&amp;lt;sql dataSource="#main-db"&amp;gt;&lt;/code&gt; with the query text inside.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Steps run top to bottom:&lt;/strong&gt; turn the body into a string, check it against the XSD, log it, replace the body, send a receipt. If a file fails the schema, the exception sends it to &lt;code&gt;error/&lt;/code&gt; through the &lt;code&gt;moveFailed&lt;/code&gt; option.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;{{files.inbox:inbox}}&lt;/code&gt;&lt;/strong&gt; is a configuration placeholder with a default. It resolves from the worker's configuration. A key with no default goes into the package manifest as required.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;${header...}&lt;/code&gt; and &lt;code&gt;expr&lt;/code&gt;&lt;/strong&gt; are the expression language, the same as in C# routes. In a condition the string becomes a predicate, in a value it is evaluated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Branching and error handling&lt;/strong&gt; are elements: &lt;code&gt;&amp;lt;choice&amp;gt;&lt;/code&gt; with &lt;code&gt;&amp;lt;when expr="..."&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;otherwise&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;tryCatch&amp;gt;&lt;/code&gt; with &lt;code&gt;&amp;lt;try&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;catch&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;finally&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;transaction&amp;gt;&lt;/code&gt; around steps that commit together. The template's first route, the test file generator, uses &lt;code&gt;&amp;lt;choice&amp;gt;&lt;/code&gt;: every fifth file it makes deliberately not XML.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The loader turns the markup into the same DSL calls you write in C#, so everything the engine can do the XML can do: retries, transactions, message history, metrics, mocks in tests. The format, the package check and swapping a module on the fly are covered in detail in &lt;a href="https://redbase.app/articles/route-xml-markup" rel="noopener noreferrer"&gt;XML routes in redb.Route&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Schema completion
&lt;/h2&gt;

&lt;p&gt;The document starts with the &lt;code&gt;urn:redb:route:1.0&lt;/code&gt; namespace. That is how Red Hat XML picks the schema the extension ships, and from then on you get completion of elements and attributes, enum values and validation as you type. The binding goes by namespace, not by file name: someone else's &lt;code&gt;route.xml&lt;/code&gt; without our namespace is left alone.&lt;/p&gt;

&lt;p&gt;The schema knows the typed options of every connector. Type &lt;code&gt;&amp;lt;to&amp;gt;&amp;lt;kafka&lt;/code&gt; and a space inside a route, and the editor offers &lt;code&gt;topic&lt;/code&gt;, &lt;code&gt;brokers&lt;/code&gt;, &lt;code&gt;key&lt;/code&gt;, &lt;code&gt;acks&lt;/code&gt;, &lt;code&gt;compressionType&lt;/code&gt; and a few dozen more Kafka options with their types and allowed values.&lt;/p&gt;

&lt;p&gt;The schema also knows the sides of an endpoint. An option that only a producer takes is flagged as an error inside &lt;code&gt;&amp;lt;from&amp;gt;&lt;/code&gt;: AS4's &lt;code&gt;addressQuery&lt;/code&gt;, for example, gives "The 'addressQuery' attribute is not allowed". The error shows in the editor, before the build and before the run.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;context.xml&lt;/code&gt; gets the same help: inside &lt;code&gt;&amp;lt;context&amp;gt;&lt;/code&gt;, after &lt;code&gt;&amp;lt;&lt;/code&gt; the editor offers &lt;code&gt;bean&lt;/code&gt;, &lt;code&gt;components&lt;/code&gt;, &lt;code&gt;dataFormat&lt;/code&gt;, &lt;code&gt;deadLetterChannel&lt;/code&gt;, &lt;code&gt;onInit&lt;/code&gt;, &lt;code&gt;redb&lt;/code&gt;, &lt;code&gt;routeBuilder&lt;/code&gt;, &lt;code&gt;routeConfiguration&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Nobody writes the schema by hand. The &lt;code&gt;redb-route-xml&lt;/code&gt; tool generates it from the connector assemblies at each redb.Route release, and the release checks that the schema in the extension matches its assemblies. A new connector option reaches the editor with the next extension version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skeletons
&lt;/h2&gt;

&lt;p&gt;Some constructs the schema cannot offer as a whole: a route without &lt;code&gt;from&lt;/code&gt; or a &lt;code&gt;choice&lt;/code&gt; without branches makes no sense. For those there are three skeletons, inserted wherever a new element may start:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Inserts&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;route&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a route with its consumer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;choice&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a choice with a &lt;code&gt;when&lt;/code&gt; branch and &lt;code&gt;otherwise&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;tryCatch&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a &lt;code&gt;try&lt;/code&gt; block with &lt;code&gt;catch&lt;/code&gt; and &lt;code&gt;finally&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;They are offered only in redb route documents and only where an element may stand: not inside an attribute and not in foreign XML.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bean references
&lt;/h2&gt;

&lt;p&gt;Registry objects, connection factories and handlers, are declared with &lt;code&gt;&amp;lt;bean name="..."&amp;gt;&lt;/code&gt;, and a route refers to them with &lt;code&gt;#&lt;/code&gt;. Type &lt;code&gt;#&lt;/code&gt; and the editor lists the names of the declared beans. The hint works in three places: the whole attribute value (&lt;code&gt;dataSource="#"&lt;/code&gt;), right after the URI scheme (&lt;code&gt;bean:#&lt;/code&gt;) and a URI option value (&lt;code&gt;?headerFilterStrategy=#&lt;/code&gt;). A &lt;code&gt;#&lt;/code&gt; inside other text, such as the SQL parameter &lt;code&gt;:#login&lt;/code&gt;, is not a reference and gets no hint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;routes&lt;/span&gt; &lt;span class="na"&gt;xmlns=&lt;/span&gt;&lt;span class="s"&gt;"urn:redb:route:1.0"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;bean&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"main-db"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"redb.Route.Sql.Connection.SqlConnectionFactory, redb.Route.Sql"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;constructorArg&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;bean&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"redb.Route.Sql.Connection.SqlConnectionOptions, redb.Route.Sql"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;key=&lt;/span&gt;&lt;span class="s"&gt;"ConnectionString"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"{{db.main.connection}}"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/bean&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/constructorArg&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/bean&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;route&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"audit"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;from&lt;/span&gt; &lt;span class="na"&gt;uri=&lt;/span&gt;&lt;span class="s"&gt;"direct://audit"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;to&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;sql&lt;/span&gt; &lt;span class="na"&gt;dataSource=&lt;/span&gt;&lt;span class="s"&gt;"#main-db"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;  &lt;span class="c"&gt;&amp;lt;!-- here # offers main-db --&amp;gt;&lt;/span&gt;
        &lt;span class="cp"&gt;&amp;lt;![CDATA[ INSERT INTO audit_log(at) VALUES (CURRENT_TIMESTAMP) ]]&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/sql&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/to&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/route&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/routes&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The limit in version 0.2.25: the hint sees only the beans of the current file and does not filter them by type. The redb templates declare their beans in &lt;code&gt;context.xml&lt;/code&gt;, so in a template's route file &lt;code&gt;#&lt;/code&gt; offers nothing yet. A wrong or dangling reference is caught either way by &lt;code&gt;redb-route-xml check&lt;/code&gt; and &lt;code&gt;pack&lt;/code&gt;: the bean's type, methods and properties are checked against the real assemblies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Route tree
&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;redb&lt;/strong&gt; button in the Activity Bar opens the &lt;strong&gt;Routes&lt;/strong&gt; view: every routes document in the workspace (&lt;code&gt;*.route.xml&lt;/code&gt;, &lt;code&gt;*.redb-route.xml&lt;/code&gt;), and for each route its &lt;code&gt;id&lt;/code&gt; and &lt;code&gt;from&lt;/code&gt; endpoint. A click opens the file at the route's line. The tree refreshes by itself when files are created, edited or deleted, and covers every folder of the workspace. Without routes, the view offers to create a project.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjxb6snsr7sojntohmdj9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjxb6snsr7sojntohmdj9.png" alt="Route tree and route text" width="800" height="587"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The shot shows the serial number request route from the SerialNumbers.Xml demo: an XSD check in &lt;code&gt;tryCatch&lt;/code&gt;, a decision in a transaction, two choices. It reads well as text, but where the transaction ends and where a message goes without a matching branch is easier to see in the graph.&lt;/p&gt;

&lt;h2&gt;
  
  
  Route graph
&lt;/h2&gt;

&lt;p&gt;The graph button in the editor title, &lt;strong&gt;Open With&lt;/strong&gt; and &lt;strong&gt;redb Route Graph&lt;/strong&gt;, or &lt;strong&gt;redb Route: Open Route Graph&lt;/strong&gt; open the same file as a graph. The graph is a second view of the same text: every edit in it is an edit of the XML and is undone with the usual &lt;code&gt;Ctrl+Z&lt;/code&gt;. The file is never reserialized and every edit is a point replacement: comments, indentation and attribute order in untouched text stay as the author left them, and a new attribute added from the panel goes last in the tag. A step moves or is deleted together with the comment lines directly above it (a blank line breaks the link, a comment below the step stays put) and takes the indentation of its new place. The text editor stays the default.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzkt52umuz5n58q9as811.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzkt52umuz5n58q9as811.png" alt="The serial numbers route as a graph" width="799" height="586"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;How to read the graph:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tiles of one size&lt;/strong&gt; with glyphs in the Enterprise Integration Patterns notation and a short caption: &lt;code&gt;from&lt;/code&gt;, &lt;code&gt;bean&lt;/code&gt;, &lt;code&gt;sql&lt;/code&gt;, &lt;code&gt;choice&lt;/code&gt;, &lt;code&gt;loop&lt;/code&gt;, &lt;code&gt;log&lt;/code&gt;. What a step does (the property name, the condition, the SQL) is in the tooltip and the properties panel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrappers on the route line.&lt;/strong&gt; &lt;code&gt;transaction&lt;/code&gt;, &lt;code&gt;loop&lt;/code&gt;, &lt;code&gt;split&lt;/code&gt;, &lt;code&gt;metered&lt;/code&gt; put their steps between their own tile and an &lt;code&gt;end&lt;/code&gt; tile; &lt;code&gt;choice&lt;/code&gt;, &lt;code&gt;tryCatch&lt;/code&gt; and &lt;code&gt;filter&lt;/code&gt; fan out into branches. A run of &lt;code&gt;setHeader&lt;/code&gt; and &lt;code&gt;setProperty&lt;/code&gt; steps folds into one &lt;code&gt;set ×N&lt;/code&gt; tile.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paths by meaning.&lt;/strong&gt; Conditional paths are dashed: orange where a message goes past a step (filter not passed, no branch matched, a duplicate), grey with a cross where it is dropped (&lt;code&gt;sample&lt;/code&gt;, &lt;code&gt;debounce&lt;/code&gt;), red where a step throws. In the shot the orange "no match" path goes around both branches of the &lt;code&gt;choice&lt;/code&gt; inside the transaction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Colors by role.&lt;/strong&gt; Green in, blue out, purple your code (&lt;code&gt;bean&lt;/code&gt;), orange data changes, cyan routing, grey observation, red errors.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What you can do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;See an endpoint taken apart.&lt;/strong&gt; For a step with a URI the properties panel splits it into the scheme, the path and the connector's options, with enum values picked from a list. A sensitive option, a password or a key, is masked and cannot be edited from the panel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;See the settings.&lt;/strong&gt; A &lt;code&gt;{{files.inbox:inbox}}&lt;/code&gt; value is shown together with what it resolves to: the graph reads &lt;code&gt;config/context.sample.json&lt;/code&gt; and &lt;code&gt;config/*.config.json&lt;/code&gt; next to the file or up to three folders above it, and merges them the way a Tsak worker does. An edit always keeps the placeholder itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edit with the mouse.&lt;/strong&gt; Insert a step from the palette at any "+" between steps, delete it with &lt;code&gt;Delete&lt;/code&gt;, drag it to a new place or into a container, rename it; attributes and texts are edited in the properties panel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Change the layout.&lt;/strong&gt; Left to right or top down, fold or unfold every container at once, zoom, fold a route to its title.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Among the templates, &lt;code&gt;redb-route-eip&lt;/code&gt; gives the most telling graph: its &lt;code&gt;routes/orders.route.xml&lt;/code&gt; has six integration patterns in a row.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mermaid diagrams
&lt;/h2&gt;

&lt;p&gt;The graph lives in the editor, while a README, a wiki or a ticket needs a picture that will not drift from the code. &lt;strong&gt;redb Route: Show Mermaid Diagram&lt;/strong&gt; saves the current file, runs the &lt;code&gt;redb-route-xml mermaid&lt;/code&gt; tool and opens the diagram text in a new Markdown tab. It needs the .NET tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet tool &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; redb.Route.Xml.CodeGen
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A project from a template already has it in its local manifest, and the same diagram comes from a terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet tool restore
dotnet redb-route-xml mermaid routes/files.route.xml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the output for the routes of the &lt;code&gt;redb-route-file&lt;/code&gt; template, unedited:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
  n0["orders-files-feed: timer://orders-files-feed?period={{fil..."]
  n1{"choice"}
  n0 --&amp;gt; n1
  n2{{"when: header.CamelTimerCounter % 5 == 4"}}
  n1 -.-&amp;gt; n2
  n3["setBodyStatic"]
  n2 --&amp;gt; n3
  n4(["otherwise"])
  n1 -.-&amp;gt; n4
  n5["setBodyExpression"]
  n4 --&amp;gt; n5
  n6[["file://{{files.inbox:inbox}}?fileName=order-${header.Came..."]]
  n3 --&amp;gt; n6
  n5 --&amp;gt; n6
  n7["orders-files-in: file://{{files.inbox:inbox}}?antInclude=..."]
  n8["convertBody"]
  n7 --&amp;gt; n8
  n9["validateXsdNamespace"]
  n8 --&amp;gt; n9
  n10["richLogScope"]
  n9 --&amp;gt; n10
  n11["setBodyExpression"]
  n10 --&amp;gt; n11
  n12[["file://{{files.outbox:outbox}}?fileName=${header.redbFile..."]]
  n11 --&amp;gt; n12&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;It is text, not a picture: GitHub, GitLab and most wikis render such a block themselves, and in VS Code you see it through the Markdown preview plus a Mermaid extension. The diagram is built from the same definition tree as the running route: a diamond for &lt;code&gt;choice&lt;/code&gt;, a hexagon for the condition, a stadium for &lt;code&gt;otherwise&lt;/code&gt;, a double frame for a sending endpoint. Node captions are engine step names (&lt;code&gt;setBodyExpression&lt;/code&gt;, &lt;code&gt;richLogScope&lt;/code&gt;), not markup elements, and long URIs are cut short.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check and pack
&lt;/h2&gt;

&lt;p&gt;The route is written and the graph has been checked by eye. Next comes the same check as on a build server, only on your machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet build &lt;span class="nt"&gt;-p&lt;/span&gt;:PackRouteOnBuild&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt;:Version&lt;span class="o"&gt;=&lt;/span&gt;1.0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The build checks the markup against the schema, every endpoint option against its connector, the beans against the real assemblies, resources such as &lt;code&gt;resources/order.xsd&lt;/code&gt; and the configuration keys, then writes the package &lt;code&gt;pkg/orders-1.0.0.tpkg&lt;/code&gt;. An error in the markup becomes a build error, and a package with an error is not built. Without a package, the same checks run with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet redb-route-xml check &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What else it finds, such as literal secrets in a URI or dangling bean references, and what its messages look like is shown in the &lt;a href="https://redbase.app/articles/route-xml-markup" rel="noopener noreferrer"&gt;article about the format&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  On to the worker
&lt;/h2&gt;

&lt;p&gt;The template brings a stand too: the compose file starts the Tsak worker image and mounts &lt;code&gt;pkg/&lt;/code&gt; as its module folder.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; deploy/docker-compose.files.yml up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The log shows &lt;code&gt;processed order-N.xml: ...&lt;/code&gt; lines. Every fifth test file is deliberately not XML: it fails the schema and goes to &lt;code&gt;error/&lt;/code&gt;, the rest get a receipt in &lt;code&gt;outbox&lt;/code&gt;. On a worker of your own it is enough to drop the &lt;code&gt;.tpkg&lt;/code&gt; into its &lt;code&gt;modules&lt;/code&gt; folder: the worker loads the module by itself and unloads it the same way when the file is removed. Settings go in &lt;code&gt;context.json&lt;/code&gt; next to the package or in the worker's configuration. How Tsak builds services out of such modules is covered in the article &lt;a href="https://redbase.app/articles/tsak-microservices" rel="noopener noreferrer"&gt;about microservices on Tsak&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Today&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Own language server, hover, diagnostics&lt;/td&gt;
&lt;td&gt;none: validation and messages come from Red Hat XML against the schema&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extension settings&lt;/td&gt;
&lt;td&gt;none: the schema catalog is registered with Red Hat XML through its API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;#&lt;/code&gt; hints&lt;/td&gt;
&lt;td&gt;beans of the current file, no type filter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New connector options&lt;/td&gt;
&lt;td&gt;arrive with the next extension version&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mermaid diagram&lt;/td&gt;
&lt;td&gt;Markdown text, needs the &lt;code&gt;redb-route-xml&lt;/code&gt; tool&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New Project&lt;/td&gt;
&lt;td&gt;needs a .NET SDK; template packages install with one click&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Offline&lt;/td&gt;
&lt;td&gt;schema, tree, graph and skeletons work; installing also needs the Red Hat XML &lt;code&gt;.vsix&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;After an update&lt;/td&gt;
&lt;td&gt;the &lt;strong&gt;redb&lt;/strong&gt; Activity Bar button appears after &lt;strong&gt;Developer: Reload Window&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open VSX&lt;/td&gt;
&lt;td&gt;no; for VSCodium and Cursor there is the &lt;code&gt;.vsix&lt;/code&gt; from the release&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Versions
&lt;/h2&gt;

&lt;p&gt;The extension has its own numbering, 0.2.x, which does not move with the redb packages. Every redb.Route release regenerates the schema, the element list and the bean catalog from its assemblies and checks them against what the extension ships. The extension first reached the Marketplace on October 23, 2026; version 0.2.25 went up on October 29 and matches the &lt;a href="https://github.com/redbase-app/redb-route/releases/tag/v4.6.0" rel="noopener noreferrer"&gt;redb-route v4.6.0&lt;/a&gt; release, which carries the same &lt;code&gt;.vsix&lt;/code&gt;. The extension is licensed under Apache 2.0.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;If this was useful, a ⭐ on &lt;a href="https://github.com/redbase-app" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; helps others find it.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;More of my writing: &lt;a href="https://redbase.app/articles" rel="noopener noreferrer"&gt;redbase.app/articles&lt;/a&gt;, and on &lt;a href="https://dev.to/rinat_kozin"&gt;dev.to&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>vscode</category>
      <category>xml</category>
      <category>eip</category>
    </item>
    <item>
      <title>EF Core vs redb: an object graph against 54 tables and Include</title>
      <dc:creator>rinat kozin</dc:creator>
      <pubDate>Wed, 04 Nov 2026 23:37:50 +0000</pubDate>
      <link>https://dev.to/rinat_kozin/ef-core-vs-redb-an-object-graph-against-54-tables-and-include-19j2</link>
      <guid>https://dev.to/rinat_kozin/ef-core-vs-redb-an-object-graph-against-54-tables-and-include-19j2</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmgsg5k0fp9cvqzjwbe41.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmgsg5k0fp9cvqzjwbe41.png" alt="redb.Core" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;An employee with addresses, contacts and dictionaries: 54 tables and 31 Include calls in EF Core, one class in &lt;strong&gt;redb&lt;/strong&gt; Pro. Measured on 100k objects: who wins where&lt;/p&gt;

&lt;p&gt;One employee from our examples takes 62 SQL queries and almost two seconds to read in EF Core. In redb Pro the same employee, from the same data and in the same shape, takes 131 ms and nine queries, and 36 ms and five on the repeat run. The difference is not that one engine is "fast" and the other "slow". It is how many tables have to be put back together to get one business object out of the database.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fll21pvbe2dh3abcbmd1z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fll21pvbe2dh3abcbmd1z.png" alt="redb vs EF" width="767" height="717"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Below: what exactly we compared, why a graph is expensive in normal form, how redb Pro learned to read it without folding everything, and where EF Core is simply faster. There are quite a few such places, and they are in the tables too.&lt;/p&gt;

&lt;h2&gt;
  
  
  The object under test
&lt;/h2&gt;

&lt;p&gt;Every measurement uses one model from the examples project. Shortened, it looks like this (the full class is in the repository, &lt;code&gt;redb.Examples/Models/ExampleModels.cs&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;RedbScheme&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Employee"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EmployeeProps&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;FirstName&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;Age&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Salary&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Department&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]?&lt;/span&gt; &lt;span class="n"&gt;Skills&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;[]?&lt;/span&gt; &lt;span class="n"&gt;SkillLevels&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Address&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;HomeAddress&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;        &lt;span class="c1"&gt;// Address -&amp;gt; BuildingInfo -&amp;gt; arrays&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Address&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;WorkAddress&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Contact&lt;/span&gt;&lt;span class="p"&gt;[]?&lt;/span&gt; &lt;span class="n"&gt;Contacts&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;          &lt;span class="c1"&gt;// Contact -&amp;gt; ContactDetail[]&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;RedbObject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ProjectMetricsProps&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;?&lt;/span&gt; &lt;span class="n"&gt;CurrentProject&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Dictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Address&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;?&lt;/span&gt; &lt;span class="n"&gt;OfficeLocations&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Dictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;?&lt;/span&gt; &lt;span class="n"&gt;BonusByYear&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Dictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;Year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Quarter&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;?&lt;/span&gt; &lt;span class="n"&gt;PerformanceReviews&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A typical business object: a few scalar fields, three addresses with a nested building, an array of contacts with metadata, dictionaries, references to other objects. For redb it is one class with an attribute. The database scheme is created from it at start-up, with no migrations.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same object for EF Core
&lt;/h2&gt;

&lt;p&gt;To keep the comparison fair, EF Core got the same model in normal form, with no JSON columns. We did not build it by hand but with our own tool: the &lt;code&gt;redb flat&lt;/code&gt; command of &lt;code&gt;redb.CLI&lt;/code&gt; unfolds redb classes into relational tables.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;redb flat &lt;span class="nt"&gt;-a&lt;/span&gt; redb.Examples.dll &lt;span class="nt"&gt;-tp&lt;/span&gt; postgres &lt;span class="nt"&gt;--ddl-only&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;--ddl-only&lt;/code&gt; the same command also copies the data from a redb database into the flat tables. That is useful outside benchmarks as well, whenever analysts or reports need a plain relational copy.&lt;/p&gt;

&lt;p&gt;The result is 54 tables and 59 foreign keys: a table for every nested class, every array and every dictionary, and a collection inside a collection element goes one level further down. The EF classes were generated by the standard &lt;code&gt;dotnet ef dbcontext scaffold&lt;/code&gt; and left untouched.&lt;/p&gt;

&lt;p&gt;Next comes what an EF developer writes by hand. Reading an employee as a whole takes 31 &lt;code&gt;Include&lt;/code&gt; calls (excerpt):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;IQueryable&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;EmployeeProps&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;WithGraph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt; &lt;span class="n"&gt;IQueryable&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;EmployeeProps&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EmployeeProps_Skills&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EmployeeProps_HomeAddress&lt;/span&gt;&lt;span class="p"&gt;!)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ThenInclude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EmployeeProps_HomeAddress_Building&lt;/span&gt;&lt;span class="p"&gt;!)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ThenInclude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EmployeeProps_HomeAddress_Building_Amenities&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EmployeeProps_Contacts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ThenInclude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EmployeeProps_Contacts_Metadata&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// ... 25 more chains&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AsSplitQuery&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AsSplitQuery&lt;/code&gt; is mandatory here. One query joining 30 independent collections would multiply rows: skills by contacts, contacts by offices and so on. With splitting, EF sends one query per collection, hence 31 queries for every graph read. Why &lt;code&gt;Include&lt;/code&gt; turns into a discipline of its own in large models is covered on our &lt;a href="https://redbase.app/why-redbase" rel="noopener noreferrer"&gt;Why redb&lt;/a&gt; page.&lt;/p&gt;

&lt;p&gt;One more thing: EF returns entities of 54 tables, while the application needs the business class. So there has to be a mapping that assembles &lt;code&gt;EmployeeProps&lt;/code&gt; from those entities. redb returns &lt;code&gt;RedbObject&amp;lt;EmployeeProps&amp;gt;&lt;/code&gt; directly, with no layer in between.&lt;/p&gt;

&lt;p&gt;How much code the EF side needed to return the same object:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Lines&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Entity classes from scaffold, 54 files&lt;/td&gt;
&lt;td&gt;1,124&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DbContext&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;930&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Include&lt;/code&gt; graph and mapping of the employee to the business class&lt;/td&gt;
&lt;td&gt;about 360&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The mapping time is part of the EF measurement, because we compare the path to one and the same result: a ready business object. For a business, something else matters more. Every model change in EF means a migration and edits to entities, &lt;code&gt;Include&lt;/code&gt; chains and the mapping. In redb only the class changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  How redb stores a graph
&lt;/h2&gt;

&lt;p&gt;redb has no table per class. Objects live in one table and field values in another, one row per field. A nested class, an array element or a dictionary entry is also a value row linked to its parent. A graph of any depth is stored in the same two tables.&lt;/p&gt;

&lt;p&gt;That has a simple consequence for reads. To load N objects as a whole, redb Pro reads the object rows and then all values of all N objects in one query; if values point at list items, one more query fetches them. Nested references to other objects add one such round per level of depth. The query count follows reference depth, not the number of fields or collections. Scheme and type metadata are checked alongside, and with the props cache on, objects already in the cache get a hash check instead of a values read. The props tree is built in memory. So an employee with 30 collections takes nine queries on the first run and five on the repeat in our runs, never thirty-one.&lt;/p&gt;

&lt;p&gt;Loading objects from a ready list of ids is the easy part, though. The hard part is finding that list: filtering, sorting and taking a page by fields that are stored as rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before: fold everything, then filter
&lt;/h2&gt;

&lt;p&gt;A LINQ query over fields used to rebuild the object with a "pivot". It read all value rows of the fields involved across the whole scheme, folded them with &lt;code&gt;GROUP BY&lt;/code&gt; per object, and only then applied the filter, the sort and the page.&lt;/p&gt;

&lt;p&gt;The cost of such a query depended on the size of the scheme, not on the answer. Finding one employee or none cost the same: the database folded everything first. Version 3.7.1 added a prefilter that narrows the rows before folding (it has &lt;a href="https://redbase.app/articles/pvt-prefilter" rel="noopener noreferrer"&gt;its own article&lt;/a&gt;). It helped wherever the condition could be expressed at the level of a value row, but the folding stayed.&lt;/p&gt;

&lt;h2&gt;
  
  
  After: streams
&lt;/h2&gt;

&lt;p&gt;redb Pro 4.3.0 added a new query form that we call "streams" internally. The objects of the scheme now drive the query, and each field joins the value table with its own &lt;code&gt;LEFT JOIN&lt;/code&gt;. Nothing is folded. The filter, the sort and the page sit next to the joins, and the server can stop once the page is full.&lt;/p&gt;

&lt;p&gt;Here is how it looks for a query with three conditions, sorted by salary, with a page at offset 9000. The SQL is simplified: parameters and the trash check are removed, the shape is the real one.&lt;/p&gt;

&lt;p&gt;Before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;pvt_cte&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_id_object&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;array_agg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_id_structure&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000019&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_array_index&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;))[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nv"&gt;"Position"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;array_agg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_long&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_id_structure&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000017&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_array_index&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;))[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nv"&gt;"Age"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;array_agg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_numeric&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_id_structure&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000020&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_array_index&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;))[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nv"&gt;"Salary"&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;_values&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_id_structure&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ANY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARRAY&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1000019&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;1000017&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;1000020&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_id_object&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;_id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;_objects&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;_id_scheme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000014&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_id_object&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pvt_cte&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;_objects&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pvt_cte&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_id_object&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nv"&gt;"Position"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nv"&gt;"Age"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nv"&gt;"Salary"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;70000&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="nv"&gt;"Salary"&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="k"&gt;OFFSET&lt;/span&gt; &lt;span class="mi"&gt;9000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;pvt_cte&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pvt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_id_object&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pvt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"Position"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pvt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"Age"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pvt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"Salary"&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;o_src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_id&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;_id_object&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_string&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nv"&gt;"Position"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_long&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nv"&gt;"Age"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_numeric&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nv"&gt;"Salary"&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;_objects&lt;/span&gt; &lt;span class="n"&gt;o_src&lt;/span&gt;
    &lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;_values&lt;/span&gt; &lt;span class="n"&gt;f1&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;f1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_id_object&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o_src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;f1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_id_structure&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000019&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;f1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_array_index&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
    &lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;_values&lt;/span&gt; &lt;span class="n"&gt;f2&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;f2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_id_object&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o_src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;f2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_id_structure&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000017&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;f2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_array_index&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
    &lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;_values&lt;/span&gt; &lt;span class="n"&gt;f3&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;f3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_id_object&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o_src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;f3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_id_structure&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000020&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;f3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_array_index&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;o_src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_id_scheme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000014&lt;/span&gt;
    &lt;span class="k"&gt;OFFSET&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;pvt&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;pvt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"Position"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;pvt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"Age"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;pvt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"Salary"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;70000&lt;/span&gt;
  &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;pvt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"Salary"&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pvt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_id_object&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="k"&gt;OFFSET&lt;/span&gt; &lt;span class="mi"&gt;9000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pvt_cte&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;_objects&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pvt_cte&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_id_object&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_id_scheme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000014&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;pvt_cte&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"Salary"&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pvt_cte&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_id_object&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first plan has a 26 MB hash aggregate, a hash join and about 38 thousand pages read. The second has three merges (Merge Left Join) over an index where a field's rows are already ordered by object: no hashing, no sorting, about 7,500 pages, and memory does not grow with the scheme. On a database of 200k objects this page went from 702-740 ms to 362 ms; from a cold disk, 1.8-2.3 s against 0.6-0.8 s.&lt;/p&gt;

&lt;p&gt;The wide row is the same as before, so &lt;code&gt;OR&lt;/code&gt;, &lt;code&gt;NOT&lt;/code&gt; and arithmetic between fields work unchanged. The &lt;code&gt;OFFSET 0&lt;/code&gt; keeps PostgreSQL from pushing the filter down into the streams: in a value table the columns are correlated, while the statistics treat them as independent and misjudge row counts by tens of times.&lt;/p&gt;

&lt;p&gt;Streams read fields that have one value per object: plain fields, dictionary entries and fields of classes inside them, list items. On PostgreSQL and SQL Server this works with or without a page; on SQLite a plain list still needs &lt;code&gt;Take&lt;/code&gt;. Arrays, &lt;code&gt;DISTINCT&lt;/code&gt; and a sort computed by an expression over a base field stay on the old form.&lt;/p&gt;

&lt;h3&gt;
  
  
  Indexes: column order decides which question an index answers
&lt;/h3&gt;

&lt;p&gt;The most useful finding of this work was about indexes, not query shape. In the stock schema the value indexes were built as "structure, object, value". Such an index answers "what is this object's value", but not "which objects have this value". A filter needs the second.&lt;/p&gt;

&lt;p&gt;In 4.4.0 all three databases got "structure, value, object" indexes. They give two things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A narrow condition drives the query.&lt;/strong&gt; When the filter has a selective condition (equality, &lt;code&gt;IN&lt;/code&gt;, a closed range), the query starts from the value index and the other fields join to the objects it found. Finding one order number among 689k records on a copy of a production database went from 2.4 s to 30 ms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The leading form for pages.&lt;/strong&gt; A page sorted by a field walks that field's index in page order, reads the other fields point by point and stops at the hundredth row. The first page of the three-condition query went from 229 ms to 2 ms. This is in PostgreSQL Pro and SQLite Pro.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What streams brought on three databases
&lt;/h2&gt;

&lt;p&gt;Measured on the examples database (100k employees, 8.4 million value rows as of October 12-13, 2026), warm cache, times in milliseconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PostgreSQL:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;Pivot&lt;/th&gt;
&lt;th&gt;Streams&lt;/th&gt;
&lt;th&gt;Streams with new indexes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;count with two conditions&lt;/td&gt;
&lt;td&gt;162-165&lt;/td&gt;
&lt;td&gt;153&lt;/td&gt;
&lt;td&gt;61&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;page with filter and sort&lt;/td&gt;
&lt;td&gt;365-382&lt;/td&gt;
&lt;td&gt;305&lt;/td&gt;
&lt;td&gt;183&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;page, sort only&lt;/td&gt;
&lt;td&gt;335-340&lt;/td&gt;
&lt;td&gt;146&lt;/td&gt;
&lt;td&gt;84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;count with array &lt;code&gt;Contains&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;350-359&lt;/td&gt;
&lt;td&gt;110&lt;/td&gt;
&lt;td&gt;43&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;batch of aggregates&lt;/td&gt;
&lt;td&gt;370-389&lt;/td&gt;
&lt;td&gt;146&lt;/td&gt;
&lt;td&gt;66&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Without the new indexes, streams were not slower than the pivot in any of the eleven queries measured; with them, 2-8x faster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SQL Server.&lt;/strong&gt; The gap is wider here: the pivot cost seconds and up to 1.2 GB of memory per query.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;Pivot&lt;/th&gt;
&lt;th&gt;Streams&lt;/th&gt;
&lt;th&gt;Faster by&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;page by salary, wide filter&lt;/td&gt;
&lt;td&gt;1,090&lt;/td&gt;
&lt;td&gt;66&lt;/td&gt;
&lt;td&gt;17x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;page by age and last name, narrow filter&lt;/td&gt;
&lt;td&gt;7,327&lt;/td&gt;
&lt;td&gt;157&lt;/td&gt;
&lt;td&gt;47x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;count, filter by department&lt;/td&gt;
&lt;td&gt;2,917&lt;/td&gt;
&lt;td&gt;88&lt;/td&gt;
&lt;td&gt;33x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;group by department&lt;/td&gt;
&lt;td&gt;3,217&lt;/td&gt;
&lt;td&gt;116&lt;/td&gt;
&lt;td&gt;28x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rank within department by salary, 5k rows&lt;/td&gt;
&lt;td&gt;2,009&lt;/td&gt;
&lt;td&gt;63&lt;/td&gt;
&lt;td&gt;32x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Without a single new index, on the stock schema, the gain is 4-25x.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SQLite.&lt;/strong&gt; It turned out that what matters most here is the connection, not the query shape. The stock SQLite page cache is 2 MB, the database file is 2.5 GB. Memory-mapping the file made any query reading more than a few thousand rows 2-3x faster, the old form included. With "value second" indexes, streams are 8-22x faster than the pivot; queries that read almost the whole scheme get nothing from the indexes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Production data
&lt;/h3&gt;

&lt;p&gt;Synthetic data is good for repeatability, but the real problems only show up on live data. Debugging ran on a copy of the production database of a transportation management application: 809k objects, 38.2 million value rows.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;one order number, page (PostgreSQL)&lt;/td&gt;
&lt;td&gt;2,464&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;page by weight, wide filter (PostgreSQL)&lt;/td&gt;
&lt;td&gt;3,895&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a month of routes, count (SQL Server)&lt;/td&gt;
&lt;td&gt;639&lt;/td&gt;
&lt;td&gt;63&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a month of routes, page by driver and trip (SQL Server)&lt;/td&gt;
&lt;td&gt;837&lt;/td&gt;
&lt;td&gt;165&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In the SQL Server rows a good part of the gain came not from the query form but from how the &lt;code&gt;IN&lt;/code&gt; list is passed. The list used to go as one string through &lt;code&gt;STRING_SPLIT&lt;/code&gt;; the server expected 50 rows from it, got 80 thousand and planned for the wrong estimate. Separate parameters made both the old and the new form 2-5x faster.&lt;/p&gt;

&lt;p&gt;The production copy also showed what the synthetic data did not: queries with no gain and a row estimate error that had to be worked around separately. The real application's queries on this copy gave 157 correct answers out of 157.&lt;/p&gt;

&lt;h3&gt;
  
  
  Speed-up as a correctness check
&lt;/h3&gt;

&lt;p&gt;We checked every answer of the new form not against the old form but against a count done in memory over the loaded objects. Comparing "planner on versus off" cannot see an error that both forms make. This check found 15 result errors in old and shared code: an object without a field value that vanished from &lt;code&gt;Where(x =&amp;gt; x.F == null)&lt;/code&gt;, tree aggregates that counted over the whole scheme, a page count that ignored &lt;code&gt;Take&lt;/code&gt; and &lt;code&gt;Skip&lt;/code&gt;, and others. All 15 are fixed in redb Pro on the three databases; the regression tests pass with the planner on and off.&lt;/p&gt;

&lt;h2&gt;
  
  
  redb Pro vs EF Core: methodology
&lt;/h2&gt;

&lt;p&gt;Now the comparison itself. The numbers below come from the run published on the &lt;a href="https://redbase.app/results" rel="noopener noreferrer"&gt;results page&lt;/a&gt;, where every example is listed row by row.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stand:&lt;/strong&gt; AMD Ryzen 5 5500U, 4 cores and 4 GB of memory per container; PostgreSQL 18, SQL Server Developer, SQLite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Versions:&lt;/strong&gt; run of November 2, 2026, .NET 10; redb Pro built from source after the 4.5.1 release; EF Core 10.0.13 with Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data:&lt;/strong&gt; more than 100k objects and 8.5 million properties at the time of the run. Both databases are filled by the same generator (example E203), so the content matches and only the ids differ. Some examples create objects and were run a different number of times, so at run time the databases held about 100k and 110k employees. That does not change the order of magnitude.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only redb Pro is compared.&lt;/strong&gt; The Pro packages (&lt;code&gt;redb.Core.Pro&lt;/code&gt;, &lt;code&gt;redb.Postgres.Pro&lt;/code&gt;, &lt;code&gt;redb.MSSql.Pro&lt;/code&gt;, &lt;code&gt;redb.SQLite.Pro&lt;/code&gt;) are closed-source but free of charge with no license key for the whole 3.x and 4.x lines, commercial production use included; licensing returns only with major 5.0, and versions already in use stay free. They are enabled with &lt;code&gt;AddRedbPro()&lt;/code&gt; at configuration. Free runs queries in the old form: it folds the values first and filters after. Streams and the leading form are Pro only. Speeding up Free is separate work and a separate write-up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;EF Core on PostgreSQL only.&lt;/strong&gt; The EF twins were written and measured on PostgreSQL. They were not run on SQL Server or SQLite. The normal form there is the same, the same 54 tables and the same fan-out of queries, so the picture for complex graphs would be the same; we do not quote numbers for them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plain LINQ only&lt;/strong&gt; on both sides, no raw SQL. Where EF cannot express a query (window functions, tree traversal with depth, soft delete), the table shows N/A with no time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Both sides got indexes.&lt;/strong&gt; For EF, the ones a developer would add for the filters: a trigram GIN for &lt;code&gt;Contains&lt;/code&gt;, &lt;code&gt;text_pattern_ops&lt;/code&gt; for &lt;code&gt;StartsWith&lt;/code&gt;, B-trees on filter and sort fields, composite "value, owner" indexes on collection tables. redb builds its indexes itself: the value indexes with each upgrade of its database module or schema, and Pro's streams indexes at start-up when they are missing. The exception is when the administrator keeps schema changes to themselves (&lt;code&gt;AutoApplyDatabaseUpgrades = false&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time to a business object on both sides.&lt;/strong&gt; redb's time includes building &lt;code&gt;RedbObject&amp;lt;EmployeeProps&amp;gt;&lt;/code&gt;, EF's includes mapping the entities to the same class.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Settings:&lt;/strong&gt; JIT off in PostgreSQL for both sides; EF gets a new context from the factory for each example, change tracking on; model building and the first connection happen before the timer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three runs:&lt;/strong&gt; the first; a repeat on warm data, where redb runs with the props cache on: the cache is empty at the start of the run and fills as it goes, so a second read of an object within one example may already come from the cache; and a third that reads through the filled cache. EF has no props cache: its repeat is compared with the third run. The per-example tables below come from the first run, where the cache plays no part.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A word about noise. The machine is shared and other work runs on it in parallel. Individual numbers drift from run to run, sometimes by 1.5-2x, so we do not count a difference below 1.5x as either a win or a loss. The order of magnitude is stable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;Median over the examples where both sides produced a result, PostgreSQL:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Examples&lt;/th&gt;
&lt;th&gt;redb Pro&lt;/th&gt;
&lt;th&gt;EF Core&lt;/th&gt;
&lt;th&gt;EF slower by&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;first&lt;/td&gt;
&lt;td&gt;110&lt;/td&gt;
&lt;td&gt;107 ms&lt;/td&gt;
&lt;td&gt;816 ms&lt;/td&gt;
&lt;td&gt;7.7x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;repeat&lt;/td&gt;
&lt;td&gt;102&lt;/td&gt;
&lt;td&gt;68 ms&lt;/td&gt;
&lt;td&gt;790 ms&lt;/td&gt;
&lt;td&gt;11.6x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;with props cache&lt;/td&gt;
&lt;td&gt;102&lt;/td&gt;
&lt;td&gt;61 ms&lt;/td&gt;
&lt;td&gt;790 ms&lt;/td&gt;
&lt;td&gt;13.0x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every example row by row, for all three databases and all three runs, is on &lt;a href="https://redbase.app/results" rel="noopener noreferrer"&gt;redbase.app/results&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Broken down by example (first run, 109 comparable pairs):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Examples&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;redb Pro faster by 5x or more&lt;/td&gt;
&lt;td&gt;54&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;of those, by 10x or more&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;redb Pro faster by 1.25-5x&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;about even&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EF Core faster&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The big gaps are queries that return whole employees. First run, ms:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;EF Core&lt;/th&gt;
&lt;th&gt;redb Pro&lt;/th&gt;
&lt;th&gt;Queries EF / redb&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;E002&lt;/td&gt;
&lt;td&gt;load an employee by id&lt;/td&gt;
&lt;td&gt;1,951&lt;/td&gt;
&lt;td&gt;131&lt;/td&gt;
&lt;td&gt;62 / 9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E010&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Where&lt;/code&gt; by salary, 100 objects&lt;/td&gt;
&lt;td&gt;927&lt;/td&gt;
&lt;td&gt;187&lt;/td&gt;
&lt;td&gt;31 / 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E041&lt;/td&gt;
&lt;td&gt;filter on a field three levels deep&lt;/td&gt;
&lt;td&gt;765&lt;/td&gt;
&lt;td&gt;62&lt;/td&gt;
&lt;td&gt;31 / 7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E051&lt;/td&gt;
&lt;td&gt;array &lt;code&gt;Contains&lt;/code&gt;, several values with OR&lt;/td&gt;
&lt;td&gt;1,932&lt;/td&gt;
&lt;td&gt;76&lt;/td&gt;
&lt;td&gt;31 / 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E060&lt;/td&gt;
&lt;td&gt;dictionary &lt;code&gt;ContainsKey&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;864&lt;/td&gt;
&lt;td&gt;76&lt;/td&gt;
&lt;td&gt;31 / 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E080&lt;/td&gt;
&lt;td&gt;sort by salary&lt;/td&gt;
&lt;td&gt;2,835&lt;/td&gt;
&lt;td&gt;136&lt;/td&gt;
&lt;td&gt;58 / 8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E183&lt;/td&gt;
&lt;td&gt;&lt;code&gt;FirstOrDefaultAsync&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2,919&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;32 / 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E187&lt;/td&gt;
&lt;td&gt;update an existing object&lt;/td&gt;
&lt;td&gt;843&lt;/td&gt;
&lt;td&gt;51&lt;/td&gt;
&lt;td&gt;54 / 17&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Times and query counts are from the first run; the props cache plays no part in it. redb's query count includes scheme metadata lookups. E002 loads in two steps on both sides, first an employee and then the same one by id, hence EF's twice 31 queries.&lt;/p&gt;

&lt;p&gt;The "queries" column shows why EF loses here. Every query that returns employees turns into 31 SQL queries over the graph's tables in EF, plus assembling the entities and mapping them to the business class. The filter itself is fast in EF, the indexes are in place. Everything after the filter is what costs.&lt;/p&gt;

&lt;p&gt;Writes favour redb too, though less: bulk insert of complex objects (E000, first run) 873 ms against 1,515, and filling the database with 100k employees (E203) 335 objects per second against 237.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where EF Core is faster
&lt;/h2&gt;

&lt;p&gt;No caveats here: on flat data and scalar operations the flat table wins. First run, ms:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;EF Core&lt;/th&gt;
&lt;th&gt;redb Pro&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;E164&lt;/td&gt;
&lt;td&gt;base-field operations on one object&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E089&lt;/td&gt;
&lt;td&gt;a 101-node tree, one save per node (deliberate antipattern)&lt;/td&gt;
&lt;td&gt;702&lt;/td&gt;
&lt;td&gt;1,480&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E150&lt;/td&gt;
&lt;td&gt;sum and average over the array elements of all employees&lt;/td&gt;
&lt;td&gt;104&lt;/td&gt;
&lt;td&gt;491&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E150A&lt;/td&gt;
&lt;td&gt;aggregate over the array element at a given index&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;165&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E193&lt;/td&gt;
&lt;td&gt;average with a filter&lt;/td&gt;
&lt;td&gt;77&lt;/td&gt;
&lt;td&gt;161&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E194&lt;/td&gt;
&lt;td&gt;min and max with a filter&lt;/td&gt;
&lt;td&gt;160&lt;/td&gt;
&lt;td&gt;268&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E128&lt;/td&gt;
&lt;td&gt;simple grouping&lt;/td&gt;
&lt;td&gt;294&lt;/td&gt;
&lt;td&gt;454&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E142&lt;/td&gt;
&lt;td&gt;grouping by elements of a nested array&lt;/td&gt;
&lt;td&gt;580&lt;/td&gt;
&lt;td&gt;842&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;E089 is slow on purpose, on both sides: every node is saved by a separate call, &lt;code&gt;CreateChildAsync&lt;/code&gt; in redb and &lt;code&gt;SaveChangesAsync&lt;/code&gt; in EF. It shows what a single save costs and serves as the baseline for the right way, a bulk insert (E088). There redb is faster: 77 ms against 218.&lt;/p&gt;

&lt;p&gt;Eight more examples go to EF: &lt;code&gt;AnyAsync&lt;/code&gt; with a condition, a tree count and a simple tree query, a sum over a top-level field, an average over a base field, a sum with a filter, an object without props and a mixed batch. Thirteen more are about even: &lt;code&gt;CountAsync&lt;/code&gt;, &lt;code&gt;AnyAsync&lt;/code&gt;, &lt;code&gt;AllAsync&lt;/code&gt;, a projection, &lt;code&gt;Min&lt;/code&gt; and &lt;code&gt;Max&lt;/code&gt;, a sum over a base field, grouping with a filter, aggregates with a filter, a condition on a field and an array at once, moving a node and tree selections.&lt;/p&gt;

&lt;p&gt;The mechanism is the same everywhere. A flat table keeps an employee's fields in one row and an array in one child table. Creating a tree node is one insert into one table; computing an average is one pass over a narrow column. In redb an object is an object row plus a row per field, an array is a row per element, and an aggregate first selects the objects of the scheme, then reads the values of another field and joins them by object. When the graph is not needed, that work gives nothing back.&lt;/p&gt;

&lt;p&gt;It is telling how the picture changes once a nested field enters the aggregate. An average over a base field is faster in EF (E145: 30 ms against 50). A batch of aggregates that includes &lt;code&gt;HomeAddress.Building.Floor&lt;/code&gt; (E127) needs joins to the address and building tables in EF, and the result flips: 136 ms in redb against 426 in EF.&lt;/p&gt;

&lt;h2&gt;
  
  
  The props cache: when you need it
&lt;/h2&gt;

&lt;p&gt;redb has an object cache in process memory. It holds a whole &lt;code&gt;RedbObject&lt;/code&gt; with its Props, keyed by object id, and serves it only if the row hash in the database has not changed. An object from a list, whose hash arrives with the row, is served with no extra query; &lt;code&gt;LoadAsync&lt;/code&gt; by id makes one short hash check and takes the graph from memory. When the object changes in the database, its hash changes, and the next read loads it again.&lt;/p&gt;

&lt;p&gt;That makes it clear where the cache pays off: many reads of the same objects, which also change. Reference data, cards, active orders, anything the application rereads all the time. For such a load you do not need to stand up Redis and keep it consistent with the database yourself: the hash check is built in. Each process has its own cache, not shared between nodes; the same hash check keeps a cluster correct.&lt;/p&gt;

&lt;p&gt;Where the cache does not pay off is visible in the published run as well. On PostgreSQL the third run, reading through the filled cache, is faster than the repeat by more than 1.25x in 22 examples out of 152, within noise in 128, and slower in two. Queries that pick new objects every time get nothing from the cache, and on very short queries the hash check can add milliseconds.&lt;/p&gt;

&lt;p&gt;Settings in &lt;code&gt;RedbServiceConfiguration&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EnablePropsCache&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;turn the cache on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PropsCacheMaxSize&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;entry limit; one entry is one object, and an object with nested references to other objects takes several entries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PropsCacheTtl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;60 minutes&lt;/td&gt;
&lt;td&gt;entry lifetime from the moment it was put in the cache&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Eviction is least recently used: when the entry count goes over the limit, the least recently read tenth goes at once. If eviction is frequent, the cache idles: it spends memory and hardly ever hits.&lt;/p&gt;

&lt;p&gt;How to size it. Count not the objects that change but the ones read within one entry lifetime, together with the objects they reference. For example, if about 10k objects change per day and reads reach noticeably wider, 100k entries is a sensible starting point, and &lt;code&gt;PropsCacheTtl&lt;/code&gt; should then go from one hour to a working day. From there, observation decides: &lt;code&gt;GetStats()&lt;/code&gt; on the cache reports entries, hits, misses and hit rate, and the log shows a "Props cache is full" warning when more than a fifth of the limit is evicted within 10 seconds. Frequent warnings mean one of two things: raise the limit or turn the cache off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequent changes: database maintenance
&lt;/h2&gt;

&lt;p&gt;Changing an object in redb rewrites value rows: by default by delete and insert, while the &lt;code&gt;ChangeTracking&lt;/code&gt; strategy in Pro writes only the values that changed. The value columns are indexed, so PostgreSQL can almost never update a row in place (HOT): an audit of a production database counted 790k updates of the value table in 9.5 hours, three of them HOT. The result is dead rows and index bloat. This is not a redb trait but ordinary PostgreSQL hygiene for any table with frequent changes to indexed columns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PostgreSQL.&lt;/strong&gt; The stock autovacuum threshold, 20% of the table's rows (&lt;code&gt;autovacuum_vacuum_scale_factor = 0.2&lt;/code&gt;), means millions of dead rows before the first vacuum on a 38-million-row value table. It is worth lowering that threshold for the value table specifically. Bloated indexes are found with &lt;code&gt;pgstatindex&lt;/code&gt; and rebuilt with &lt;code&gt;REINDEX INDEX CONCURRENTLY&lt;/code&gt;, without blocking writes. In one of our production projects this is a nightly job on the application side: &lt;code&gt;VACUUM (ANALYZE)&lt;/code&gt; of the value and object tables, then a rebuild of every B-tree index with more than 30% empty leaf space, smallest first, under an advisory lock so that two nodes never run the job at once. An interrupted &lt;code&gt;REINDEX CONCURRENTLY&lt;/code&gt; leaves an invalid &lt;code&gt;*_ccnew&lt;/code&gt; index, which the job logs for a manual drop. How much it matters: after this hygiene the production database shrank from 79 GB to 15 GB.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SQL Server.&lt;/strong&gt; The same idea: a regular job that runs &lt;code&gt;ALTER INDEX ... REORGANIZE&lt;/code&gt; or &lt;code&gt;REBUILD&lt;/code&gt; depending on fragmentation, plus statistics updates.&lt;/p&gt;

&lt;p&gt;redb deliberately ships no vacuum or rebuild schedule: these decisions depend on data volume and belong to the database administrator. The provider sources on GitHub have an &lt;code&gt;sql/maintenance&lt;/code&gt; folder with scripts for one-off index operations.&lt;/p&gt;

&lt;p&gt;This hygiene applies to any database with frequent changes, PostgreSQL, SQL Server or another, with redb or without it. A detailed treatment is beyond the scope of this article; the topic is covered at length in database documentation and in administrators' write-ups.&lt;/p&gt;

&lt;h2&gt;
  
  
  Development speed
&lt;/h2&gt;

&lt;p&gt;The numbers above are about query time. For a business, developer time matters at least as much.&lt;/p&gt;

&lt;p&gt;In redb the model is a C# class. At start-up the database scheme is reconciled with the class: a new field, a removed field, a type change are applied automatically, with no migration files. Removing a field from the class removes it from the scheme together with its values; for rollouts where the old and new versions share the database, this is switched off (&lt;code&gt;DefaultStrictDeleteExtra = false&lt;/code&gt;). When the meaning of the data changes and not just its shape (a field split in two, values recalculated), Pro has data migrations, &lt;code&gt;IRedbMigration&amp;lt;T&amp;gt;&lt;/code&gt;. But most model edits need none of that.&lt;/p&gt;

&lt;p&gt;This is felt most while the business is still looking for the product's shape. The model changes daily: a field added to an order, an address moved into its own class, a string turned into a dictionary. In EF each such edit means a migration, applying and rolling it back, and edits to entities, &lt;code&gt;Include&lt;/code&gt; chains and mapping. In our example that is 54 entities, a 930-line context, 31 &lt;code&gt;Include&lt;/code&gt; calls and about 360 lines of mapping, all of which has to be kept consistent. In redb you edit one class and work with it as a business object right away.&lt;/p&gt;

&lt;p&gt;Projections are there too: &lt;code&gt;Select&lt;/code&gt; takes fields at any depth, dictionary entries included, and the server reads only those (shortened from example E075):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;redb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;EmployeeProps&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Salary&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;60000m&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HomeAddress&lt;/span&gt;&lt;span class="p"&gt;!.&lt;/span&gt;&lt;span class="n"&gt;Building&lt;/span&gt;&lt;span class="p"&gt;!.&lt;/span&gt;&lt;span class="n"&gt;Floor&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OrderByDescending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Salary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FirstName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Salary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;HomeCity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HomeAddress&lt;/span&gt;&lt;span class="p"&gt;!.&lt;/span&gt;&lt;span class="n"&gt;City&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;HomeFloor&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HomeAddress&lt;/span&gt;&lt;span class="p"&gt;!.&lt;/span&gt;&lt;span class="n"&gt;Building&lt;/span&gt;&lt;span class="p"&gt;!.&lt;/span&gt;&lt;span class="n"&gt;Floor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;HqCity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OfficeLocations&lt;/span&gt;&lt;span class="p"&gt;![&lt;/span&gt;&lt;span class="s"&gt;"HQ"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;City&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToListAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To be fair: EF does not lose on projections, it is one select over a few columns, and in the run E075 is even (271 ms for EF, 292 for redb). redb's gain here is not speed but that the projection is written against the same business class, without knowing which of the 54 tables holds the field.&lt;/p&gt;

&lt;p&gt;What the team gets in the end:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The model is one class.&lt;/strong&gt; No &lt;code&gt;DbContext&lt;/code&gt;, no Fluent API, no entity per collection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A schema without migration files.&lt;/strong&gt; New and removed fields and type changes are applied at start-up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A business object straight away.&lt;/strong&gt; &lt;code&gt;LoadAsync&lt;/code&gt; and &lt;code&gt;Query&amp;lt;T&amp;gt;().ToListAsync()&lt;/code&gt; return &lt;code&gt;RedbObject&amp;lt;T&amp;gt;&lt;/code&gt; with the whole graph, with no &lt;code&gt;Include&lt;/code&gt; and no mapping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LINQ at any depth.&lt;/strong&gt; Filters, sorts and projections over nested classes, arrays and dictionaries are plain C# expressions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trees out of the box.&lt;/strong&gt; Object hierarchies and queries over them (&lt;code&gt;TreeQuery&lt;/code&gt;, &lt;code&gt;WhereHasAncestor&lt;/code&gt;, &lt;code&gt;WhereHasDescendant&lt;/code&gt;), with depth as a parameter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What plain EF Core LINQ does not have.&lt;/strong&gt; 45 of the 155 EF twins stayed N/A: tree queries of any depth (17 examples), window functions &lt;code&gt;RANK&lt;/code&gt;, &lt;code&gt;ROW_NUMBER&lt;/code&gt;, &lt;code&gt;LAG&lt;/code&gt;/&lt;code&gt;LEAD&lt;/code&gt;, &lt;code&gt;NTILE&lt;/code&gt;, running sums (11 examples), soft delete into a trash that releases unique keys, unique keys over the content of a nested class, a subtree or list elements.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No workarounds.&lt;/strong&gt; The EF twins needed &lt;code&gt;OrderBy(_id)&lt;/code&gt; in 54 examples only so that &lt;code&gt;Take&lt;/code&gt; with split queries would not lose graph children; &lt;code&gt;DISTINCT ON&lt;/code&gt; had to be emulated by grouping and a second load. redb needs neither.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Analytics in LINQ.&lt;/strong&gt; &lt;code&gt;GroupBy&lt;/code&gt; with keys from nested fields and list items, grouping by elements of arrays at any depth (&lt;code&gt;GroupByArray&lt;/code&gt;), &lt;code&gt;WithWindow&lt;/code&gt; windows with &lt;code&gt;Win.RowNumber&lt;/code&gt;, &lt;code&gt;Rank&lt;/code&gt;, &lt;code&gt;Lag&lt;/code&gt;, &lt;code&gt;Lead&lt;/code&gt;, &lt;code&gt;Ntile&lt;/code&gt;, a batch of aggregates in one pass (&lt;code&gt;AggregateAsync&lt;/code&gt;); all of it works over a tree as well.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Indexes out of the box.&lt;/strong&gt; For EF the filter indexes were picked by hand; redb ships with its own and updates them itself when its database module version changes, and Pro adds the streams indexes at start-up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQL visible before it runs.&lt;/strong&gt; &lt;code&gt;ToSqlStringAsync()&lt;/code&gt; shows the text of a query, grouping or window, and in Pro the planner's decision as comments too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability.&lt;/strong&gt; Every database command is its own OpenTelemetry span from &lt;code&gt;RedbActivitySource&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Several nodes at start-up.&lt;/strong&gt; The schema is created and upgraded under a lock, once, even when nodes start together.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One model, three databases.&lt;/strong&gt; PostgreSQL, SQL Server and SQLite with the same application code; &lt;code&gt;redb export&lt;/code&gt; and &lt;code&gt;redb import&lt;/code&gt; move a whole database between them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A relational copy on demand.&lt;/strong&gt; &lt;code&gt;redb flat&lt;/code&gt; unfolds the data into normal form for reports and analytics.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to choose
&lt;/h2&gt;

&lt;p&gt;The numbers lead to a simple rule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;redb where the object is a graph.&lt;/strong&gt; An order with line items, addresses and status history. A customer card with contacts, documents and a dictionary of attributes. A route with stops, cargo and trips. In normal form such objects scatter over dozens of tables, and every read reassembles them. redb keeps the graph in two tables, reads it in bulk and changes the schema together with the class, without migrations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;EF Core and a flat table where the data is flat.&lt;/strong&gt; Logs, events, sensor readings, time series: many rows with a few scalar fields, bulk writes and aggregates over columns. You can store that in redb, but it is an antipattern: every log row becomes an object row plus a row per field, writes and storage multiply, and there is no graph to gain from.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Together.&lt;/strong&gt; redb needs no separate database and does not compete with EF for the model. Graph objects live in redb, logs and trends in ordinary tables next to them, in the same database. And if a graph has to go to reports or analytics in relational shape, &lt;code&gt;redb flat&lt;/code&gt; unfolds it into normal form, data included.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The comparison on 100k employees showed two things, and both are honest. On complex graphs redb Pro is an order of magnitude faster than EF Core: the median by 7.7-13x, individual queries by tens and hundreds of times. On flat data and scalar aggregates EF Core is faster, sometimes by several times.&lt;/p&gt;

&lt;p&gt;redb Pro's speed on graphs rests on three things: the graph is stored in two tables and read in bulk, field queries run as streams instead of folding everything, and "structure, value, object" indexes let a narrow condition or a page start in the right place. All the numbers of the run are published on &lt;a href="https://redbase.app/results" rel="noopener noreferrer"&gt;redbase.app/results&lt;/a&gt;. The examples, the EF twins and the flat schema are in the open repository, and the run can be repeated on your own hardware. redb Pro costs nothing for that: on the 3.x and 4.x lines the packages are free and need no key, commercial production use included.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;If this was useful, a ⭐ on &lt;a href="https://github.com/redbase-app" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; helps others find it.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;More of my writing: &lt;a href="https://redbase.app/articles" rel="noopener noreferrer"&gt;redbase.app/articles&lt;/a&gt;, and on &lt;a href="https://dev.to/rinat_kozin"&gt;dev.to&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>efcore</category>
      <category>postgres</category>
      <category>performance</category>
    </item>
    <item>
      <title>AS4 in .NET without a Java MSH: ebMS 3.0, WS-Security and non-repudiation receipts as a route step</title>
      <dc:creator>rinat kozin</dc:creator>
      <pubDate>Thu, 01 Oct 2026 14:42:55 +0000</pubDate>
      <link>https://dev.to/rinat_kozin/as4-in-net-without-a-java-msh-ebms-30-ws-security-and-non-repudiation-receipts-as-a-route-step-15lj</link>
      <guid>https://dev.to/rinat_kozin/as4-in-net-without-a-java-msh-ebms-30-ws-security-and-non-repudiation-receipts-as-a-route-step-15lj</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvl49gw9u9fkw627wewwa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvl49gw9u9fkw627wewwa.png" alt="redb.Route.AS4" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;AS4 brings signed, encrypted document exchange to EU government and enterprise networks, now as a native &lt;strong&gt;redb.Route&lt;/strong&gt; connector with no Java MSH or gateway.&lt;/p&gt;

&lt;p&gt;If you want a homely analogy, AS4 is a registered letter with a delivery receipt, except the document itself travels over the network instead of on paper. You send it to a partner; the partner acknowledges receipt. The difference from post is that a paper receipt proves only that the envelope arrived, while an AS4 receipt is cryptographically bound to the content: it proves that this exact document arrived, byte for byte.&lt;/p&gt;

&lt;p&gt;If you exchange documents with European B2B, you almost certainly already run AS4. It is the OASIS ebMS 3.0 profile that stands behind EU eDelivery: legal documents in e-CODEX and e-Justice, electronic invoices on the Peppol network, gas operators' requests in ENTSOG, electronic transport documents in eFTI. AS4 is the direct successor to AS2, except that where AS2 carried an S/MIME envelope, this wire carries SOAP 1.2 with an &lt;code&gt;eb:Messaging&lt;/code&gt; header, WS-Security and a signed receipt. Where AS2 returned an MDN with a MIC, AS4 returns an &lt;code&gt;eb:Receipt&lt;/code&gt; with non-repudiation: proof that your partner received exactly the document you signed.&lt;/p&gt;

&lt;p&gt;In .NET this world has been badly served. The mature AS4 implementations live in Java: Holodeck B2B, Apache Domibus (the reference EU eDelivery access point), phase4. So next to your .NET backend you run either a separate JVM process or a commercial gateway with its own license, its own inbox directory and its own ops team. The base .NET stack does not cover AS4: no SOAP envelope with ebMS headers, no WS-Security over MIME attachments, no receipt you can actually point to.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;redb.Route.As4&lt;/code&gt; puts AS4 inside the route. Sending is a &lt;code&gt;To&lt;/code&gt; step; receiving is a &lt;code&gt;From&lt;/code&gt; step. The document is assembled, compressed, signed and encrypted, sent to the partner, and a verified receipt comes back. Or the other way around: the envelope arrives, is decrypted, the signature verifies, the document lands in the route and a receipt goes back to the partner. One process, one deployment, one trace tree. Below: what it looks like in code, and where the real difficulty actually lies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why AS4, and not "just HTTPS"
&lt;/h2&gt;

&lt;p&gt;Fair question: if the channel is already under TLS, why also sign and encrypt the document on top? The answer is the same as in the AS2 world, and it is not paranoia. TLS protects the channel: it lives from your socket to the partner's socket and ends the moment the bytes hit disk. In a load balancer's log, in a proxy's archive, on the recipient's filesystem, the document is already in the clear. AS4 protects the document itself: it stays signed and encrypted the whole way and at rest, and only the holder of the private key can decrypt it.&lt;/p&gt;

&lt;p&gt;The bigger point is the receipt. TLS has none. AS4 does: the receiver answers with a signed receipt whose non-repudiation references point at exactly what you signed. That is non-repudiation. When a document carries money, deadlines or a legal obligation, you do not just need to know it was delivered, you need proof that this exact document was delivered. That is why AS4 is mandatory wherever the document is a legal commitment and not a letter.&lt;/p&gt;

&lt;h2&gt;
  
  
  AS4 in one minute
&lt;/h2&gt;

&lt;p&gt;On the wire it is an HTTP POST with &lt;code&gt;multipart/related&lt;/code&gt; and SOAP 1.2. The envelope has two parts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The header.&lt;/strong&gt; &lt;code&gt;eb:Messaging&lt;/code&gt; carries the business metadata: &lt;code&gt;eb:UserMessage&lt;/code&gt; with the message id, the parties (&lt;code&gt;eb:PartyId&lt;/code&gt;), the service and action (&lt;code&gt;eb:Service&lt;/code&gt;, &lt;code&gt;eb:Action&lt;/code&gt;), and the four mandatory four-corner properties (&lt;code&gt;originalSender&lt;/code&gt;, &lt;code&gt;finalRecipient&lt;/code&gt;). Next to it sits the &lt;code&gt;wsse:Security&lt;/code&gt; block: &lt;code&gt;wsu:Timestamp&lt;/code&gt;, the sender's certificate (&lt;code&gt;wsse:BinarySecurityToken&lt;/code&gt;), the encrypted session key (&lt;code&gt;xenc:EncryptedKey&lt;/code&gt;), the signature (&lt;code&gt;ds:Signature&lt;/code&gt;) and one &lt;code&gt;xenc:EncryptedData&lt;/code&gt; per encrypted attachment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The attachments.&lt;/strong&gt; The business document is not in the SOAP body; it travels as separate MIME parts (SOAP with Attachments, SwA). A part is gzipped, then encrypted, then the whole message is signed. The SOAP body is usually empty. This is the key difference from our SOAP connector, which speaks MTOM: in AS4 the payload always rides as an attachment.&lt;/p&gt;

&lt;p&gt;The receiver decrypts with its private key, verifies the signature against the sender's certificate, decompresses, and returns a signed receipt &lt;strong&gt;in the same HTTP response&lt;/strong&gt;. A synchronous receipt is mandatory in the eDelivery AS4 profile: the sender waits for it immediately, not in a separate request.&lt;/p&gt;

&lt;h2&gt;
  
  
  A readable route: the endpoint is a string
&lt;/h2&gt;

&lt;p&gt;redb.Route is &lt;a href="https://redbase.app/articles" rel="noopener noreferrer"&gt;Apache Camel for .NET&lt;/a&gt;: a route reads &lt;code&gt;From → … → To&lt;/code&gt;, and an endpoint is a URI string. The AS4 connector adds two schemes, &lt;code&gt;as4&lt;/code&gt; (HTTP) and &lt;code&gt;as4s&lt;/code&gt; (HTTPS), and they read like a sentence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;as4s://ap.partner.example/as4?connectionFactory=node&amp;amp;partner=acme   # where we send to
as4:/as4/in?host=0.0.0.0&amp;amp;port=4090&amp;amp;connectionFactory=node           # where we listen
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The string states the intent up front: where we go, which port we listen on, which node and partner. There is a fluent builder too, and it compiles to the same URI, so an endpoint address can live in &lt;code&gt;appsettings.json&lt;/code&gt; and move between environments without touching code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRedbRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRedbRouteAs4&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddRouteBuilder&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MyRoutes&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AddRedbRouteAs4()&lt;/code&gt; registers the schemes. The receive side runs on the shared Kestrel host that every HTTP-based connector in the process uses: an HTTP, SOAP, AS2 and AS4 route in one worker never fight over a port, each one listens on its own path. Certificates, passwords and algorithms do not live in the URI, because the URI is the route key and ends up in logs, traces and the dashboard. Everything secret goes into a separate object.&lt;/p&gt;

&lt;p&gt;The same integration can be written as Route-XML instead of code: endpoints in &lt;code&gt;.route.xml&lt;/code&gt; files, the node and partners in &lt;code&gt;&amp;lt;bean&amp;gt;&lt;/code&gt;. It is pleasant to write in VS Code with the &lt;code&gt;redb-route-xml&lt;/code&gt; extension: completion and validation against the schema, a route tree, a step graph. The extension is not on the Marketplace; it ships as a file with every release. Download &lt;code&gt;redb-route-xml-&amp;lt;version&amp;gt;.vsix&lt;/code&gt; from the releases page and install it through "Extensions → … → Install from VSIX" or with &lt;code&gt;code --install-extension redb-route-xml-&amp;lt;version&amp;gt;.vsix&lt;/code&gt; (VS Code usually pulls in the base Red Hat XML extension on its own). Below, every C# example is paired with its XML equivalent, and full markup examples live in the repository: &lt;code&gt;demos/XmlDemo&lt;/code&gt; and &lt;code&gt;demos/SerialNumbersDemo/SerialNumbers.Xml&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The node and its partners: one object, not a scatter of parameters
&lt;/h2&gt;

&lt;p&gt;In AS4 everything hangs off the agreement (the P-Mode): who, what, to whom, with which algorithms, whether a receipt is required. With us the agreement is a typed object in the registry. Our node (&lt;code&gt;As4ConnectionFactory&lt;/code&gt;) knows our identity, our keys and the list of partners; each partner (&lt;code&gt;As4Partner&lt;/code&gt;) is one agreement.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddToRegistry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"node"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;As4ConnectionFactory&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;OurPartyId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"urn:oasis:names:tc:ebcore:partyid-type:unregistered:us"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ExternalHostName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ap.us.example"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// host in our eb:MessageId, not the machine name&lt;/span&gt;
    &lt;span class="n"&gt;SigningCertificate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ourPfx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;               &lt;span class="c1"&gt;// our key: signs outgoing messages and receipts&lt;/span&gt;
    &lt;span class="n"&gt;DecryptionCertificates&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;ourPfx&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;       &lt;span class="c1"&gt;// our keys: decrypt what partners encrypt for us&lt;/span&gt;
    &lt;span class="n"&gt;Partners&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;As4Partner&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"acme"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;PartyId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"urn:oasis:names:tc:ebcore:partyid-type:unregistered:acme"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Service&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"urn:example:services:invoice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Action&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Submit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;PartnerSigningCertificates&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;acmeCer&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;   &lt;span class="c1"&gt;// verifies the partner's signature&lt;/span&gt;
            &lt;span class="n"&gt;PartnerEncryptionCertificate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;acmeCer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// encrypts payloads for the partner&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A route refers to the node by name (&lt;code&gt;connectionFactory=node&lt;/code&gt;) and, when sending, to the partner (&lt;code&gt;partner=acme&lt;/code&gt;). The receive endpoint does not name a partner: one receive URL accepts every partner of the node, and the connector tells the sender apart from the envelope itself. A certificate in the list is not "validity in general": a signature is accepted only from a partner certificate and only if it covers what the profile requires. Key rotation is a list of several certificates, not a stop in the exchange.&lt;/p&gt;

&lt;p&gt;The same node and partner in markup (&lt;code&gt;context.xml&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;context&lt;/span&gt; &lt;span class="na"&gt;xmlns=&lt;/span&gt;&lt;span class="s"&gt;"urn:redb:route:1.0"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;bean&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"acme"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"redb.Route.As4.As4Partner, redb.Route.As4"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;key=&lt;/span&gt;&lt;span class="s"&gt;"Name"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"acme"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;key=&lt;/span&gt;&lt;span class="s"&gt;"PartyId"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"urn:oasis:names:tc:ebcore:partyid-type:unregistered:acme"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;key=&lt;/span&gt;&lt;span class="s"&gt;"Service"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"urn:example:services:invoice"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;key=&lt;/span&gt;&lt;span class="s"&gt;"Action"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"Submit"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;key=&lt;/span&gt;&lt;span class="s"&gt;"PartnerSigningCertificates"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;list&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;bean&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"System.Security.Cryptography.X509Certificates.X509CertificateLoader, System.Security.Cryptography"&lt;/span&gt;
              &lt;span class="na"&gt;factoryMethod=&lt;/span&gt;&lt;span class="s"&gt;"LoadCertificateFromFile"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;constructorArg&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"{{as4.certificates}}/acme.cer"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/bean&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/list&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/property&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;key=&lt;/span&gt;&lt;span class="s"&gt;"PartnerEncryptionCertificate"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;bean&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"System.Security.Cryptography.X509Certificates.X509CertificateLoader, System.Security.Cryptography"&lt;/span&gt;
            &lt;span class="na"&gt;factoryMethod=&lt;/span&gt;&lt;span class="s"&gt;"LoadCertificateFromFile"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;constructorArg&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"{{as4.certificates}}/acme.cer"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/bean&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/property&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/bean&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;bean&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"node-key"&lt;/span&gt;
        &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"System.Security.Cryptography.X509Certificates.X509CertificateLoader, System.Security.Cryptography"&lt;/span&gt;
        &lt;span class="na"&gt;factoryMethod=&lt;/span&gt;&lt;span class="s"&gt;"LoadPkcs12FromFile"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;constructorArg&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"{{as4.certificates}}/us.pfx"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;constructorArg&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"{{as4.password}}"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/bean&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;bean&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"node"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"redb.Route.As4.As4ConnectionFactory, redb.Route.As4"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;key=&lt;/span&gt;&lt;span class="s"&gt;"OurPartyId"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"urn:oasis:names:tc:ebcore:partyid-type:unregistered:us"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;key=&lt;/span&gt;&lt;span class="s"&gt;"ExternalHostName"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"ap.us.example"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;key=&lt;/span&gt;&lt;span class="s"&gt;"SigningCertificate"&lt;/span&gt; &lt;span class="na"&gt;ref=&lt;/span&gt;&lt;span class="s"&gt;"node-key"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;key=&lt;/span&gt;&lt;span class="s"&gt;"DecryptionCertificates"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;list&amp;gt;&amp;lt;ref&lt;/span&gt; &lt;span class="na"&gt;bean=&lt;/span&gt;&lt;span class="s"&gt;"node-key"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&amp;lt;/list&amp;gt;&amp;lt;/property&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;key=&lt;/span&gt;&lt;span class="s"&gt;"Partners"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;list&amp;gt;&amp;lt;ref&lt;/span&gt; &lt;span class="na"&gt;bean=&lt;/span&gt;&lt;span class="s"&gt;"acme"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&amp;lt;/list&amp;gt;&amp;lt;/property&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/bean&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;bean&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"as4-in"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"redb.Route.Processors.InMemoryIdempotentRepository, redb.Route"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;/context&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Sending: compress, encrypt, sign, wait for the receipt
&lt;/h2&gt;

&lt;p&gt;Sending is a &lt;code&gt;To&lt;/code&gt; step. The body becomes a single attachment: a &lt;code&gt;byte[]&lt;/code&gt; as is, a &lt;code&gt;string&lt;/code&gt; in UTF-8, a &lt;code&gt;Stream&lt;/code&gt; copied through.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;redb.Route.As4.Fluent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"direct://outbound"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;As4Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OriginalSender&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"urn:example:c1"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// four corners: the original sender&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;As4Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FinalRecipient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"urn:example:c4"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// and the final recipient&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;As4&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://ap.acme.example/as4"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
             &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConnectionFactory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"node"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
             &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Partner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"acme"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Behind that one &lt;code&gt;To&lt;/code&gt; line: gzip, encryption with the partner's key, a signature with your key, the POST and the parsing of the response. The producer waits for the receipt and checks it: signed by a partner certificate and referencing exactly the digests we signed. The result lands on &lt;code&gt;exchange.Out&lt;/code&gt;, so the route can decide:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Header on &lt;code&gt;exchange.Out&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redbAs4.receiptValid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;bool&lt;/code&gt;: the receipt arrived and verified, non-repudiation included&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redbAs4.receiptMessageId&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the &lt;code&gt;eb:MessageId&lt;/code&gt; of the receipt itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redbAs4.messageId&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the &lt;code&gt;eb:MessageId&lt;/code&gt; of the message we sent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Exceptions instead of hand-parsing the response:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;th&gt;Exception&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The partner answered with an ebMS error&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;As4ErrorSignalException&lt;/code&gt; (&lt;code&gt;ErrorCode&lt;/code&gt;, &lt;code&gt;Description&lt;/code&gt;, &lt;code&gt;ErrorDetail&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No receipt in the response, or no response within &lt;code&gt;timeout&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;As4ReceiptException&lt;/code&gt; (&lt;code&gt;EBMS:0301&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A receipt with someone else's signature or different digests&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;As4ReceiptException&lt;/code&gt; (&lt;code&gt;EBMS:0302&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The same sending route in markup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- routes/outbox.route.xml --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;routes&lt;/span&gt; &lt;span class="na"&gt;xmlns=&lt;/span&gt;&lt;span class="s"&gt;"urn:redb:route:1.0"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;route&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"as4-outbox"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;from&lt;/span&gt; &lt;span class="na"&gt;uri=&lt;/span&gt;&lt;span class="s"&gt;"direct://outbound"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;setHeader&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"redbAs4.property.originalSender"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"urn:example:c1"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;setHeader&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"redbAs4.property.finalRecipient"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"urn:example:c4"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;to&lt;/span&gt; &lt;span class="na"&gt;uri=&lt;/span&gt;&lt;span class="s"&gt;"as4s://ap.acme.example/as4?connectionFactory=node&amp;amp;amp;partner=acme"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/route&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/routes&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Receiving: decrypt, verify, hand to the route, answer with a receipt
&lt;/h2&gt;

&lt;p&gt;Receiving is a &lt;code&gt;From&lt;/code&gt; step, which is to say a route source.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="nf"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;As4&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Receive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/as4/in"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"0.0.0.0"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Port&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;4090&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConnectionFactory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"node"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;IdempotentRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"as4-in"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ValidateXsd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoiceSchema&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// validate the business document against its XSD&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"direct://process-invoice"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The arriving envelope is decrypted, its signature verified, decompressed and matched to a partner, and what lands in the route is a &lt;strong&gt;clean business document&lt;/strong&gt; with the right &lt;code&gt;Message.ContentType&lt;/code&gt; (say &lt;code&gt;application/xml&lt;/code&gt; or &lt;code&gt;application/edi-x12&lt;/code&gt;), not a transport wrapper. The exchange metadata lives under &lt;code&gt;redbAs4.*&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Header&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redbAs4.partner&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the partner the message was matched to&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;redbAs4.signatureValid&lt;/code&gt; / &lt;code&gt;signerThumbprint&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the signature verified against the partner certificate; its thumbprint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;redbAs4.fromPartyId&lt;/code&gt; / &lt;code&gt;toPartyId&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the parties of the message&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;redbAs4.service&lt;/code&gt; / &lt;code&gt;action&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the business operation from &lt;code&gt;eb:CollaborationInfo&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redbAs4.messageId&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the &lt;code&gt;eb:MessageId&lt;/code&gt; of the incoming message&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One detail matters here. The receipt goes back to the partner &lt;strong&gt;after&lt;/strong&gt; the route has run. If your &lt;code&gt;To&lt;/code&gt; failed and the transaction rolled back, there will be no successful receipt, because otherwise you would have acknowledged a document you did not store. A failed route becomes an ebMS error (&lt;code&gt;EBMS:0004&lt;/code&gt;), and the sender retries the delivery.&lt;/p&gt;

&lt;p&gt;The same receiving route in markup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- routes/inbox.route.xml --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;routes&lt;/span&gt; &lt;span class="na"&gt;xmlns=&lt;/span&gt;&lt;span class="s"&gt;"urn:redb:route:1.0"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;route&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"as4-inbox"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;from&lt;/span&gt; &lt;span class="na"&gt;uri=&lt;/span&gt;&lt;span class="s"&gt;"as4:/as4/in?host=0.0.0.0&amp;amp;amp;port=4090&amp;amp;amp;connectionFactory=node&amp;amp;amp;idempotentRepository=as4-in"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;validateXsd&lt;/span&gt; &lt;span class="na"&gt;file=&lt;/span&gt;&lt;span class="s"&gt;"invoice.xsd"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;to&lt;/span&gt; &lt;span class="na"&gt;uri=&lt;/span&gt;&lt;span class="s"&gt;"direct://process-invoice"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/route&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/routes&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Reliability from the engine: duplicates and redelivery of the same message
&lt;/h2&gt;

&lt;p&gt;The profile requires duplicate detection, reliable messaging (Reception Awareness) and retries. In redb.Route none of that is written again: the connector stands on the engine's existing machinery and invents no retry loop, no duplicate store and no counter of its own.&lt;/p&gt;

&lt;p&gt;On receive, &lt;code&gt;idempotentRepository&lt;/code&gt; is mandatory. The &lt;code&gt;eb:MessageId&lt;/code&gt; of an incoming message is claimed only after every security check (a forged message cannot take the id of a real one), confirmed when the route succeeds and released when it fails. A duplicate arrives: the partner gets the receipt again, but the message is not delivered to the route a second time. The store comes in flavours, and the choice decides what survives a restart. &lt;code&gt;InMemoryIdempotentRepository&lt;/code&gt; forgets everything on restart and cannot see a duplicate that arrives on another node behind a load balancer; for production there is a redb or SQL store. The deduplication window is the store's &lt;code&gt;Ttl&lt;/code&gt;, and it must be longer than the sender's whole retry schedule.&lt;/p&gt;

&lt;p&gt;On send, a retry is &lt;code&gt;OnException&lt;/code&gt;. The producer sets &lt;code&gt;redbAs4.messageId&lt;/code&gt; on the exchange before the first attempt and keeps the signed request on the exchange: a redelivery sends &lt;strong&gt;the same bytes&lt;/strong&gt;, the same id and the same signature. That is not pedantry, it is what the protocol asks for: in AS4 a redelivery is the same message. The partner recognises the duplicate and answers with the receipt it stored for the first transmission (Domibus does exactly this), and its digests point at the very signature we resent.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;OnException&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;As4ReceiptException&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MaximumRedeliveries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RedeliveryDelay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;30&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseExponentialBackOff&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Handled&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"direct://as4-undelivered"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// dead letter after the last attempt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same handler in markup (a file-level section, applying to every route in the file):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;onException&lt;/span&gt; &lt;span class="na"&gt;exceptions=&lt;/span&gt;&lt;span class="s"&gt;"redb.Route.As4.As4ReceiptException, redb.Route.As4"&lt;/span&gt;
             &lt;span class="na"&gt;handled=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt; &lt;span class="na"&gt;maximumRedeliveries=&lt;/span&gt;&lt;span class="s"&gt;"5"&lt;/span&gt; &lt;span class="na"&gt;redeliveryDelay=&lt;/span&gt;&lt;span class="s"&gt;"00:00:30"&lt;/span&gt;
             &lt;span class="na"&gt;exponentialBackOff=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;to&lt;/span&gt; &lt;span class="na"&gt;uri=&lt;/span&gt;&lt;span class="s"&gt;"direct://as4-undelivered"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/onException&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The engine's retry lives in memory: a restart loses the exchange it was retrying. For schedules measured in hours, hold the document in an outbox of your own (a redb object with the saved &lt;code&gt;redbAs4.messageId&lt;/code&gt;) and resend what still has no receipt from a &lt;code&gt;timer:&lt;/code&gt; route.&lt;/p&gt;

&lt;h2&gt;
  
  
  Streaming: huge documents are normal
&lt;/h2&gt;

&lt;p&gt;In B2B you meet attachments of hundreds of megabytes, and holding them whole in memory is not an option. An outgoing attachment is written to the wire through the core's stream cache: a large payload passes through a temporary file, not through a &lt;code&gt;byte[]&lt;/code&gt;. Compression and encryption are streamed (streamed AES-GCM on BouncyCastle), and an incoming attachment can be read as a stream (&lt;code&gt;streamBody&lt;/code&gt;) rather than a buffer expanded into memory.&lt;/p&gt;

&lt;p&gt;There are bounds, too: the request body size (&lt;code&gt;maxRequestBodySize&lt;/code&gt;, 100 MB by default), the SOAP envelope size (&lt;code&gt;maxEnvelopeCharacters&lt;/code&gt;, 1 MB; the payload rides as attachments and is not counted against it), and the partner's response size (&lt;code&gt;maxResponseBodySize&lt;/code&gt;, 4 MB; a receipt is kilobytes). A forward-only stream that cannot be reread is read once: to retry a send, cache it in the route with &lt;code&gt;.StreamCaching()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The crypto layer we had to write ourselves
&lt;/h2&gt;

&lt;p&gt;This is where AS4 is genuinely hard, and why there is no mainstream .NET implementation. There was no built-in path, and not out of laziness: the API has specific gaps.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SignedXml&lt;/code&gt; in .NET cannot resolve &lt;code&gt;cid:&lt;/code&gt; references to MIME attachments. So the signature is handled by hand: the connector parses &lt;code&gt;ds:Reference&lt;/code&gt; and &lt;code&gt;SignedInfo&lt;/code&gt; itself, applies the Attachment-Content-Signature-Transform and Exclusive C14N (through the public &lt;code&gt;XmlDsigExcC14NTransform&lt;/code&gt;), and signs with RSA PKCS#1 v1.5.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;EncryptedXml&lt;/code&gt; in .NET cannot do AES-GCM, and its &lt;code&gt;DecryptKey&lt;/code&gt; supports only OAEP-SHA1, while the profile requires OAEP with MGF1-SHA256. So the session key is decrypted by hand, and the data is encrypted through &lt;code&gt;System.Security.Cryptography.AesGcm&lt;/code&gt; by the rules of XML Encryption 1.1 (a 12-byte IV, a 16-byte tag, order "IV, ciphertext, tag").&lt;/p&gt;

&lt;p&gt;Untrusted XML goes only through the core's &lt;code&gt;SafeXml&lt;/code&gt;: a DTD on input is refused (SOAP 1.2 itself does not allow one), and the envelope is size-limited. Against signed-element substitution (signature wrapping) the defence is that duplicate &lt;code&gt;wsu:Id&lt;/code&gt; values are rejected and every reference must cover &lt;code&gt;eb:Messaging&lt;/code&gt;, &lt;code&gt;soap:Body&lt;/code&gt;, each attachment and the &lt;code&gt;wsu:Timestamp&lt;/code&gt; when present. Against replay, a &lt;code&gt;wsu:Timestamp&lt;/code&gt; check by the WSS4J rules plus deduplication on &lt;code&gt;messageId&lt;/code&gt;. The text of decryption and signature errors never leaves: the detail goes to the log only, no oracle. Passwords are marked &lt;code&gt;[Sensitive]&lt;/code&gt; and redacted from logs and the dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 1.16 profile: what we accept, what we reject
&lt;/h2&gt;

&lt;p&gt;The connector implements the eDelivery AS4 1.16 profile and nothing beyond it. That is not a self-imposed limit, it is the reason it talks to real partners: the moment an implementation starts "guessing" at extensions, interop breaks.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;We accept&lt;/th&gt;
&lt;th&gt;Otherwise&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Signature&lt;/td&gt;
&lt;td&gt;RSA-SHA256&lt;/td&gt;
&lt;td&gt;the configuration is refused at startup; on input, &lt;code&gt;EBMS:0101&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Digest&lt;/td&gt;
&lt;td&gt;SHA-256&lt;/td&gt;
&lt;td&gt;the same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payload encryption&lt;/td&gt;
&lt;td&gt;AES-128-GCM&lt;/td&gt;
&lt;td&gt;at startup; on input, &lt;code&gt;EBMS:0102&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Key transport&lt;/td&gt;
&lt;td&gt;RSA-OAEP, MGF1-SHA256&lt;/td&gt;
&lt;td&gt;at startup; on input, &lt;code&gt;EBMS:0102&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Canonicalization&lt;/td&gt;
&lt;td&gt;Exclusive C14N&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EBMS:0101&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Key reference&lt;/td&gt;
&lt;td&gt;BST / IssuerSerial / KeyIdentifier (on input)&lt;/td&gt;
&lt;td&gt;BST by default on send&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An unsigned or unencrypted message&lt;/td&gt;
&lt;td&gt;never&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EBMS:0103&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A payload in the SOAP body&lt;/td&gt;
&lt;td&gt;never (attachments only)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EBMS:0002&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Some of what the specification calls optional is simply out of scope: Pull and asynchronous receipts. The common 1.16 profile does not require them either; it states plainly that a synchronous receipt is mandatory and an asynchronous one must not be used.&lt;/p&gt;

&lt;h2&gt;
  
  
  Interop is proven, not claimed
&lt;/h2&gt;

&lt;p&gt;An AS4 connector that only talks to itself proves nothing. This one is tested against independent implementations, with no shared code, in both directions.&lt;/p&gt;

&lt;p&gt;Against Holodeck B2B 8.1.1: both directions, across the whole profile matrix, with signatures, encryption, compression and each of the three key-reference forms. Against Apache Domibus 5.1 (the reference EU eDelivery access point, on a Harmony AP 2.6.2 stand): also both ways. We send, Domibus accepts (&lt;code&gt;RECEIVED&lt;/code&gt;); Domibus sends, the route gets the document, and Domibus accepts our receipt (&lt;code&gt;ACKNOWLEDGED&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Interop also surfaced things that are invisible without a live partner. Domibus's &lt;code&gt;eDeliveryAS4Policy&lt;/code&gt; (Strict, without IncludeTimestamp) rejects &lt;code&gt;wsu:Timestamp&lt;/code&gt;, so by default the connector sets no timestamp, and turns it on when the partner's policy asks. And on a redelivery Domibus returns the receipt it stored for the first transmission: had we rebuilt the message with a fresh signature, its digests would not have matched. That is why a redelivery resends the same bytes. This is exactly the class of problem the specification text does not contain and that only interop finds.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it differs from a Java MSH or a gateway
&lt;/h2&gt;

&lt;p&gt;AS4 in a .NET project used to be closed two ways: a separate Java MSH (a Message Service Handler, the AS4 term for an access point) or a commercial gateway. The difference is not whether they "can" do it, the protocol is one. The difference is where AS4 lives relative to your logic.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Commercial gateway&lt;/th&gt;
&lt;th&gt;Java MSH (Domibus/Holodeck)&lt;/th&gt;
&lt;th&gt;redb.Route.As4&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Process&lt;/td&gt;
&lt;td&gt;a separate box&lt;/td&gt;
&lt;td&gt;a separate JVM beside&lt;/td&gt;
&lt;td&gt;your .NET process&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Received document&lt;/td&gt;
&lt;td&gt;in an inbox directory&lt;/td&gt;
&lt;td&gt;in an inbox directory&lt;/td&gt;
&lt;td&gt;a message in the route&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How you get it&lt;/td&gt;
&lt;td&gt;a poller job&lt;/td&gt;
&lt;td&gt;a poller job&lt;/td&gt;
&lt;td&gt;straight into the pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy&lt;/td&gt;
&lt;td&gt;its own installation&lt;/td&gt;
&lt;td&gt;its own installation&lt;/td&gt;
&lt;td&gt;with your application&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observability&lt;/td&gt;
&lt;td&gt;its own panel&lt;/td&gt;
&lt;td&gt;JVM logs&lt;/td&gt;
&lt;td&gt;the shared traces and statistics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Further processing&lt;/td&gt;
&lt;td&gt;outside the gateway, by hand&lt;/td&gt;
&lt;td&gt;outside the server, by hand&lt;/td&gt;
&lt;td&gt;the same EIPs, in the same route&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;License&lt;/td&gt;
&lt;td&gt;commercial&lt;/td&gt;
&lt;td&gt;open&lt;/td&gt;
&lt;td&gt;open, no key&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A separate MSH is justified when the AS4 contour is deliberately kept in an isolated zone, say a DMZ run by another team. When the document ends up in your .NET backend anyway, an intermediate process is one more hop, one more directory and one more component in the audit scheme.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it is used
&lt;/h2&gt;

&lt;p&gt;AS4 is needed wherever a document carries an obligation and the channel is dictated by someone else. A few typical situations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Peppol and e-invoicing.&lt;/strong&gt; An electronic invoice on the Peppol network travels by AS4 between access points. If you build your own access point or receive invoices directly, AS4 becomes a route step rather than an external box: the invoice arrives, is parsed, validated against UBL/CII, and lands in the ERP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;EU government and legal documents (e-CODEX, e-Justice).&lt;/strong&gt; Court and legal documents between national systems travel by eDelivery AS4 with strict signature and non-repudiation requirements. Here the legal weight of the document is the point of the channel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Energy (ENTSOG).&lt;/strong&gt; Requests, schedules and balancing documents between gas operators. The exchange is mandatory, the profile is strict, and the partners are other operators' nodes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Transport and customs (eFTI).&lt;/strong&gt; Electronic transport documents and customs papers. The same profile: signature, encryption, receipt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enterprise exchange with an EU public buyer.&lt;/strong&gt; Tender, reporting and regulatory documents that European buyers and regulators require over eDelivery AS4.&lt;/p&gt;

&lt;p&gt;Across all of them one thing is the same: the document must arrive reliably and with proof, and then be processed in your system. That seam is what the connector closes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;Nothing is free, so it is better to say plainly what is not here yet.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pull (the receiver fetches the message itself) and asynchronous receipts are not implemented. The common 1.16 profile does not require them, but if a specific partner wants Pull, this is not there yet.&lt;/li&gt;
&lt;li&gt;eDelivery AS4 2.0 (elliptic curves instead of RSA-OAEP) is not supported. The connector targets the 1.x line, common profile 1.16.&lt;/li&gt;
&lt;li&gt;Dynamic partner discovery (Peppol SMP/SML) is a separate, system-level task. The connector takes the address and the certificate from the agreement; where you get them is up to your integration.&lt;/li&gt;
&lt;li&gt;TLS against a partner's MSH has not been exercised live: both interop stands ran plain HTTP, while TLS and mutual TLS are covered by loopback handshakes. With a real partner it is worth checking separately.&lt;/li&gt;
&lt;li&gt;The engine's retry lives in memory, so schedules measured in hours need an outbox (which is a route pattern, not the connector).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do I need a Java MSH beside it?&lt;/strong&gt; No. It is a native .NET connector: &lt;code&gt;HttpClient&lt;/code&gt;, the shared Kestrel host and &lt;code&gt;System.Security.Cryptography&lt;/code&gt;. It is both the sender and the receive access point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AS4 or AS2?&lt;/strong&gt; Whatever your partner requires. AS2 is S/MIME and MDN; AS4 is SOAP, WS-Security and an ebMS receipt. EU government and Peppol dictate AS4, while US retail and logistics are often AS2. We cover AS2 &lt;a href="https://redbase.app/articles/as2-edi" rel="noopener noreferrer"&gt;separately&lt;/a&gt;; both connectors live in one process and run side by side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where do certificates go?&lt;/strong&gt; In &lt;code&gt;As4ConnectionFactory&lt;/code&gt; and &lt;code&gt;As4Partner&lt;/code&gt;, as &lt;code&gt;X509Certificate2&lt;/code&gt;. Passwords are marked &lt;code&gt;[Sensitive]&lt;/code&gt;, and there are no secrets in the URI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can one worker carry HTTP, SOAP, AS2 and AS4?&lt;/strong&gt; Yes. They receive on the shared Kestrel host, each route on its own path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if the partner answers with an error?&lt;/strong&gt; The producer throws &lt;code&gt;As4ErrorSignalException&lt;/code&gt; with the code and description; &lt;code&gt;OnException&lt;/code&gt; decides whether to retry or move to a dead letter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Synchronous or asynchronous receipt?&lt;/strong&gt; Synchronous, in the same HTTP response. It is a requirement of the 1.16 profile, which is why &lt;code&gt;transacted&lt;/code&gt; is not allowed on a send: you cannot defer a synchronous receipt to the end of a transaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package redb.Route.As4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The package is &lt;a href="https://www.nuget.org/packages/redb.Route.As4/" rel="noopener noreferrer"&gt;redb.Route.As4 on NuGet&lt;/a&gt;; the source and the full DSL reference are in the &lt;a href="https://github.com/redbase-app/redb-route/tree/main/redb.Route.As4" rel="noopener noreferrer"&gt;connector README&lt;/a&gt;. AS4 is one more transport in the redb.Route family, alongside Kafka, RabbitMQ, AS2, IBM MQ and the rest: the same &lt;code&gt;From → … → To&lt;/code&gt;, the same EIPs, the same observability. The only difference is that the wire carries a SOAP envelope with ebMS headers, which a European access point is waiting for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;If this was useful, a ⭐ on &lt;a href="https://github.com/redbase-app" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; helps others find it.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;More of my writing: redbase.app/articles, and on &lt;a href="https://dev.to/rinat_kozin"&gt;dev.to&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>opensource</category>
      <category>as4</category>
    </item>
    <item>
      <title>Redis in .NET routes: cache, Pub/Sub, Streams with consumer groups and Claim Check in one connector</title>
      <dc:creator>rinat kozin</dc:creator>
      <pubDate>Wed, 23 Sep 2026 19:06:00 +0000</pubDate>
      <link>https://dev.to/rinat_kozin/redis-in-net-routes-cache-pubsub-streams-with-consumer-groups-and-claim-check-in-one-connector-5b5f</link>
      <guid>https://dev.to/rinat_kozin/redis-in-net-routes-cache-pubsub-streams-with-consumer-groups-and-claim-check-in-one-connector-5b5f</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkr32gahv9sm0n8h7u9qp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkr32gahv9sm0n8h7u9qp.png" alt="redb.Route.Redis" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Redis connector for &lt;strong&gt;redb.Route&lt;/strong&gt;: every data structure as a route step, Pub/Sub, Streams with consumer groups, a list as a queue, and Claim Check on Redis.&lt;/p&gt;

&lt;p&gt;In an integration project Redis is rarely just one thing. It caches answers from external systems, holds counters and locks, carries events over Pub/Sub, keeps a log of messages in Streams, and works as a job queue on a list. Usually each of those roles lives in its own piece of code, with its own connection, its own retries and its own logging.&lt;/p&gt;

&lt;p&gt;In redb.Route they all live in one connector, &lt;code&gt;redb.Route.Redis&lt;/code&gt;. Any Redis operation becomes a step of a route, and a subscription, a stream or a list becomes its input. The connection, the telemetry and the graceful shutdown are the ones every other connector uses. Underneath it is StackExchange.Redis 2.11.8.&lt;/p&gt;

&lt;p&gt;The consumers are described here as of version 4.1.0: pattern subscriptions, a stream without a group keeping a position of its own, claiming back stream entries that got stuck, and a reliable list queue all arrived in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing the package, and the address
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package redb.Route.Redis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRedbRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRedbRouteRedis&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddRouteBuilder&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderRoutes&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An endpoint address reads &lt;code&gt;redis:OPERATION:resource?parameters&lt;/code&gt;. The first path segment is the Redis operation (case does not matter), everything after the first colon is the key, the channel or the stream name. Colons inside a key are kept, so &lt;code&gt;redis:SET:session:42?ttl=300&lt;/code&gt; writes the key &lt;code&gt;session:42&lt;/code&gt; with a 300 second time to live.&lt;/p&gt;

&lt;p&gt;The fluent builder produces the same address, and the rest of this post uses it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"session:42"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Ttl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;300&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;// redis:SET:session:42?ttl=300&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Redis&lt;/code&gt; has ready factories for keys, Pub/Sub, streams and lists (&lt;code&gt;Set&lt;/code&gt;, &lt;code&gt;Get&lt;/code&gt;, &lt;code&gt;Incr&lt;/code&gt;, &lt;code&gt;Publish&lt;/code&gt;, &lt;code&gt;Subscribe&lt;/code&gt;, &lt;code&gt;XAdd&lt;/code&gt;, &lt;code&gt;XRead&lt;/code&gt;, &lt;code&gt;LPush&lt;/code&gt; and the rest). Any other operation comes from &lt;code&gt;Redis.Command(operation, key)&lt;/code&gt;, for example &lt;code&gt;Redis.Command("HSET", "customer:42").Field("email")&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every data structure as a route step
&lt;/h2&gt;

&lt;p&gt;The producer covers the operations of all the main Redis structures:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Structure&lt;/th&gt;
&lt;th&gt;Operations&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Strings and keys&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SET&lt;/code&gt;, &lt;code&gt;GET&lt;/code&gt;, &lt;code&gt;DEL&lt;/code&gt;, &lt;code&gt;EXISTS&lt;/code&gt;, &lt;code&gt;EXPIRE&lt;/code&gt;, &lt;code&gt;INCR&lt;/code&gt;, &lt;code&gt;DECR&lt;/code&gt;, &lt;code&gt;SETNX&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lists&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;LPUSH&lt;/code&gt;, &lt;code&gt;RPUSH&lt;/code&gt;, &lt;code&gt;LPOP&lt;/code&gt;, &lt;code&gt;RPOP&lt;/code&gt;, &lt;code&gt;LLEN&lt;/code&gt;, &lt;code&gt;LRANGE&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hashes&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;HSET&lt;/code&gt;, &lt;code&gt;HGET&lt;/code&gt;, &lt;code&gt;HMSET&lt;/code&gt;, &lt;code&gt;HMGET&lt;/code&gt;, &lt;code&gt;HGETALL&lt;/code&gt;, &lt;code&gt;HDEL&lt;/code&gt;, &lt;code&gt;HLEN&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sets&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SADD&lt;/code&gt;, &lt;code&gt;SREM&lt;/code&gt;, &lt;code&gt;SMEMBERS&lt;/code&gt;, &lt;code&gt;SCARD&lt;/code&gt;, &lt;code&gt;SISMEMBER&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sorted sets&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ZADD&lt;/code&gt;, &lt;code&gt;ZREM&lt;/code&gt;, &lt;code&gt;ZRANGE&lt;/code&gt;, &lt;code&gt;ZCARD&lt;/code&gt;, &lt;code&gt;ZSCORE&lt;/code&gt;, &lt;code&gt;ZRANGEBYSCORE&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Geo&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;GEOADD&lt;/code&gt;, &lt;code&gt;GEODIST&lt;/code&gt;, &lt;code&gt;GEORADIUS&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HyperLogLog&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;PFADD&lt;/code&gt;, &lt;code&gt;PFCOUNT&lt;/code&gt;, &lt;code&gt;PFMERGE&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bitmaps&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SETBIT&lt;/code&gt;, &lt;code&gt;GETBIT&lt;/code&gt;, &lt;code&gt;BITCOUNT&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Messaging&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;PUBLISH&lt;/code&gt;, &lt;code&gt;XADD&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Any command&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;COMMAND&lt;/code&gt;, with the command name in &lt;code&gt;CustomCommand(...)&lt;/code&gt; and its arguments in a header&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Where an operation takes its data from is the same rule everywhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;the value or the element&lt;/strong&gt; is the message body. In lists, hashes, sets, sorted sets and streams a &lt;code&gt;byte[]&lt;/code&gt; body is written as it is, without being turned into a string;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;scalar parameters&lt;/strong&gt; live in the address: &lt;code&gt;field&lt;/code&gt; for hashes, &lt;code&gt;score&lt;/code&gt;, &lt;code&gt;minScore&lt;/code&gt; and &lt;code&gt;maxScore&lt;/code&gt; for sorted sets, &lt;code&gt;start&lt;/code&gt; and &lt;code&gt;stop&lt;/code&gt; for ranges, &lt;code&gt;longitude&lt;/code&gt;, &lt;code&gt;latitude&lt;/code&gt;, &lt;code&gt;member1&lt;/code&gt;, &lt;code&gt;member2&lt;/code&gt; and &lt;code&gt;geoUnit&lt;/code&gt; for geo, &lt;code&gt;offset&lt;/code&gt; and &lt;code&gt;bit&lt;/code&gt; for bitmaps;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;sets of values&lt;/strong&gt; come in headers: the field map for &lt;code&gt;HMSET&lt;/code&gt; (&lt;code&gt;redbRedis.HashFields&lt;/code&gt;), the field names for &lt;code&gt;HMGET&lt;/code&gt; (&lt;code&gt;redbRedis.FieldNames&lt;/code&gt;), the source keys for &lt;code&gt;PFMERGE&lt;/code&gt; (&lt;code&gt;redbRedis.SourceKeys&lt;/code&gt;), the centre and the radius for &lt;code&gt;GEORADIUS&lt;/code&gt;, the arguments for &lt;code&gt;COMMAND&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key, the field, the channel and the stream name may carry &lt;code&gt;${...}&lt;/code&gt; templates, which are resolved per message.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The result of an operation becomes the body of the next step.&lt;/strong&gt; &lt;code&gt;GET&lt;/code&gt; returns a string or &lt;code&gt;null&lt;/code&gt;, &lt;code&gt;INCR&lt;/code&gt; the new value of the counter, &lt;code&gt;LRANGE&lt;/code&gt; and &lt;code&gt;SMEMBERS&lt;/code&gt; an array of strings, &lt;code&gt;HGETALL&lt;/code&gt; a dictionary, &lt;code&gt;GEORADIUS&lt;/code&gt; the members it found:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="nf"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"direct://order-created"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Incr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"stats:orders:today"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders today: ${body}"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SETNX&lt;/code&gt; writes the value only when the key is not there yet and returns &lt;code&gt;"OK"&lt;/code&gt; or &lt;code&gt;null&lt;/code&gt;. Together with &lt;code&gt;Ttl(...)&lt;/code&gt; that is a lock for the duration of the work, or a way to drop repeats.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pub/Sub: events for whoever is listening right now
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="nf"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"direct://publish-event"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders.events"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="nf"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders.events"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"direct://notify"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After a publish the number of recipients is in the body and in the &lt;code&gt;redbRedis.Publish.Recipients&lt;/code&gt; header. On the subscriber side the body is the message text, and the headers carry the channel (&lt;code&gt;redbRedis.Channel&lt;/code&gt;), the type (&lt;code&gt;PubSub&lt;/code&gt;) and the time it arrived.&lt;/p&gt;

&lt;p&gt;A pattern subscription is &lt;code&gt;PSUBSCRIBE&lt;/code&gt;: &lt;code&gt;From(Redis.PSubscribe("orders.*"))&lt;/code&gt; receives messages from every channel matching the pattern, and &lt;code&gt;redbRedis.Channel&lt;/code&gt; holds the channel the message actually came from. The &lt;code&gt;usePattern&lt;/code&gt; option turns a plain &lt;code&gt;SUBSCRIBE&lt;/code&gt; into a pattern subscription too.&lt;/p&gt;

&lt;p&gt;Redis Pub/Sub delivers a message only to those connected at the moment of the publish, and stores nothing. That is the right fit for notifications and cache invalidation. When you need delivery you can rely on, use Streams.&lt;/p&gt;

&lt;h2&gt;
  
  
  Streams: a log of messages with consumer groups
&lt;/h2&gt;

&lt;p&gt;Writing to a stream:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="nf"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"direct://order-created"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;XAdd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;StreamMaxLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;100_000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The body goes into a &lt;code&gt;data&lt;/code&gt; field, alongside a &lt;code&gt;timestamp&lt;/code&gt; field in milliseconds. For a field map of your own, pass a dictionary in the &lt;code&gt;redbRedis.StreamFields&lt;/code&gt; header. &lt;code&gt;StreamMaxLength&lt;/code&gt; trims the stream by length, approximately by default (&lt;code&gt;MAXLEN ~&lt;/code&gt;); &lt;code&gt;StreamApproximate(false)&lt;/code&gt; makes the trimming exact. The id of the new entry comes back in the body and in the &lt;code&gt;redbRedis.Stream.MessageId&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;Reading with a consumer group:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="nf"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;XRead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConsumerGroup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"billing"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConsumerName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"node-1"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StreamReadCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StreamClaimMinIdle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;60_000&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"direct://bill"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What happens underneath:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;the group is created for you&lt;/strong&gt; at start, together with the stream if it isn't there yet. By default it starts at the end of the stream and gets the entries added after it was created; &lt;code&gt;StreamStartPosition("0")&lt;/code&gt; starts at the first entry. If the group already exists, the consumer simply joins it;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;entries are read in batches&lt;/strong&gt; of &lt;code&gt;streamReadCount&lt;/code&gt;. With nothing new to read the consumer waits &lt;code&gt;streamBlockTimeMs&lt;/code&gt; (1000 ms by default) and polls again;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the body&lt;/strong&gt; is the dictionary of the entry's fields, each field is also a &lt;code&gt;redbRedis.Stream.&amp;lt;field&amp;gt;&lt;/code&gt; header, and the entry id is in &lt;code&gt;redbRedis.MessageId&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the consumer name&lt;/strong&gt; defaults to the machine name.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Several nodes sharing a group, each with its own consumer name, split the stream between them: every entry goes to exactly one of them. That is how one stream is processed by a cluster without duplicates.&lt;/p&gt;

&lt;h3&gt;
  
  
  Acknowledgement and a second attempt
&lt;/h3&gt;

&lt;p&gt;A group consumer acknowledges an entry (XACK) once the whole unit of work has finished well, the route transaction included. An entry whose route failed is not acknowledged and stays in the group's pending list, which is what makes this at-least-once.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;StreamClaimMinIdle(ms)&lt;/code&gt; brings those entries back into work. On every poll the consumer claims (XAUTOCLAIM) the group's entries that have been pending for at least that long and processes them again: its own failed ones, and the ones left behind by a consumer on a node that died. Pick an idle time longer than your slowest route, or an entry will be claimed while it is still being worked on.&lt;/p&gt;

&lt;p&gt;Where losing an entry is acceptable, there is &lt;code&gt;StreamNoAck()&lt;/code&gt;: reading goes with &lt;code&gt;NOACK&lt;/code&gt;, an entry counts as delivered the moment it is read and never enters the pending list (at-most-once). It does not combine with &lt;code&gt;StreamClaimMinIdle&lt;/code&gt;, and an endpoint asking for both is refused.&lt;/p&gt;

&lt;h3&gt;
  
  
  A stream without a group
&lt;/h3&gt;

&lt;p&gt;Without a group every consumer reads the whole stream and keeps the position itself, moving it past each entry it has read. That is how one log is handed to several independent readers, or replayed from the beginning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="nf"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;XRead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;StreamStartPosition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"0"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"direct://rebuild-projection"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With no &lt;code&gt;StreamStartPosition&lt;/code&gt; the consumer starts at the entries added after it started, &lt;code&gt;"0"&lt;/code&gt; reads from the first entry, and an entry id continues after that entry. The &lt;code&gt;&amp;gt;&lt;/code&gt; position means "not yet delivered to this consumer group" and only means anything to a group: without one the consumer is not created. Nothing holds a failed entry back when there is no group, and reading moves on.&lt;/p&gt;

&lt;h2&gt;
  
  
  A list as a job queue
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="nf"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"direct://enqueue-job"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LPush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"jobs"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="nf"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"BRPOP"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"jobs"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ProcessingList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"jobs:processing"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PollDelay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;500&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"direct://run-job"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;LPUSH&lt;/code&gt; at the head and reading from the tail give a queue in arrival order. The consumer takes items one at a time by polling and waits &lt;code&gt;pollDelayMs&lt;/code&gt; while the list is empty. &lt;code&gt;BLPOP&lt;/code&gt; and &lt;code&gt;BRPOP&lt;/code&gt; here are the names of the operations rather than blocking commands: a blocking read would hold the shared connection the whole process works through.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;ProcessingList&lt;/code&gt; the queue is reliable. The item is moved (LMOVE) into the processing list and leaves it only after the route succeeded. A failed item goes back to the queue atomically, to the side it was taken from, and is processed again; whatever a previous run left in the processing list is returned to the queue at start. One consumer per processing list. &lt;code&gt;LMOVE&lt;/code&gt; is available in Redis 6.2 and later.&lt;/p&gt;

&lt;p&gt;Without &lt;code&gt;ProcessingList&lt;/code&gt; the item is popped before processing and a failed one is gone. That suits work you can afford to lose, such as warming a cache.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connections and secrets
&lt;/h2&gt;

&lt;p&gt;The shortest path is parameters in the address: &lt;code&gt;connectionString&lt;/code&gt; (&lt;code&gt;localhost:6379&lt;/code&gt; by default), &lt;code&gt;database&lt;/code&gt; and &lt;code&gt;password&lt;/code&gt;. The password is marked as a secret and is redacted in logs.&lt;/p&gt;

&lt;p&gt;In production a named connection factory in the context registry is nicer, because secrets stay out of the route address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddToRegistry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"prod"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;RedisConnectionFactory&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ConnectionString&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"redis-1:6379,redis-2:6379"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"route"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Password&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;secrets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RedisPassword&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Ssl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;SslProtocols&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Tls12, Tls13"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"direct://rates"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"rates:usd"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ConnectionFactory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"prod"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Ttl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;300&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The factory knows about Redis 6+ ACL users, TLS with a choice of protocols, Sentinel (&lt;code&gt;ServiceName&lt;/code&gt;), connect and operation timeouts, keep-alive, the reconnect policy (exponential or linear) and a channel prefix. Configuration mistakes are not masked: a factory name that is not in the registry, or a typo in &lt;code&gt;SslProtocols&lt;/code&gt;, gives a clear error on connect instead of a silent fall back to the defaults. The same goes for address parameters: an option the endpoint does not know, or a value of the wrong type, is refused and named, not quietly dropped.&lt;/p&gt;

&lt;p&gt;Every endpoint keeps one StackExchange.Redis connection and reconnects on its own. A lost and a restored connection are both logged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claim Check on Redis
&lt;/h2&gt;

&lt;p&gt;The Claim Check pattern from the Enterprise Integration Patterns catalog takes a heavy body out of the message for the length of a route and brings it back when it is needed again. &lt;code&gt;RedisClaimCheckRepository&lt;/code&gt; is the store for it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;RedisClaimCheckRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;RedisConnectionFactory&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;ConnectionString&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"redis.internal:6379"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;defaultTtl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromHours&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="nf"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"direct://inbound-invoice"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ClaimCheck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ClaimCheckOperation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Push&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// body into Redis, the key in the message&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"direct://route-by-headers"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ClaimCheck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ClaimCheckOperation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pop&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;// body back, the entry removed&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"direct://archive"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The store writes the data as it is, in binary, and puts Redis's own time to live on the entry. Keys get the &lt;code&gt;redb:claimcheck:&lt;/code&gt; prefix, which you can change. Read-and-remove is atomic: it runs as a single Lua script, so it works on Redis older than 6.2, where there is no &lt;code&gt;GETDEL&lt;/code&gt; yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observability and shutdown
&lt;/h2&gt;

&lt;p&gt;Every producer call opens an OpenTelemetry span named &lt;code&gt;redis &amp;lt;OPERATION&amp;gt;&lt;/code&gt;, tagged &lt;code&gt;db.system=redis&lt;/code&gt;, with the resource and the operation on it. Redis calls show up in the same trace as the rest of the route.&lt;/p&gt;

&lt;p&gt;Consumers stop gracefully, as they do in every other redb.Route connector: they stop taking new messages and finish the ones already in flight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package redb.Route
dotnet add package redb.Route.Redis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/redbase-app/redb-route" rel="noopener noreferrer"&gt;github.com/redbase-app/redb-route&lt;/a&gt;. The package is on &lt;a href="https://www.nuget.org/packages/redb.Route.Redis" rel="noopener noreferrer"&gt;NuGet&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;If this was useful — a ⭐ on &lt;a href="https://github.com/redbase-app" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; helps others find it.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;More of my writing: redbase.app/articles, and on &lt;a href="https://dev.to/rinat_kozin"&gt;dev.to&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>redis</category>
      <category>integration</category>
      <category>opensource</category>
    </item>
    <item>
      <title>XML routes in redb.Route: a schema the editor picks up, checks at build time, modules swapped live</title>
      <dc:creator>rinat kozin</dc:creator>
      <pubDate>Tue, 22 Sep 2026 18:27:21 +0000</pubDate>
      <link>https://dev.to/rinat_kozin/xml-routes-in-redbroute-a-schema-the-editor-picks-up-checks-at-build-time-modules-swapped-live-2j7c</link>
      <guid>https://dev.to/rinat_kozin/xml-routes-in-redbroute-a-schema-the-editor-picks-up-checks-at-build-time-modules-swapped-live-2j7c</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fydsrhln8q5p5n3aunowd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fydsrhln8q5p5n3aunowd.png" alt="redb.Route.Xml" width="800" height="420"&gt;&lt;/a&gt;&lt;br&gt;
"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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;In 4.0 redb.Route gained a second way to write a route: XML. A &lt;code&gt;.route.xml&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Around the format grew the things it was meant for: a schema the editor resolves by namespace, package checks that run inside &lt;code&gt;dotnet build&lt;/code&gt;, 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.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where the example comes from
&lt;/h2&gt;

&lt;p&gt;The serial numbers module came out of a &lt;a href="https://github.com/orgs/redbase-app/discussions/12" rel="noopener noreferrer"&gt;GitHub discussion&lt;/a&gt;. 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 &lt;a href="https://github.com/redbase-app/redb-route/tree/main/demo/SerialNumbersDemo" rel="noopener noreferrer"&gt;SerialNumbersDemo&lt;/a&gt;: two partners (one over SFTP, one over AS2), SQL Server, objects in redb, a quota report on a schedule, and packaging into a &lt;code&gt;.tpkg&lt;/code&gt; for Tsak. Its routes are written in C#.&lt;/p&gt;

&lt;p&gt;Next to it lives the XML twin, &lt;code&gt;SerialNumbers.Xml&lt;/code&gt;. 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.&lt;/p&gt;
&lt;h2&gt;
  
  
  One route, two spellings
&lt;/h2&gt;

&lt;p&gt;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#:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="nf"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RouteUris&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SerialNumberRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RouteId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"serial-number-request"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MessageHistory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;DoTry&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ValidateXsd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;XmlSchemas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SerialNumberRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unmarshal&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SerialNumberRequestXml&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"application/xml"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DoCatch&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ValidationException&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ProcessWithRedb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IntakeRecorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RecordInvalidAsync&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${header.serials.partner}: ${header.serials.fileName} violates the schema"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Warning&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EndTryCatch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Transacted&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ProcessWithRedb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SerialRequestService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RegisterAsync&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Choice&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;When&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;DecisionOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;MessageStatuses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Accepted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ProcessWithRedb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SerialRequestService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AllocateAsync&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ProcessWithRedb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SerialRequestService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QueueResponseAsync&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;When&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;DecisionOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;MessageStatuses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Rejected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ProcessWithRedb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SerialRequestService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RejectAsync&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ProcessWithRedb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SerialRequestService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QueueResponseAsync&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EndChoice&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EndTransaction&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c1"&gt;// ...logs after the commit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same route in markup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;route&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"serial-number-request"&lt;/span&gt; &lt;span class="na"&gt;description=&lt;/span&gt;&lt;span class="s"&gt;"A serial number request: validate, decide, respond"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;from&lt;/span&gt; &lt;span class="na"&gt;uri=&lt;/span&gt;&lt;span class="s"&gt;"direct://serial-number-request"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;messageHistory/&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;tryCatch&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;try&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;validateXsd&lt;/span&gt; &lt;span class="na"&gt;file=&lt;/span&gt;&lt;span class="s"&gt;"SerialNumberRequest.xsd"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;unmarshal&lt;/span&gt; &lt;span class="na"&gt;format=&lt;/span&gt;&lt;span class="s"&gt;"application/xml"&lt;/span&gt;
                 &lt;span class="na"&gt;target=&lt;/span&gt;&lt;span class="s"&gt;"SerialNumbers.Core.Integration.Xml.SerialNumberRequestXml, SerialNumbers.Core"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/try&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;catch&lt;/span&gt; &lt;span class="na"&gt;exceptions=&lt;/span&gt;&lt;span class="s"&gt;"redb.Route.Validation.ValidationException"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;to&lt;/span&gt; &lt;span class="na"&gt;uri=&lt;/span&gt;&lt;span class="s"&gt;"bean:#intake?method=RecordInvalid"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;log&lt;/span&gt; &lt;span class="na"&gt;level=&lt;/span&gt;&lt;span class="s"&gt;"Warning"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;${header.serials.partner}: ${header.serials.fileName} violates the schema&lt;span class="nt"&gt;&amp;lt;/log&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;stop/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/catch&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/tryCatch&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;transaction&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;to&lt;/span&gt; &lt;span class="na"&gt;uri=&lt;/span&gt;&lt;span class="s"&gt;"bean:#serial-requests?method=Register"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;choice&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;when&lt;/span&gt; &lt;span class="na"&gt;expr=&lt;/span&gt;&lt;span class="s"&gt;"header.serials.decision == 'Accepted'"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;to&lt;/span&gt; &lt;span class="na"&gt;uri=&lt;/span&gt;&lt;span class="s"&gt;"bean:#serial-requests?method=Allocate"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;to&lt;/span&gt; &lt;span class="na"&gt;uri=&lt;/span&gt;&lt;span class="s"&gt;"bean:#serial-requests?method=QueueResponse"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/when&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;when&lt;/span&gt; &lt;span class="na"&gt;expr=&lt;/span&gt;&lt;span class="s"&gt;"header.serials.decision == 'Rejected'"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;to&lt;/span&gt; &lt;span class="na"&gt;uri=&lt;/span&gt;&lt;span class="s"&gt;"bean:#serial-requests?method=Reject"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;to&lt;/span&gt; &lt;span class="na"&gt;uri=&lt;/span&gt;&lt;span class="s"&gt;"bean:#serial-requests?method=QueueResponse"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/when&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/choice&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/transaction&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;log&lt;/span&gt; &lt;span class="na"&gt;level=&lt;/span&gt;&lt;span class="s"&gt;"Debug"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;${messageHistory()}&lt;span class="nt"&gt;&amp;lt;/log&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/route&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The correspondence is line by line. &lt;code&gt;DoTry&lt;/code&gt; and &lt;code&gt;DoCatch&lt;/code&gt; became &lt;code&gt;&amp;lt;tryCatch&amp;gt;&lt;/code&gt; with &lt;code&gt;&amp;lt;try&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;catch&amp;gt;&lt;/code&gt; branches, &lt;code&gt;Transacted()&lt;/code&gt; became &lt;code&gt;&amp;lt;transaction&amp;gt;&lt;/code&gt;, predicate lambdas became expressions in an &lt;code&gt;expr&lt;/code&gt; attribute. The transaction boundary is visible to the eye: everything inside &lt;code&gt;&amp;lt;transaction&amp;gt;&lt;/code&gt; commits together, everything outside it is written after the commit.&lt;/p&gt;

&lt;p&gt;A service call, &lt;code&gt;ProcessWithRedb(SerialRequestService.RegisterAsync)&lt;/code&gt;, became &lt;code&gt;bean:#serial-requests?method=Register&lt;/code&gt;. The bean is a thin wrapper that takes redb from the exchange and hands the exchange to the same service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SerialRequestBeans&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;Register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IExchange&lt;/span&gt; &lt;span class="n"&gt;exchange&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;SerialRequestService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RegisterAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Redb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exchange&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;exchange&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;Allocate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IExchange&lt;/span&gt; &lt;span class="n"&gt;exchange&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;SerialRequestService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AllocateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Redb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exchange&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;exchange&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Reject, QueueResponse: the same&lt;/span&gt;

    &lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;IRedbService&lt;/span&gt; &lt;span class="nf"&gt;Redb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IExchange&lt;/span&gt; &lt;span class="n"&gt;exchange&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;exchange&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServiceProvider&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;GetService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IRedbService&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
           &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvalidOperationException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"The exchange carries no IRedbService."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The redb instance is the one &lt;code&gt;ProcessWithRedb&lt;/code&gt; 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 &lt;strong&gt;when&lt;/strong&gt; a step runs, the service decides &lt;strong&gt;what&lt;/strong&gt; happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not a second engine, a second spelling
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;Filter(...)&lt;/code&gt;, &lt;code&gt;When(...)&lt;/code&gt;, &lt;code&gt;SetHeader(...)&lt;/code&gt; and the rest of the DSL exactly as C# code would. What follows comes for free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Everything the engine can do, the markup can do.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A route converts back to C#.&lt;/strong&gt; &lt;code&gt;redb-route-xml csharp&lt;/code&gt; prints a &lt;code&gt;RouteBuilder&lt;/code&gt; class. In the readable style, markup comments become code comments; in the machine style the code carries &lt;code&gt;#line&lt;/code&gt; directives, so a debugger breakpoint lands on the line of the XML file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The diagram comes from the same source.&lt;/strong&gt; &lt;code&gt;redb-route-xml mermaid&lt;/code&gt; builds a flowchart from the definition tree, so the picture in your documentation cannot drift from the running route.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migration goes one route at a time.&lt;/strong&gt; Both spellings live in one context, address the same &lt;code&gt;direct:&lt;/code&gt; endpoints and beans, and there is no reason to convert everything at once.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  A schema the editor picks up by itself
&lt;/h2&gt;

&lt;p&gt;A markup document starts with a namespace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;routes&lt;/span&gt; &lt;span class="na"&gt;xmlns=&lt;/span&gt;&lt;span class="s"&gt;"urn:redb:route:1.0"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The VS Code extension hands the schema over for that namespace through an OASIS catalog, and from there &lt;a href="https://marketplace.visualstudio.com/items?itemName=redhat.vscode-xml" rel="noopener noreferrer"&gt;Red Hat XML&lt;/a&gt; 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 &lt;code&gt;route.xml&lt;/code&gt; without our namespace is left alone.&lt;/p&gt;

&lt;p&gt;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: &lt;code&gt;timer&lt;/code&gt;, &lt;code&gt;direct&lt;/code&gt;, &lt;code&gt;seda&lt;/code&gt;, &lt;code&gt;vm&lt;/code&gt;. For &lt;code&gt;sql:&lt;/code&gt; that means &lt;code&gt;dataSource&lt;/code&gt;, &lt;code&gt;placeholderStyle&lt;/code&gt; with its &lt;code&gt;At&lt;/code&gt;, &lt;code&gt;Colon&lt;/code&gt; and &lt;code&gt;Question&lt;/code&gt; values, &lt;code&gt;readOnly&lt;/code&gt; and the rest, each with its type and default. Options a connector marks as sensitive are shown as secrets.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Figgraalus9ttijy7gun3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Figgraalus9ttijy7gun3.png" alt="Completion and validation of the markup in VS Code" width="800" height="885"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The graph editor: the text stays the truth
&lt;/h2&gt;

&lt;p&gt;The second mode of the same extension opens a route as a graph: &lt;code&gt;Open With&lt;/code&gt;, then &lt;code&gt;redb Route Graph&lt;/code&gt;, 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 &lt;code&gt;Ctrl+Z&lt;/code&gt;. Comments, indentation and attribute order stay as the author left them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcg6ow1bnu174yzzwlz7x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcg6ow1bnu174yzzwlz7x.png" alt="A route in the VS Code graph editor" width="800" height="422"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What it gives you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two orientations, one look.&lt;/strong&gt; The route is drawn Mermaid-style: curved edges, diamonds for branchings, hexagons for conditions, brackets around &lt;code&gt;filter&lt;/code&gt; and &lt;code&gt;transaction&lt;/code&gt;. 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 and &lt;code&gt;Ctrl&lt;/code&gt;+wheel, and both the layout and the zoom are remembered per file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A properties panel on click.&lt;/strong&gt; 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 &lt;code&gt;{{sftp.host}}&lt;/code&gt; placeholder is edited as written, with the value it resolves to from configuration shown next to it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Editing with the mouse.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft76o6pe2jll4tqgxcfd3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft76o6pe2jll4tqgxcfd3.png" alt="The endpoint properties panel" width="800" height="810"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  An endpoint: a short URI or a structure
&lt;/h2&gt;

&lt;p&gt;A short address is written as in C#:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;to&lt;/span&gt; &lt;span class="na"&gt;uri=&lt;/span&gt;&lt;span class="s"&gt;"kafka://orders?key=${header.tripId}"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A long one reads better as a structure. SQL text goes into the element's content without escaping, parameters become their own lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;to&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;sql&lt;/span&gt; &lt;span class="na"&gt;dataSource=&lt;/span&gt;&lt;span class="s"&gt;"#main-db"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="cp"&gt;&amp;lt;![CDATA[ INSERT INTO auth_log(login, at) VALUES (:#login, :#at) ]]&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;param&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"login"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"${header.login}"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;param&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"at"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"${dateformat(now(), 'o')}"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/sql&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/to&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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: &lt;strong&gt;no connector carries code written specially for XML&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Factories, configuration and secrets
&lt;/h2&gt;

&lt;p&gt;Objects the routes refer to are declared in the package's &lt;code&gt;context.xml&lt;/code&gt;. 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;bean&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"main-db"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"redb.Route.Sql.Connection.SqlConnectionFactory, redb.Route.Sql"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;constructorArg&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;bean&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"redb.Route.Sql.Connection.SqlConnectionOptions, redb.Route.Sql"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;key=&lt;/span&gt;&lt;span class="s"&gt;"ConnectionString"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"{{db.main.connection}}"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/bean&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/constructorArg&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/bean&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;{{key}}&lt;/code&gt; and &lt;code&gt;{{key:default}}&lt;/code&gt; resolve through the configuration chain before the value is converted, so &lt;code&gt;{{ldap.port:636}}&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;factoryMethod&lt;/code&gt; builds the object with a static method instead of a constructor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;bean&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"globex"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"redb.Route.As2.As2ConnectionFactory, redb.Route.As2"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;key=&lt;/span&gt;&lt;span class="s"&gt;"OurCertificate"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;bean&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"System.Security.Cryptography.X509Certificates.X509CertificateLoader, System.Security.Cryptography.X509Certificates"&lt;/span&gt;
          &lt;span class="na"&gt;factoryMethod=&lt;/span&gt;&lt;span class="s"&gt;"LoadPkcs12FromFile"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;constructorArg&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"{{As2.CertificateDirectory}}/hub.pfx"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;constructorArg&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"{{As2.CertificatePassword}}"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/bean&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/property&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;key=&lt;/span&gt;&lt;span class="s"&gt;"As2From"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"{{As2.Id}}"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;key=&lt;/span&gt;&lt;span class="s"&gt;"As2To"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"GLOBEX"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;key=&lt;/span&gt;&lt;span class="s"&gt;"Sign"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;property&lt;/span&gt; &lt;span class="na"&gt;key=&lt;/span&gt;&lt;span class="s"&gt;"Encrypt"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/bean&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Packages bring their own elements
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;&amp;lt;cache&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;rest&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;transformJson&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;payload&amp;gt;&lt;/code&gt; and the bridge to redb storage appeared: &lt;code&gt;&amp;lt;redbGet&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;redbSave&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;redbQuery&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;redbDelete&amp;gt;&lt;/code&gt;, plus a &lt;code&gt;&amp;lt;redb&amp;gt;&lt;/code&gt; block in the context.&lt;/p&gt;

&lt;p&gt;Here is a route from the second demo, &lt;code&gt;XmlDemo&lt;/code&gt;, which works with redb without a line of C#:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;route&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"redbdemo-cycle"&lt;/span&gt; &lt;span class="na"&gt;description=&lt;/span&gt;&lt;span class="s"&gt;"redb bridge: upsert one row by unique key, query it back"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;from&lt;/span&gt; &lt;span class="na"&gt;uri=&lt;/span&gt;&lt;span class="s"&gt;"timer://redbdemo?period={{demo.period:5000}}"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;setBody&lt;/span&gt; &lt;span class="na"&gt;expr=&lt;/span&gt;&lt;span class="s"&gt;'{"name":"xmldemo:heartbeat","value_unique":"xmldemo:heartbeat","properties":{"ProcessorName":"xmldemo","MessageKey":"heartbeat","Confirmed":true}}'&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;redbSave&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"redb.Route.RedbCore.Models.IdempotentEntryProps, redb.Route.Core"&lt;/span&gt; &lt;span class="na"&gt;byUnique=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;redbQuery&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"redb.Route.RedbCore.Models.IdempotentEntryProps, redb.Route.Core"&lt;/span&gt;
             &lt;span class="na"&gt;where=&lt;/span&gt;&lt;span class="s"&gt;"ProcessorName == 'xmldemo' AND MessageKey == 'heartbeat'"&lt;/span&gt;
             &lt;span class="na"&gt;orderBy=&lt;/span&gt;&lt;span class="s"&gt;"MessageKey"&lt;/span&gt; &lt;span class="na"&gt;take=&lt;/span&gt;&lt;span class="s"&gt;"10"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;setBody&lt;/span&gt; &lt;span class="na"&gt;expr=&lt;/span&gt;&lt;span class="s"&gt;"REDBDEMO total=${body.Count}"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;to&lt;/span&gt; &lt;span class="na"&gt;uri=&lt;/span&gt;&lt;span class="s"&gt;"log://redbdemo"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/route&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;&amp;lt;redbSave byUnique="true"&amp;gt;&lt;/code&gt; stores the object by a unique key: a row with that key is updated, no new one appears. &lt;code&gt;&amp;lt;redbQuery&amp;gt;&lt;/code&gt; turns the &lt;code&gt;where&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;context&lt;/span&gt; &lt;span class="na"&gt;xmlns=&lt;/span&gt;&lt;span class="s"&gt;"urn:redb:route:1.0"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;redb&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;syncScheme&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"redb.Route.RedbCore.Models.IdempotentEntryProps, redb.Route.Core"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/redb&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/context&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The routes project
&lt;/h2&gt;

&lt;p&gt;XML routes live in an ordinary .NET project with a layout convention. One command creates it, &lt;code&gt;redb-route-xml new&lt;/code&gt;, and from there the package is built out of what sits in its places:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;What is there&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;routes/*.route.xml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;routes; load order comes from the manifest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;context.xml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;components, context beans, the one-off &lt;code&gt;&amp;lt;onInit&amp;gt;&lt;/code&gt; pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;resources/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;XSD, XSLT and anything routes reference by file name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;config/{Name}.config.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;module identity: context name and autostart&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;config/context.sample.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a sample of the settings for whoever deploys it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;schema/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the schema for working without the extension, bound in &lt;code&gt;.vscode&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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 &lt;code&gt;.tpkg&lt;/code&gt; therefore goes to staging and to production without a rebuild: the environment changes, the package does not.&lt;/p&gt;

&lt;p&gt;If a routes project references its own types (as &lt;code&gt;SerialNumbers.Xml&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests: the same test kit
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;TheCanonicalTestKitFlow_WorksOverAnXmlRoute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddXmlRoutesFromContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"""
&lt;/span&gt;        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;routes&lt;/span&gt; &lt;span class="n"&gt;xmlns&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"urn:redb:route:1.0"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"under-test"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"direct://tk-in"&lt;/span&gt;&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;setHeader&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"seen"&lt;/span&gt; &lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"kafka://orders"&lt;/span&gt;&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;routes&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="s"&gt;""");
&lt;/span&gt;    &lt;span class="n"&gt;_context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AdviceAllRoutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MockEndpoints&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"kafka://*"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;mock&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Mock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"kafka://orders"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ExpectMessageCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ExpectHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"seen"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SendBody&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"direct://tk-in"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"payload"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AssertIsSatisfiedAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A route from a file loads the same way, with one line: &lt;code&gt;AddXmlRoutes("routes/orders.route.xml")&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;WeaveById&lt;/code&gt; finds a step by its &lt;code&gt;id&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checks before you ship
&lt;/h2&gt;

&lt;p&gt;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 &lt;strong&gt;all&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;The second line is the package check, &lt;code&gt;redb-route-xml check&lt;/code&gt; and &lt;code&gt;pack&lt;/code&gt;. It plugs straight into the build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet build &lt;span class="nt"&gt;-p&lt;/span&gt;:PackRouteOnBuild&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt;:Version&lt;span class="o"&gt;=&lt;/span&gt;1.0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the build the check runs against the fresh output and sees the real assemblies. What it verifies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the whole document against the schema, with the position of every error;&lt;/li&gt;
&lt;li&gt;registry references: &lt;code&gt;#main-db&lt;/code&gt; with no declaration in the package is named, while a SQL parameter &lt;code&gt;:#login&lt;/code&gt; is not a reference at all, the difference being where the hash sits;&lt;/li&gt;
&lt;li&gt;beans against the real assemblies: the type exists, &lt;code&gt;&amp;lt;property&amp;gt;&lt;/code&gt; properties are writable, the method in &lt;code&gt;bean:#x?method=M&lt;/code&gt; is really there;&lt;/li&gt;
&lt;li&gt;resources: the schema named by &lt;code&gt;validateXsd file=&lt;/code&gt; is in the package;&lt;/li&gt;
&lt;li&gt;secrets written literally, such as &lt;code&gt;password=...&lt;/code&gt; in a URI;&lt;/li&gt;
&lt;li&gt;required configuration keys, which go into the manifest.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is its answer to a typo in a bean method name and in an element name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And to the corrected file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;serial-numbers-xml 0.0.0: 1 artifact(s), 0 required config key(s) - ok
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Separately the gate &lt;strong&gt;warns&lt;/strong&gt;, where the decision belongs to the author: a step that will never run (anything after &lt;code&gt;&amp;lt;stop/&amp;gt;&lt;/code&gt; in the same list), an undeclared &lt;code&gt;#name&lt;/code&gt; that module code may register at startup, and a condition that compares outside a placeholder. The last one is worth spelling out. Written &lt;code&gt;expr="${header.kind} == 'order'"&lt;/code&gt;, 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, &lt;code&gt;header.kind == 'order'&lt;/code&gt;, it compares. The gate names the line where the braces are one pair too many.&lt;/p&gt;

&lt;p&gt;The tool also watches itself: built against a different version of &lt;code&gt;redb.Route.Xml&lt;/code&gt; than the project's assemblies, it stops and names both versions.&lt;/p&gt;

&lt;h2&gt;
  
  
  A module with no assembly of its own
&lt;/h2&gt;

&lt;p&gt;A package holding only &lt;code&gt;.route.xml&lt;/code&gt;, &lt;code&gt;context.xml&lt;/code&gt;, resources and configuration is a complete Tsak module. Drop the &lt;code&gt;.tpkg&lt;/code&gt; into the &lt;code&gt;modules&lt;/code&gt; folder of a running worker and it picks it up. This is the log for &lt;code&gt;XmlDemo&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;modules&lt;/code&gt; and the module unloads just as quietly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;#name&lt;/code&gt;. That is how &lt;code&gt;SerialNumbers.Xml&lt;/code&gt; 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 &lt;a href="https://redbase.app/articles/tsak-microservices" rel="noopener noreferrer"&gt;microservices with Tsak&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Expressions: one language, errors at load
&lt;/h2&gt;

&lt;p&gt;The expression language is the same one C# routes use, covered in a separate article on &lt;a href="https://redbase.app/articles/route-expressions" rel="noopener noreferrer"&gt;redb.Route expressions&lt;/a&gt;. Three things matter for XML.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Position decides meaning.&lt;/strong&gt; In a condition (&lt;code&gt;filter&lt;/code&gt;, &lt;code&gt;when&lt;/code&gt;, &lt;code&gt;validate&lt;/code&gt;) the string becomes a predicate; in a value (&lt;code&gt;setBody&lt;/code&gt;, &lt;code&gt;setHeader&lt;/code&gt;, &lt;code&gt;toD&lt;/code&gt;) it becomes an evaluated object; in &lt;code&gt;uri&lt;/code&gt; and &lt;code&gt;id&lt;/code&gt; it stays a literal. You never have to ask whether something is an expression: the attribute it sits in answers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Whitespace around operators does not matter&lt;/strong&gt;, 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;filter&lt;/span&gt; &lt;span class="na"&gt;expr=&lt;/span&gt;&lt;span class="s"&gt;"header.amount&amp;gt;1000"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;filter&lt;/span&gt; &lt;span class="na"&gt;expr=&lt;/span&gt;&lt;span class="s"&gt;"header.amount &amp;amp;gt; 1000"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;filter&lt;/span&gt; &lt;span class="na"&gt;expr=&lt;/span&gt;&lt;span class="s"&gt;"header.amount &amp;amp;lt; 10"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Diagnostics read as expressions.&lt;/strong&gt; &lt;code&gt;messageHistory()&lt;/code&gt; returns the trail of an exchange step by step: as a table, as a single line &lt;code&gt;log &amp;gt; choice &amp;gt; to(http)&lt;/code&gt;, as a JSON array, or as numbers (&lt;code&gt;'count'&lt;/code&gt;, &lt;code&gt;'totalMs'&lt;/code&gt;, &lt;code&gt;'slowestMs'&lt;/code&gt;). &lt;code&gt;stats('otel:...')&lt;/code&gt; reads counters of the OpenTelemetry layer that endpoint statistics do not carry: &lt;code&gt;throttle&lt;/code&gt; delays, &lt;code&gt;circuitbreaker&lt;/code&gt; trips, messages dropped by &lt;code&gt;filter&lt;/code&gt;. In markup that turns into conditions which used to need code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;setHeader&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"trail"&lt;/span&gt; &lt;span class="na"&gt;expr=&lt;/span&gt;&lt;span class="s"&gt;"messageHistory('compact')"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;choice&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;when&lt;/span&gt; &lt;span class="na"&gt;expr=&lt;/span&gt;&lt;span class="s"&gt;"messageHistory('slowestMs') &amp;amp;gt; 500"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;log&lt;/span&gt; &lt;span class="na"&gt;level=&lt;/span&gt;&lt;span class="s"&gt;"Warning"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;${routeId} slow: ${messageHistory()}&lt;span class="nt"&gt;&amp;lt;/log&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/when&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/choice&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trail is recorded when &lt;code&gt;&amp;lt;messageHistory/&amp;gt;&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to know before you start
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Topology is written out.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code stays where code belongs.&lt;/strong&gt; Business logic lives in services, the markup calls them through beans. When a service signature differs from &lt;code&gt;(IExchange)&lt;/code&gt; or &lt;code&gt;(IExchange, CancellationToken)&lt;/code&gt;, a two-line wrapper sits between them, as above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;XML is added, it does not replace.&lt;/strong&gt; Routes move to markup one at a time while the rest keep running in C#. If a route outgrows the markup, &lt;code&gt;redb-route-xml csharp&lt;/code&gt; turns it into code, and from there it evolves as an ordinary &lt;code&gt;RouteBuilder&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The tool installs from NuGet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet tool &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; redb.Route.Xml.CodeGen
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It creates a routes project by the package convention, with the schema and the editor binding in place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;redb-route-xml new Orders &lt;span class="nt"&gt;--context&lt;/span&gt; orders
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The project builds and packs with the check, as shown above. From there the &lt;code&gt;.tpkg&lt;/code&gt; goes into a Tsak worker's &lt;code&gt;modules&lt;/code&gt;, and a route turns into C# or into a diagram whenever you want:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet build &lt;span class="nt"&gt;-p&lt;/span&gt;:PackRouteOnBuild&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt;:Version&lt;span class="o"&gt;=&lt;/span&gt;1.0.0
redb-route-xml csharp routes/main.route.xml &lt;span class="nt"&gt;--namespace&lt;/span&gt; Orders.Routes
redb-route-xml mermaid routes/main.route.xml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The markup library is the &lt;code&gt;redb.Route.Xml&lt;/code&gt; package, version 4.1.0; the VS Code extension ships as a &lt;code&gt;.vsix&lt;/code&gt; attached to the &lt;a href="https://github.com/redbase-app/redb-route" rel="noopener noreferrer"&gt;redb-route release on GitHub&lt;/a&gt;. Both demos live in the &lt;code&gt;demo&lt;/code&gt; folder of the same repository.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;If this was useful — a ⭐ on &lt;a href="https://github.com/redbase-app" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; helps others find it.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;More of my writing: &lt;a href="https://redbase.app/articles" rel="noopener noreferrer"&gt;redbase.app/articles&lt;/a&gt;, and on &lt;a href="https://dev.to/grelikt"&gt;dev.to&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>integration</category>
      <category>vscode</category>
      <category>eip</category>
    </item>
    <item>
      <title>redb 4.0.1 is out: a reliability release for all four .NET products. Transactions, the SQL connector and cluster isolation got the most work. Pro stays free, no license key.
https://redbase.app/
https://github.com/redbase-app</title>
      <dc:creator>rinat kozin</dc:creator>
      <pubDate>Fri, 18 Sep 2026 14:34:54 +0000</pubDate>
      <link>https://dev.to/rinat_kozin/redb-401-is-out-a-reliability-release-for-all-four-net-products-transactions-the-sql-3fc1</link>
      <guid>https://dev.to/rinat_kozin/redb-401-is-out-a-reliability-release-for-all-four-net-products-transactions-the-sql-3fc1</guid>
      <description>&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body flex items-center justify-between"&gt;
        &lt;a href="https://redbase.app/" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;redbase.app&lt;/span&gt;
          

        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;
&lt;br&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://github.com/redbase-app" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Favatars.githubusercontent.com%2Fu%2F256871492%3Fs%3D280%26v%3D4" height="280" class="m-0" width="280"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://github.com/redbase-app" rel="noopener noreferrer" class="c-link"&gt;
            redbase-app · GitHub
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            redbase-app has 4 repositories available. Follow their code on GitHub.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgithub.githubassets.com%2Ffavicons%2Ffavicon.svg" width="32" height="32"&gt;
          github.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


</description>
    </item>
    <item>
      <title>redb for business: your team writes business logic, the infrastructure is already built</title>
      <dc:creator>rinat kozin</dc:creator>
      <pubDate>Tue, 15 Sep 2026 16:46:04 +0000</pubDate>
      <link>https://dev.to/rinat_kozin/redb-for-business-your-team-writes-business-logic-the-infrastructure-is-already-built-5d99</link>
      <guid>https://dev.to/rinat_kozin/redb-for-business-your-team-writes-business-logic-the-infrastructure-is-already-built-5d99</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6ixv3khrz6w6neablzps.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6ixv3khrz6w6neablzps.png" alt="redb ecosystem" width="800" height="420"&gt;&lt;/a&gt;&lt;br&gt;
With redb your team writes only business logic: what the ecosystem replaces, what it costs, which risks it removes, and what to know before you start.&lt;/p&gt;

&lt;p&gt;On September 12 we shipped redb 4.0.0. The ecosystem's four products (a data store, an integration engine, a runtime and an identity server) moved to a shared major version: 76 packages, an internal security audit, and builds on .NET 10, which Microsoft supports until November 2028. The engineering changes are covered in &lt;a href="https://redbase.app/articles/ecosystem-400" rel="noopener noreferrer"&gt;a separate post&lt;/a&gt;. This one is for the people deciding what to build a backend on: what the ecosystem replaces, what it costs, which risks it removes, and what to know before you start.&lt;/p&gt;

&lt;p&gt;Here's what's in it, briefly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;redb.Core&lt;/strong&gt; stores typed objects in PostgreSQL, MS SQL Server or SQLite. The data structure is a C# class, the database schema is synchronized from code, and there are no migrations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;redb.Route&lt;/strong&gt; is an integration engine built on Enterprise Integration Patterns, in the same family as Apache Camel, with 29 transports ranging from Kafka, RabbitMQ and IBM MQ to SOAP, gRPC, AS2 and SFTP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;redb.Tsak&lt;/strong&gt; is the runtime: modules deploy and swap live, nodes form a cluster, and everything is visible and managed from one dashboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;redb.Identity&lt;/strong&gt; is an OpenID Connect and OAuth 2.1 server with HTTP, gRPC and SOAP facades, running as a module of the same runtime.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Your team writes business logic, not infrastructure
&lt;/h2&gt;

&lt;p&gt;In a typical project, before the first line of business logic, the team builds infrastructure: connections to brokers and external systems, retries and duplicate protection, database migrations, zero-downtime deployment, monitoring, authorization. That's months of work in which the business gets no new features, followed by years of maintaining all of it.&lt;/p&gt;

&lt;p&gt;In redb that layer is already written, tested and maintained. A developer describes data as a class, a process as a route, and a connection to an external system as a connector address. Everything else goes into what the business actually pays for.&lt;/p&gt;

&lt;p&gt;In numbers, counted on real systems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2.2–4 person-years of infrastructure&lt;/strong&gt; before the first line of business logic in &lt;a href="https://redbase.app/articles/integration-layer-cost" rel="noopener noreferrer"&gt;a TMS built on WSO2 MI and EF Core&lt;/a&gt;, and &lt;strong&gt;2.8–4.4 person-years&lt;/strong&gt; in the &lt;a href="https://redbase.app/articles/payment-platform-cost" rel="noopener noreferrer"&gt;payment platform model&lt;/a&gt;, plus 0.8–1.1 of a permanent headcount every year to maintain it. At a fully loaded $60–150 per engineering hour, that model comes to roughly $286k–$1.11M up front and $77k–$285k a year. With redb, that layer isn't yours to write or maintain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Twenty minutes instead of half a day to a day&lt;/strong&gt; to test a product idea that needs a new field or entity: no migration, no test environment, no rollback script. That's an order of magnitude, and the team gets to compare three to five options instead of shipping the first one that works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integration definitions four times shorter:&lt;/strong&gt; in a &lt;a href="https://redbase.app/articles/esb-migration" rel="noopener noreferrer"&gt;migration off an ESB&lt;/a&gt;, 861 lines of WSO2 XML became 212 lines of route.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Development moves several times faster not because people type faster, but because they stop writing what has already been written. For the business, that means a shorter path from idea to production and fewer hires for the same roadmap.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it replaces
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;The usual approach&lt;/th&gt;
&lt;th&gt;In redb&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Data&lt;/td&gt;
&lt;td&gt;An ORM, migrations and a separate process to roll them out&lt;/td&gt;
&lt;td&gt;The C# class is the schema&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Integration&lt;/td&gt;
&lt;td&gt;Apache Camel or WSO2 MI on Java, hand-written connectors&lt;/td&gt;
&lt;td&gt;redb.Route routes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runtime and operations&lt;/td&gt;
&lt;td&gt;Home-grown glue: zero-downtime deployment, dead-letter queues, dashboards&lt;/td&gt;
&lt;td&gt;redb.Tsak&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Identity&lt;/td&gt;
&lt;td&gt;Keycloak or WSO2 Identity Server on Java, with databases of their own&lt;/td&gt;
&lt;td&gt;redb.Identity&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cost of that stack doesn't show up in license fees; it shows up in the sheer volume of someone else's infrastructure. In the &lt;a href="https://redbase.app/articles/integration-layer-cost" rel="noopener noreferrer"&gt;breakdown of a real TMS&lt;/a&gt; on WSO2 MI and EF Core, the identity server alone brought 227 service tables across four databases, against 53 tables in the system's own business model. Every one of them has to be deployed, backed up and upgraded along with the product. redb.Identity keeps clients, tokens and keys in the same redb store as the rest of your data.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Licensing.&lt;/strong&gt; The open part is Apache 2.0. Pro features (optimized queries, partial change writes, parallel materialization, clustering) are free across the entire 4.x line, commercial use included. No license key, no registration, no per-core or per-node fees. The intent is to keep Pro free beyond 4.x as well. Pro packages are closed source, but the source is available on request to companies that adopt the ecosystem, for security audits, escrow or their own builds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The real costs&lt;/strong&gt; are people and hardware. As shown above, your people don't have to write and maintain the infrastructure, and the money side is worked out in the &lt;a href="https://redbase.app/articles/payment-platform-cost" rel="noopener noreferrer"&gt;payment platform breakdown&lt;/a&gt;: the model is open, the assumptions are stated, and you can plug in your own hourly rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  People: one language for the whole backend
&lt;/h2&gt;

&lt;p&gt;The data store, integrations, runtime and identity server are written and maintained by one C# team. You don't need separate Java specialists for Camel, Keycloak or WSO2, and you hire from the broad .NET talent pool.&lt;/p&gt;

&lt;p&gt;Three rules hold across the ecosystem: a data structure is a class, an entry point is a route, an integration is a connector. A new developer learns one approach instead of five that accumulated over the years, and code review doesn't burn time arguing about the right way to do things.&lt;/p&gt;

&lt;p&gt;The integration vocabulary is shared with the industry. Pattern names come from the Enterprise Integration Patterns book that Apache Camel and WSO2 are built on, so an integrator's experience carries over without retraining. Since 4.0, integrations can be described without C# at all, as declarative XML routes in the spirit of WSO2 MI configurations. The markup gives you beans, SQL and the other connectors, reading, querying, saving and deleting objects in the redb store, and transactions. Such a route deploys to Tsak as a package containing a single XML file. Custom code, if you need it, is written in .NET, just as WSO2 MI extensions are written in Java.&lt;/p&gt;

&lt;h2&gt;
  
  
  Speed of change
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A new data field&lt;/strong&gt; is a new class property. The schema synchronizes at startup, with no migrations and no release windows for them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Modules swap live&lt;/strong&gt;, without restarting the node and without a maintenance window; there's a whole section on that below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An integration can change without recompiling:&lt;/strong&gt; since 4.0 a module can be a single XML route.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integrations are tested without brokers.&lt;/strong&gt; The test kit replaces calls to Kafka, queues and databases with mocks without touching the route itself, so regression testing no longer needs a dedicated environment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Speed doesn't mean giving up control. Since 4.0, production schema changes can be handed entirely to your DBA: the application stops with a clear message and provides the upgrade script instead of running DDL on its own at startup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Releases without a maintenance window
&lt;/h2&gt;

&lt;p&gt;Hot deployment and graceful shutdown in redb aren't a feature you switch on before a release; they're the normal lifecycle of every module.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A new module version is checked before the running one is touched.&lt;/strong&gt; The runtime loads it separately and confirms the package opens and contains a module; if it doesn't, the running version stays as it is. The old version then shuts down gracefully, stops accepting new messages and finishes the ones in progress, after which the new version starts. The brief pause affects only that module's context: the process doesn't restart, and modules in other contexts on the node don't notice the deployment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shutdown is graceful by default.&lt;/strong&gt; A route first stops accepting new messages, then finishes in-flight ones within a configured timeout, and only then closes transports and database connections. Deployment, module removal and shutting down a whole node all work this way: a node first gives up cluster leadership, then stops its contexts, and one slow context can't cut short the shutdown of the others.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nodes are serviced without downtime.&lt;/strong&gt; A cordoned node takes no new work and hands its cluster routes to its peers, after which it can be upgraded or rebooted. A readiness check tells Kubernetes when a node can't do its work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In money terms:&lt;/strong&gt; in the payment platform model, preparing and running release windows costs about 450 hours a year. Here there's no window to run, and deployment stops being a late-night event with people on call.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of this is about deploying your modules. Moving the platform itself to a new major version, such as 4.0, is planned separately: the nodes of one cluster group are upgraded together, and on a large database the schema upgrade is best done ahead of time in a quiet window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No new database required.&lt;/strong&gt; redb installs straight into an existing production database, PostgreSQL or MS SQL Server: its tables and functions sit next to yours and leave other objects alone. Backups, replication and database monitoring stay as they are, and if that's how your organization works, the DBA runs the install from a ready-made script.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A cluster without double processing.&lt;/strong&gt; Since 4.0, uniqueness in the cluster is enforced by the database itself: one module is never assigned to two nodes, and two nodes starting on an empty database don't create two clusters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiple clusters are built into the structure.&lt;/strong&gt; The topology is a tree of cluster, group and node, and one database holds several clusters with their own groups and nodes. Inter-cluster communication, including across regions, is on the roadmap; until it lands, clusters in different regions are connected with redb.Route routes over a broker or HTTP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One dashboard for every node:&lt;/strong&gt; routes, errors, the dead-letter queue with replay, the audit log, logs. Prometheus scrapes the metrics, traces go to OpenTelemetry, and nodes deploy to Kubernetes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A traffic spike doesn't take down its neighbors.&lt;/strong&gt; Since 4.0 every entry point can have its own limit: excess requests are rejected before processing starts, while neighboring routes on the same port keep working. The dashboard shows which routes hit their limit, which tells you where capacity is short before errors appear.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High-frequency streams in the same database.&lt;/strong&gt; Vehicle coordinates, sensor readings and other streams are ingested by redb.Route connectors: Kafka, MQTT, RabbitMQ and the rest. Raw points aren't written as redb objects but into ordinary time-series tables (partitioned ones, for example) in the same database, as bulk inserts batched by a route aggregator. The stream needs no separate database, and a foreign key to a redb object (a vehicle, a trip, a sensor) works as in any other table. Objects hold the entities and totals, time-series tables hold the raw stream, just as in the &lt;a href="https://redbase.app/articles/tms-architecture" rel="noopener noreferrer"&gt;TMS architecture&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One build, two topologies.&lt;/strong&gt; The same modules deploy as a monolith or as a cluster of microservices; the &lt;a href="https://redbase.app/articles/tsak-microservices" rel="noopener noreferrer"&gt;Tsak post&lt;/a&gt; covers this in detail.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Security and audit
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Module code is signed.&lt;/strong&gt; The runtime can be configured to refuse unsigned modules, and the signature is applied with your key in your build pipeline. The ecosystem's release archives are signed with cosign.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Third-party modules run separately.&lt;/strong&gt; A module runs with the rights of the worker process, and a signature proves who built it but doesn't limit what it does. So a module from a vendor whose code you haven't reviewed is deployed in its own worker container: the OS and the container provide the isolation, and it's managed from the same dashboard as everything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The identity server is checked against the official OpenID Foundation conformance suite.&lt;/strong&gt; The Config OP and Basic OP (35 modules) profiles pass with zero failures. The suite is open source and runs in Docker, and the results and run steps are &lt;a href="https://github.com/redbase-app/redb-identity/blob/main/OPENID_CERTIFICATION.md" rel="noopener noreferrer"&gt;published in the repository&lt;/a&gt;, so anyone can reproduce the check; here's &lt;a href="https://redbase.app/articles/identity-openid-conformance" rel="noopener noreferrer"&gt;how the run went&lt;/a&gt;. The protocol work for formal certification is done, while the OpenID Certified mark itself is granted by the Foundation through a separate paid application. Out of the box: multi-factor authentication, federation with external providers and LDAP, SCIM, DPoP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Before 4.0, the Identity facades and the Tsak dashboard went through an internal security audit&lt;/strong&gt;, and everything it found is fixed in this release. Since 4.0, access tokens carry an RFC 9068 audience, so a token issued for an external API doesn't open the identity server's management API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A change journal for auditors.&lt;/strong&gt; Since 4.0, write interceptors give the application "who changed what, and when" without touching business code, and in partial change-write mode (Pro) the per-field before and after values as well. Runtime administrator actions go to a separate journal.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Independence from the vendor
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Everything runs inside your perimeter.&lt;/strong&gt; No SaaS, no license server, and nothing goes back to the vendor: neither license checks nor usage data. The routes' own telemetry doesn't send itself anywhere: your Prometheus scrapes metrics from the node, and OpenTelemetry trace export is off by default and gets turned on with the address of your collector or Jaeger. Packages and images mirror into your internal feed and registry, after which no access to outside services is needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your data stays yours.&lt;/strong&gt; It lives in your PostgreSQL, MS SQL Server or SQLite, the storage layout is open and documented, and the database is readable with plain SQL. Built-in export and import cover the whole database, including between different database engines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data residency is decided by where you deploy.&lt;/strong&gt; The system doesn't depend on an outside licensor or an external service, so regulated and air-gapped environments run it the same way as any other.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Who's behind it
&lt;/h2&gt;

&lt;p&gt;redb is an ecosystem of four products on a single release line. The project already has an outside contributor, and we welcome more: contributions go through GitHub, with the rules in &lt;a href="https://github.com/redbase-app/redb/blob/main/CONTRIBUTING.md" rel="noopener noreferrer"&gt;CONTRIBUTING.md&lt;/a&gt;. Its direction is shaped by feedback: GitHub discussions, external security reports, comments on articles.&lt;/p&gt;

&lt;p&gt;What matters for a business isn't the size of the team; it's what stays in your hands whatever happens: open source under Apache 2.0, Pro source on request, data in your own database, and releases with signed artifacts and public changelogs for every product. Fixes are pinned by integration tests on three databases. Libraries target .NET 8, 9 and 10, and .NET 10 is supported by Microsoft until November 2028.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deployment (including to the cloud), support, implementation help and SLAs&lt;/strong&gt; are available by arrangement. So is a provider for another database, such as Oracle or MySQL; PostgreSQL, MS SQL Server and SQLite are supported out of the box. You can get in touch through &lt;a href="https://redbase.app/about" rel="noopener noreferrer"&gt;redbase.app/about&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to know before you start
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;redb is the platform your product is built on.&lt;/strong&gt; Accounting, pricing, logistics and the rest of your domain logic are written by your team or by a third-party vendor by arrangement, while the infrastructure underneath is already in place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It runs where you deploy it.&lt;/strong&gt; redb runs on your servers or in your cloud rather than as a subscription service, and deployment and support can be handed to third-party vendors by arrangement.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Getting started
&lt;/h2&gt;

&lt;p&gt;You don't have to adopt the whole ecosystem at once, and a phased rollout breaks nothing: redb sits next to what already runs, in the same database and alongside your existing systems. A pilot with a single module is a good way in: pick an integration or service with a clear boundary, move it over and compare the upkeep under real load. That's exactly what the &lt;a href="https://redbase.app/articles/esb-migration" rel="noopener noreferrer"&gt;ESB migration story&lt;/a&gt; did, counting the move of one module line by line. From there, modules move over one at a time, at whatever pace suits the business.&lt;/p&gt;

&lt;p&gt;For a first look, the &lt;a href="https://github.com/redbase-app/redb-tsak/pkgs/container/redb-tsak-stack" rel="noopener noreferrer"&gt;redb-tsak-stack&lt;/a&gt; image, with the runtime and dashboard in one container, is all you need. Packages are on &lt;a href="https://www.nuget.org/profiles/relikt" rel="noopener noreferrer"&gt;NuGet&lt;/a&gt; and the source is on &lt;a href="https://github.com/redbase-app/" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. To talk about a pilot, implementation or support, reach out through &lt;a href="https://redbase.app/about" rel="noopener noreferrer"&gt;redbase.app/about&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;If this was useful — a ⭐ on &lt;a href="https://github.com/redbase-app" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; helps others find it.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;More of my writing: &lt;a href="https://redbase.app/articles" rel="noopener noreferrer"&gt;redbase.app/articles&lt;/a&gt;, and on &lt;a href="https://dev.to/rinat_kozin"&gt;dev.to&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>architecture</category>
      <category>opensource</category>
      <category>business</category>
    </item>
    <item>
      <title>redb 4.0: XML routes, lazy references, unique keys and WS-Trust in one major release</title>
      <dc:creator>rinat kozin</dc:creator>
      <pubDate>Mon, 14 Sep 2026 18:15:54 +0000</pubDate>
      <link>https://dev.to/rinat_kozin/redb-40-xml-routes-lazy-references-unique-keys-and-ws-trust-in-one-major-release-4adk</link>
      <guid>https://dev.to/rinat_kozin/redb-40-xml-routes-lazy-references-unique-keys-and-ws-trust-in-one-major-release-4adk</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwppfhvd9ecbpnat3cq83.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwppfhvd9ecbpnat3cq83.png" alt="redb ecosystem" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One major release across the ecosystem: what's new in &lt;strong&gt;redb.Core, Route, Tsak and Identity&lt;/strong&gt;, what got fixed on security, and what breaks.&lt;/p&gt;

&lt;p&gt;A lot of this release was built burning the midnight oil. Feedback from people using the stack kept feeding both the thinking and the fixes: comments on articles, GitHub discussions, reports from live deployments. After a marathon stretch of work, 4.0.0 is out.&lt;/p&gt;

&lt;p&gt;This is a major release for the whole ecosystem at once. The &lt;strong&gt;redb.Core&lt;/strong&gt; store, the &lt;strong&gt;redb.Route&lt;/strong&gt; integration engine, the &lt;strong&gt;redb.Tsak&lt;/strong&gt; runtime and the &lt;strong&gt;redb.Identity&lt;/strong&gt; OpenID server all ship under one version number: 76 packages on NuGet (up from 66 in 3.7), seven container images on GHCR, and archives for Windows and Linux. The Pro edition stays free and needs no license key across the entire 4.x line.&lt;/p&gt;

&lt;p&gt;There is enough here that each product deserves its own deep dive, and those are coming. This post is the short version, close to a list: what's new, what was fixed on security, and what to know before upgrading. Full changelogs are at &lt;a href="https://redbase.app/releases" rel="noopener noreferrer"&gt;redbase.app/releases&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  redb.Route: XML routes, a test kit and data formats
&lt;/h2&gt;

&lt;p&gt;Ten new packages and a noticeable rework of the engine itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  A shared vocabulary: the EIP book
&lt;/h3&gt;

&lt;p&gt;Wire Tap, Content-Based Router, Splitter, Aggregator, Content Enricher, Claim Check, Idempotent Receiver: none of these names were invented for redb.Route. They all come from &lt;em&gt;Enterprise Integration Patterns: Designing, Building, and Deploying Messaging Solutions&lt;/em&gt; by Gregor Hohpe and Bobby Woolf (Addison-Wesley, 2003), a catalog of 65 messaging patterns. Apache Camel, WSO2 Micro Integrator and redb.Route are all built on it, which makes it the common vocabulary of integration: once you recognize a pattern by name, you recognize it in every one of these tools. The redb.Route DSL verbs follow Camel's spelling: &lt;code&gt;WireTap&lt;/code&gt;, &lt;code&gt;Choice&lt;/code&gt;, &lt;code&gt;Split&lt;/code&gt;, &lt;code&gt;Aggregate&lt;/code&gt;, &lt;code&gt;Enrich&lt;/code&gt;, &lt;code&gt;ClaimCheck&lt;/code&gt;, &lt;code&gt;IdempotentConsumer&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The whole catalog is free on the authors' site, with a diagram and a short explanation for every pattern: &lt;a href="https://www.enterpriseintegrationpatterns.com/" rel="noopener noreferrer"&gt;enterpriseintegrationpatterns.com&lt;/a&gt;. The chapters on Message Routing and Message Transformation are the place to start: per-partner formats, translators between them and a single point of entry are covered there pattern by pattern.&lt;/p&gt;

&lt;h3&gt;
  
  
  New packages
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;redb.Route.Xml&lt;/code&gt;.&lt;/strong&gt; A route is described declaratively in &lt;code&gt;.route.xml&lt;/code&gt; and loads into the same fluent DSL you write in C#. The whole document is parsed at load time, and every problem comes back in one list: an unknown element with a suggestion, a malformed expression, an endpoint scheme that isn't registered. The XSD is generated from the element registry, &lt;code&gt;context.xml&lt;/code&gt; declares the context's components and beans, and a generator prints the XML back out as C#. The redb store is reachable straight from the markup through &lt;code&gt;&amp;lt;redbGet&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;redbQuery&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;redb.Route.TestKit&lt;/code&gt;.&lt;/strong&gt; Test a route without its brokers and without touching the route: &lt;code&gt;AdviceRoute&lt;/code&gt; with &lt;code&gt;ReplaceFrom&lt;/code&gt;, &lt;code&gt;MockEndpoints("kafka://*")&lt;/code&gt;, a &lt;code&gt;MockEndpoint&lt;/code&gt; with expectations and scripted replies, &lt;code&gt;NotifyBuilder&lt;/code&gt;. No dependency on any test framework.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;redb.Route.Templates&lt;/code&gt;.&lt;/strong&gt; Message bodies from Scriban or Liquid templates. The output type drives escaping of substituted values, templates compile at &lt;code&gt;Start()&lt;/code&gt;, and the sandbox blocks method calls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;redb.Route.DataFormats.Csv&lt;/code&gt;, &lt;code&gt;.Protobuf&lt;/code&gt;, &lt;code&gt;.Avro&lt;/code&gt;, &lt;code&gt;.Yaml&lt;/code&gt;&lt;/strong&gt;, plus Base64, GZip and Zip in redb.Route itself. Protobuf and Avro support Confluent framing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;redb.Route.JsonTransform&lt;/code&gt;.&lt;/strong&gt; JSON-to-JSON transformation with JSONata on a native .NET engine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;redb.Route.Cache&lt;/code&gt;.&lt;/strong&gt; Cache as an EIP: a &lt;code&gt;Cache(key, ttl)…EndCache()&lt;/code&gt; scope and a &lt;code&gt;cache:&lt;/code&gt; component, in process or over any &lt;code&gt;IDistributedCache&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;redb.Route.XPath2&lt;/code&gt;.&lt;/strong&gt; XPath 2.0 for what 1.0 can't do: regular expressions, sequences, &lt;code&gt;if&lt;/code&gt;, &lt;code&gt;for&lt;/code&gt;, &lt;code&gt;some&lt;/code&gt; and &lt;code&gt;every&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The engine
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One expression language.&lt;/strong&gt; Conditions, values and &lt;code&gt;${...}&lt;/code&gt; templates go through one grammar and one compiler instead of three engines. A broken condition now fails &lt;code&gt;Start()&lt;/code&gt; instead of quietly turning into a constant on live traffic. New in the language: &lt;code&gt;stats(target, metric)&lt;/code&gt; for endpoint statistics, &lt;code&gt;format(value, pattern, culture)&lt;/code&gt;, &lt;code&gt;uuid()&lt;/code&gt; and the &lt;code&gt;%&lt;/code&gt; operator.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;bean:&lt;/code&gt; component.&lt;/strong&gt; Your own code as an ordinary endpoint: the object comes from the context registry or is created with constructor DI, and URI parameters bind to its properties.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interception and completion&lt;/strong&gt; in the Apache Camel shape: &lt;code&gt;Intercept&lt;/code&gt;, &lt;code&gt;InterceptFrom&lt;/code&gt;, &lt;code&gt;InterceptSendToEndpoint&lt;/code&gt;, &lt;code&gt;OnCompletion&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Aggregation without lambdas.&lt;/strong&gt; A library of ready-made &lt;code&gt;AggregationStrategies&lt;/code&gt; (collect bodies into a list, concatenate, sum, min and max by expression, merge into a header or property), &lt;code&gt;Enrich&lt;/code&gt; without a strategy, and a throttle whose limit comes from the message. &lt;code&gt;Aggregate&lt;/code&gt;, &lt;code&gt;RecipientList&lt;/code&gt;, &lt;code&gt;DynamicRouter&lt;/code&gt;, &lt;code&gt;IdempotentConsumer&lt;/code&gt;, &lt;code&gt;Enrich&lt;/code&gt; and &lt;code&gt;PollEnrich&lt;/code&gt; accept an expression, not just a delegate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;REST DSL in &lt;code&gt;redb.Route.Http&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;Rest("/api/orders").Get("/{id}")&lt;/code&gt; with JSON binding, &lt;code&gt;Consumes&lt;/code&gt;/&lt;code&gt;Produces&lt;/code&gt;, and OpenAPI 3.0.3 served at &lt;code&gt;{basePath}/openapi.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metrics from inside a route.&lt;/strong&gt; &lt;code&gt;IExchange.Context&lt;/code&gt; and &lt;code&gt;UseMetricsSnapshot()&lt;/code&gt; make the OpenTelemetry layer readable in process.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No more Newtonsoft.Json.&lt;/strong&gt; &lt;code&gt;jpath&lt;/code&gt; moved to JsonPath.Net (RFC 9535) on System.Text.Json, which leaves redb.Route with a single JSON model.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Load and hosting
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per-endpoint admission limits.&lt;/strong&gt; HTTP, SOAP, AS2 and gRPC gained &lt;code&gt;maxConcurrentRequests&lt;/code&gt; and a wait queue: an excess request gets &lt;code&gt;429&lt;/code&gt; with &lt;code&gt;Retry-After&lt;/code&gt; before the pipeline runs, and a neighboring route on the same port keeps its own budget. Rejections land in a separate &lt;code&gt;Rejected&lt;/code&gt; counter, so shedding load never looks like failing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;concurrentConsumers=auto&lt;/code&gt;&lt;/strong&gt; on RabbitMQ, AMQP 1.0, IBM MQ, SQS and MQTT: one consumer per processor, with a minimum of two. The default is still one, which preserves message order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebSocket and SignalR on the shared Kestrel host&lt;/strong&gt;, next to HTTP, gRPC, SOAP and AS2: one port, handshake authentication supplied by the host, and a backplane seam for scale-out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trusted proxies on the shared host.&lt;/strong&gt; &lt;code&gt;X-Forwarded-For&lt;/code&gt; is walked right to left past every listed proxy, &lt;code&gt;X-Forwarded-Proto&lt;/code&gt; restores the client's scheme, and every transport on the host sees the real address. A reader's comment on the 3.7 write-up prompted this one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Connectors
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Kafka:&lt;/strong&gt; a commit on partition revocation no longer loses a record that was read but not yet processed, and &lt;code&gt;seekTo&lt;/code&gt; applies per partition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;S3:&lt;/strong&gt; the consumer no longer loses objects, and connection settings actually reach the AWS SDK.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firebase:&lt;/strong&gt; FCM multicast and topic management, copy and signed URLs in Storage, streaming downloads, and &lt;code&gt;databaseId&lt;/code&gt; for Firestore projects with several databases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Llm:&lt;/strong&gt; system prompt caching on Anthropic with cache token counters, a fixed preamble ahead of the conversation history, HTTP/2 keep-alive so long answers survive VPNs and NAT, speech-to-text through &lt;code&gt;stt://&lt;/code&gt;, and file downloads from Telegram.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SOAP:&lt;/strong&gt; the consumer serves TLS, a route knows who called it, and the route chooses the fault code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TLS without a certificate&lt;/strong&gt; no longer opens a plaintext port anywhere: WebSocket, SignalR and TCP refuse to start in that configuration.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  redb.Core(.Pro): lazy references and unique keys
&lt;/h2&gt;

&lt;p&gt;Everything below behaves the same on PostgreSQL, MSSQL and SQLite, in Free and in Pro.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lazy references.&lt;/strong&gt; A reference at the depth boundary arrives as a stub carrying &lt;code&gt;id&lt;/code&gt;, scheme and hash, and the first access to &lt;code&gt;Props&lt;/code&gt; loads exactly that object. A reference that should stay lazy at any depth is marked &lt;code&gt;virtual&lt;/code&gt;, with &lt;code&gt;EnableLazyReferences&lt;/code&gt; switched on. A collection of stubs loads in one query through &lt;code&gt;LoadReferencesAsync&lt;/code&gt;, with no N+1. Saving the parent, computing hashes and serializing never wake a stub.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;ValueUnique&lt;/code&gt; object key.&lt;/strong&gt; A readable string (order number, SKU, external id), unique within the scheme, with upsert by that key through &lt;code&gt;SaveByUniqueAsync&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;[RedbUnique]&lt;/code&gt; on Props fields.&lt;/strong&gt; Uniqueness enforced by a database index for scalars of any type, including &lt;code&gt;decimal&lt;/code&gt;, dates and &lt;code&gt;byte[]&lt;/code&gt;; for fields of nested classes; for a whole nested class, array or dictionary, where the key is the content of the subtree; and for collection elements within one object or across the scheme. &lt;code&gt;GetByUniqueAsync&lt;/code&gt; is a single index probe, and a violation surfaces as one &lt;code&gt;RedbUniqueViolationException&lt;/code&gt; on all three databases. Duplicates already sitting in existing data go into a report instead of failing startup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A contract for schema upgrades.&lt;/strong&gt; The schema module is applied at application startup. If the application's role can't change the schema, startup stops with a typed &lt;code&gt;RedbSchemaOutdatedException&lt;/code&gt;, and the script for your DBA comes from &lt;code&gt;GetUpgradeScript()&lt;/code&gt; or &lt;code&gt;redb schema --upgrade&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;CancellationToken&lt;/code&gt; across the entire async API.&lt;/strong&gt; A cancelled &lt;code&gt;SaveAsync&lt;/code&gt; rolls back completely, and the token is never read after the commit: "cancelled" can never mean "but actually saved".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write interceptors (&lt;code&gt;IRedbSaveInterceptor&lt;/code&gt;)&lt;/strong&gt; in the EF style: &lt;code&gt;SavingAsync&lt;/code&gt; can adjust the object or veto the save, &lt;code&gt;SavedAsync&lt;/code&gt; sees the outcome, and the same pair exists for deletes. Under ChangeTracking the interceptor receives the applied diff with property paths, ready to feed your own audit journal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transaction isolation level on demand&lt;/strong&gt; through &lt;code&gt;BeginTransactionAsync(IsolationLevel)&lt;/code&gt;, with &lt;code&gt;DbErrorClassifier.IsSerializationFailure&lt;/code&gt; for retrying serializable transactions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Maintenance&lt;/code&gt;:&lt;/strong&gt; &lt;code&gt;AnalyzeAsync&lt;/code&gt; refreshes planner statistics in one call on any database, and &lt;code&gt;GetIndexStatsAsync&lt;/code&gt; reads index health.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;LoadJsonAsync(id, depth)&lt;/code&gt;&lt;/strong&gt; returns an object as raw JSON with no CLR type, and &lt;code&gt;RedbObject.ToString()&lt;/code&gt; returns the same canonical JSON.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;byte[]&lt;/code&gt; is stored as one BLOB&lt;/strong&gt; instead of a row per byte, and the old layout converts itself. A scheme remembers the namespace of its type, so two &lt;code&gt;Order&lt;/code&gt; classes from different projects can no longer adopt each other's scheme.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance:&lt;/strong&gt; indexes on the &lt;code&gt;_values&lt;/code&gt; FK columns, faster ChangeTracking, SQLite hashes stored as &lt;code&gt;BLOB(16)&lt;/code&gt;, and an index on &lt;code&gt;_value_string&lt;/code&gt; on MSSQL too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixes that affected 3.x:&lt;/strong&gt; saving a parent whose reference was set by &lt;code&gt;id&lt;/code&gt; only overwrote the referenced object; &lt;code&gt;DateOnly&lt;/code&gt; on existing databases read back as &lt;code&gt;0001-01-01&lt;/code&gt;; MSSQL cut nested references in LINQ results at depth 1 where PostgreSQL and SQLite used 10.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  redb.Tsak: a cluster on database keys and modules from XML
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The cluster rests on database unique keys.&lt;/strong&gt; Locks, nodes, groups and module assignments are backed by redb.Core partial unique indexes. Two nodes starting on an empty database no longer create two cluster roots: the node that loses the race adopts the winner. Assignment writes carry the leader's epoch, so a stale leader can't place one module on two nodes. An existing database backfills its keys on the first start.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Modules from XML.&lt;/strong&gt; A &lt;code&gt;.tpkg&lt;/code&gt; can carry nothing but &lt;code&gt;.route.xml&lt;/code&gt; and &lt;code&gt;context.xml&lt;/code&gt;, with no assembly at all, and such a package hot-reloads just like a C# module. An XML route in the worker sees every markup element from the shared layer: &lt;code&gt;&amp;lt;cache&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;rest&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;redbGet&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The dashboard tells shedding from silence.&lt;/strong&gt; A Shedding Routes panel shows routes that hit their admission limit, and endpoints now show &lt;code&gt;Rejected&lt;/code&gt; and Bytes Out. Page auto-refresh survives a transient error, and monitoring charts no longer freeze under the cursor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;About page:&lt;/strong&gt; dashboard and node versions, uptime, and for admins the list of redb assemblies actually loaded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trusted proxies:&lt;/strong&gt; &lt;code&gt;Tsak:Http:TrustedProxies&lt;/code&gt; takes addresses and CIDR networks, after which the API key throttle and module routes see the real client behind a chain of proxies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Audit and Dead-letter pages work on PostgreSQL.&lt;/strong&gt; The daily &lt;code&gt;tsak_audit_log&lt;/code&gt; retention sweep started working on PostgreSQL at the same time, so on a node that has lived on Postgres for a while, check the table's size before the first sweep runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cluster protocol:&lt;/strong&gt; a heartbeat no longer reverts a concurrent cordon, a cordoned node gets no new modules, and a clustered route stopped through the API starts again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cluster-ready defaults:&lt;/strong&gt; on a shared database, Quartz ships in clustered mode (on SQLite it switches itself off, with a warning in the log), and a node's &lt;code&gt;ApiEndpoint&lt;/code&gt; is detected automatically instead of every node registering &lt;code&gt;localhost:9090&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  redb.Identity: WS-Trust, token audiences and a console built from pages
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WS-Trust facade (&lt;code&gt;redb.Identity.Soap&lt;/code&gt;).&lt;/strong&gt; A third transport after HTTP and gRPC: &lt;code&gt;Issue&lt;/code&gt;, &lt;code&gt;Validate&lt;/code&gt;, &lt;code&gt;Cancel&lt;/code&gt; and &lt;code&gt;Renew&lt;/code&gt; over SOAP, for systems that generate their clients from WSDL. Issuer, client registry and token store are shared, so a client registered over HTTP gets a token over SOAP. The facade refuses to start without TLS, and mTLS with certificate thumbprint pinning is available.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RFC 9068 audiences in access tokens.&lt;/strong&gt; The resources of granted scopes and the application's new &lt;code&gt;AccessTokenAudiences&lt;/code&gt; go into &lt;code&gt;aud&lt;/code&gt;, and the management API accepts only tokens that carry its own audience.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contracts in a carrier package of their own.&lt;/strong&gt; Hot-reloading one module no longer splits the shared DTOs into two copies, which used to make typed bodies vanish on their way through &lt;code&gt;direct-vm://&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Uniqueness on redb.Core primitives.&lt;/strong&gt; On MSSQL, &lt;code&gt;ClientId&lt;/code&gt;, &lt;code&gt;ScopeName&lt;/code&gt; and &lt;code&gt;ReferenceId&lt;/code&gt; are protected by an index for the first time, and an external identity can no longer be linked to two local users by a race.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An admin console built from pages instead of dialogs.&lt;/strong&gt; Federation providers get a full page with claim mappings, a claim mapper's owner is now selectable, users are created through a wizard, and there's a new API resources catalog plus a dashboard with KPIs and audit by category.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DPoP behind a TLS-terminating proxy&lt;/strong&gt;, and the correct &lt;code&gt;client_id&lt;/code&gt; in the audit log for clients that send credentials in the form body.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Security
&lt;/h2&gt;

&lt;p&gt;Some of these came in as external reports; others turned up in our own audit of the facades and the dashboard. The mechanics of each will be in the per-product deep dives. If you run 3.x in production, this list alone is a reason to upgrade.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;redb.Identity:&lt;/strong&gt; an HTTP header could impersonate any user. Internal-only headers are now stripped at the edge of all three facades: HTTP, gRPC and SOAP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;redb.Identity:&lt;/strong&gt; the consent page took the application name and scope list from its own URL, and the consent form could be submitted on behalf of another user without their session. The page now renders from a server-signed ticket, the user comes only from the session, and the forms check &lt;code&gt;Origin&lt;/code&gt; and refuse to be framed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;redb.Identity:&lt;/strong&gt; introspection answered any authenticated client about any token. It now answers only members of the token's audience.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;redb.Tsak:&lt;/strong&gt; dashboard pages opened without a login. Every page is now protected, and access tiers match the worker API: audit and users for Admin, dead-letter and logs for Operator.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;redb.Tsak:&lt;/strong&gt; module signatures are verified on every load path, process startup included, and the bytes loaded are exactly the bytes verified. Passwords no longer reach the audit log, sessions are revalidated every five minutes, and the login throttle keys on username and client address together.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;redb.Core:&lt;/strong&gt; the default password hasher is now bcrypt. Legacy SHA256 hashes still verify and move to bcrypt on the next password change.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Breaking changes
&lt;/h2&gt;

&lt;p&gt;The list is short, but go through it before upgrading.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;redb.Tsak and redb.Identity clusters upgrade with a stop.&lt;/strong&gt; Nodes of one group must not run mixed versions: stop the whole group, deploy 4.0, start it again. The first start backfills the keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A large database needs a maintenance window.&lt;/strong&gt; The upgrade adds columns and builds partial indexes under exclusive locks. It's easier to apply &lt;code&gt;GetUpgradeScript()&lt;/code&gt; ahead of time from one node and start the application with &lt;code&gt;AutoApplyDatabaseUpgrades = false&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;redb.Core:&lt;/strong&gt; Props-level lazy loading is gone entirely (&lt;code&gt;EnableLazyLoadingForProps&lt;/code&gt;, &lt;code&gt;WithLazyLoading()&lt;/code&gt;, the &lt;code&gt;lazyLoadProps&lt;/code&gt; parameter). &lt;code&gt;_objects._value_string&lt;/code&gt; is capped at 450 characters, and a database holding longer values refuses the upgrade with a list of objects to move to &lt;code&gt;_note&lt;/code&gt; or Props. Accessing &lt;code&gt;Props&lt;/code&gt; on a reference beyond the depth now goes to the database; for Blazor WebAssembly there's &lt;code&gt;LazyReferenceAccess = Throw&lt;/code&gt;. Assemblies compiled against 3.x need a rebuild.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;redb.Route:&lt;/strong&gt; the string forms of &lt;code&gt;LoopExpression&lt;/code&gt;, &lt;code&gt;DelayExpression&lt;/code&gt; and &lt;code&gt;ThrottleExpression&lt;/code&gt; became &lt;code&gt;Loop&lt;/code&gt;, &lt;code&gt;Delay&lt;/code&gt; and &lt;code&gt;Throttle&lt;/code&gt;, and &lt;code&gt;SetBodyExpression("…")&lt;/code&gt; and its siblings became &lt;code&gt;SetBody(Expr("…"))&lt;/code&gt;. Newtonsoft.Json no longer arrives transitively. &lt;code&gt;${...}&lt;/code&gt; renders numbers and dates culture-invariant, &lt;code&gt;${...}&lt;/code&gt; in a consumer URI fails &lt;code&gt;Start()&lt;/code&gt;, and zero, &lt;code&gt;"0"&lt;/code&gt;, &lt;code&gt;"no"&lt;/code&gt; and &lt;code&gt;"off"&lt;/code&gt; are now false in conditions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;redb.Identity:&lt;/strong&gt; a backend that introspects another client's tokens (the classic SPA and BFF pair) must be added to that client's &lt;code&gt;AccessTokenAudiences&lt;/code&gt;. The SQLite audit table changed its timestamp column type, so delete SQLite audit files before upgrading.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Getting it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package redb.Core
dotnet add package redb.Postgres        &lt;span class="c"&gt;# or redb.MSSql / redb.SQLite&lt;/span&gt;
dotnet add package redb.Postgres.Pro    &lt;span class="c"&gt;# Pro: free, no license key&lt;/span&gt;

dotnet add package redb.Route
dotnet add package redb.Route.TestKit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Libraries target &lt;code&gt;net8.0&lt;/code&gt;, &lt;code&gt;net9.0&lt;/code&gt; and &lt;code&gt;net10.0&lt;/code&gt;; host applications and images are built on .NET 10. Tsak and Identity ship as archives for Windows and Linux (&lt;a href="https://github.com/redbase-app/redb-tsak/releases" rel="noopener noreferrer"&gt;redb-tsak&lt;/a&gt;, &lt;a href="https://github.com/redbase-app/redb-identity/releases" rel="noopener noreferrer"&gt;redb-identity&lt;/a&gt;) and as images on &lt;a href="https://github.com/orgs/redbase-app/packages" rel="noopener noreferrer"&gt;GHCR&lt;/a&gt;. The easiest way to try Tsak is the &lt;code&gt;redb-tsak-stack&lt;/code&gt; image, which runs the worker and the dashboard in one container.&lt;/p&gt;

&lt;p&gt;Packages: &lt;a href="https://www.nuget.org/profiles/relikt" rel="noopener noreferrer"&gt;nuget.org/profiles/relikt&lt;/a&gt;. Source: &lt;a href="https://github.com/redbase-app/" rel="noopener noreferrer"&gt;github.com/redbase-app&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Each product gets its own deep dive: redb.Core with code for lazy references and keys, redb.Route with the test kit and XML routes, and Tsak and Identity with their own stories. A VS Code extension for XML routes, with a text mode and a graph editor, is in the works.&lt;/p&gt;

&lt;p&gt;Thanks to everyone who left comments, opened discussions and sent reports from production: a good part of this release grew out of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;If this was useful — a ⭐ on &lt;a href="https://github.com/redbase-app" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; helps others find it.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;More of my writing: &lt;a href="https://redbase.app/articles" rel="noopener noreferrer"&gt;redbase.app/articles&lt;/a&gt;, and on &lt;a href="https://dev.to/rinat_kozin"&gt;dev.to&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>opensource</category>
      <category>redb</category>
    </item>
    <item>
      <title>Your access token was stolen. Now what: three layers of defence in your own OpenID provider</title>
      <dc:creator>rinat kozin</dc:creator>
      <pubDate>Tue, 08 Sep 2026 17:48:00 +0000</pubDate>
      <link>https://dev.to/rinat_kozin/your-access-token-was-stolen-now-what-three-layers-of-defence-in-your-own-openid-provider-4hon</link>
      <guid>https://dev.to/rinat_kozin/your-access-token-was-stolen-now-what-three-layers-of-defence-in-your-own-openid-provider-4hon</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flzpxdqaz68jh9mmygozj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flzpxdqaz68jh9mmygozj.png" alt="redb.Identity" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A bearer token works for whoever holds it. Three layers in &lt;strong&gt;redb.Identity&lt;/strong&gt;: BFF, key-bound tokens via DPoP, fast revocation. Plus what a stolen database yields.&lt;/p&gt;

&lt;p&gt;An access token is a bearer token by default, and that phrase means exactly what it says: whoever holds it, is you. The server cannot tell the real owner from someone who pulled the token out of a browser that was not theirs. Until it expires, that token works from any machine, in any country, and in your logs it looks like perfectly ordinary traffic.&lt;/p&gt;

&lt;p&gt;Then comes the unpleasant part. Access-token lifetimes in real configurations run from fifteen minutes to an hour. For all of that time the attacker has full access, and nobody gets a signal about it.&lt;/p&gt;

&lt;p&gt;We built &lt;code&gt;redb.Identity&lt;/code&gt;, our own OAuth 2.1 / OpenID Connect provider on .NET, and "what if the token gets stolen" was not a question we could answer with a slogan. What came out of it is three layers: do not let it be stolen, make the stolen thing worthless, kill it fast. Below is each one, with code and with the limits of where it applies.&lt;/p&gt;

&lt;p&gt;After that, a nastier scenario: what is left for an attacker who walked off not with a token, but with the entire database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where token theft actually comes from
&lt;/h2&gt;

&lt;p&gt;Discussions about stolen tokens tend to collapse into XSS. That is correct but incomplete. If a token lives in the browser, all of the following reach it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;XSS in your own application&lt;/strong&gt;, the classic;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;XSS through a dependency.&lt;/strong&gt; A compromised npm package gets exactly the same access to the page as the code you wrote. You can build a flawless application and still hand out tokens, because somewhere in the transitive dependency tree one package changed hands. Auditing your own code does not close this one;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;browser extensions.&lt;/strong&gt; The user installs them, and they have access to the DOM and to the page's network calls;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;localStorage&lt;/code&gt;.&lt;/strong&gt; Readable by any JavaScript on the same origin, and it survives reloads and tab closes. Storing a token there is a discipline of its own, and enough has been written about it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What all four share: the attacker walks away with &lt;strong&gt;the token itself&lt;/strong&gt;. After that they need neither the victim's browser, nor their network, nor their session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer one: do not let it be stolen
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How most identity servers build their admin console
&lt;/h3&gt;

&lt;p&gt;Take any mature identity server and look at how its admin console is built. Keycloak ships a React application. WSO2 Identity Server ships React too, for both Console and My Account. Both operate as &lt;strong&gt;public OAuth clients&lt;/strong&gt;: the browser runs code+PKCE, receives an access token, and keeps it.&lt;/p&gt;

&lt;p&gt;This is not sloppiness, it is the default that settled in for SPAs years ago. It has a price. An access token with administrator rights over the identity server sits in page memory that JavaScript can read. Any of the four vectors above, landing on that page, means leaking the admin token of the very server that grants access to every other system in the company.&lt;/p&gt;

&lt;p&gt;Worth noting that the IETF's current BCP for browser apps, &lt;code&gt;OAuth 2.0 for Browser-Based Applications&lt;/code&gt;, recommends &lt;strong&gt;the BFF pattern&lt;/strong&gt; and describes browser-held tokens as something to move away from. The industry has voted; mature products are simply carrying compatibility with what was written earlier.&lt;/p&gt;

&lt;h3&gt;
  
  
  What a BFF is, briefly
&lt;/h3&gt;

&lt;p&gt;Backend-for-Frontend: a server-side application sits between the browser and the API. It runs the OIDC exchange &lt;strong&gt;over the back channel&lt;/strong&gt;, server to server, and keeps the tokens. The browser gets an &lt;code&gt;HttpOnly&lt;/code&gt; session cookie. There is no token in the browser at all, and JavaScript cannot reach it by construction rather than by agreement.&lt;/p&gt;

&lt;h3&gt;
  
  
  How ours is built
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;redb.Identity.Web&lt;/code&gt; is the reference admin console and account portal. It runs on &lt;strong&gt;Blazor Server&lt;/strong&gt; plus cookie authentication:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRazorComponents&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;AddInteractiveServerComponents&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddCookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Cookie&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"identity.web.session"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Cookie&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HttpOnly&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Cookie&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SecurePolicy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CookieSecurePolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Cookie&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SameSite&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SameSiteMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Lax&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ExpireTimeSpan&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromHours&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;8&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SlidingExpiration&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After login the tokens go onto the authentication ticket, not out to the client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;AuthenticationTokenExtensions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StoreTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;BuildTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When server-side code needs an access token to call Identity, it takes it from the current context rather than from the browser's request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetAccessTokenAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_accessor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HttpContext&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Identity&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="n"&gt;ClaimsIdentity&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;IsAuthenticated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetTokenAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CookieAuthenticationDefaults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AuthenticationScheme&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"access_token"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Intermediate state works the same way. The MFA challenge, the consent challenge and the impersonation state are separate &lt;code&gt;HttpOnly&lt;/code&gt; cookies under DataProtection, not fields in &lt;code&gt;localStorage&lt;/code&gt; and not query parameters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;SameSite&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SameSiteMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Lax&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="n"&gt;HttpOnly&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Blazor Server adds a level that a classic BFF does not have. Markup renders on the server and only a diff travels to the browser over SignalR. &lt;strong&gt;There is physically no JavaScript bundle holding application state.&lt;/strong&gt; There is nothing to attack, not because we hid it well, but because nothing is there.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where BFF does not help
&lt;/h3&gt;

&lt;p&gt;With a BFF, an attacker who achieves JavaScript execution on the page can still call the API as the victim: the browser attaches the cookie automatically. What they &lt;strong&gt;cannot&lt;/strong&gt; do is take the token away and work with it later.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Stolen token (SPA)&lt;/th&gt;
&lt;th&gt;XSS behind a BFF&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Where the attacker operates from&lt;/td&gt;
&lt;td&gt;their own machine, anywhere&lt;/td&gt;
&lt;td&gt;only from the victim's browser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;For how long&lt;/td&gt;
&lt;td&gt;the token's whole lifetime&lt;/td&gt;
&lt;td&gt;while the page stays open&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Survives closing the tab&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can it be replayed later&lt;/td&gt;
&lt;td&gt;yes, the token is gone&lt;/td&gt;
&lt;td&gt;no, there is nothing to take&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Odds of catching it by anomaly&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;higher&lt;/strong&gt;: foreign IP, foreign user-agent, foreign geography&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;lower&lt;/strong&gt;: IP, user-agent and working hours all match the victim&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row runs against the BFF, and it deserves to be said out loud because it breaks the convenient picture. A stolen token is used from someone else's machine, which is precisely the signal impossible-travel detection exists for. XSS inside the victim's browser arrives from their own address, with their user-agent, during their working hours, and is behaviourally almost indistinguishable from the person. So on detectability the BFF &lt;strong&gt;loses&lt;/strong&gt;, and the symmetric "different IP" argument does not work in our favour.&lt;/p&gt;

&lt;p&gt;A caveat to the caveat: you can only cash in that advantage if you actually have anomaly detection. We do not have it yet, which gets its own section further down.&lt;/p&gt;

&lt;p&gt;So the BFF wins four rows out of five and clearly loses the fifth. It converts a quiet hour of access from another country into an active session inside the victim's browser while they are looking at the screen. A smaller blast radius, not a cure, and certainly not a substitute for monitoring.&lt;/p&gt;

&lt;h3&gt;
  
  
  Take a cookie, remember CSRF
&lt;/h3&gt;

&lt;p&gt;This consequence is easy to forget in the relief of having no token around. A browser never attaches a bearer header on its own, but it does attach a cookie to any request aimed at your domain, whatever page that request came from. In other words, &lt;strong&gt;moving to a BFF does not remove the risk, it changes its shape&lt;/strong&gt;: token theft is replaced by cross-site request forgery.&lt;/p&gt;

&lt;p&gt;So the BFF runs both lines of defence. Session cookies and every piece of intermediate state carry &lt;code&gt;SameSite=Lax&lt;/code&gt;, &lt;code&gt;HttpOnly&lt;/code&gt; and &lt;code&gt;Secure&lt;/code&gt;. On top of that the pipeline enables &lt;code&gt;app.UseAntiforgery()&lt;/code&gt;, and every form that changes anything carries &lt;code&gt;&amp;lt;AntiforgeryToken /&amp;gt;&lt;/code&gt;: login, MFA challenge, consent, email change, password reset, device verification, profile edit. Twelve forms, none without a token.&lt;/p&gt;

&lt;p&gt;Both lines are needed together. &lt;code&gt;SameSite=Lax&lt;/code&gt; blocks cross-site POSTs, but it is not a full replacement for an antiforgery token: it does not save you from an attack launched from your own subdomain, and it stops helping the moment some flow needs &lt;code&gt;SameSite=None&lt;/code&gt;. The rule is simple. You took a cookie, so bring CSRF protection, no matter how good the other flags are.&lt;/p&gt;

&lt;h3&gt;
  
  
  What about mobile clients
&lt;/h3&gt;

&lt;p&gt;The BFF is a browser pattern, and native applications do not fit into it. Telling a mobile app to embed a web view for the sake of a BFF would in fact violate RFC 8252 directly: it requires the system browser for authorisation, not a component living inside the app.&lt;/p&gt;

&lt;p&gt;Native gets a different combination:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;the system browser&lt;/strong&gt; for authorisation per RFC 8252, plus PKCE, which OAuth 2.1 makes mandatory;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the operating system key store&lt;/strong&gt; for tokens, Keychain and Keystore, rather than a file in the app sandbox;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;layer two, meaning DPoP&lt;/strong&gt;, and here it carries the main weight. The private key is generated inside the device's secure storage and never leaves it, so a token lifted out of the app is useless without it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The precise way to put it: &lt;strong&gt;BFF solves the problem for the browser, DPoP solves it for everything else.&lt;/strong&gt; A browser has somewhere to hide the token server-side, a mobile app does not, so what gets protected there is not storage but use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer two: make the theft worthless
&lt;/h2&gt;

&lt;p&gt;This is &lt;strong&gt;DPoP&lt;/strong&gt;, RFC 9449, Demonstrating Proof-of-Possession. The idea is simple and radical: stop issuing bearer tokens.&lt;/p&gt;

&lt;p&gt;The client generates a key pair and keeps the private half. When asking for a token it presents the public half, and the server stamps that key's thumbprint into the issued token. From then on &lt;strong&gt;every&lt;/strong&gt; request to a resource carries a separate short JWT proof, signed with the private key and bound to a specific method and URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="nf"&gt;POST&lt;/span&gt; &lt;span class="nn"&gt;/api/orders&lt;/span&gt; &lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;
&lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DPoP eyJhbGciOiJSUzI1NiIsInR5cCI6...&lt;/span&gt;
&lt;span class="na"&gt;DPoP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2Iiwiandr...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Steal the token without the private key and you have a useless string. The server will demand a proof, and there is nothing to sign it with.&lt;/p&gt;

&lt;p&gt;What is implemented on our side:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;binding at issuance.&lt;/strong&gt; The key thumbprint per RFC 7638 (SHA-256 over the JWK, base64url) goes into the &lt;code&gt;cnf.jkt&lt;/code&gt; claim per RFC 7800. The token now knows which key it belongs to;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DPoP-Nonce&lt;/code&gt; per §8&lt;/strong&gt;, built on HMAC-signed stateless nonces. The server can demand a fresh proof without holding per-client state;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;a store of consumed &lt;code&gt;jti&lt;/code&gt; values keyed by &lt;code&gt;jkt&lt;/code&gt;.&lt;/strong&gt; The same proof will not pass twice, even if it was intercepted whole;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;asymmetric algorithms only&lt;/strong&gt;, from an allow-list, as §4.2 requires. A symmetric "proof" proves nothing, because both sides know the key;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;a separate package, &lt;code&gt;redb.Identity.Resource.Dpop&lt;/code&gt;.&lt;/strong&gt; This is the validator for your own resource APIs. Proofs must be checked somewhere other than the provider itself, otherwise the protection ends at the identity server's boundary while the data lives further along.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A note on comparisons. DPoP now exists in Keycloak and in WSO2 as well. It arrived there later, and in Keycloak it sat in preview status for a long while, but "they do not have it" would be wrong. The right question is different: &lt;strong&gt;check your specific version for whether it is on and what status it carries&lt;/strong&gt;, because a preview feature in production is a separate conversation with your own security team.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer three: kill it fast
&lt;/h2&gt;

&lt;p&gt;Even perfect binding does not remove the need to revoke. Somebody left the company, a device was lost, an incident was confirmed. From there only one question matters: &lt;strong&gt;how long until the revocation actually reaches every system.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Corporate requirement documents love this point, and it usually appears in them as an open question rather than as a decision.&lt;/p&gt;

&lt;p&gt;What exists:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;token revocation per RFC 7009&lt;/strong&gt;, idempotent: a repeated revocation returns 200 as §2.1 requires, and does not turn into a source of script failures;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;refresh-token rotation.&lt;/strong&gt; A used refresh token is revoked, and presenting it again means somebody is working from a copy;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;session idle timeout.&lt;/strong&gt; Every session carries its last-activity time, refreshed on real actions: a refresh exchange, cookie validation, a &lt;code&gt;userinfo&lt;/code&gt; call. A session that has idled past the limit is killed automatically;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;backchannel logout in two modes.&lt;/strong&gt; This is the interesting one, so in more detail.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Classic OIDC Backchannel Logout is push: the provider knocks on every registered application and reports that a session has ended. That works right up to the first network partition or crashed replica. An application the knock never reached goes on believing the session is alive.&lt;/p&gt;

&lt;p&gt;So next to push we run a &lt;strong&gt;pull feed of revoked session identifiers&lt;/strong&gt;, with a cursor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /api/v1/identity/revoked-sids/add
GET  /api/v1/identity/revoked-sids/since?cursor=...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An application that came back from a crash, or lost connectivity for ten minutes, simply asks what has been revoked since its cursor and catches up. No revocation is lost because a node happened to be unreachable when the broadcast went out.&lt;/p&gt;

&lt;p&gt;And a detail that ties layer one to layer three. The BFF checks that same list &lt;strong&gt;on every request&lt;/strong&gt; while validating the session cookie:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OnValidatePrincipal&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;sid&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Principal&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;FindFirst&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sid"&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;sub&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Principal&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;FindFirst&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// if sid/sub is in the cluster-wide revoked list, drop the cookie&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So "sign out everywhere" kills not only tokens but the live UI session too, across every replica rather than just the one that happened to receive the logout call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The routine around it
&lt;/h2&gt;

&lt;p&gt;Three layers are the visible part. Below them sits work you only notice if you go looking. The parts I consider worth listing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Password history.&lt;/strong&gt; Configurable depth, reuse of a previous password is rejected, hashed with the same algorithm as the current one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TOTP with replay protection.&lt;/strong&gt; The problem with a stock TOTP implementation: a code is valid across a tolerance window, and the same code can be presented twice if you are quick. We store the last accepted step and reject a code from a step already used, even when it formally falls inside the window:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LastTotpStep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasValue&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LastTotpStep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On top of that the MFA row is taken under a lock before verification, so parallel attempts by one user cannot diverge on the read-then-write.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SMS and email one-time codes live server-side, not in state.&lt;/strong&gt; The code itself is stored hashed (SHA-256) and marked consumed under &lt;code&gt;LockForUpdate&lt;/code&gt;, while the encrypted challenge state carries only a reference to it. The client never holds anything the code could be reconstructed from.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recovery codes are genuinely single-use.&lt;/strong&gt; They are marked consumed inside the same transaction that creates the session. Not "mark first, create after", but atomically, otherwise the gap between the two operations becomes a window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Constant-time secret comparison.&lt;/strong&gt; &lt;code&gt;CryptographicOperations.FixedTimeEquals&lt;/code&gt; appears in sixteen files: password-reset tokens, email verification, email change, server-side OTPs, the bootstrap secret. A timing attack on string comparison is not exotic, it is what automated scanners look for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limiting on three levels&lt;/strong&gt;: per IP, per &lt;code&gt;client_id&lt;/code&gt; through a token bucket, and a separate ceiling on failed attempts per &lt;code&gt;(IP + user)&lt;/code&gt; pair, logged to a dedicated security channel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Order of proxy-header processing.&lt;/strong&gt; &lt;code&gt;X-Forwarded-For&lt;/code&gt; is sanitised &lt;strong&gt;before&lt;/strong&gt; the rate limiter and the lockout counter ever see it. Do it the other way around and an attacker forges the header to bypass both, and can also get somebody else's IP locked out. It only applies when the socket peer is on the trusted-proxy allow-list, otherwise the header is ignored entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The idempotency cache sits after authorisation, not before.&lt;/strong&gt; Otherwise a revoked token unlocks a cached response produced while it was still alive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;__Host-&lt;/code&gt; prefix is emitted only when &lt;code&gt;Secure=true&lt;/code&gt;.&lt;/strong&gt; RFC 6265bis §4.1.3.2 requires it, and it is easy to get wrong: set the prefix, forget the flag, and end up with a cookie the browser silently drops while you hunt for the bug in your code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SSRF protection on outbound fetches.&lt;/strong&gt; There are exactly two situations where a client can make our server follow a link it supplied: resolving &lt;code&gt;jwks_uri&lt;/code&gt; and fetching a request object by &lt;code&gt;request_uri&lt;/code&gt;. Both go through one guard that refuses loopback, RFC 1918 private ranges, link-local including the &lt;code&gt;169.254.169.254&lt;/code&gt; cloud metadata address, and RFC 6598 CGNAT space. Private targets open only behind an explicit flag, for single-host test rigs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Refusal of &lt;code&gt;alg:none&lt;/code&gt;.&lt;/strong&gt; Our JAR implementation (RFC 9101) accepts a signed request object, verifies the signature against the client's keys and takes the parameters from inside the JWT. An unsigned object is rejected always, whatever the settings say: it throws away precisely the integrity guarantee JAR exists to provide. FAPI 2.0 forbids it outright.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One hundred and nine typed audit events&lt;/strong&gt; across seven categories, into a flat table and optionally multicast to Kafka, Elasticsearch or RabbitMQ. Passwords and secrets never reach the audit: a client-secret rotation records the fact, not the value.&lt;/p&gt;

&lt;h2&gt;
  
  
  What if the whole database is stolen
&lt;/h2&gt;

&lt;p&gt;So far this has been about one token. Now the worst case: the attacker has a full dump.&lt;/p&gt;

&lt;p&gt;The threat model changes completely. No rate limits, no audit, no revocation. The attacker works offline, on their own hardware, for as long as they like, and you will not find out. The only thing protecting the data at that moment is the shape it is stored in.&lt;/p&gt;

&lt;h3&gt;
  
  
  Passwords
&lt;/h3&gt;

&lt;p&gt;Hashed with &lt;strong&gt;Argon2id&lt;/strong&gt;, using the OWASP 2023 parameters: 64 MiB of memory, 3 iterations, 4 lanes, a 16-byte per-user salt and a 32-byte hash.&lt;/p&gt;

&lt;p&gt;Three properties that buys. The password is hashed rather than encrypted, so no key exists anywhere that could decrypt everything at once. Each user gets their own salt, so rainbow tables are useless and you cannot crack everyone whose password is &lt;code&gt;qwerty123&lt;/code&gt; in a single pass. The computation is deliberately slow, so instead of billions of attempts per second the attacker gets a handful.&lt;/p&gt;

&lt;p&gt;Then &lt;strong&gt;memory&lt;/strong&gt;, which is the real difference from bcrypt. Bcrypt needs roughly 4 KB per computation. On a GPU or a purpose-built ASIC that means tens of thousands of parallel instances, and the gap between a defender on an ordinary CPU and an attacker on a farm becomes enormous. Argon2id at 64 MiB breaks that arithmetic: 24 GB of video memory holds on the order of 380 lanes instead of tens of thousands. This is why Argon2id won the Password Hashing Competition and why OWASP lists it first.&lt;/p&gt;

&lt;p&gt;Bcrypt has not gone anywhere, and that is deliberate. The &lt;code&gt;redb.Core&lt;/code&gt; storage layer historically hashed passwords with bcrypt at work factor 12, and existing deployments have exactly that in their tables. So Identity registers a dispatcher that &lt;strong&gt;writes new hashes with Argon2id while still verifying old bcrypt and even ancient salted SHA-256&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TryAddSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IPasswordHasher&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;sp&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;argon2&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Argon2idPasswordHasher&lt;/span&gt;&lt;span class="p"&gt;(...);&lt;/span&gt;   &lt;span class="c1"&gt;// 64 MiB, t=3, p=4&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;bcrypt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;BcryptPasswordHasher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workFactor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Bcrypt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WorkFactor&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Algorithm&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;PasswordHashAlgorithm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Bcrypt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;bcrypt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;MultiFormatPasswordHasher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;argon2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bcrypt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Upgrade-on-login is wired too: on a successful sign-in the hasher is asked whether the stored format is stale, and if so the password is quietly rehashed to Argon2id and saved. The database migrates itself as people log in. No forced password reset for everyone at once, the kind users hate and support desks survive like a natural disaster.&lt;/p&gt;

&lt;h3&gt;
  
  
  Everything else in the database
&lt;/h3&gt;

&lt;p&gt;Passwords are one row among many. The full inventory:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What is stored&lt;/th&gt;
&lt;th&gt;In what form&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;User passwords&lt;/td&gt;
&lt;td&gt;Argon2id, legacy bcrypt until first sign-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Password history&lt;/td&gt;
&lt;td&gt;same hasher as the current password&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OAuth client secrets&lt;/td&gt;
&lt;td&gt;bcrypt hash, not recoverable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TOTP secrets&lt;/td&gt;
&lt;td&gt;encrypted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recovery codes&lt;/td&gt;
&lt;td&gt;PBKDF2-HMAC-SHA256, per-code salt, plus a pepper&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SMS and email one-time codes&lt;/td&gt;
&lt;td&gt;SHA-256, single-use, consumed under a lock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Password-reset, verification and email-change tokens&lt;/td&gt;
&lt;td&gt;hashed, compared in constant time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Private signing keys&lt;/td&gt;
&lt;td&gt;encrypted through DataProtection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DataProtection key ring&lt;/td&gt;
&lt;td&gt;encrypted at rest&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two of those deserve their own paragraphs.&lt;/p&gt;

&lt;h3&gt;
  
  
  The pepper does not live in the database
&lt;/h3&gt;

&lt;p&gt;Recovery codes are protected by more than a salt. The formula also takes a &lt;strong&gt;pepper&lt;/strong&gt;, a separate secret supplied through an environment variable and never stored in a table.&lt;/p&gt;

&lt;p&gt;That is exactly the difference between a salt and a pepper. A salt sits next to the hash and defeats rainbow tables, but it does nothing against brute force. A pepper does not sit next to it. With only a dump in hand there is nothing to brute-force recovery codes with, because the formula uses a value the dump does not contain.&lt;/p&gt;

&lt;h3&gt;
  
  
  The key chain ends at a root that is not in the dump
&lt;/h3&gt;

&lt;p&gt;This is the part that matters most when an identity server's database is stolen. The worst outcome is not "the passwords were cracked", it is "the private signing key was obtained". With that, a token can be forged for anyone, administrators included, and no amount of password hardening helps.&lt;/p&gt;

&lt;p&gt;Private signing keys are stored encrypted, in an &lt;code&gt;EncryptedPem&lt;/code&gt; field protected by &lt;code&gt;IDataProtector.Protect&lt;/code&gt; under a dedicated purpose. The obvious next question is what protects the DataProtection key ring itself, given that it also lives in the database. Otherwise you get a circle where the lock and the key sit in the same box.&lt;/p&gt;

&lt;p&gt;There is no circle. The key ring is encrypted at rest by one of three means: an X.509 certificate, a 32-byte AES-GCM master key, or your own hook into a KMS or Vault. And this is not a recommendation in the documentation, it is a startup condition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequireAtRestEncryption&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AllowEphemeralKeys&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvalidOperationException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"DataProtection key-ring is unprotected at rest. Configure ONE of: ..."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;RequireAtRestEncryption&lt;/code&gt; defaults to &lt;code&gt;true&lt;/code&gt;. &lt;strong&gt;The server refuses to start in production if the key ring is unprotected.&lt;/strong&gt; Turning the check off takes an explicit setting, named clearly enough that you will not do it by accident.&lt;/p&gt;

&lt;p&gt;The net result: a dump yields encrypted signing keys, an encrypted key ring, and no root key. Ciphertext all the way down.&lt;/p&gt;

&lt;h3&gt;
  
  
  What a stolen database does achieve
&lt;/h3&gt;

&lt;p&gt;Without this part the paragraphs above would be lying by omission.&lt;/p&gt;

&lt;p&gt;Encryption does not undo a breach. A dump readily gives up &lt;strong&gt;personal data&lt;/strong&gt; (names, emails, phone numbers, departments, managers, employee numbers), &lt;strong&gt;organisational structure&lt;/strong&gt; (groups, roles, who has access to what), &lt;strong&gt;session metadata&lt;/strong&gt; (IP addresses, devices, activity times, meaning who works from where) and &lt;strong&gt;the entire audit trail&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Under most data-protection regimes that is a personal-data breach with every obligation attached, regardless of the fact that not a single password was cracked. Saying "everything is encrypted" in that situation closes a conversation that must not be closed.&lt;/p&gt;

&lt;p&gt;And a second, operational caveat. The whole chain rests on &lt;strong&gt;the root key living somewhere other than the database&lt;/strong&gt;. If your backup is taken together with the configuration file, or with the container's environment variables, the protection collapses to zero: the attacker has the box and the key. Database backups and secret storage belong in different perimeters with different access rights. &lt;code&gt;RequireAtRestEncryption&lt;/code&gt; protects against a stolen dump, not against a stolen backup, and those are different things.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is actually exposed
&lt;/h2&gt;

&lt;p&gt;A separate question, often more important than any cryptographic detail: &lt;strong&gt;which part of the system is reachable from the internet at all.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In most identity servers the admin console lives in the same process and on the same port as the protocol endpoints, under a path like &lt;code&gt;/admin&lt;/code&gt;. It can be isolated, there are host settings and a reverse proxy for that, but the separation is &lt;strong&gt;by URL and by configuration&lt;/strong&gt;. One mistake in the proxy rules or a regression in the hostname settings, and the management plane is outside.&lt;/p&gt;

&lt;p&gt;Ours separates by process and by port.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The core is not networked at all.&lt;/strong&gt; Every &lt;code&gt;redb.Identity.Core&lt;/code&gt; endpoint lives on a &lt;code&gt;direct-vm://&lt;/code&gt; route, an in-process transport. This is not "listening on localhost": there is no network path to the core in principle, except through a facade you explicitly stood up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inside the HTTP facade the planes sit on different ports:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"Http"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"PublicPort"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5002&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ManagementPort"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PublicPort&lt;/code&gt; carries only &lt;code&gt;/connect/*&lt;/code&gt; and &lt;code&gt;/.well-known/*&lt;/code&gt;. &lt;code&gt;ManagementPort&lt;/code&gt; carries &lt;code&gt;/api/v1/identity/*&lt;/code&gt; and &lt;code&gt;/scim/*&lt;/code&gt;, and the setting's own comment states in plain words that production wants a separate firewalled port. SCIM is additionally gated by a flag and can stay down entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The emergency bootstrap endpoint is separated further.&lt;/strong&gt; &lt;code&gt;POST /internal/bootstrap-admin&lt;/code&gt; lives on the management port but &lt;strong&gt;outside&lt;/strong&gt; the &lt;code&gt;/api/v1/identity/&lt;/code&gt; base path, so a firewall rule can close it independently of everything else. It has no bearer authentication by design; protection is a header secret compared in constant time. CORS on it is disabled deliberately, because it is a back-channel operator tool and is never invoked from a browser.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The UI is a separate application.&lt;/strong&gt; &lt;code&gt;redb.Identity.Web&lt;/code&gt; is a standalone ASP.NET host that talks to Identity over a server-side HTTP client. A different machine, a different network segment, or not deployed at all: Identity does not stop working either way. It ships as source rather than as a package precisely because it is a reference you are meant to edit.&lt;/p&gt;

&lt;p&gt;The point of the whole arrangement is the cost of a mistake. Exposing the management plane here requires deliberately publishing the management port or deploying the Web app into a DMZ. That is hard to do by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  An external arbiter
&lt;/h2&gt;

&lt;p&gt;Everything above can be asserted about any server, and usually is. The trouble is that your own tests find exactly the bugs you already knew to look for.&lt;/p&gt;

&lt;p&gt;We ran the server through the &lt;strong&gt;official OpenID Foundation conformance suite&lt;/strong&gt;, the same one the Foundation certifies providers with. Config OP passes with no failures. Basic OP is thirty-five modules, and &lt;code&gt;FAILED&lt;/code&gt; among them is zero.&lt;/p&gt;

&lt;p&gt;Far more interesting is what it found in us. Scope-derived claims (phone, address) were landing in the &lt;code&gt;id_token&lt;/code&gt;, and an &lt;code&gt;id_token&lt;/code&gt; is forwarded to third parties and written to logs as proof of sign-in. So a user's phone number travelled considerably further than the client ever asked for. That is a PII leak, and none of our own tests caught it, because we did not know it was a bug. There is a full write-up of that run, including suite setup and the complete list of findings: &lt;a href="https://redbase.app/articles/identity-openid-conformance" rel="noopener noreferrer"&gt;running our OpenID server through the official suite&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Two modules in the report show as &lt;code&gt;SKIPPED&lt;/code&gt;, and both concern unsigned request objects with &lt;code&gt;alg:none&lt;/code&gt;. They are skipped because the server refuses to advertise the unsafe mode. In a conformance report you read the reason, not the colour of the row.&lt;/p&gt;

&lt;p&gt;We do not carry the OpenID Certified™ mark. That is a trademark, granted by the Foundation through a separate paid submission. What is claimed is only what is true: the server is run against the official suite, and these are the results.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is not there yet
&lt;/h2&gt;

&lt;p&gt;The section without which this would be an advertisement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Risk-based authentication.&lt;/strong&gt; The inputs are already stored: session IP, user-agent, a readable device label, sign-in history in the audit trail, failed-attempt counters. What is missing is the rules engine and the escalation policy on top of them.&lt;/p&gt;

&lt;p&gt;You can see the gap most precisely in &lt;code&gt;acr&lt;/code&gt;. The server &lt;strong&gt;reports&lt;/strong&gt; the level reached: &lt;code&gt;acr&lt;/code&gt; of &lt;code&gt;1&lt;/code&gt; for single-factor and &lt;code&gt;2&lt;/code&gt; for verified multi-factor, plus &lt;code&gt;amr&lt;/code&gt; with the concrete method (&lt;code&gt;pwd&lt;/code&gt;, &lt;code&gt;otp&lt;/code&gt;, &lt;code&gt;mfa&lt;/code&gt;, &lt;code&gt;hwk&lt;/code&gt;). What it does not act on is an incoming &lt;code&gt;acr_values&lt;/code&gt; as a &lt;strong&gt;demand&lt;/strong&gt; to raise the level. The parameter is advertised in discovery, but you cannot use it to make the server ask for a second factor. That is exactly what step-up needs, along with RFC 9470.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trusted devices as a first-class entity.&lt;/strong&gt; The device is recorded on the session, but there is no record saying "trust this device until such a date".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SAML 2.0.&lt;/strong&gt; Not implemented in either direction. The decision is deliberate and written down: OIDC federation covers modern integrations, while full SAML means XML signatures, metadata exchange, three binding types and Single Logout with all its quirks. The conditions under which we would take it on are written down too, and the first is a real customer with an enterprise IdP that will not speak OIDC.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;From the RFCs:&lt;/strong&gt; Rich Authorization Requests (9396), the JWT profile for access tokens (9068, the &lt;code&gt;typ=at+jwt&lt;/code&gt; header), the &lt;code&gt;iss&lt;/code&gt; parameter in the authorisation response (9207), step-up (9470). Plus CIBA and OIDC Federation 1.0.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On mTLS specifically, because this one is easy to misrepresent.&lt;/strong&gt; Transport mTLS exists: the gRPC facade can require a client certificate and pin it by thumbprint against an allow-list, and for the management port that is the recommended production setting. What does not exist is &lt;strong&gt;RFC 8705&lt;/strong&gt;, which is a different thing: mTLS as a way of authenticating an OAuth client at the token endpoint, plus binding the issued token to the certificate through &lt;code&gt;cnf.x5t#S256&lt;/code&gt;. The first protects the channel, the second would make the token as non-transferable as DPoP already makes it. We went the DPoP route.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FAPI 2.0 is not claimed as a profile.&lt;/strong&gt; Individual bricks from it are in place: mandatory PAR can be switched on per client, &lt;code&gt;alg:none&lt;/code&gt; is always rejected, and only asymmetric algorithms are allowed for request objects. But a profile is not a set of checkboxes, it is passing the corresponding conformance plan, and we have not run it. If your security team requires FAPI, treat that as a separate body of work, and better to learn it now.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to take away
&lt;/h2&gt;

&lt;p&gt;Three thoughts, if any survive the scroll.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First.&lt;/strong&gt; A token in the browser is a choice, not a necessity. The BFF is the recommended pattern &lt;strong&gt;for browser applications&lt;/strong&gt;, and moving to it changes not the probability of compromise but its radius: instead of a quiet hour of access from another country you get activity inside the victim's browser, bounded by an open tab. The price of the trade is that such activity is harder to catch by anomaly, which is worth holding in mind rather than treating the BFF as a free improvement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second.&lt;/strong&gt; A bearer token is also a choice. DPoP turns a stolen string into a useless one, it switches on at the provider rather than by rewriting every client at once, and it works where a BFF cannot: mobile and desktop native, service-to-service calls. Check whether your server has it, and what status it carries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third.&lt;/strong&gt; Revocation speed is an architecture question, not a token-lifetime question. Push notifications to applications break at the first network partition. A pull feed with a cursor survives partitions and crashed nodes, and costs very little.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fourth, on database theft.&lt;/strong&gt; Check two things about your own server, both of which take five minutes. One: what hashes your passwords and whether upgrade-on-login exists, because without it the database never migrates to a modern algorithm. Two, and this matters more: &lt;strong&gt;where the key lives that encrypts your private signing keys.&lt;/strong&gt; If it is in the same database, the encryption is decorative, and the worst case of a stolen dump is wide open.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;redb.Identity&lt;/code&gt; is Apache 2.0, runs on PostgreSQL, MS SQL and SQLite with no code changes, and can be hosted as an in-process module if you do not want a network between your services. Have a look on &lt;a href="https://github.com/redbase-app" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;If this was useful — a ⭐ on &lt;a href="https://github.com/redbase-app" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; helps others find it.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;More of my writing: &lt;a href="https://redbase.app/articles" rel="noopener noreferrer"&gt;redbase.app/articles&lt;/a&gt;, and on &lt;a href="https://dev.to/rinat_kozin"&gt;dev.to&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>oauth</category>
      <category>opensource</category>
      <category>csharp</category>
    </item>
    <item>
      <title>WS-Trust over OAuth: a third transport for the OpenID server, for the estate that runs on SOAP</title>
      <dc:creator>rinat kozin</dc:creator>
      <pubDate>Tue, 01 Sep 2026 20:07:53 +0000</pubDate>
      <link>https://dev.to/rinat_kozin/ws-trust-over-oauth-a-third-transport-for-the-openid-server-for-the-estate-that-runs-on-soap-144a</link>
      <guid>https://dev.to/rinat_kozin/ws-trust-over-oauth-a-third-transport-for-the-openid-server-for-the-estate-that-runs-on-soap-144a</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzcbvgzro8trgb14r3ztk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzcbvgzro8trgb14r3ztk.png" alt="redb.Identity" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;redb.Identity&lt;/strong&gt; gained a WS-Trust facade: Issue, Validate, Cancel, Renew on the same core routes. The same JWT, the same client registry, WSDL for the generator.&lt;/p&gt;

&lt;p&gt;When we built the gRPC facade the claim was this: the logic of redb.Identity lives in the core behind &lt;code&gt;direct-vm://identity-*&lt;/code&gt; addresses, and a transport is a thin translator on top of it. The second facade supported the claim. But two transports, both modern, both built on roughly the same assumptions about the world, test it gently. The real test is a third one built on entirely different assumptions.&lt;/p&gt;

&lt;p&gt;It now exists. Next to HTTP and gRPC stands WS-Trust: &lt;code&gt;Issue&lt;/code&gt;, &lt;code&gt;Validate&lt;/code&gt;, &lt;code&gt;Cancel&lt;/code&gt;, &lt;code&gt;Renew&lt;/code&gt; over SOAP, on the same core routes, with the same issuer and the same token store. A token minted over SOAP is accepted over HTTP and gets the same verdict there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who this is for
&lt;/h2&gt;

&lt;p&gt;There are estates where SOAP is not "legacy" but the current norm. Banking back offices, insurance, industrial control, government. They run WCF and CXF buses, their integrations are generated from WSDL, and the team maintaining them knows how to debug an envelope and does not know how to debug an OAuth redirect. Asking such an estate to "just fetch a token over HTTP with a form-encoded body" is asking it to write and maintain code for which it has neither the tooling nor the habits.&lt;/p&gt;

&lt;p&gt;WS-Trust is the OAuth of that world. It is older, it is more verbose, it is about XML, but its job is exactly the same: a client arrives with its credentials and leaves with a token. If the authorization server speaks it, the integration stops being a project and becomes a client generated from WSDL.&lt;/p&gt;

&lt;p&gt;From the consumer's side it looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;soap:Envelope&lt;/span&gt; &lt;span class="na"&gt;xmlns:soap=&lt;/span&gt;&lt;span class="s"&gt;"http://schemas.xmlsoap.org/soap/envelope/"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;soap:Header&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;wsse:Security&lt;/span&gt; &lt;span class="na"&gt;xmlns:wsse=&lt;/span&gt;&lt;span class="s"&gt;"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;wsse:UsernameToken&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;wsse:Username&amp;gt;&lt;/span&gt;billing-service&lt;span class="nt"&gt;&amp;lt;/wsse:Username&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;wsse:Password&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;/wsse:Password&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/wsse:UsernameToken&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/wsse:Security&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/soap:Header&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;soap:Body&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;wst:RequestSecurityToken&lt;/span&gt; &lt;span class="na"&gt;xmlns:wst=&lt;/span&gt;&lt;span class="s"&gt;"http://docs.oasis-open.org/ws-sx/ws-trust/200512"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;wst:RequestType&amp;gt;&lt;/span&gt;http://docs.oasis-open.org/ws-sx/ws-trust/200512/Issue&lt;span class="nt"&gt;&amp;lt;/wst:RequestType&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/wst:RequestSecurityToken&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/soap:Body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/soap:Envelope&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not a word about OAuth. Inside it is an ordinary &lt;code&gt;client_credentials&lt;/code&gt; call to the same core that serves HTTP.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually added
&lt;/h2&gt;

&lt;p&gt;Four WS-Trust operations, laid onto core routes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;Where it goes&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Issue&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;identity-token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;mint a token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Renew&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;identity-token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;refresh it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Validate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;identity-introspect&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;is it still alive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Cancel&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;identity-revoke&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;revoke it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Client credentials arrive in a &lt;code&gt;UsernameToken&lt;/code&gt; and are translated into &lt;code&gt;client_id&lt;/code&gt; and &lt;code&gt;client_secret&lt;/code&gt;, the same fields the core takes from everyone else. The facade has no authentication path of its own, and that is the point: a second path would mean a second place where it is decided who gets in.&lt;/p&gt;

&lt;p&gt;One detail breaks the shape gRPC taught us. There, every operation is its own address and routing is free. WS-Trust puts all four on &lt;strong&gt;one&lt;/strong&gt; address and tells them apart by the WS-Addressing &lt;code&gt;Action&lt;/code&gt; header. Generated clients expect exactly that, so the operation is resolved by parsing the request rather than by the address it arrived on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What goes into the RSTR
&lt;/h2&gt;

&lt;p&gt;Our ordinary JWT, as it is, in a &lt;code&gt;wsse:BinarySecurityToken&lt;/code&gt; with a value type declaring it a JWT.&lt;/p&gt;

&lt;p&gt;That was a decision, not a default, and it was made before any code. The alternative was to build a SAML assertion: its own claims model, its own conditions, its own XML signature. The difference is not the writing effort but that the system would gain a &lt;strong&gt;second token format&lt;/strong&gt;, and it would have to be taken through revocation and introspection, which today know one.&lt;/p&gt;

&lt;p&gt;We chose to start with JWT and return to SAML when a client turns up that requires it, rather than on an assumption about the market. The cost is stated plainly: some WS-Trust clients expect SAML specifically and will not work with a JWT. For them this facade covers part of the niche, not all of it.&lt;/p&gt;

&lt;p&gt;In exchange, what would otherwise have taken weeks works immediately: &lt;code&gt;Validate&lt;/code&gt; and &lt;code&gt;Cancel&lt;/code&gt; operate on a token minted over SOAP, because it is literally the same object that travels over HTTP.&lt;/p&gt;

&lt;h2&gt;
  
  
  A refusal arrives as a fault, not as a body
&lt;/h2&gt;

&lt;p&gt;A client told the call succeeded has no reason to look inside the answer. So a refusal from the core has to arrive as a &lt;code&gt;soap:Fault&lt;/code&gt; with a code of its own: generated WS-Trust clients branch on the code, and &lt;code&gt;FailedAuthentication&lt;/code&gt; is not the same outcome to them as &lt;code&gt;InvalidRequest&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The translation is arranged so as not to become a third independent copy. Reading the core's verdict lives once, in the shared &lt;code&gt;IdentityVerdict&lt;/code&gt;; what remains on the SOAP side is rendering that verdict into its own vocabulary:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Core verdict&lt;/th&gt;
&lt;th&gt;Fault&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Unauthenticated&lt;/code&gt;, &lt;code&gt;Forbidden&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wst:FailedAuthentication&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;InvalidScope&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wst:InvalidScope&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;RateLimited&lt;/code&gt;, &lt;code&gt;Unavailable&lt;/code&gt;, &lt;code&gt;Timeout&lt;/code&gt;, &lt;code&gt;ServerError&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;soap:Server&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;everything else&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wst:InvalidRequest&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;WS-Trust names considerably fewer outcomes than HTTP does, so several verdicts land on one fault. Where they do, the reason text carries what the code cannot, which is why it is never dropped.&lt;/p&gt;

&lt;p&gt;A word on &lt;code&gt;RateLimited&lt;/code&gt;. WS-Trust has no code for "wait", and &lt;code&gt;soap:Server&lt;/code&gt; is the closer of the two available readings: the refusal is ours, not the caller's, and there is nothing in their request to rewrite.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real test: one token, two transports, one verdict
&lt;/h2&gt;

&lt;p&gt;Everything else can be attributed to careful implementation. This cannot.&lt;/p&gt;

&lt;p&gt;A client is registered over HTTP through &lt;code&gt;/connect/register&lt;/code&gt;. A token is fetched over SOAP. That token is then presented back over HTTP on an admin endpoint, and it is accepted. And the other way round: a read-only client is refused a write on both transports.&lt;/p&gt;

&lt;p&gt;That is what the word "facade" means here. Not "a SOAP-compatible authorization server alongside the real one", but the same server with a third door.&lt;/p&gt;

&lt;h2&gt;
  
  
  Boundaries, named up front
&lt;/h2&gt;

&lt;p&gt;No browser flows: &lt;code&gt;authorize&lt;/code&gt;, the consent screen, MFA, device confirmation. The reason is not about SOAP; they need a browser, redirects and a cookie session. That niche has its own answer, WS-Federation, and it is filed as a separate decision rather than as an omission.&lt;/p&gt;

&lt;p&gt;No DPoP either: RFC 9449 binds its proof to an HTTP method and URL.&lt;/p&gt;

&lt;p&gt;No management surface in the first version. gRPC has one, and an estate arriving for WS-Trust usually needs tokens issued, not users administered over SOAP.&lt;/p&gt;

&lt;h2&gt;
  
  
  TLS is mandatory here, and the facade will not start without it
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;UsernameToken&lt;/code&gt; in its ordinary form carries the client's password in clear text. An STS on plain HTTP therefore publishes credentials to everyone on the path.&lt;/p&gt;

&lt;p&gt;We did not write a warning about that into the log. A warning is read afterwards, a refusal is read beforehand, so the facade simply does not start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;redb.Identity.Soap refuses to start without TLS: WS-Security UsernameToken carries
the client secret in clear text.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is exactly one way out and it is named explicitly: &lt;code&gt;AllowPlaintext&lt;/code&gt;, for a run behind a proxy that already terminates TLS. It is the operator's statement that the wire is protected some other way, not a way around the check. Setting it silently is not possible.&lt;/p&gt;

&lt;p&gt;Client certificates are supported at the TLS layer, with modes and thumbprint pinning, the same way as on the gRPC facade.&lt;/p&gt;

&lt;h2&gt;
  
  
  The WSDL is served on GET
&lt;/h2&gt;

&lt;p&gt;The audience for this facade builds clients with generators, and a generator needs a document. A &lt;code&gt;GET&lt;/code&gt; on the same address serves the WSDL with &lt;code&gt;soap:address&lt;/code&gt; rewritten to the URL it was fetched from. From there &lt;code&gt;dotnet-svcutil&lt;/code&gt;, &lt;code&gt;svcutil&lt;/code&gt; or &lt;code&gt;wsimport&lt;/code&gt; do their work.&lt;/p&gt;

&lt;p&gt;An STS that answers only POST is simply unusable for this audience.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to see it in ten seconds
&lt;/h2&gt;

&lt;p&gt;There is nothing to install: SOAP is ordinary HTTP with an XML body, so the demo is written against plain &lt;code&gt;Invoke-WebRequest&lt;/code&gt; and prints everything that goes out and comes back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;pwsh&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-File&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;demos/demo_soap_facade.ps1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten steps: the WSDL, registering a client over HTTP, &lt;code&gt;Issue&lt;/code&gt; over SOAP, &lt;code&gt;Validate&lt;/code&gt;, &lt;code&gt;Cancel&lt;/code&gt;, a second &lt;code&gt;Validate&lt;/code&gt; that now answers negatively, a refusal on a wrong secret with its fault code, an anonymous request, a junk body answered with &lt;code&gt;wst:InvalidRequest&lt;/code&gt;, and finally a token minted over SOAP and spent over HTTP.&lt;/p&gt;

&lt;h2&gt;
  
  
  What covers it
&lt;/h2&gt;

&lt;p&gt;Twenty-eight tests on the facade itself: parsing the RST, translating into core parameters, the fault table, end-to-end runs through a live listener into a real core, and separately a check that the per-IP rate limit actually works behind the facade.&lt;/p&gt;

&lt;p&gt;That last one deserves a note. The core keys its rate limiting, its lockout after failed logins and its device metadata on the caller's address. With no address those checks do not fail, they quietly do nothing, which looks exactly like "no abuse". So forwarding the address was not enough; we had to see the limit actually trip behind the facade.&lt;/p&gt;

&lt;h2&gt;
  
  
  What had to be fixed in the connector itself
&lt;/h2&gt;

&lt;p&gt;The facade is built on &lt;code&gt;redb.Route.Soap&lt;/code&gt;, and working on it exposed three holes in the connector. All three are general, none specific to Identity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A route could not choose its fault code.&lt;/strong&gt; The consumer took only the text from the exception and always set the code to &lt;code&gt;soap:Server&lt;/code&gt;. Every refusal caused by the caller therefore arrived as "we broke", leaving them to wait for us to fix what was theirs to fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The consumer could not serve TLS.&lt;/strong&gt; It registered the listener with a "needs HTTPS" flag but passed neither certificate, nor password, nor client-certificate mode. And the listener DSL could not produce the &lt;code&gt;soaps&lt;/code&gt; scheme at all. In that state an HTTPS SOAP endpoint could not be brought up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A prefixed fault code was written without declaring the prefix.&lt;/strong&gt; A fault code is a QName in both SOAP versions, and &lt;code&gt;wst:FailedAuthentication&lt;/code&gt; with &lt;code&gt;wst&lt;/code&gt; unbound reads fine to the eye and does not resolve in a strict client. Strict clients are precisely the ones that branch on the code.&lt;/p&gt;

&lt;p&gt;The connector now has fifty-six tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deployment
&lt;/h2&gt;

&lt;p&gt;The facade ships as a separate Tsak module with its own &lt;code&gt;identity.soap&lt;/code&gt; context and no compile-time reference to &lt;code&gt;redb.Identity.Core&lt;/code&gt; at all: it talks to the core only through &lt;code&gt;direct-vm://&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="nl"&gt;"identity.soap"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"IdentityTransport"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Soap"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Host"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.0.0.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Port"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5021&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/sts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Ssl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"AllowPlaintext"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"ClientCertificateMode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NoCertificate"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Wsdl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"EmitHttpCompatHeaders"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Ssl&lt;/code&gt; and &lt;code&gt;AllowPlaintext&lt;/code&gt; values here are the ones that ship in the box, and they are meant for development. In production you set &lt;code&gt;Ssl: true&lt;/code&gt; with a certificate and drop &lt;code&gt;AllowPlaintext&lt;/code&gt;. We left them this way deliberately: a module that refuses to start on a fresh machine reads as broken rather than as intended.&lt;/p&gt;

&lt;p&gt;The port is separate from the HTTP facade by default. Technically SOAP is ordinary HTTP/1.1 and could share a host. The separation buys something else: WS-Trust can be closed off from the internet while plain OIDC stays open, and the two surfaces get their own firewall rules.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;EmitHttpCompatHeaders&lt;/code&gt; is on by default and should stay on, for the reason described above about rate limiting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What comes next
&lt;/h2&gt;

&lt;p&gt;The facade is built, covered by tests and a demo. It has not yet been exercised on a live worker, and until that run we do not call it proven in the field.&lt;/p&gt;

&lt;p&gt;After that, as circumstances dictate. The management surface can be added with the same technique already worked out on gRPC. SAML assertions will appear when a client turns up that needs them specifically.&lt;/p&gt;

&lt;p&gt;If you have a WCF or CXF estate, the cheapest check takes a minute: bring up the worker and feed your generator the address &lt;code&gt;http://localhost:5021/sts?wsdl&lt;/code&gt;. It will build the client, and that client will bring back a real token from the same core that serves your HTTP.&lt;/p&gt;

&lt;p&gt;There is a separate write-up on the second transport: &lt;a href="https://redbase.app/articles/identity-grpc-facade" rel="noopener noreferrer"&gt;the OpenID server gained gRPC alongside HTTP&lt;/a&gt;. And another on putting this same server through the official OpenID Foundation conformance suite: &lt;a href="https://redbase.app/articles/identity-openid-conformance" rel="noopener noreferrer"&gt;running our OpenID server through the official suite&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;If this was useful — a ⭐ on &lt;a href="https://github.com/redbase-app" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; helps others find it.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;More of my writing: &lt;a href="https://redbase.app/articles" rel="noopener noreferrer"&gt;redbase.app/articles&lt;/a&gt;, and on &lt;a href="https://dev.to/rinat_kozin"&gt;dev.to&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>oauth</category>
      <category>soap</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Microservices in .NET without building a platform: a cluster of workers, one dashboard, hot-swapped module</title>
      <dc:creator>rinat kozin</dc:creator>
      <pubDate>Mon, 31 Aug 2026 18:54:51 +0000</pubDate>
      <link>https://dev.to/rinat_kozin/microservices-in-net-without-building-a-platform-a-cluster-of-workers-one-dashboard-hot-swapped-3n7p</link>
      <guid>https://dev.to/rinat_kozin/microservices-in-net-without-building-a-platform-a-cluster-of-workers-one-dashboard-hot-swapped-3n7p</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy6ku6gs1p6qo02vudxkv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy6ku6gs1p6qo02vudxkv.png" alt="redb.Tsak" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same artifact deploys as a monolith and as a cluster of &lt;strong&gt;microservices&lt;/strong&gt;. Same modules, one dashboard over every worker, hot swap with no container restart.&lt;/p&gt;

&lt;p&gt;When a team splits a monolith into microservices, it pays for the split in operations. There used to be one process: one set of metrics, one log, one knob labelled "restart that thing". Now there are nine processes, and each has its own story about how to look at the dead-letter queue, how to stop one route without dropping the rest, and how to roll out a new version without hitting the window where nobody is running it.&lt;/p&gt;

&lt;p&gt;Usually that operations layer gets built from scratch: Prometheus, Grafana, a hand-rolled health controller, a deploy script, a chat bot for restarts. It takes about as long as the split itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;redb.Tsak&lt;/strong&gt; offers a different trade: the operations layer lives in the runtime, and it is the same one whether you run one worker or nine. Cluster, dashboard, REST API, CLI, probes, metrics and tracing do not depend on the topology you picked. You decide how to carve up the processes, not how to operate them afterwards.&lt;/p&gt;

&lt;p&gt;How a module becomes a running service with a dashboard and a deployment story was &lt;a href="https://redbase.app/articles/worker-to-tsak" rel="noopener noreferrer"&gt;a separate article&lt;/a&gt;. It ended on a teaser about the cluster. This is that sequel.&lt;/p&gt;

&lt;h2&gt;
  
  
  One artifact, two topologies
&lt;/h2&gt;

&lt;p&gt;Tsak is a worker (a container or a self-contained archive) plus the modules that arrive into it. A module is a &lt;code&gt;.tpkg&lt;/code&gt;, an ordinary ZIP holding a manifest, DLLs and configuration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Orders.tpkg
├── manifest.json          { "Name": "Orders", "EntryPoints": ["Orders.dll"] }
├── Orders.dll             entry point: ITsakModule or InitRoute.main
├── Orders.config.json     the module's business settings
└── Orders.Domain.dll      a private dependency
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From there the topology decides, not the code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;As a monolith:&lt;/strong&gt; every &lt;code&gt;.tpkg&lt;/code&gt; goes into one worker's &lt;code&gt;modules/&lt;/code&gt;. Each gets its own isolated &lt;code&gt;AssemblyLoadContext&lt;/code&gt;, its own route context, its own property bag and its own lifecycle. One process, many independent parts. Calls between modules go through &lt;code&gt;direct-vm://&lt;/code&gt;: the same exchange, no serialization, no loopback hop, no TLS handshake.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;As microservices:&lt;/strong&gt; the same &lt;code&gt;.tpkg&lt;/code&gt; files spread across separate workers. One worker per module or per group of modules, a container each, all pointed at one database and joined into a cluster.&lt;/p&gt;

&lt;p&gt;The module code does not change by a single line. What changes is where the file was put and what the environment variables say. That is the property that matters: "monolith or microservices" stops being an architectural decision you make on day one and becomes a deployment parameter you can change on a Monday.&lt;/p&gt;

&lt;p&gt;Microservices are the recommended shape, and here is why.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like on a live project
&lt;/h2&gt;

&lt;p&gt;A real dev topology from one project: three workers, a module each, plus one web console covering all three.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Shared by every worker: one database, one cluster, one broker factory.&lt;/span&gt;
&lt;span class="na"&gt;x-db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nl"&gt;&amp;amp;db&lt;/span&gt;
  &lt;span class="na"&gt;ConnectionStrings__Postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Host=${PG_HOST};Database=app;Username=..."&lt;/span&gt;

&lt;span class="na"&gt;x-cluster&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nl"&gt;&amp;amp;cluster&lt;/span&gt;
  &lt;span class="na"&gt;Tsak__Cluster__Enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
  &lt;span class="na"&gt;Tsak__Cluster__ClusterName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
  &lt;span class="na"&gt;Tsak__Cluster__GroupName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;

&lt;span class="na"&gt;x-rmq&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nl"&gt;&amp;amp;rmq&lt;/span&gt;
  &lt;span class="na"&gt;Tsak__Contexts__default__RabbitMq__Host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${RMQ_HOST}&lt;/span&gt;
  &lt;span class="na"&gt;Tsak__Contexts__default__RabbitMq__Exchange&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app.events&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;app-core&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/redbase-app/redb-tsak-worker:3.7.2-net10&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;*db&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;*cluster&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;*rmq&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;Tsak__Modules__AssemblyPaths__0&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/app/modules&lt;/span&gt;
      &lt;span class="na"&gt;Tsak__Cluster__NodeId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app-core-1&lt;/span&gt;
      &lt;span class="na"&gt;Tsak__Cluster__ApiEndpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://app-core:9090&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./output/core:/app/modules"&lt;/span&gt; &lt;span class="pi"&gt;]&lt;/span&gt;

  &lt;span class="na"&gt;app-api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/redbase-app/redb-tsak-worker:3.7.2-net10&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;*db&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;*cluster&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;*rmq&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;Tsak__Modules__AssemblyPaths__0&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/app/modules&lt;/span&gt;
      &lt;span class="na"&gt;Tsak__Cluster__NodeId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app-api-1&lt;/span&gt;
      &lt;span class="na"&gt;Tsak__Cluster__ApiEndpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://app-api:9090&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./output/api:/app/modules"&lt;/span&gt; &lt;span class="pi"&gt;]&lt;/span&gt;

  &lt;span class="na"&gt;app-integration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/redbase-app/redb-tsak-worker:3.7.2-net10&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;*db&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;*cluster&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;*rmq&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;Tsak__Modules__AssemblyPaths__0&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/app/modules&lt;/span&gt;
      &lt;span class="na"&gt;Tsak__Cluster__NodeId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app-integration-1&lt;/span&gt;
      &lt;span class="na"&gt;Tsak__Cluster__ApiEndpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://app-integration:9090&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./output/integration:/app/modules"&lt;/span&gt; &lt;span class="pi"&gt;]&lt;/span&gt;

  &lt;span class="c1"&gt;# One web console for all three workers.&lt;/span&gt;
  &lt;span class="na"&gt;tsak-web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/redbase-app/redb-tsak-web:3.7.2-net10&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;*db&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;*cluster&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;Tsak__Web__Mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cluster&lt;/span&gt;
      &lt;span class="na"&gt;Tsak__Web__ServiceApiKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${TSAK_WEB_API_KEY}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things there are worth noticing.&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;the three workers run the same image&lt;/strong&gt;. The official &lt;code&gt;redb-tsak-worker&lt;/code&gt;, not something built for this project. The only differences are the mounted modules folder and the &lt;code&gt;NodeId&lt;/code&gt;. A custom image is what you build when you want the modules baked in rather than mounted, and that is a choice rather than a requirement.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;the web console does not list the nodes&lt;/strong&gt;. In &lt;code&gt;cluster&lt;/code&gt; mode it reads them from the cluster topology, which lives in the same database. Add a fourth worker and it shows up in the dashboard on its own.&lt;/p&gt;

&lt;p&gt;The cluster forms around that shared database: leader election, node registration, heartbeats and module distribution are data in redb rather than separate infrastructure. No ZooKeeper, no etcd, no Consul, no Redis to install. A single worker with clustering on is already a working one-node cluster: it elects itself leader and starts handing out routes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why microservices are the better default
&lt;/h2&gt;

&lt;p&gt;The monolith layout works honestly, and for a small system it is simpler. Past a certain size, though, separate processes give you what in-process isolation cannot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure stops at the container boundary.&lt;/strong&gt; &lt;code&gt;AssemblyLoadContext&lt;/code&gt; isolation protects you from version conflicts and from one module reaching into another's statics. It does not protect you from an &lt;code&gt;OutOfMemoryException&lt;/code&gt;, from a native library that took the process down, or from a leak that ate the working set. A separate container does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Limits go on the thing that needs them.&lt;/strong&gt; An integration module pulling hundred-megabyte XML and an API module returning JSON need different memory. In one process they share a single ceiling, and that ceiling is set by the worst case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You scale what is loaded.&lt;/strong&gt; Three API replicas and one integration replica is an ordinary picture. In a monolith you scale everything together, including the parts that did not ask for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Release cadences come apart.&lt;/strong&gt; Updating one &lt;code&gt;.tpkg&lt;/code&gt; does not make you think about the other eight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ownership boundaries match team boundaries.&lt;/strong&gt; Each team gets its worker, its modules, its row in the dashboard and its set of endpoints.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The honest price.&lt;/strong&gt; Splitting turns free in-process &lt;code&gt;direct-vm://&lt;/code&gt; calls into network calls. That is the cost, and it should be paid deliberately. The consolation is that the route code changes its address, not its logic: a producer publishes to a logical endpoint, and what that resolves to (&lt;code&gt;seda:&lt;/code&gt; inside the process, &lt;code&gt;rabbitmq:&lt;/code&gt; between processes) is configuration. The sensible tactic is to keep tightly coupled modules in one worker and cut along the boundary where the conversation is already asynchronous through a broker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Microservices do not take hot reload away
&lt;/h2&gt;

&lt;p&gt;Splitting into microservices usually costs you hot reload. The reasoning goes: a service is a container, an update is a new image, a new image is a pod restart. Want it faster, build your own plugin system.&lt;/p&gt;

&lt;p&gt;In Tsak hot swap does not depend on topology. It lives in the worker, and there can be any number of workers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Updating a module on a running worker: replace one file.&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; ./output/Orders.tpkg /app/modules/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;HotReloadService&lt;/code&gt; notices the changed timestamp and performs a graceful swap: it brings up a new &lt;code&gt;AssemblyLoadContext&lt;/code&gt;, lets it settle, waits for the old context to finish its in-flight messages, stops it and releases it. Nothing is dropped, the process does not restart, and the other modules never notice.&lt;/p&gt;

&lt;p&gt;Deleting a file is a first-class deployment operation rather than an error: &lt;code&gt;rm modules/Orders.tpkg&lt;/code&gt; stops every module of that package atomically, closes its transports and connections and disposes its isolated context. Neighbouring packages keep running.&lt;/p&gt;

&lt;p&gt;In a cluster &lt;code&gt;RollingUpdate&lt;/code&gt; kicks in and nodes update in sequence. &lt;strong&gt;There is never a moment when zero nodes run the new version, and never a moment when in-flight messages are lost.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The knobs you actually turn in production:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HotReload:ScanIntervalSeconds&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How often the module directories are scanned.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HotReload:RollingUpdate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;In a cluster, nodes update one after another rather than together.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HotReload:StartupTimeoutSeconds&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;60&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How long to let the new version settle before retiring the old one.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HotReload:KeepVersions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How many previous versions stay available for a one-command rollback.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HotReload:RemovalDebounceScans&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How many consecutive scans a file must stay missing to count as deleted. Protects against atomic replacement, where the file vanishes for an instant.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HotReload:AdditionStabilityScans&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How many consecutive scans a new file must hold the same size and timestamp before it is opened. Protects against reading a half-copied archive.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HotReload:Collectible&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full unload of the assembly context. Off deliberately, explained near the end.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last two arrived in 3.7.0, after working out why a large archive an operator dropped in by hand was sometimes picked up half-written.&lt;/p&gt;

&lt;p&gt;Configuration reloads hot as well: editing &lt;code&gt;context.json&lt;/code&gt; or &lt;code&gt;{Module}.config.json&lt;/code&gt; re-merges the layers and restarts the affected context. The worker stays up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Groups, nodes, assignments: slice the isolation however you like
&lt;/h2&gt;

&lt;p&gt;The topology is a three-level tree, and it lives in redb as ordinary objects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cluster:default                      scheme _tsak_clusters
 └── group:default:default           scheme _tsak_groups
      ├── node:default:worker-1      scheme _tsak_nodes
      ├── node:default:worker-2
      └── node:default:worker-3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every level is a boundary you can put to work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A cluster&lt;/strong&gt; separates environments and products. Different &lt;code&gt;ClusterName&lt;/code&gt; values in one database do not see each other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A group&lt;/strong&gt; isolates leader election, assignment and rebalancing. Inside one cluster you can keep an &lt;code&gt;edge&lt;/code&gt; group for the workers facing outward and a &lt;code&gt;batch&lt;/code&gt; group for the nightly processing, and a re-election in one leaves the other alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A node&lt;/strong&gt; is a worker. It registers itself, sends a heartbeat every 15 seconds, and is evicted from the registry after 60 seconds of silence. The leader lock is taken for 30 seconds and renewed; every state change is stamped with the leader's epoch, so a leader that lost the election cannot corrupt state after the fact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A context&lt;/strong&gt; is one more level, this time inside a worker. A named context joins several modules under one property bag and one lifecycle; an anonymous one is created for every module that was not assigned anywhere.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Tsak"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Contexts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"api"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Modules"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Api.Orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Api.Catalog"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"AutoStart"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Out of those pieces almost any layout assembles: from one worker holding every module to nine workers holding one each, grouped by area of responsibility.&lt;/p&gt;

&lt;p&gt;What the cluster adds on top:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Module distribution across nodes.&lt;/strong&gt; The leader spreads contexts over the live nodes and reassigns them when a node joins or leaves. The strategy today is round-robin, weighted ones are planned.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Active-passive for a consumer route, out of the box.&lt;/strong&gt; A route reading Kafka or firing on a schedule runs on exactly one node. The node dies, the lock expires, a neighbour takes over. No duplicates, no downtime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cordon and uncordon.&lt;/strong&gt; A node steps out of assignment without stopping: &lt;code&gt;tsak cluster cordon node-2&lt;/code&gt;, its modules move to the others, and the node can be serviced.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduled work as cluster singletons.&lt;/strong&gt; The built-in daily jobs (audit and dead-letter retention sweeps) are marked &lt;code&gt;.Cluster(true)&lt;/code&gt; and run on one node rather than on every one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swappable coordination.&lt;/strong&gt; &lt;code&gt;ILeaderElection&lt;/code&gt;, &lt;code&gt;IDistributedLock&lt;/code&gt;, &lt;code&gt;INodeRegistry&lt;/code&gt;, &lt;code&gt;IClusterCoordinator&lt;/code&gt;, &lt;code&gt;IClusterBootstrap&lt;/code&gt; and &lt;code&gt;IAssignmentManager&lt;/code&gt; are interfaces. If coordinating through the database is not what you want, register your own implementation, say one over Kubernetes Lease objects, with a single DI line before &lt;code&gt;AddTsakCluster()&lt;/code&gt;. Nothing else changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  One dashboard over every worker
&lt;/h2&gt;

&lt;p&gt;The web console is a separate Blazor Server process that finds the nodes itself in cluster mode. Its sidebar splits into three groups: the whole cluster, the sections of the selected node, and settings. This is an operator's workplace: the console is where you act, not only where you look.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cluster overview&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Section&lt;/th&gt;
&lt;th&gt;What is there&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dashboard&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Node statuses, a status donut, metric sparklines, a sortable and filterable node table.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cluster&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The three-level topology tree, module assignments, per-node health, click-through into a node.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Inside a node&lt;/strong&gt;, eleven sections, switched in the same sidebar without losing the selected node:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Section&lt;/th&gt;
&lt;th&gt;What is there&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Overview&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Process cards: CPU, working set, managed memory, threads, thread-pool queue, collections per GC generation.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Contexts&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The node's route contexts with status and endpoint count, plus start, stop and restart.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Endpoints&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Consumer and producer endpoints per route.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Routes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Every route of every context at once: status, message count, error rate, click-through.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Route detail&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One route in full: definition, current state, the exchanges in flight right now, recent diagnostics.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Watchdog&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Suspected and hung routes with stop and restart buttons.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Modules&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Loaded modules: name, version, status, dependencies, description.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scheduler&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Quartz jobs: group, cron, state, next fire time, pause, resume, fire now.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Monitoring&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Four live Chart.js graphs: CPU, memory, threads, garbage collection. Refreshed every ten seconds, twelve hours of history.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Logs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The ring-buffer log viewer with search, level filter and tail mode.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Audit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The trail of admin actions, who pressed what.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dead-letter&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Failed exchanges: list, replay, discard.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Settings&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Section&lt;/th&gt;
&lt;th&gt;What is there&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Auth &amp;amp; Users&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;API keys and accounts: create, revoke with confirmation.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Login is its own page: in cluster mode the accounts come from redb, in standalone mode from configuration.&lt;/p&gt;

&lt;p&gt;Four items on that list deserve their own paragraph.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-node monitoring.&lt;/strong&gt; The graphs are drawn from the worker's own metric history rather than from an external system. Prometheus and Grafana plug in alongside and are not going anywhere, but seeing what one node is doing right now does not require standing them up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exchanges in flight.&lt;/strong&gt; The route detail shows which exchanges are sitting in a route at this moment. When a queue is not draining, "is it stuck or just slow" is answered by looking, not by attaching a debugger to production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watchdog.&lt;/strong&gt; The service continuously classifies routes and tells suspected apart from hung. It will restart them on its own if you let it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Replaying a failed exchange.&lt;/strong&gt; The replay button in Dead-letter performs exactly one replay even if two operators press it at the same instant: the claim is taken with a conditional &lt;code&gt;UPDATE&lt;/code&gt;, and the database decides which of the two won.&lt;/p&gt;

&lt;p&gt;The console runs on its own design system, no Bootstrap, no MUI, no Tailwind: CSS variables, system fonts, dark and light themes, inline SVG icons.&lt;/p&gt;

&lt;h2&gt;
  
  
  Management, not just observation
&lt;/h2&gt;

&lt;p&gt;The dashboard is one of three heads. Under all of them sits the same REST API, &lt;strong&gt;70 endpoints across 16 controllers&lt;/strong&gt;, all speaking JSON.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Group&lt;/th&gt;
&lt;th&gt;Endpoints&lt;/th&gt;
&lt;th&gt;About&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/health&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Kubernetes probes: startup, live, ready. Auth-exempt.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/system&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Health, metrics, metric history, process info, effective configuration, loaded assemblies.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/contexts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;List, start, stop, restart, reset route states, endpoints, remove.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/contexts/{ctx}/routes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Routes: start, stop, force-stop, in-flight exchanges, metrics.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/modules&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;List, remove, upload, validate signature, roll back.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/scheduler&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Scheduler: status, scheduled jobs, running jobs, pause, resume, fire now.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/cluster&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Status, nodes, rebalance, remove node, cordon, uncordon.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/watchdog&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Status, alerts, enable, test alert.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/exchanges&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Dead-letter queue: list, replay, discard.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/logs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Incremental tail, file list, download.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/diagnostics&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Cluster-wide and per-route dumps.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/auth&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;API keys: create, list, revoke.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/users&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Users.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/audit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;The admin-action trail.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/lifecycle&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;The lifecycle event feed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/dashboard&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;An aggregated snapshot for the console in one round-trip.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The API itself is built nicely: it is an ordinary route context called &lt;code&gt;_system&lt;/code&gt;, one HTTP listener whose pipeline reads "header bridge, auth, controller dispatch". Tsak manages itself with the same engine it runs your routes on.&lt;/p&gt;

&lt;p&gt;The second head is the CLI: &lt;code&gt;tsak&lt;/code&gt;, one binary, &lt;strong&gt;57 commands&lt;/strong&gt;, connection profiles, tabular output for humans and JSON for CI.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tsak login http://prod-1:9090 &lt;span class="nt"&gt;--key&lt;/span&gt; &lt;span class="nv"&gt;$PROD_KEY&lt;/span&gt; &lt;span class="nt"&gt;--profile&lt;/span&gt; prod
tsak profile use prod

tsak context list                    &lt;span class="c"&gt;# a table&lt;/span&gt;
tsak context list &lt;span class="nt"&gt;--output&lt;/span&gt; json      &lt;span class="c"&gt;# for jq in a pipeline&lt;/span&gt;

tsak route force-stop orders route-1 &lt;span class="c"&gt;# take down a hung route&lt;/span&gt;
tsak route inflight orders route-1   &lt;span class="c"&gt;# see what is sitting in it&lt;/span&gt;
tsak dlq replay 42                   &lt;span class="c"&gt;# replay a failed exchange&lt;/span&gt;
tsak cluster cordon node-2           &lt;span class="c"&gt;# step a node out of assignment&lt;/span&gt;
tsak module deploy ./Orders.tpkg     &lt;span class="c"&gt;# ship a module&lt;/span&gt;
tsak module rollback Orders          &lt;span class="c"&gt;# go back one version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third head is a typed C# client, for automating operations from your own code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddTsakClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BaseUrl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"http://tsak-prod:9090"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ApiKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Ops&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ITsakApiClient&lt;/span&gt; &lt;span class="n"&gt;tsak&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;RestartFailedAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;contexts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tsak&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ListContextsAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;contexts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"Failed"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tsak&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RestartContextAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On access: keys are stored as an HMAC-SHA256 hash, the raw key is never persisted, comparison is constant-time, a key carries roles, an expiry and a revocation, and a revoked key stops being accepted across every node within thirty seconds. Inter-node calls use the same authentication: there is no implicit trust between nodes.&lt;/p&gt;

&lt;p&gt;Since 3.7.0 all of it is also &lt;strong&gt;closed by default&lt;/strong&gt;. The management API binds &lt;code&gt;127.0.0.1&lt;/code&gt; rather than &lt;code&gt;0.0.0.0&lt;/code&gt;, so exposing it is a deliberate act. A roleless key is denied instead of being treated as admin. The console has a real server-side cookie session, the password is checked against a BCrypt hash, and the login is rate-limited.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kubernetes, Prometheus, Jaeger
&lt;/h2&gt;

&lt;p&gt;External observability plugs in without a shim, because Tsak was written for containers from the start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three probes, not one.&lt;/strong&gt; Split by pod lifecycle phase, all three auth-exempt.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;startupProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/api/health/startup&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;9090&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;livenessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/api/health/live&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;    &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;9090&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/api/health/ready&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;   &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;9090&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference between liveness and readiness is deliberate. &lt;strong&gt;Liveness intentionally does not check module health&lt;/strong&gt;, otherwise a rolling update would turn into a restart loop. Readiness is stricter: any context in a non-running state takes the pod out of the load balancer without restarting it, and the cluster redistributes assignments meanwhile.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prometheus with no extra port.&lt;/strong&gt; With &lt;code&gt;Tsak:Metrics:Prometheus:Enabled&lt;/code&gt; the metrics are served at &lt;code&gt;/metrics&lt;/code&gt; on the same port as the API. The OpenTelemetry listener sits on loopback, and the facade route proxies it out. No second port to open, and on Windows no URL ACL, because Kestrel binds the sockets rather than &lt;code&gt;HttpListener&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;prometheus.io/scrape&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
    &lt;span class="na"&gt;prometheus.io/port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9090"&lt;/span&gt;
    &lt;span class="na"&gt;prometheus.io/path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/metrics"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exporter is pre-flighted: if the loopback bind ever fails, Tsak logs a warning and runs without metrics. An optional exporter has no business taking the worker down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Jaeger and any OTLP collector.&lt;/strong&gt; Traces leave through the standard OTLP exporter, &lt;code&gt;Tsak:Tracing:Otlp:Enabled&lt;/code&gt; plus an endpoint. The redb.Route &lt;code&gt;ActivitySource&lt;/code&gt; is registered in the OpenTelemetry pipeline, so the spans opened by route processors and transports are collected on their own. The service name in Jaeger comes from &lt;code&gt;Tsak:Tracing:ServiceName&lt;/code&gt;, which is exactly right in a microservice layout: each worker carries its own name, and the trace is stitched across the broker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ready-made artifacts.&lt;/strong&gt; The repository ships Kubernetes manifests (a Deployment with the correct probes, a Service, a ServiceMonitor for Prometheus Operator), an importable Grafana dashboard, and a local Prometheus + Grafana + Jaeger stack behind one &lt;code&gt;docker compose&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pod identity.&lt;/strong&gt; In a cluster the &lt;code&gt;NodeId&lt;/code&gt; has to survive a restart or assignments drift. The downward API binds it to the pod name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Tsak__Cluster__NodeId&lt;/span&gt;
    &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;fieldRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;fieldPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;metadata.name&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;POD_IP&lt;/span&gt;
    &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;fieldRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;fieldPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;status.podIP&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Tsak__Cluster__ApiEndpoint&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://$(POD_IP):9090&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Graceful termination.&lt;/strong&gt; Set &lt;code&gt;Tsak:Shutdown:TimeoutSeconds&lt;/code&gt; five seconds below &lt;code&gt;terminationGracePeriodSeconds&lt;/code&gt; so that deregistering the node keeps its buffer. The order is SIGTERM, cluster deregistration, context drain, scheduler shutdown, log flush. SIGKILL never gets its turn.&lt;/p&gt;

&lt;p&gt;On top of that the worker counts for itself: process metrics with twelve hours of history sampled every ten seconds (4320 points), per-context and per-route metrics (messages per second, error rate, in-flight count), a two-thousand-entry ring-buffer log queryable over REST and in the console, and diagnostic dumps per route and across the cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Identity arrives as a package in the same worker
&lt;/h2&gt;

&lt;p&gt;The same module format carries a finished product. A full OAuth 2.1 and OpenID Connect server ships as a set of &lt;code&gt;.tpkg&lt;/code&gt; files.&lt;/p&gt;

&lt;p&gt;Four packages: &lt;code&gt;redb.Identity.Core&lt;/code&gt; (the server itself: schemes, stores, MFA, WebAuthn, federation, audit, key rotation) plus three transport facades, &lt;code&gt;Http&lt;/code&gt;, &lt;code&gt;Grpc&lt;/code&gt; and &lt;code&gt;Soap&lt;/code&gt;. The facades are thin bridges with no business logic.&lt;/p&gt;

&lt;p&gt;It lays out however you want, on exactly the same logic as your own modules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One worker:&lt;/strong&gt; &lt;code&gt;Core&lt;/code&gt; + &lt;code&gt;Http&lt;/code&gt;, and you have an OP on its port.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two workers:&lt;/strong&gt; &lt;code&gt;Core&lt;/code&gt; + &lt;code&gt;Http&lt;/code&gt; faces outward, &lt;code&gt;Core&lt;/code&gt; + &lt;code&gt;Grpc&lt;/code&gt; serves service-to-service calls. Different cluster groups, different limits, different exposure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inside your own worker:&lt;/strong&gt; put &lt;code&gt;redb.Identity.Core&lt;/code&gt; next to your module and call it straight from your route.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// No HTTP, no serialization, no loopback: the same exchange.&lt;/span&gt;
&lt;span class="nf"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"http:0.0.0.0:5090/api/login"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"direct-vm://identity-token"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the shared-runtime benefit in its purest form: two products that know nothing about each other end up in one process and talk without a network, because both speak in route addresses.&lt;/p&gt;

&lt;p&gt;The substance is tested and proven: OIDC Core, OAuth 2.1, introspection, dynamic client registration, Device Code, PAR, JAR, DPoP, backchannel logout, SCIM 2.0, TOTP, OTP over SMS and email, WebAuthn. The official OpenID Foundation conformance suite passes with zero failures on the Config OP and Basic OP profiles.&lt;/p&gt;

&lt;p&gt;And it is observed by the same dashboard as everything else: Identity routes appear in the shared list, its metrics in the shared graphs, its logs in the shared buffer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your own worker, when you want one
&lt;/h2&gt;

&lt;p&gt;The module stays yours and runs anywhere. The entry point is either a type implementing &lt;code&gt;ITsakModule&lt;/code&gt; or a public static class &lt;code&gt;InitRoute&lt;/code&gt; with a &lt;code&gt;main(IRouteContext)&lt;/code&gt; method. The second is an Apache Camel style convention, and anyone can call it.&lt;/p&gt;

&lt;p&gt;A debug host in full:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;services&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ServiceCollection&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddLogging&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddSimpleConsole&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRedb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseSqlite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Data Source=echo_demo.db"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;sp&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BuildServiceProvider&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IRedbService&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;().&lt;/span&gt;&lt;span class="nf"&gt;InitializeAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ensureCreated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;RouteContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;contextId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"echo-worker"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ILoggerFactory&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ILoggerFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;());&lt;/span&gt;

&lt;span class="n"&gt;EchoModule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InitRoute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// the exact method the Tsak worker calls&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fifty lines, and the module runs under a debugger in your IDE with breakpoints and single-stepping. That beats attaching to a hot-loaded assembly context inside a running worker.&lt;/p&gt;

&lt;p&gt;The same trick works when you want a host of your own: your DI, your configuration, your metric collection. The &lt;a href="https://github.com/redbase-app/redb-tsak" rel="noopener noreferrer"&gt;redb.Tsak&lt;/a&gt; sources are Apache 2.0, the packages are &lt;a href="https://www.nuget.org/profiles/relikt" rel="noopener noreferrer"&gt;on NuGet&lt;/a&gt;, and there is no closed runtime in the middle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five configuration layers: why one image serves nine workers
&lt;/h2&gt;

&lt;p&gt;This is the mechanism that lets the three services above start from a single image.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Layer 1: Tsak:Contexts:default                 base for every context
Layer 2: Tsak:Contexts:{name}                  settings of one context
Layer 3: modules/{Module}/context.json         the module's infrastructure defaults
Layer 4: modules/{Module}/{Module}.config.json the module's business settings
Layer 5: Tsak:Contexts:{name}:Override         operations get the final word
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The layers deep-merge: nested objects complete each other rather than replacing wholesale. A module brings sensible defaults inside its own archive, operations overrides what has to differ in production, and the module's code anticipates none of it.&lt;/p&gt;

&lt;p&gt;The practical consequence for secrets: LDAP and SMTP passwords and JWT signing keys arrive through the Override layer from environment variables and land in named connection factories. They are not in the &lt;code&gt;.tpkg&lt;/code&gt;, not in endpoint URIs, not in the logs and not in the dashboard.&lt;/p&gt;

&lt;p&gt;On top of that, the worker's shared layer already carries &lt;strong&gt;28 redb.Route connectors&lt;/strong&gt;: RabbitMQ, Kafka, AMQP, Azure Service Bus, IBM MQ, SQS, Redis, S3, Elasticsearch, SQL, gRPC, SOAP, AS2, SignalR, WebSocket, MQTT, TCP, mail, files, FTP, SFTP, LDAP, Telegram, Firebase, LLM and the rest. A module talking to Kafka does not carry the driver with it: the worker already has it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;The boundaries worth knowing before you adopt it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assembly-context unloading is off by default.&lt;/strong&gt; &lt;code&gt;HotReload:Collectible = false&lt;/code&gt;, deliberately: &lt;code&gt;Reflection.Emit&lt;/code&gt; (used by &lt;code&gt;XmlSerializer&lt;/code&gt;, serialization generators and compiled regular expressions) does not survive an unload. The price is that old assembly contexts stay in memory until the process restarts. Their count is exposed as the &lt;code&gt;LeakedAlcCount&lt;/code&gt; metric, so it is visible. For a worker updated once a week this is immaterial; for one updated twenty times a day, a nightly restart settles it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is one distribution strategy.&lt;/strong&gt; Round-robin. Weighted strategies are planned, the &lt;code&gt;IAssignmentManager&lt;/code&gt; interface for them already exists, and your own implementation can be plugged in today.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coordination lives in the database.&lt;/strong&gt; Leader election, locks and the node registry are rows in redb. The upside is that no separate membership infrastructure is needed. The downside is that the database becomes a participant in coordination. If that does not suit you, all six coordination interfaces are replaceable in DI, for instance with an implementation over Kubernetes Lease objects, leaving redb as storage for modules and keys only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quartz in a cluster wants a real database.&lt;/strong&gt; &lt;code&gt;RAMJobStore&lt;/code&gt; for development, &lt;code&gt;AdoJobStore&lt;/code&gt; for production. The schema is created on first start, with no DBA action required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trying it out
&lt;/h2&gt;

&lt;p&gt;The fastest path is a single &lt;a href="https://github.com/redbase-app/redb-tsak/pkgs/container/redb-tsak-stack" rel="noopener noreferrer"&gt;&lt;code&gt;redb-tsak-stack&lt;/code&gt;&lt;/a&gt; container: worker and web console in one image, in the spirit of &lt;code&gt;rabbitmq:management&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-p&lt;/span&gt; 9090:9090 &lt;span class="nt"&gt;-p&lt;/span&gt; 8085:8085 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; ./modules:/app/worker/modules &lt;span class="se"&gt;\&lt;/span&gt;
  ghcr.io/redbase-app/redb-tsak-stack:3.7.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API comes up on 9090 and the dashboard on 8085. Drop a &lt;code&gt;.tpkg&lt;/code&gt; into &lt;code&gt;./modules&lt;/code&gt; and ten seconds later the module is in the list and its routes are on the graphs. Neither a database nor a broker is needed for a first run: the default storage is in-memory.&lt;/p&gt;

&lt;p&gt;When it comes to spreading across machines, the separate &lt;code&gt;redb-tsak-worker&lt;/code&gt; and &lt;code&gt;redb-tsak-web&lt;/code&gt; images take over, as in the compose file above. Ready-made templates for all four cases (worker only, console only, stack, stack with PostgreSQL) live in the repository under &lt;code&gt;publish/docker/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Without Docker there is a &lt;a href="https://github.com/redbase-app/redb-tsak/releases" rel="noopener noreferrer"&gt;self-contained archive&lt;/a&gt;: unpack, run, put the modules next to it. That is the monolith layout in one file, with the same dashboard and the same API. Image and archive signatures are verified with cosign.&lt;/p&gt;

&lt;p&gt;The current number on the line is 3.7.2. The libraries target &lt;code&gt;net8.0&lt;/code&gt;, &lt;code&gt;net9.0&lt;/code&gt; and &lt;code&gt;net10.0&lt;/code&gt;; applications and images are built on .NET 10 and tagged &lt;code&gt;-net10&lt;/code&gt;. Pro stays proprietary but free, with no licence key, across the whole 3.x line, clustering included: there is no node limit. The worker's main unit suite passes in full on this line, 647 of 647 on .NET 10.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sources: &lt;a href="https://github.com/redbase-app/redb-tsak" rel="noopener noreferrer"&gt;github.com/redbase-app/redb-tsak&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Releases and archives: &lt;a href="https://github.com/redbase-app/redb-tsak/releases" rel="noopener noreferrer"&gt;redb-tsak/releases&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Images: &lt;a href="https://github.com/redbase-app/redb-tsak/pkgs/container/redb-tsak-stack" rel="noopener noreferrer"&gt;the stack in one container&lt;/a&gt;, &lt;a href="https://github.com/orgs/redbase-app/packages?repo_name=redb-tsak" rel="noopener noreferrer"&gt;everything on GHCR&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;NuGet packages: &lt;a href="https://www.nuget.org/profiles/relikt" rel="noopener noreferrer"&gt;nuget.org/profiles/relikt&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The ecosystem as a whole: &lt;a href="https://redbase.app/" rel="noopener noreferrer"&gt;redbase.app&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Starting with the monolith layout is easier, and moving to microservices can wait until there is a reason. The pleasant part is that the move costs an edit to a compose file rather than a rewrite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;If this was useful — a ⭐ on &lt;a href="https://github.com/redbase-app" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; helps others find it.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;More of my writing: &lt;a href="https://redbase.app/articles" rel="noopener noreferrer"&gt;redbase.app/articles&lt;/a&gt;, and on &lt;a href="https://dev.to/rinat_kozin"&gt;dev.to&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>microservices</category>
      <category>kubernetes</category>
      <category>devops</category>
    </item>
    <item>
      <title>SOAP in .NET without WCF: WS-Security, MTOM and ?wsdl as a route step</title>
      <dc:creator>rinat kozin</dc:creator>
      <pubDate>Fri, 28 Aug 2026 18:00:42 +0000</pubDate>
      <link>https://dev.to/rinat_kozin/soap-in-net-without-wcf-ws-security-mtom-and-wsdl-as-a-route-step-1of</link>
      <guid>https://dev.to/rinat_kozin/soap-in-net-without-wcf-ws-security-mtom-and-wsdl-as-a-route-step-1of</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpqp5w2rozuv8uqsu228t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpqp5w2rozuv8uqsu228t.png" alt="redb.Route.SOAP" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SOAP&lt;/strong&gt; with WS-Security, MTOM and WSDL publishing is now a native redb.Route connector: no WCF, no CoreWCF, no standalone gateway.&lt;/p&gt;

&lt;p&gt;SOAP did not go away. If you sell airline tickets, you talk to Amadeus, Sabre and Travelport over SOAP. If you integrate with a bank, a government portal, an insurance backend, a telco billing system or almost any enterprise product bought before 2015, the contract is a WSDL and the wire is a &lt;code&gt;&amp;lt;soap:Envelope&amp;gt;&lt;/code&gt;. WS-Security signatures, MTOM attachments, SOAP 1.1 next to SOAP 1.2, a &lt;code&gt;soap:Fault&lt;/code&gt; on error: that world is alive, it pays the bills, and it is not migrating to REST because you asked nicely.&lt;/p&gt;

&lt;p&gt;In .NET, calling it has become awkward. WCF as a full framework is gone. CoreWCF exists but every published version carries an unpatched crypto advisory, so pulling it in trades one problem for another. &lt;code&gt;dotnet-svcutil&lt;/code&gt; generates a client from a WSDL at build time, but that is code generation glued to the side of your app, not an integration step. And hosting a SOAP endpoint, adding WS-Security by hand, or wiring MTOM tends to end in a pile of &lt;code&gt;System.ServiceModel&lt;/code&gt; configuration nobody wants to own.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;redb.Route.Soap&lt;/code&gt; takes a different route. SOAP becomes an ordinary step in a pipeline in your own .NET process. Call a service, get a typed reply. Host an endpoint, hand the body to a route, return a response. Everything is in-box: &lt;code&gt;HttpClient&lt;/code&gt;, the shared Kestrel host, and &lt;code&gt;System.Security.Cryptography.Xml&lt;/code&gt; for WS-Security. No WCF, no &lt;code&gt;System.ServiceModel&lt;/code&gt; runtime, no vulnerable dependency, no separate gateway. Let us walk through how it is used, and why a native connector inside the ESB beats a codegen client or a standalone box.&lt;/p&gt;

&lt;h2&gt;
  
  
  SOAP in one minute
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRedbRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRedbRouteSoap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddRouteBuilder&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MyRoutes&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Call a service&lt;/span&gt;
&lt;span class="nf"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"direct://get-fares"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Soap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://gds/air.svc"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ConnectionFactory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"amadeus"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Operation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"GetFares"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// Host a SOAP endpoint&lt;/span&gt;
&lt;span class="nf"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Soap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/svc/orders"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"0.0.0.0"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Port&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;4090&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HandleOrder&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AddRedbRouteSoap()&lt;/code&gt; registers the &lt;code&gt;soap&lt;/code&gt; and &lt;code&gt;soaps&lt;/code&gt; schemes and shares one Kestrel receive server with every other HTTP-based connector in the process. In the default &lt;strong&gt;Payload&lt;/strong&gt; mode the message body is the XML of &lt;code&gt;&amp;lt;soap:Body&amp;gt;&lt;/code&gt;: send a fragment, receive a fragment, no code generation, works against any service.&lt;/p&gt;

&lt;h2&gt;
  
  
  The endpoint is a string (or a fluent builder)
&lt;/h2&gt;

&lt;p&gt;Every endpoint reads two equivalent ways. Some people prefer the type-safe builder, some prefer a plain URI they can drop into config. They compile to the same thing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Fluent&lt;/span&gt;
&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Soap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://gds/air.svc"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ConnectionFactory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"amadeus"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Operation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"GetFares"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;// String URI (identical)&lt;/span&gt;
&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"soaps://gds/air.svc?connectionFactory=amadeus&amp;amp;operation=GetFares"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The consumer side is the same story:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="nf"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Soap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/svc/orders"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"0.0.0.0"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Port&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;4090&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ConnectionFactory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;// or&lt;/span&gt;
&lt;span class="nf"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"soap:/svc/orders?host=0.0.0.0&amp;amp;port=4090&amp;amp;connectionFactory=orders"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;soap&lt;/code&gt; is HTTP, &lt;code&gt;soaps&lt;/code&gt; is HTTPS. The producer address is &lt;code&gt;soap[s]://host/path&lt;/code&gt;; the consumer is &lt;code&gt;soap:/path?host=&amp;amp;port=&lt;/code&gt; with the path kept intact. Because the URI is just a string, the endpoint can come from &lt;code&gt;appsettings.json&lt;/code&gt; and change per environment without touching code.&lt;/p&gt;

&lt;h2&gt;
  
  
  A service binding is one object, not a scatter of parameters
&lt;/h2&gt;

&lt;p&gt;Certificates, credentials, the SOAP version and the data format never live in a URI. They live on a &lt;code&gt;SoapConnectionFactory&lt;/code&gt;, registered once by name. Routes reference it with &lt;code&gt;.ConnectionFactory("name")&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddToRegistry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"amadeus"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;SoapConnectionFactory&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;EndpointUrl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"https://gds/air.svc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;SoapVersion&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SoapVersion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Soap11&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DefaultAction&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"urn:GetFares"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="c1"&gt;// WS-Security material, all optional&lt;/span&gt;
    &lt;span class="n"&gt;SigningCert&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ourPfx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// our cert + PRIVATE key: signs outgoing, decrypts incoming&lt;/span&gt;
    &lt;span class="n"&gt;EncryptCert&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;partnerCer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// partner's PUBLIC cert: encrypts outgoing, authenticates their signature&lt;/span&gt;
    &lt;span class="n"&gt;Username&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"svc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Password&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"secret"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// UsernameToken&lt;/span&gt;

    &lt;span class="n"&gt;Mtom&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;               &lt;span class="c1"&gt;// MTOM/XOP attachments&lt;/span&gt;
    &lt;span class="n"&gt;Wsdl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"contracts/air.wsdl"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;              &lt;span class="c1"&gt;// published on GET ?wsdl (consumer)&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The password field is marked sensitive, so it is redacted in logs and in the runtime dashboard. Move from staging to production by swapping the registered object, not by editing the routes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways the route sees the message
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;camel-cxf&lt;/code&gt; has data formats; so does this connector, on &lt;code&gt;SoapConnectionFactory.DataFormat&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Payload&lt;/code&gt;&lt;/strong&gt; (default). The body is the inner &lt;code&gt;&amp;lt;soap:Body&amp;gt;&lt;/code&gt; XML. Works with any service, no types, no codegen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Message&lt;/code&gt;&lt;/strong&gt;. A transparent proxy: the body is the whole envelope, in and out. Log SOAP traffic, forward it untouched, inspect it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Pojo&lt;/code&gt;&lt;/strong&gt;. Typed request and response objects via &lt;code&gt;XmlSerializer&lt;/code&gt;, the .NET analogue of JAXB document/literal. The DTOs are ordinary XML-serializable types and may be generated from a WSDL with &lt;code&gt;dotnet-svcutil&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddToRegistry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"air"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;SoapConnectionFactory&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;EndpointUrl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"https://gds/air.svc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DataFormat&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SoapDataFormat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pojo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ResponseType&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GetFaresResponse&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"direct://q"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;In&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;GetFaresRequest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Route&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"JFK-LHR"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Soap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://gds/air.svc"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ConnectionFactory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"air"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="c1"&gt;// e.Out.Body is now a GetFaresResponse&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You choose the level of typing. A generic pass-through pipeline stays in Payload; a service with a fixed contract goes Pojo and works with real objects.&lt;/p&gt;

&lt;h2&gt;
  
  
  WS-Security that authenticates, in-box
&lt;/h2&gt;

&lt;p&gt;WS-Security is where SOAP integrations usually get stuck. Here it is three fields on the factory and it runs on &lt;code&gt;System.Security.Cryptography.Xml&lt;/code&gt;, no CoreWCF anywhere near it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;UsernameToken&lt;/strong&gt;: set &lt;code&gt;Username&lt;/code&gt; (and &lt;code&gt;Password&lt;/code&gt;); the producer prepends a &lt;code&gt;&amp;lt;wsse:Security&amp;gt;&lt;/code&gt; header, the consumer surfaces the credentials to the route.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;XML-Signature&lt;/strong&gt;: with &lt;code&gt;SigningCert&lt;/code&gt; set, the producer signs the &lt;code&gt;&amp;lt;soap:Body&amp;gt;&lt;/code&gt; (Exclusive C14N, SHA-256, embedded X.509); the consumer verifies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;XML-Encryption&lt;/strong&gt;: with &lt;code&gt;EncryptCert&lt;/code&gt; set, the producer encrypts the Body to the partner; the consumer decrypts with its private key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The verification is the part that matters. When the partner certificate is configured, signature verification is authenticated: the signer must be that exact certificate, and the signature must cover the Body, so a forged self-signed signature or a signature over a decoy element is rejected. That is the difference between "the XML was not tampered" and "this message is really from the partner", and it is easy to get wrong.&lt;/p&gt;

&lt;p&gt;The encryption goes out in the layout real WS-Security stacks produce and expect: the &lt;code&gt;EncryptedKey&lt;/code&gt; in the &lt;code&gt;&amp;lt;wsse:Security&amp;gt;&lt;/code&gt; header, joined by a &lt;code&gt;ReferenceList&lt;/code&gt; to the &lt;code&gt;EncryptedData&lt;/code&gt; in the Body, AES-256 under an RSA-OAEP key wrap. It is not a private dialect. An independent crypto stack decrypts it end to end (more on that below).&lt;/p&gt;

&lt;h2&gt;
  
  
  Binary attachments: MTOM
&lt;/h2&gt;

&lt;p&gt;Large binaries do not belong base64-inflated inside the XML. Set &lt;code&gt;Mtom = true&lt;/code&gt; and attachments travel as &lt;code&gt;multipart/related&lt;/code&gt; with XOP, on a side plane so the Body contract stays clean, the same way Camel keeps them on an &lt;code&gt;AttachmentMessage&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"&amp;lt;Upload xmlns=\"urn:svc\"&amp;gt;&amp;lt;file&amp;gt;"&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt;
    &lt;span class="s"&gt;"&amp;lt;xop:Include xmlns:xop=\"http://www.w3.org/2004/08/xop/include\" href=\"cid:doc-1\"/&amp;gt;&amp;lt;/file&amp;gt;&amp;lt;/Upload&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;SoapHeaders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Attachments&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SoapAttachment&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"doc-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"application/pdf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pdfBytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;producer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Exchange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the receiving side the route reads inbound attachments off &lt;code&gt;redbSoap.attachments&lt;/code&gt;, and replies with its own list. The connector will not silently echo the caller's attachments back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Publish your WSDL on ?wsdl
&lt;/h2&gt;

&lt;p&gt;.NET Core has no runtime WSDL import, by design, so the connector does not pretend to. What it does do is publish a contract. Point &lt;code&gt;SoapConnectionFactory.Wsdl&lt;/code&gt; at a file or inline XML on a consumer, and it is served on &lt;code&gt;GET {path}?wsdl&lt;/code&gt; with the &lt;code&gt;&amp;lt;soap:address&amp;gt;&lt;/code&gt; rewritten to the address the caller actually reached you on. Pair it with &lt;code&gt;Pojo&lt;/code&gt; mode for typed request and response, and you have a contract-first SOAP service.&lt;/p&gt;

&lt;h2&gt;
  
  
  SOAP as a controller
&lt;/h2&gt;

&lt;p&gt;Here is the part that does not exist in a codegen client. SOAP is a first-class controller transport in redb.Route, next to HTTP, gRPC and SignalR. Write a controller, dispatch by operation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"air"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AirController&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RedbController&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Method name = SOAP operation; the XML body binds in, the typed reply serializes out.&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;GetFaresResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetFares&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;FromBody&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;GetFares&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;GetFaresResponse&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Price&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;Quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;SoapOperation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"HealthCheck"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;           &lt;span class="c1"&gt;// explicit name when it differs from the method&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nf"&gt;Health&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Behind a SOAP endpoint:&lt;/span&gt;
&lt;span class="nf"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Soap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/svc/air"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"0.0.0.0"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Port&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;4090&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RedbSoapController&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AirController&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The operation the SOAP consumer parsed from the request maps to a method, the XML body binds to the parameter, and the return value goes back as the response. No HTTP attributes, no manual envelope handling, a &lt;code&gt;soap:Fault&lt;/code&gt; on error. The same controller class works behind any transport, so a service can speak SOAP and REST at once with one implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it is used, and why native beats a gateway
&lt;/h2&gt;

&lt;p&gt;The scenarios are the boring, load-bearing ones. A travel platform querying GDS fares over SOAP and republishing them as JSON. A bank integration that receives a signed SOAP request, validates it, and writes to a ledger. A government or healthcare portal with a strict WSDL contract you must serve exactly. A legacy ERP that only speaks SOAP 1.1 with a UsernameToken.&lt;/p&gt;

&lt;p&gt;With a codegen client or a standalone gateway, SOAP lives beside your integration. You generate a proxy, or you run a separate process, and then you still have to move the message into your actual pipeline, translate the fault, correlate the trace across a boundary. With &lt;code&gt;redb.Route.Soap&lt;/code&gt; it is one step of a route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="nf"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Soap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/svc/orders"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"0.0.0.0"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Port&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;8443&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ConnectionFactory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"self"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderSchema&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Unmarshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"xml"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Marshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"json"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"kafka://orders"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"&amp;lt;Ack xmlns=\"urn:svc\"&amp;gt;accepted&amp;lt;/Ack&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One process, one deployment, one trace. The SOAP request lands, gets validated, transformed, dropped into Kafka, and the partner gets an envelope back, and all of it shows up in the same OpenTelemetry span tree as every other step. A &lt;code&gt;Client&lt;/code&gt; span on the call, a &lt;code&gt;Server&lt;/code&gt; span on the endpoint, endpoint statistics, secret redaction: the same cross-cutting behavior every other connector in the family has.&lt;/p&gt;

&lt;h2&gt;
  
  
  Interop proven, not claimed
&lt;/h2&gt;

&lt;p&gt;A SOAP connector that only talks to itself is not a SOAP connector. This one is checked against an independent stack, the Node.js &lt;code&gt;soap&lt;/code&gt; library, which shares no code with it, in both directions: our producer against their server, their client against our consumer, plain SOAP and MTOM. And the WS-Security encryption is decrypted end to end by an independent crypto stack (Node's OpenSSL) straight off our envelope: RSA-OAEP key unwrap, AES-256-CBC, the XML-Encryption padding stripped as the spec mandates. The wire is standard, and that is demonstrated, not asserted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest boundaries
&lt;/h2&gt;

&lt;p&gt;Runtime WSDL import (a client introspecting a remote WSDL on the fly) is absent from .NET Core by design; the connector serves a static WSDL contract and pairs it with Pojo types. WSDL is not generated from CLR types by reflection; contract-first is the model. A few rare WS-* variants (external &lt;code&gt;CipherReference&lt;/code&gt;, non-OAEP key transport, UsernameToken &lt;code&gt;PasswordDigest&lt;/code&gt;) are not handled yet. None of that blocks the mainline: SOAP 1.1 and 1.2, faults, the two header planes, WS-Security with authenticated signatures and standard-layout encryption, MTOM, WSDL publishing, and controllers.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do I need CoreWCF or System.ServiceModel?&lt;/strong&gt; No. The baseline is &lt;code&gt;HttpClient&lt;/code&gt;, the shared Kestrel host, and &lt;code&gt;System.Security.Cryptography.Xml&lt;/code&gt;. CoreWCF was deliberately avoided because every version carries an unpatched crypto advisory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SOAP 1.1 or 1.2?&lt;/strong&gt; Both. &lt;code&gt;SoapVersion&lt;/code&gt; drives the Content-Type and the SOAPAction placement; faults are parsed for both, regardless of the namespace prefix a WCF or CXF peer uses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can one service speak SOAP and REST?&lt;/strong&gt; Yes, through the controller transport: the same &lt;code&gt;RedbController&lt;/code&gt; sits behind &lt;code&gt;Soap.Listen(...)&lt;/code&gt; and &lt;code&gt;Http.Listen(...)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where do certificates go?&lt;/strong&gt; On the &lt;code&gt;SoapConnectionFactory&lt;/code&gt;, as &lt;code&gt;X509Certificate2&lt;/code&gt;. The password is redacted in logs and the dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package redb.Route.Soap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The package is &lt;a href="https://www.nuget.org/packages/redb.Route.Soap/" rel="noopener noreferrer"&gt;redb.Route.Soap on NuGet&lt;/a&gt;; the source and the full DSL reference are in the &lt;a href="https://github.com/redbase-app/redb-route/tree/main/redb.Route.Soap" rel="noopener noreferrer"&gt;connector README&lt;/a&gt;. SOAP is one more transport in the redb.Route family, alongside Kafka, RabbitMQ, AS2, IBM MQ and the rest: the same &lt;code&gt;From → … → To&lt;/code&gt;, the same EIPs, the same observability. The only difference is that the message on the wire is a &lt;code&gt;&amp;lt;soap:Envelope&amp;gt;&lt;/code&gt; a twenty-year-old enterprise system is waiting for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;If this was useful — a ⭐ on &lt;a href="https://github.com/redbase-app" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; helps others find it.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;More of my writing: &lt;a href="https://redbase.app/articles" rel="noopener noreferrer"&gt;redbase.app/articles&lt;/a&gt;, and on &lt;a href="https://dev.to/rinat_kozin"&gt;dev.to&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>opensource</category>
      <category>api</category>
      <category>soap</category>
    </item>
  </channel>
</rss>
