<?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>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: &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>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>
    <item>
      <title>redb 3.7.1: props search up to 100x faster. An alternative to EF Core, or a companion to it</title>
      <dc:creator>rinat kozin</dc:creator>
      <pubDate>Wed, 26 Aug 2026 17:14:21 +0000</pubDate>
      <link>https://dev.to/rinat_kozin/redb-371-props-search-up-to-100x-faster-an-alternative-to-ef-core-or-a-companion-to-it-12og</link>
      <guid>https://dev.to/rinat_kozin/redb-371-props-search-up-to-100x-faster-an-alternative-to-ef-core-or-a-companion-to-it-12og</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%2Fke5pmwlha71aj7zlow5c.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%2Fke5pmwlha71aj7zlow5c.png" alt="redb.Core" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Query cost did not depend on what you searched for: the filter sat above GROUP BY. Now the cut happens before the aggregate, on three engines, with no application code changed. Up to 100x on a date range and 5.3x on a full result set in SQL Server.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There is a class of performance problem you cannot see on small data and cannot miss on large. Ours looked like this: a query hunting for one rare order number among a hundred thousand objects took exactly as long as a search that found nothing at all. Selectivity did not move the clock. At all.&lt;/p&gt;

&lt;p&gt;The cause was the shape of the generated SQL. Props values live one per row, so the query first folds them into a wide row through &lt;code&gt;GROUP BY&lt;/code&gt; and only then applies the filter. The condition sat &lt;strong&gt;above&lt;/strong&gt; the aggregate, filtering the result of a fold rather than a column. No index can help there: by the time the condition runs, the engine has already read and folded every value of every object in the scheme. The trigram index on strings sat unused.&lt;/p&gt;

&lt;p&gt;3.7.1 adds a step that narrows the object set &lt;strong&gt;before&lt;/strong&gt; the aggregate runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;The planner walks the filter tree and tries to express it as a condition on a single value row. A row belongs to exactly one structure, so a disjunction falls out naturally: "this is a Position row and it contains the needle, or this is a Department row and it contains the needle". That condition is spliced straight into the value scan, and the fold now receives an already narrowed set.&lt;/p&gt;

&lt;p&gt;The key property: the prefilter is built as a &lt;strong&gt;superset&lt;/strong&gt;. It may let extra objects through, it may never lose one. The authoritative filter stays exactly where it was, above &lt;code&gt;GROUP BY&lt;/code&gt;. Anything the planner cannot analyse yields no prefilter and today's behaviour unchanged, so the worst outcome is the absence of a speedup rather than a change of results.&lt;/p&gt;

&lt;h2&gt;
  
  
  What already worked before
&lt;/h2&gt;

&lt;p&gt;Without this caveat the numbers below read wrong, so it goes here rather than in a footnote.&lt;/p&gt;

&lt;p&gt;Cutting before the aggregate has always existed in redb. Just not for props, but for the object's own fields: id, parent, creation and modification dates, name. Such a condition was spliced straight into the value scan:&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;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;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;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;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="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;your&lt;/span&gt; &lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="o"&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;In code that is &lt;code&gt;WhereRedb&lt;/code&gt;, and it is what production queries have been leaning on all along. In our own production and in redb.Identity such filters are almost everywhere: pick within a subtree, within a date range, within a set of ids, by owner. As long as objects were cut by &lt;code&gt;_objects&lt;/code&gt;, the fold received an already narrowed set and ran fast.&lt;/p&gt;

&lt;p&gt;The hole was exactly where there is nothing to cut by on &lt;code&gt;_objects&lt;/code&gt;. Then the only selective condition left was props, and props sat above the aggregate and cut nothing: one rare order number cost the same as searching for nothing. 3.7.1 closes that case, and the two cuts now compose: first by objects, then by value rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was measured
&lt;/h2&gt;

&lt;p&gt;Two shapes, both plain LINQ, no special calls. And both &lt;strong&gt;without&lt;/strong&gt; &lt;code&gt;WhereRedb&lt;/code&gt;, which is the worst case: nothing to narrow by on &lt;code&gt;_objects&lt;/code&gt;, the whole weight falls on props.&lt;/p&gt;

&lt;p&gt;The first is one search box across several fields. This is what a UI search looks like when the user has typed a word and you have to look in both the job title and the department.&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;found&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;Employee&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;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;Position&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Design"&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;Department&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Design"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OrderByRedb&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="n"&gt;Id&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;ToListAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One needle, &lt;code&gt;Design&lt;/code&gt;, two fields, an &lt;code&gt;OR&lt;/code&gt; between them. In the table below this is the needle row. Before 3.7.1 that query read and folded the values of &lt;strong&gt;every&lt;/strong&gt; employee in the scheme and only then checked for "Design". Now the rows that obviously cannot match are cut on the way in.&lt;/p&gt;

&lt;p&gt;The second is a date range matching 3 288 objects out of 100 000, or 3.3%.&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;hired&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;Employee&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;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;HireDate&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="nf"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2030&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="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
             &lt;span class="p"&gt;&amp;amp;&amp;amp;&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;HireDate&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2031&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="m"&gt;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;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;ToListAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;All three engines seeded identically: 100 000 objects, roughly 8.4M value rows, statistics refreshed. Server-side time, best of three runs.&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;PostgreSQL&lt;/th&gt;
&lt;th&gt;SQLite&lt;/th&gt;
&lt;th&gt;SQL Server&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;one needle across two string fields, ordered&lt;/td&gt;
&lt;td&gt;188 → &lt;strong&gt;91 ms&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;1150 → &lt;strong&gt;311 ms&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;55 → &lt;strong&gt;17 ms&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the same, whole result, no paging&lt;/td&gt;
&lt;td&gt;338 → &lt;strong&gt;156 ms&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;1201 → &lt;strong&gt;314 ms&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;7037 → &lt;strong&gt;1327 ms&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;date range, 3.3% of the scheme&lt;/td&gt;
&lt;td&gt;154 → &lt;strong&gt;1.5 ms&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;17 → &lt;strong&gt;under 1 ms&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;2 → 2 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read this table as the upper bound of the gain rather than as an expectation for any query. If you have a &lt;code&gt;WhereRedb&lt;/code&gt;, you were already fast and the absolute difference will be smaller. The gain grows the less there was to narrow by on &lt;code&gt;_objects&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One caveat about what is being measured. This is query time, without materialising objects in memory. A real call adds materialisation on top, and materialisation is identical in both modes. So &lt;strong&gt;the search itself got faster by exactly the ratios above&lt;/strong&gt;, while the share of that gain in the end-to-end call depends on how many objects you pull.&lt;/p&gt;

&lt;p&gt;Which is where a second, independent lever comes in: projections. &lt;code&gt;Select&lt;/code&gt; fetches only the fields you actually need instead of the whole object. One mechanism cuts the engine's work, the other cuts the materialiser's, and they compose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three engines, three different mechanisms
&lt;/h2&gt;

&lt;p&gt;The same algebra, and every engine wins for its own reason. Worth knowing before predicting numbers on your own data.&lt;/p&gt;

&lt;p&gt;PostgreSQL engages the trigram GIN and reads far fewer rows: the string index returns 40 958 rows where the structure index returns 200 000. SQLite engages a covering partial index and stops going back to the table. SQL Server reads the same pages but saves CPU instead, because the string column is &lt;code&gt;NVARCHAR(MAX)&lt;/code&gt; and the comparison carries an explicit collation.&lt;/p&gt;

&lt;p&gt;The spread shows most clearly on the date range: a hundredfold gain on PostgreSQL, sixteenfold on SQLite, and nothing whatsoever on SQL Server.&lt;/p&gt;

&lt;p&gt;That last one is not a misfire, it is a sign that there was nothing left to cut. The condition matches 3.3% of the scheme, the query asks for a hundred rows, and the work is already bounded by the limit rather than by the size of the scheme: at that density you find a hundred matches after visiting roughly three thousand objects. Two milliseconds before, two after. Remove the limit, force a walk over the whole scheme, and the gain shows up there too: 88 ms against 65. That is a useful rule of thumb for your own data. The prefilter pays off where the engine would otherwise fold the entire scheme, and gives nothing where a limit already keeps the work small.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means in practice
&lt;/h2&gt;

&lt;p&gt;We got very close to a flat table with an index. On simple conditions, an equality or a range on one field, the query now travels an index the way it would travel an ordinary column of an ordinary table.&lt;/p&gt;

&lt;p&gt;And it keeps what a flat schema cannot have by construction. No row multiplication from joins: values fold per object in a single pass, and related collections do not turn a result set into a cartesian product you then have to collapse. No &lt;code&gt;Include&lt;/code&gt; either: the graph loads by depth rather than by naming every branch by hand. On deep graphs that lands somewhere a classic ORM built on joins and &lt;code&gt;Include&lt;/code&gt; does not reach.&lt;/p&gt;

&lt;p&gt;Hence the two-sided positioning in the title. Nobody is asking you to replace EF Core wholesale: the two live side by side in one application, on one database. The relational part, where the schema is stable and the table is flat, stays with EF, which is the right home for it. redb takes what a flat model finds hard: shifting sets of fields, heterogeneous entities inside one scheme, trees and graphs that would otherwise become a chain of &lt;code&gt;Include&lt;/code&gt; and a pile of duplicated rows. The choice is about the shape of your data, not about ideology.&lt;/p&gt;

&lt;p&gt;There is no head-to-head benchmark against EF in this article, and I am not going to invent one. What is described here are properties of the execution model, not a measurement we did not take.&lt;/p&gt;

&lt;p&gt;That measurement is coming separately. A deep dive is in preparation with code for both sides, execution plans and numbers: a simple condition against a flat indexed table, a graph several levels deep against &lt;code&gt;Include&lt;/code&gt;, collections where a join multiplies rows, and what projections do about all of it. This post is short and about one change; that one will be long and about where this model wins and where it loses.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boundaries, honestly
&lt;/h2&gt;

&lt;p&gt;The prefilter is opt-in through &lt;code&gt;EnablePvtPrefilter&lt;/code&gt; and off by default.&lt;/p&gt;

&lt;p&gt;Today it covers a disjunction over selective fields and a range or equality on a single field. A top-level &lt;code&gt;AND&lt;/code&gt; across different fields does not qualify: a value row belongs to one structure, and a conjunction of different fields is not expressible at row level. Filters over arrays, dictionaries, &lt;code&gt;null&lt;/code&gt; checks, cross-field comparisons and computed expressions are recognised as unanalysable and yield no prefilter.&lt;/p&gt;

&lt;p&gt;There is also a case where the prefilter had to step aside. On SQLite a query with a limit and no ordering at all streams without the prefilter and stops at the hundredth group; with it the planner switches to a multi-index OR, loses the ordering, needs a temporary B-tree and materialises everything. Measured: 8-12 ms against 388-521 ms, with no overlap between the ranges. So on SQLite, in that single shape, no prefilter is emitted. PostgreSQL knows no such trouble, and SQL Server cannot even produce the offending shape, because its paging is required to carry an &lt;code&gt;ORDER BY&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A word on how this was verified. The invariant that results must match row for row is held by a suite of differential tests: every query runs twice, with the flag and without, over the same data, and the id sets are compared. That suite caught a defect that would otherwise have shipped: the row form cuts rows rather than objects, so an object qualifying through one branch of a disjunction lost the column values belonging to the other branches. The object set stayed intact, which is why fifteen hundred existing tests noticed nothing, while &lt;code&gt;DistinctBy&lt;/code&gt; and &lt;code&gt;OrderBy&lt;/code&gt; over such a field quietly lied. Fixed before publication.&lt;/p&gt;

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

&lt;p&gt;Search finally costs what you are searching for. A rare condition is now cheap instead of costing the same as a query that finds nothing.&lt;/p&gt;

&lt;p&gt;Three engines, one flag, zero changes in application code. The same LINQ, the same model, nothing to rewrite: turn it on and go.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;up to 100x&lt;/strong&gt; faster on a date range in PostgreSQL, 154 ms became one and a half;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3.7x&lt;/strong&gt; on string search in SQLite and twice as fast in PostgreSQL;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5.3x&lt;/strong&gt; on a full result set in SQL Server, where seven seconds became 1.3.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And not a single row of any result changed. The prefilter is a superset by construction, and the invariant is pinned by differential tests that run every query both ways and compare id sets exactly. That is not a figure of speech but a working tool, and it is what caught the defect that would otherwise have shipped.&lt;/p&gt;

&lt;p&gt;Compose that with projections and you get two independent levers: one cuts the engine's work, the other the materialiser's. On simple conditions the query travels an index the way a flat table would, and on deep graphs you keep what a flat schema does not have: no rows multiplied by joins, no &lt;code&gt;Include&lt;/code&gt; written by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it lives
&lt;/h2&gt;

&lt;p&gt;The prefilter lives inside &lt;a href="https://github.com/redbase-app/redb" rel="noopener noreferrer"&gt;redb&lt;/a&gt; and works on all three Pro providers: PostgreSQL, SQL Server, SQLite. One flag, &lt;code&gt;EnablePvtPrefilter&lt;/code&gt; in &lt;code&gt;RedbServiceConfiguration&lt;/code&gt;, turns it on. It is a step of query compilation rather than a separate mode: the same LINQ, the same result set, the same result, the same observability. The difference is that the aggregate now receives not the whole scheme, but only what could possibly match.&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>sql</category>
      <category>postgres</category>
      <category>sqlite</category>
    </item>
    <item>
      <title>redb.Route: Control Bus for .NET routes: stop, restart and react to route events at runtime</title>
      <dc:creator>rinat kozin</dc:creator>
      <pubDate>Wed, 26 Aug 2026 09:31:00 +0000</pubDate>
      <link>https://dev.to/rinat_kozin/redb-control-bus-for-net-routes-stop-restart-and-react-to-route-events-at-runtime-329h</link>
      <guid>https://dev.to/rinat_kozin/redb-control-bus-for-net-routes-stop-restart-and-react-to-route-events-at-runtime-329h</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%2Fj9gomscnav641nziiswl.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%2Fj9gomscnav641nziiswl.png" alt="redb.Route" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manage routes at runtime by sending a message: start, stop, suspend, restart, plus a consumer that turns route lifecycle events into messages.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A running integration is not a static thing. A deploy needs to drain a queue and stop a route before the new build takes over. A downstream service falls over, and the route hammering it should back off instead of piling up retries. An operator needs to suspend one branch of the flow without restarting the whole process. Something fails at 3am, and the on-call dashboard should light up on its own, not because someone was tailing logs.&lt;/p&gt;

&lt;p&gt;Apache Camel solved the operational half of this a long time ago with the &lt;strong&gt;Control Bus&lt;/strong&gt; pattern: you manage routes by sending a message to a special endpoint, the same way you move any other message. redb.Route brings that pattern to .NET, with the Camel command set, and adds one thing Camel does not have: a consumer that turns route lifecycle events back into messages you can route.&lt;/p&gt;

&lt;p&gt;So there are two directions here. Outbound: tell a route to start, stop, suspend, resume or restart. Inbound: subscribe to what routes are doing and react. Both are ordinary steps of a pipeline. Let us look at the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Control Bus in one minute
&lt;/h2&gt;

&lt;p&gt;The component is registered out of the box, there is no package to add. Address it by URI or by the fluent DSL.&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;// Stop a route by id&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;"controlbus:route?routeId=orders&amp;amp;action=stop"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Same thing, fluent&lt;/span&gt;
&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ControlBus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ControlBusAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Stop&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a producer step. When a message reaches it, the named route is stopped. Everything else is variations on the action and one consumer for events.&lt;/p&gt;

&lt;h2&gt;
  
  
  Manage a route by sending a message
&lt;/h2&gt;

&lt;p&gt;The action is the verb. The full set mirrors Camel:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Effect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Start&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Start the route.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Stop&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Stop the route (consumer removed, the route stays registered).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Suspend&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Suspend the route.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Resume&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Resume a stopped or suspended route.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Restart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Stop, then start after &lt;code&gt;restartDelay&lt;/code&gt; (default 1000 ms).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Report the route's status.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Stats&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Report the route's statistics.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Fail&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Stop the route and mark it failed.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In the fluent form the action is an enum; as a URI it is the &lt;code&gt;action&lt;/code&gt; query parameter.&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.ControlBus&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ControlBus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ControlBusAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Suspend&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"payments"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ControlBus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ControlBusAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Restart&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="k"&gt;async&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;// fire-and-forget&lt;/span&gt;

&lt;span class="c1"&gt;// URI equivalents&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;"controlbus:route?routeId=payments&amp;amp;action=suspend"&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;"controlbus:route?routeId=orders&amp;amp;action=restart&amp;amp;async=true"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because it is a normal route step, the control action can be the tail of any pipeline. A timer that suspends a batch route outside business hours. A webhook that restarts a route on a config change. A choice branch that stops a consumer when a poison message is seen. You are not calling a management API from the outside; you are routing a message, with all the same retries, error handling and observability as the rest of the flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  A route can manage itself
&lt;/h2&gt;

&lt;p&gt;Pass &lt;code&gt;current&lt;/code&gt; as the route id and the action targets the route that is sending the message. This is how a route reacts to its own 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="nf"&gt;From&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;CheckHealth&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="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;GetHeader&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"downstreamDown"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ControlBus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ControlBusAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Suspend&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"current"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// back off, stop consuming&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;End&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 that detects its downstream is unhealthy suspends itself instead of spinning through failures. Something external, a timer or an operator message, resumes it later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part Camel does not have: react to route events
&lt;/h2&gt;

&lt;p&gt;This is the direction that is usually missing. In Camel the Control Bus is producer-only: you send commands, you do not subscribe to what happens. redb.Route adds &lt;code&gt;controlbus:notify&lt;/code&gt;, a &lt;strong&gt;consumer&lt;/strong&gt; that emits route and context lifecycle events as messages. You put it on the &lt;code&gt;From&lt;/code&gt; side and route the events anywhere.&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;"controlbus:notify"&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="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;evt&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="n"&gt;In&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetHeader&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="n"&gt;ControlBusHeaders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;// RouteStarted, RouteStopped, RouteErrored, ...&lt;/span&gt;
        &lt;span class="kt"&gt;var&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;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;GetHeader&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="n"&gt;ControlBusHeaders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RouteId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="k"&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;.&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;GetHeader&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DateTimeOffset&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;ControlBusHeaders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Timestamp&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;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"kafka://route-events"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The events cover the lifecycle of routes and of the context itself: &lt;code&gt;RouteStarted&lt;/code&gt;, &lt;code&gt;RouteStopped&lt;/code&gt;, &lt;code&gt;RouteSuspending&lt;/code&gt;, &lt;code&gt;RouteErrored&lt;/code&gt;, &lt;code&gt;ContextStarting&lt;/code&gt;, &lt;code&gt;ContextStarted&lt;/code&gt;, &lt;code&gt;ContextStopping&lt;/code&gt;, &lt;code&gt;ContextStopped&lt;/code&gt;, &lt;code&gt;ExchangeTimedOut&lt;/code&gt;. Each message carries the details as headers: the event name, the affected route id, a timestamp, and where relevant the error, the exchange id and the elapsed time.&lt;/p&gt;

&lt;p&gt;Filter to what you care about with the &lt;code&gt;events&lt;/code&gt; and &lt;code&gt;routeId&lt;/code&gt; 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="c1"&gt;// Only failures and stops, only for the orders route&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;"controlbus:notify?events=RouteErrored,RouteStopped&amp;amp;routeId=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;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"slack://alerts"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the interesting part is that both directions compose. Events on the way in, commands on the way out, in one route: this is a self-healing loop with no external control plane.&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;"controlbus:notify?events=RouteErrored"&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="nf"&gt;SetHeader&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="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;GetHeader&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="n"&gt;ControlBusHeaders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RouteId&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Delay&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;ControlBus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ControlBusAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"current"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// or the captured route id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An errored route emits an event, a route consumes it, waits, and restarts the offender. The supervision logic is a pipeline, visible and testable like any other, not a hardcoded policy buried in the engine.&lt;/p&gt;

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

&lt;p&gt;Without a Control Bus, runtime route management is a pile of one-off plumbing. A custom admin controller that reaches into the engine to stop a route. A &lt;code&gt;BackgroundService&lt;/code&gt; that polls a flag table to know when to pause. A logging appender wired to an alerting SDK so someone hears about a failure. Each is a separate mechanism, with its own lifecycle, its own tests, its own way of being wrong.&lt;/p&gt;

&lt;p&gt;The Control Bus turns all of that into routing. Management commands are messages &lt;code&gt;To&lt;/code&gt; an endpoint. Lifecycle events are messages &lt;code&gt;From&lt;/code&gt; an endpoint. They go through the same DSL, the same error handling, the same OpenTelemetry traces as your business flows, and they land in the same place your team already looks.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero-downtime deploys&lt;/strong&gt;: suspend and drain a route on a signal, flip, resume.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backpressure and circuit-breaking at the route level&lt;/strong&gt;: a route suspends itself when its downstream is unhealthy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operational events as data&lt;/strong&gt;: pipe &lt;code&gt;RouteErrored&lt;/code&gt; and &lt;code&gt;RouteStopped&lt;/code&gt; into Kafka, Slack, a metrics sink, an incident tool, an audit log.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-healing&lt;/strong&gt;: notify in, restart out, in one small route.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The same control from a dashboard: redb.Tsak
&lt;/h2&gt;

&lt;p&gt;Everything above is the programmatic face: a message drives a route's lifecycle. There is an operational face too. &lt;strong&gt;redb.Tsak&lt;/strong&gt;, the runtime that hosts your routes, exposes the same route and context lifecycle in a dashboard, so an operator can start, stop, suspend or resume a route, and manage whole contexts, live, without writing a route or shipping a deploy. One capability, two entry points: a message from inside a flow, or a click from outside it, over the same engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Camel parity, and beyond
&lt;/h2&gt;

&lt;p&gt;If you come from Camel, the command side is familiar: &lt;code&gt;controlbus:route&lt;/code&gt; with &lt;code&gt;routeId&lt;/code&gt; and &lt;code&gt;action&lt;/code&gt;, plus &lt;code&gt;controlbus:language&lt;/code&gt; for expression-based control, producer-only, the standard verbs. redb.Route matches that command set. The &lt;code&gt;controlbus:notify&lt;/code&gt; consumer is the addition: Camel gives you EventNotifier as an SPI you implement in Java and register; redb.Route gives you the same information as a first-class endpoint you route from, no interface to implement, no wiring, just &lt;code&gt;From("controlbus:notify")&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Is it a separate package?&lt;/strong&gt; No. Control Bus is part of core &lt;code&gt;redb.Route&lt;/code&gt; and is registered out of the box. Nothing to install.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does stopping a route remove it?&lt;/strong&gt; No. &lt;code&gt;Stop&lt;/code&gt; and &lt;code&gt;Suspend&lt;/code&gt; remove the consumer but keep the route registered, so &lt;code&gt;Resume&lt;/code&gt; or &lt;code&gt;Start&lt;/code&gt; brings it back. &lt;code&gt;Fail&lt;/code&gt; stops it and marks it errored.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can a route control another route, not just itself?&lt;/strong&gt; Yes. Pass the target route id instead of &lt;code&gt;current&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What events are available?&lt;/strong&gt; Route lifecycle (&lt;code&gt;RouteStarted&lt;/code&gt;, &lt;code&gt;RouteStopped&lt;/code&gt;, &lt;code&gt;RouteSuspending&lt;/code&gt;, &lt;code&gt;RouteErrored&lt;/code&gt;), context lifecycle (&lt;code&gt;ContextStarting&lt;/code&gt; through &lt;code&gt;ContextStopped&lt;/code&gt;), and &lt;code&gt;ExchangeTimedOut&lt;/code&gt;. Filter with &lt;code&gt;events=&lt;/code&gt; and &lt;code&gt;routeId=&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the command synchronous?&lt;/strong&gt; By default yes; pass &lt;code&gt;async=true&lt;/code&gt; (or &lt;code&gt;async: true&lt;/code&gt; in the DSL) for fire-and-forget, which also avoids a route trying to stop itself synchronously mid-exchange.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it lives
&lt;/h2&gt;

&lt;p&gt;Control Bus ships inside &lt;a href="https://www.nuget.org/packages/redb.Route/" rel="noopener noreferrer"&gt;redb.Route on NuGet&lt;/a&gt;; the DSL and the &lt;code&gt;controlbus:notify&lt;/code&gt; event set are in the &lt;a href="https://github.com/redbase-app/redb-route" rel="noopener noreferrer"&gt;documentation&lt;/a&gt;. It is one of the 30+ EIP patterns in the framework, and like the rest it is a step of a route: the same &lt;code&gt;From → … → To&lt;/code&gt;, the same observability. The difference is that the message it carries is a route telling you what it just did, or you telling a route what to do next.&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>esb</category>
    </item>
    <item>
      <title>A directory is an integration: file exchange in .NET without cron, FileSystemWatcher or hand-rolled pollers</title>
      <dc:creator>rinat kozin</dc:creator>
      <pubDate>Tue, 25 Aug 2026 21:39:03 +0000</pubDate>
      <link>https://dev.to/rinat_kozin/a-directory-is-an-integration-file-exchange-in-net-without-cron-filesystemwatcher-or-hand-rolled-39np</link>
      <guid>https://dev.to/rinat_kozin/a-directory-is-an-integration-file-exchange-in-net-without-cron-filesystemwatcher-or-hand-rolled-39np</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%2F9kobamp8tq0upbhvmt3j.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%2F9kobamp8tq0upbhvmt3j.png" alt="redb.Route.File" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Polling, waiting for a file to finish being written, idempotency and atomic temp+rename writes, as an ordinary route step.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Ask anyone who does integrations for a living what the money actually moves through, and files will be on the list. The bank drops a statement into a directory on a share. An ERP exports orders as XML on a schedule. An EDI partner pushes &lt;code&gt;.edi&lt;/code&gt; files to an FTP server. Billing lands a couple of gigabytes of CDR files overnight. A retail chain sends stock levels as CSV because that is how it was in 2009 and nobody plans to change it. File exchange is not legacy on its way out. It is a working transport with exactly one property: it looks simpler than it is.&lt;/p&gt;

&lt;p&gt;It looks simple because the first version takes an hour. &lt;code&gt;Directory.GetFiles&lt;/code&gt;, a loop, &lt;code&gt;File.ReadAllBytes&lt;/code&gt;, process, delete. Then real life starts. You picked the file up while the partner was still writing it, and half an order batch went into the database. Processing threw, and the file is already deleted. Two instances of the service grabbed the same file and created duplicate payments. Someone dropped an antivirus &lt;code&gt;.tmp&lt;/code&gt; into the directory. &lt;code&gt;FileSystemWatcher&lt;/code&gt; missed events once the directory moved to an SMB share. A deploy went out mid-processing and an 800 MB file ended up neither here nor there. Each of these gets its own workaround, and a year later you own a 2000-line half-framework nobody wants to touch.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;redb.Route.File&lt;/code&gt; closes that list with a transport. A directory becomes an ordinary message source in a route, exactly like a Kafka topic or an HTTP endpoint: polling with filters, waiting until the file is fully written, idempotency, a policy for what happens after processing, atomic writes through a temp file. Same DSL, same EIP patterns after &lt;code&gt;From&lt;/code&gt;, same telemetry. Here is what that looks like, and why a native connector inside the ESB beats a poller you wrote yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Files in a 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;AddRedbRouteFile&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="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;redb.Route.File.Fluent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Read incoming CSVs, hand the parsed result to a queue&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;FileDsl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/data/incoming"&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="s"&gt;"*.csv"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MinAge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SortBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Modified"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MoveTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/data/processed"&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;"Picked up ${header.redbFile.Name}, ${header.redbFile.Length} bytes"&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://parse"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Write the result out atomically&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://export"&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;FileDsl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/data/outgoing"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FileName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${header.orderId}.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;TempPrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;".tmp-"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole setup. No external dependencies, one registration line, and the &lt;code&gt;file&lt;/code&gt; scheme is then available both fluently and as a URI string.&lt;/p&gt;

&lt;h2&gt;
  
  
  An endpoint is a string, or a builder
&lt;/h2&gt;

&lt;p&gt;Every endpoint reads two equivalent ways: a type-safe builder in code, and a plain URI that lives in &lt;code&gt;appsettings.json&lt;/code&gt; and changes without a rebuild. They compile to the same thing, because the builder literally assembles the string.&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="nf"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FileDsl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/data/incoming"&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="s"&gt;"*.csv,*.xml"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Recursive&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Delete&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="c1"&gt;// String (identical)&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;"file:///data/incoming?include=*.csv,*.xml&amp;amp;recursive=true&amp;amp;delete=true"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Windows the path is written &lt;code&gt;file:///C:/data/incoming&lt;/code&gt; and the leading slash is dropped for you. A small thing, and reliably the one that bites when a config moves between machines.&lt;/p&gt;

&lt;h2&gt;
  
  
  The central problem: a file that is still being written
&lt;/h2&gt;

&lt;p&gt;No file system gives you a "writing finished" event. The partner opened a stream, is pushing 300 MB over a slow link, and your poller has already seen the name in the directory. The question is not whether this bites you, only how soon. The connector offers six independent mechanisms, and production setups usually combine two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Minimum age.&lt;/strong&gt; The cheapest one: leave the file alone until N milliseconds have passed since the last write.&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;FileDsl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/data/incoming"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;MinAge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Wait for the size to settle.&lt;/strong&gt; The &lt;code&gt;Changed&lt;/code&gt; strategy watches size and modification time at an interval and releases the file only once it has stopped growing for a configured period. This is the right answer for large exports that take minutes to write.&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;FileDsl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/data/incoming"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReadLock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Changed"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReadLockCheckInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// how often to look&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReadLockMinAge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// how long it must stay unchanged&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReadLockTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;120000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;// how long to wait at most&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A marker file.&lt;/strong&gt; The &lt;code&gt;MarkerFile&lt;/code&gt; strategy creates &lt;code&gt;name.redbLock&lt;/code&gt; next to the target using &lt;code&gt;FileMode.CreateNew&lt;/code&gt;, which is atomic at the OS level. A second instance that sees the marker skips the file. This is the working answer to "two pods read the same share": exactly one wins the race, and the loser never even opens the file.&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;FileDsl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/mnt/share/in"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ReadLock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"MarkerFile"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;An exclusive handle.&lt;/strong&gt; The &lt;code&gt;FileLock&lt;/code&gt; strategy opens the file exclusively and holds the handle for the duration of processing. While it is held, nobody else opens that file for reading or writing. One detail worth spelling out: the handle is taken with delete permission, so your own post-processing can delete or move the file without waiting for the lock to drop, and the body is read through the handle that is already open rather than by opening the file a second time.&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;FileDsl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/data/incoming"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ReadLock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"FileLock"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A rename.&lt;/strong&gt; The &lt;code&gt;Rename&lt;/code&gt; strategy moves the file to an internal name and works on it there. If the rename fails, someone else holds the file and the route leaves it alone. Pleasant side effect: while the file is being processed it is not in the directory under its original name, so another poller matching on a glob simply does not see 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="n"&gt;FileDsl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/data/incoming"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ReadLock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Rename"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A signal file from the sender.&lt;/strong&gt; EDI classic: the partner writes &lt;code&gt;order.csv&lt;/code&gt;, finishes it, and only then drops an empty &lt;code&gt;order.csv.done&lt;/code&gt;. Until the signal is there, the file does not exist as far as the route is concerned.&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;FileDsl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/data/incoming"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;DoneFileName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${file:name}.done"&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;${file:name}&lt;/code&gt; and &lt;code&gt;${file:name.noext}&lt;/code&gt; are both substituted, so the &lt;code&gt;order.csv&lt;/code&gt; plus &lt;code&gt;order.done&lt;/code&gt; convention works too. The signal file is removed for you once processing succeeds.&lt;/p&gt;

&lt;p&gt;Separately: the consumer ignores internal names starting with &lt;code&gt;.redb_&lt;/code&gt; and anything starting with the configured &lt;code&gt;tempPrefix&lt;/code&gt;. So a producer writing into the same directory through a temp file cannot feed itself an endless loop. That is the kind of detail a hand-rolled poller discovers on day three.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens to the file afterwards
&lt;/h2&gt;

&lt;p&gt;Exactly one policy per endpoint, and trying to set two fails validation at startup rather than silently at runtime.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Policy&lt;/th&gt;
&lt;th&gt;Effect&lt;/th&gt;
&lt;th&gt;When to reach for it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Noop()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The file stays where it is&lt;/td&gt;
&lt;td&gt;Read-only directory, or no write permission&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Delete()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Removed after success&lt;/td&gt;
&lt;td&gt;An inbox where only the fact of receipt matters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MoveTo(dir)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Moved to an archive&lt;/td&gt;
&lt;td&gt;The production default: there is always something to show&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PreMove(dir)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Moved BEFORE processing&lt;/td&gt;
&lt;td&gt;Several consumers on one directory&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;PreMove&lt;/code&gt; deserves a note, because it solves a problem people usually solve badly. The file is moved into a working subdirectory first and read only afterwards. A move within one file system is atomic, so a second instance simply does not find it and moves on. As a bonus, the directory listing tells you at any moment what is currently in flight.&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;FileDsl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/data/incoming"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PreMove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/data/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;MoveTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/data/archive"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now failure. If processing threw and the exception was not marked handled, post-processing does not run: the file stays exactly where it was and comes back on the next poll. That is deliberate, because "delete the file we could not process" is the worst available option. The flip side is that a genuinely broken file will be retried forever, which is why on a file route &lt;code&gt;OnException&lt;/code&gt; is part of the construction rather than a nice-to-have:&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;OnException&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;FormatException&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="k"&gt;true&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;FileDsl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/data/quarantine"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A handled exception completes the exchange successfully, a copy lands in quarantine, and the original goes through the normal policy.&lt;/p&gt;

&lt;p&gt;The same applies to a case people rarely plan for: the file was in the directory but could not be read. Someone holds it exclusively, permissions are wrong, the volume dropped. That is a failure for that file, not an empty message. The route will not receive zero bytes dressed up as a successful read, the file stays put, and the next poll picks it up. Obvious, right up until a hand-rolled poller hands you an empty &lt;code&gt;byte[]&lt;/code&gt; and the import faithfully records zero orders.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency
&lt;/h2&gt;

&lt;p&gt;A flag turns it on. The default key is built from the full path, the modification time and the size, so a file overwritten under the same name counts as new, while the same file is never processed twice.&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;FileDsl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/data/incoming"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Noop&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Idempotent&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;Noop() + Idempotent()&lt;/code&gt; gives you "read the directory, change nothing in it, each file exactly once". It is the only way to work with a directory you have no write access to, and it is also the most common shape when integrating with someone else's share.&lt;/p&gt;

&lt;p&gt;The registry of processed keys lives in process memory, which is worth keeping in mind: after a restart it is empty. Combined with &lt;code&gt;Delete&lt;/code&gt; or &lt;code&gt;MoveTo&lt;/code&gt; that does not matter, because the processed file is no longer in the directory. Combined with &lt;code&gt;Noop&lt;/code&gt;, a restart means a second pass, and de-duplication for that case belongs further down the route.&lt;/p&gt;

&lt;h2&gt;
  
  
  Selection: what to take, and in what order
&lt;/h2&gt;

&lt;p&gt;Processing order almost always matters in file exchange. A nightly batch of 4000 files consumed in arbitrary order gives you stock levels that drift away from reality.&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;FileDsl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/data/incoming"&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="s"&gt;"*.csv,*.xml"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;// comma-separated globs, * and ?&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Exclude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"*.tmp,~*"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SortBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Modified"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// Name, NameDesc, Modified, ModifiedDesc, Size, SizeDesc&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MaxMessagesPerPoll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;// batch size per pass&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Recursive&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                 &lt;span class="c1"&gt;// poll interval, 500 ms by default&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;MaxMessagesPerPoll&lt;/code&gt; together with sorting is your load regulator. A partner comes back after a day of downtime and drops 40 000 files, and you work through them in predictable batches from oldest to newest instead of materialising a 40 000-element list and taking the process down.&lt;/p&gt;

&lt;p&gt;Globs are matched case-insensitively, which on Linux saves you from the classic "they sent &lt;code&gt;ORDERS.CSV&lt;/code&gt; and the mask says &lt;code&gt;*.csv&lt;/code&gt;".&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing: temp plus rename, not "however it goes"
&lt;/h2&gt;

&lt;p&gt;The producer solves the mirror image of the same problem. If you write straight into a directory a partner is reading, sooner or later they read half a file.&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://export"&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;FileDsl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/data/outgoing"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FileName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders-${header.batchId}.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;TempPrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;".tmp-"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AutoCreate&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 to &lt;code&gt;.tmp-orders-42.json&lt;/code&gt; and is renamed to the target name only once it is fully written. A rename inside a file system is atomic, so the partner sees either nothing or the finished file. There is no intermediate state. On an exception the temp file cleans itself up.&lt;/p&gt;

&lt;p&gt;The target name comes, in priority order, from the &lt;code&gt;FileName&lt;/code&gt; option (full expressions over headers and body), from the incoming &lt;code&gt;redbFile.Name&lt;/code&gt; header, and failing both it is generated as &lt;code&gt;redb-{guid}&lt;/code&gt;. That last fallback is handier than it sounds: a &lt;code&gt;From(kafka).To(file)&lt;/code&gt; route works without configuring a name at all.&lt;/p&gt;

&lt;p&gt;What to do when the target already exists is configured separately:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;FileExist&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Behaviour&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Override&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Overwrite (default)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Append&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Append to the end&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Fail&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Throw&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Ignore&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Skip the write silently&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Move&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Set the existing one aside as &lt;code&gt;.bak&lt;/code&gt;, then write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TryRename&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Rename the existing one with a timestamp suffix&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;Ignore&lt;/code&gt; and &lt;code&gt;Fail&lt;/code&gt; look exotic exactly until the first export where writing the same name twice means shipping the goods twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The file name is untrusted input
&lt;/h2&gt;

&lt;p&gt;Look again at the priority order above: with no &lt;code&gt;FileName&lt;/code&gt; set, the name arrives in the &lt;code&gt;redbFile.Name&lt;/code&gt; header. And somebody put it there: an incoming partner file, an HTTP upload, a field from a queue message. Which means that in a typical &lt;code&gt;From(http).To(file)&lt;/code&gt; route, the name of the file you create is chosen by whoever sends the request.&lt;/p&gt;

&lt;p&gt;So by default the producer does not let a write escape its own directory. A name like &lt;code&gt;../../etc/cron.d/backdoor&lt;/code&gt;, or an absolute path, gets you an exception rather than a file somewhere surprising. The absolute path case is worth knowing about on its own: plain &lt;code&gt;Path.Combine&lt;/code&gt; in .NET silently discards the base directory and hands back what it was given, so "but we concatenate with the base" is not a defence.&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;// default: writes stay inside /data/outgoing&lt;/span&gt;
&lt;span class="n"&gt;FileDsl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/data/outgoing"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// deliberately allow writing outside&lt;/span&gt;
&lt;span class="n"&gt;FileDsl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/data/outgoing"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;JailStartingDirectory&lt;/span&gt;&lt;span class="p"&gt;(&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;The option has the same name on the local file system, FTP and SFTP, so it is one rule across all three transports.&lt;/p&gt;

&lt;h2&gt;
  
  
  An append log
&lt;/h2&gt;

&lt;p&gt;A useful mode of its own: append with a separator. A receipt log, an audit trail, a cumulative daily CSV all become a single route step.&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://audit"&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;FileDsl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/var/log/app"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FileName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"audit-${dateFormat(now(), 'yyyyMMdd')}.log"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FileExist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Append"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AppendChars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"\n"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Putting the date in the name expression means daily rotation falls out for free, with no separate scheduler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Headers: the file name travels the whole route
&lt;/h2&gt;

&lt;p&gt;The consumer puts a full set of metadata on the message under the &lt;code&gt;redbFile.&lt;/code&gt; prefix, available downstream in any expression, predicate or processor.&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;Contents&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redbFile.Name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;order-42.csv&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redbFile.NameOnly&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;order-42&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redbFile.Extension&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.csv&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redbFile.AbsolutePath&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full path at read time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redbFile.RelativePath&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Path relative to the polled directory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redbFile.Parent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Parent directory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redbFile.Length&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Size in bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redbFile.LastModified&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DateTimeOffset&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;After writing, the producer sets &lt;code&gt;redbFile.NameProduced&lt;/code&gt; to the path the file actually landed on. On top of that, &lt;code&gt;ContentType&lt;/code&gt; is inferred from the extension for &lt;code&gt;.json&lt;/code&gt;, &lt;code&gt;.xml&lt;/code&gt;, &lt;code&gt;.csv&lt;/code&gt;, &lt;code&gt;.txt&lt;/code&gt;, &lt;code&gt;.log&lt;/code&gt; and &lt;code&gt;.html&lt;/code&gt;, so the next step already knows what it was handed.&lt;/p&gt;

&lt;p&gt;The practical payoff shows up in routing by name, and file exchange has more of that than anyone would like:&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;FileDsl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/data/in"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Recursive&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;MoveTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/data/archive"&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="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="nf"&gt;When&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"redbFile.Extension"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isEqualTo&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="n"&gt;Matches&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;w&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://xml"&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="nf"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"redbFile.Name"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"INV_"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;Matches&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;w&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://invoices"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Otherwise&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;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"direct://unknown"&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Large files: do not read the whole thing
&lt;/h2&gt;

&lt;p&gt;The body is a &lt;code&gt;byte[]&lt;/code&gt; by default, which is convenient and completely unacceptable for a two-gigabyte CDR file. &lt;code&gt;StreamBody&lt;/code&gt; hands over an open stream instead of an array, and the stream is closed along with the exchange.&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;FileDsl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/data/cdr"&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="s"&gt;"*.dat"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;StreamBody&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;MoveTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/data/done"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ReadLines&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;Stream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;ex&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="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://cdr-record"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Streaming body plus splitter is what a splitter in an ESB exists for: the file is consumed line by line and memory holds one record instead of the whole file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The file system as a cloakroom
&lt;/h2&gt;

&lt;p&gt;There is an adjacent problem, and it shows up specifically in file integrations. The body is large, and the intermediate steps of the route never look at it. Downstream there is a broker, an HTTP call, another service, and each of them dutifully drags two hundred megabytes it has no use for through itself.&lt;/p&gt;

&lt;p&gt;The Claim Check pattern (Hohpe and Woolf) handles this like a cloakroom: the body is checked in, and a ticket travels the route in its place. The body is unpacked where it is actually needed.&lt;/p&gt;

&lt;p&gt;The store is pluggable, and for the file world the natural choice is the file system itself. &lt;code&gt;FileClaimCheckRepository&lt;/code&gt; writes each claim as its own file alongside its metadata and TTL. For large bodies that is cheaper than memory, and on a shared file system it survives both a restart and the work moving to a neighbouring instance.&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;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;IClaimCheckRepository&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;FileClaimCheckRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/data/claims"&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;6&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;FileDsl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/data/incoming"&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="s"&gt;"*.zip"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;MoveTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/data/archive"&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;Set&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"${header.redbFile.NameOnly}"&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;span class="c1"&gt;// a ticket travels on, not the archive&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;GetAndRemove&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"${header.redbFile.NameOnly}"&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;UnpackAndImport&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                  &lt;span class="c1"&gt;// body restored, ticket redeemed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The original body type is remembered in headers, so what comes back is what went in rather than a bare byte array. The store can be registered under a name on the context and referenced by string, or left unspecified entirely, in which case steps share the context-wide store.&lt;/p&gt;

&lt;p&gt;There is also a keyless stack mode, &lt;code&gt;Push&lt;/code&gt; and &lt;code&gt;Pop&lt;/code&gt;: check the body in before an enrichment call, take it back afterwards, nesting handled for you. Handy when the middle of a route needs a call that your two hundred megabytes only get in the way of.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local directory, FTP and SFTP are the same route
&lt;/h2&gt;

&lt;p&gt;This is the connector's main architectural bet. Polling, filters, sorting, idempotency, &lt;code&gt;doneFileName&lt;/code&gt;, post-processing, atomic writes and the existing-file strategies are implemented once, in a shared &lt;code&gt;redb.Route.GenericFile&lt;/code&gt; base. The local file system, FTP and SFTP are three implementations of file operations on top of it.&lt;/p&gt;

&lt;p&gt;The practical consequence is direct. When a partner says "we no longer mount the share, collect from our SFTP", you change the source rather than rewriting the intake logic:&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;// before&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;FileDsl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/mnt/partner/in"&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="s"&gt;"*.edi"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;MoveTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"archive"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;// after&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;SftpDsl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Directory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/upload/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;Host&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sftp.partner.com"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Username&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"edi"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Password&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{{sftp-pass}}"&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="s"&gt;"*.edi"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;MoveTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"archive"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Option names, post-processing semantics and failure behaviour all match, because it is literally the same code. Migrating between transports takes a minute rather than a sprint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shutting down without losing a file
&lt;/h2&gt;

&lt;p&gt;The consumer is built on the graceful shutdown machinery shared by every redb.Route transport: on &lt;code&gt;Stop&lt;/code&gt; the directory polling stops first, then the engine waits for exchanges already in flight, and only then the route goes down. A file that was being processed during a deploy is neither abandoned halfway nor left suspended between &lt;code&gt;PreMove&lt;/code&gt; and the archive.&lt;/p&gt;

&lt;p&gt;For file exchange this matters more than it does for brokers: an unacknowledged broker message returns to the queue, while a file gets no second chance automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Boundaries
&lt;/h2&gt;

&lt;p&gt;An honest list of what the connector does not do, so you do not find out in production.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Boundary&lt;/th&gt;
&lt;th&gt;How it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Polling, not file system events&lt;/td&gt;
&lt;td&gt;No &lt;code&gt;FileSystemWatcher&lt;/code&gt;. Polling is predictable, survives network shares and loses nothing when a buffer overflows. The price: latency up to &lt;code&gt;delay&lt;/code&gt;, 500 ms by default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The idempotency registry lives in process memory&lt;/td&gt;
&lt;td&gt;It survives a restart only together with &lt;code&gt;Delete&lt;/code&gt; or &lt;code&gt;MoveTo&lt;/code&gt;, which take the file out of the directory. It is also not shared between instances: separate them with &lt;code&gt;preMove&lt;/code&gt; or a &lt;code&gt;readLock&lt;/code&gt;, not with hope&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The idempotency key is built from file metadata&lt;/td&gt;
&lt;td&gt;Path, modification time, size, or your own pattern with &lt;code&gt;${file:name}&lt;/code&gt;. Header and body expressions are not available here: the key is needed before the file has been read, otherwise the check loses its point&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Processing within a poll is sequential&lt;/td&gt;
&lt;td&gt;One consumer works through a batch in order. Parallelism comes from further down the route, or from several routes on different globs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;readLock&lt;/code&gt; strategies are local file system only&lt;/td&gt;
&lt;td&gt;FTP and SFTP have &lt;code&gt;doneFileName&lt;/code&gt;, &lt;code&gt;minAge&lt;/code&gt; and &lt;code&gt;preMove&lt;/code&gt; in their place&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The polled directory is not created for you&lt;/td&gt;
&lt;td&gt;If it is missing, the poll cycle is skipped quietly. Auto-creation exists on the producer side via &lt;code&gt;AutoCreate&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Where this plugs in
&lt;/h2&gt;

&lt;p&gt;The value of a file connector is not in reading a directory. That is &lt;code&gt;Directory.GetFiles&lt;/code&gt;. The value is that after &lt;code&gt;From&lt;/code&gt; the rest of redb.Route is available: splitter and aggregator, content-based routing, de-duplication, retries, circuit breaker, transactions, distributed tracing. A typical nightly intake looks like this:&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;FileDsl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/data/incoming"&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="s"&gt;"orders_*.csv"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;DoneFileName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${file:name}.done"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SortBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Modified"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MaxMessagesPerPoll&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;PreMove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/data/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;MoveTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/data/archive"&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;"Batch ${header.redbFile.Name}"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ParseCsv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&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="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;"sql:INSERT INTO orders(...) VALUES(...)?dataSource=#pg"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;End&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-imported"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty lines instead of a bespoke service, and every line states a decision rather than the mechanics of carrying it out. The producer also opens an OpenTelemetry span on write, so "where did that file go" stops being a question for the logs.&lt;/p&gt;

&lt;p&gt;File exchange is never going to be fashionable. It is going to stay, and the difference between "we have a file integration" and "we have a reliable file integration" is measured in exactly the details listed above: file age, markers, atomic rename, order and batch size. It is pleasant when someone has already written them.&lt;/p&gt;

&lt;p&gt;Package: &lt;a href="https://www.nuget.org/packages/redb.Route.File/" rel="noopener noreferrer"&gt;redb.Route.File on NuGet&lt;/a&gt;; sources and the full option reference in the &lt;a href="https://github.com/redbase-app/redb-route/tree/main/redb.Route.File" rel="noopener noreferrer"&gt;connector README&lt;/a&gt;. Files are one more transport in the redb.Route family, next to Kafka, RabbitMQ, SFTP, AS2 and the rest: same &lt;code&gt;From → … → To&lt;/code&gt;, same EIP, same observability. The only difference is that the input is a directory somebody fills whenever it suits 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>camel</category>
    </item>
  </channel>
</rss>
