<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: rinat kozin</title>
    <description>The latest articles on DEV Community by rinat kozin (@rinat_kozin).</description>
    <link>https://dev.to/rinat_kozin</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3929984%2F9dcd2f94-0b1b-4161-9693-3e8c3f6ce385.jpg</url>
      <title>DEV Community: rinat kozin</title>
      <link>https://dev.to/rinat_kozin</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rinat_kozin"/>
    <language>en</language>
    <item>
      <title>redb 3.6.0: a bug report that turned out to live in six providers — plus AS2/EDI and a shared port</title>
      <dc:creator>rinat kozin</dc:creator>
      <pubDate>Thu, 13 Aug 2026 14:42:31 +0000</pubDate>
      <link>https://dev.to/rinat_kozin/redb-360-a-bug-report-that-turned-out-to-live-in-six-providers-plus-as2edi-and-a-shared-port-4850</link>
      <guid>https://dev.to/rinat_kozin/redb-360-a-bug-report-that-turned-out-to-live-in-six-providers-plus-as2edi-and-a-shared-port-4850</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%2Fs5te2rgut3a3menosvrc.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%2Fs5te2rgut3a3menosvrc.png" alt="redb ecosystem" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A user's report exposed a cross-conversation leak in the core query provider — 6 of 6. Also in 3.6.0: AS2/EDI, a shared Kestrel, Camel parity.&lt;/p&gt;

&lt;p&gt;For a year this stack grew on our own workload: we built what we needed and proved it in our own production. Since spring other people have been using it — and the mail changed character. Instead of "do you support X" we now get analyses: a reproduction, line numbers in our sources, and the workaround the reporter already wrote on their side while waiting for us.&lt;/p&gt;

&lt;p&gt;That is the most valuable thing that can happen to a project. Thank you to everyone who wrote in rather than quietly switching to another library. One such report is the backbone of half this release — and the defect it exposed was not where it was found.&lt;/p&gt;

&lt;p&gt;So: &lt;strong&gt;redb 3.6.0&lt;/strong&gt; — the core, 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; OIDC server, all on one number, 63 packages.&lt;/p&gt;

&lt;h2&gt;
  
  
  The defect was two layers below where it surfaced
&lt;/h2&gt;

&lt;p&gt;A developer building a chat product on &lt;code&gt;redb.Route.Llm&lt;/code&gt; wrote: "a new person's first turn reaches the model carrying somebody else's history, and their first message gets attached into a stranger's tree."&lt;/p&gt;

&lt;p&gt;That reads like an LLM-connector bug — conversation histories are stored as trees, something must be wrong in how a branch is loaded. We went to look, and the connector had nothing to do with it.&lt;/p&gt;

&lt;p&gt;In the core query provider, &lt;code&gt;WhereLeaves()&lt;/code&gt; &lt;strong&gt;replaced&lt;/strong&gt; the root CTE instead of acting as a predicate on top of it. So 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="c1"&gt;// Intent: the leaves of THIS tree&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;leaves&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;TreeQuery&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MessageProps&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WhereLeaves&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;actually meant "the freshest leaves of the scheme &lt;strong&gt;across the entire database&lt;/strong&gt;". No root filter survived into the query at all.&lt;/p&gt;

&lt;p&gt;The arithmetic from there is simple. In a multi-user deployment "the freshest leaf of the scheme" is the transcript of whoever wrote last, globally. Not only on the first turn — on &lt;strong&gt;every&lt;/strong&gt; turn. Every conversation collapses into a single feed. Worse, the next message is attached under a parent in someone else's tree, so the data is not merely read wrong, it is written wrong.&lt;/p&gt;

&lt;p&gt;The defect was cross-provider: &lt;strong&gt;six out of six&lt;/strong&gt; — Postgres, MSSql, SQLite, each in Free and Pro. The fix is the same everywhere: &lt;code&gt;tree_leaves&lt;/code&gt; / &lt;code&gt;tree_roots&lt;/code&gt; are now seeded from the root instead of displacing it.&lt;/p&gt;

&lt;p&gt;SQLite deserves its own note. The Free tier executes queries inside a &lt;strong&gt;native extension&lt;/strong&gt;, so the fix lives in &lt;code&gt;redb_pvt.c&lt;/code&gt;, not only in C#. A package carrying the previous binaries would have shipped the fix on the managed side and left the leak in Free — silently, with nothing in the log. The extension was therefore rebuilt for all three RIDs (win-x64, linux-x64, linux-arm64), and that was verified by hashing against the previous release rather than by file dates: the rebuilt Linux binaries came out byte-identical in &lt;em&gt;size&lt;/em&gt; to the old ones, so timestamps proved nothing.&lt;/p&gt;

&lt;p&gt;The lesson worth taking even if you never touch redb: &lt;strong&gt;a filter that "didn't apply" and a filter that "replaced the search space" look identical in code and differ catastrophically on data.&lt;/strong&gt; The second one raises no error. It returns a plausible answer to a different question.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second defect in the same report: a tool with no context
&lt;/h2&gt;

&lt;p&gt;The reporter's words: "the tool receives an exchange with no request context — and the only simple way to give it a user id or tenant is to ask the model to pass it as an argument, which opens a prompt-injection escalation."&lt;/p&gt;

&lt;p&gt;That is a precise security statement. If a tool learns &lt;strong&gt;on whose behalf&lt;/strong&gt; it was invoked from an argument the model produced, then talking the model into forging that argument is the whole attack.&lt;/p&gt;

&lt;p&gt;Before: the engine copied a hard-coded allowlist of three headers into the tool exchange and nothing else. Now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;tool routes run as a child of the agent exchange&lt;/strong&gt;, not as a naked one — context, scope and tracing are inherited;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the run's principal and audit tags reach tools&lt;/strong&gt; — and as the &lt;strong&gt;values resolved for the run&lt;/strong&gt;, not as raw headers. The difference matters: &lt;code&gt;?user=${header.X-User-Id}&lt;/code&gt; and &lt;code&gt;?audit=&lt;/code&gt; are resolved by the engine, and a plain header copy would have missed them entirely;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.PropagateToolHeaders(...)&lt;/code&gt; was added — an explicit list of extra headers to forward, with a trailing &lt;code&gt;*&lt;/code&gt; acting as a prefix match. The list is empty by default: propagation is a decision, not a convenience.
&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="n"&gt;route&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;"http://0.0.0.0:8080/chat"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
     &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Llm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"llm://openai/gpt-4o?user=${header.X-User-Id}&amp;amp;audit=tenant:${header.X-Tenant}"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
     &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PropagateToolHeaders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"X-Tenant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"X-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;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"direct://reply"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That method is why this release is a minor rather than a patch: new public API does not ship under a patch number.&lt;/p&gt;

&lt;h2&gt;
  
  
  AS2 and EDI, inside the route
&lt;/h2&gt;

&lt;p&gt;Large retail, 3PL operators, banks running host-to-host, automotive — for twenty years they have exchanged purchase orders, invoices and shipping notices over &lt;strong&gt;AS2&lt;/strong&gt;: signed and encrypted S/MIME over HTTP, with a signed receipt coming back. In .NET that has meant a commercial gateway or a Java server sitting beside your integration.&lt;/p&gt;

&lt;p&gt;AS2 is now an ordinary &lt;code&gt;redb.Route&lt;/code&gt; connector, schemes &lt;code&gt;as2:&lt;/code&gt; and &lt;code&gt;as2s:&lt;/code&gt;. Both directions, synchronous and asynchronous MDN, signed receipts, the full algorithm matrix (&lt;code&gt;sha-1/256/384/512&lt;/code&gt; × &lt;code&gt;aes-128/192/256-cbc&lt;/code&gt;, &lt;code&gt;3des&lt;/code&gt;, optional RFC 3274 compression). Crypto is MimeKit over Bouncy Castle — the same foundation the AS2 industry interoperates on.&lt;/p&gt;

&lt;p&gt;Interop was validated against a live &lt;strong&gt;OpenAS2 v4.9.0 in both directions&lt;/strong&gt; — signed and encrypted payload, positive MDN, MIC verified.&lt;/p&gt;

&lt;p&gt;The full walkthrough — routes, partner profiles, and how this differs from a gateway — is in &lt;a href="https://redbase.app/articles/as2-edi" rel="noopener noreferrer"&gt;the AS2 article&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  One port for several connectors
&lt;/h2&gt;

&lt;p&gt;An AS2 receiver is an HTTP server. You already have HTTP routes. The partner wants a single endpoint and TLS terminated once — so both have to live on the same port.&lt;/p&gt;

&lt;p&gt;The Kestrel multiplexer (one server per &lt;code&gt;host:port&lt;/code&gt;) used to live inside &lt;code&gt;redb.Route.Http&lt;/code&gt;. For AS2 to use it, AS2 would have to depend on a general-purpose transport connector — dragging the whole HTTP stack into an application for the sake of one class, and inverting a sensible dependency direction.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;SharedHttpServerManager&lt;/code&gt; moved into its own package, &lt;code&gt;redb.Route.Http.Hosting&lt;/code&gt;. Both connectors depend on it and not on each other; registration is idempotent and every connector resolves the same singleton. The types kept the &lt;code&gt;redb.Route.Http&lt;/code&gt; namespace, so existing code needs no edits.&lt;/p&gt;

&lt;p&gt;The honest part of this story is worth telling, because it generalises. The extraction landed &lt;strong&gt;after&lt;/strong&gt; &lt;code&gt;redb.Route.Http&lt;/code&gt; had already been published to nuget.org, and a published version cannot be replaced. Meanwhile &lt;code&gt;redb.Route.As2&lt;/code&gt; depends on &lt;code&gt;Hosting&lt;/code&gt; but &lt;strong&gt;not&lt;/strong&gt; on &lt;code&gt;Http&lt;/code&gt; — so NuGet had no reason to ever lift &lt;code&gt;Http&lt;/code&gt; to the fixed build, and anyone combining the old &lt;code&gt;Http&lt;/code&gt; with &lt;code&gt;As2&lt;/code&gt; would quietly get &lt;strong&gt;two independent Kestrel managers&lt;/strong&gt;: exactly the problem the extraction exists to prevent.&lt;/p&gt;

&lt;p&gt;There is no clever fix for that, only an honest republish: the whole &lt;code&gt;redb.Route&lt;/code&gt; line moved as one number, with Tsak and Identity following so their pins moved too. No combination of 3.6.0 packages can assemble the broken pair.&lt;/p&gt;

&lt;h2&gt;
  
  
  Camel parity: four more things
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Message History&lt;/strong&gt; — the trail an exchange leaves through a route: every node with its timing, id and label; when retries are exhausted the trail is dumped to the log, so the failing step is visible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;XSLT&lt;/strong&gt; — &lt;code&gt;.Xslt(...)&lt;/code&gt;, &lt;code&gt;.XsltContent(...)&lt;/code&gt; and the &lt;code&gt;xslt:&lt;/code&gt; component. Useful exactly where it is useful: other people's formats, easier to transform with a stylesheet than with code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Routing Slip&lt;/strong&gt; — &lt;code&gt;.RoutingSlip(...)&lt;/code&gt;: the endpoint list is computed at runtime and the exchange is piped through it in order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Property placeholders in endpoint URIs&lt;/strong&gt; — &lt;code&gt;{{key}}&lt;/code&gt; and &lt;code&gt;{{key:default}}&lt;/code&gt;, as in Apache Camel. One route text works across three environments with no branching in code.&lt;/p&gt;

&lt;h2&gt;
  
  
  IBM MQ: from half a second to single-digit milliseconds
&lt;/h2&gt;

&lt;p&gt;From the previous release rather than this one, but the number speaks for itself.&lt;/p&gt;

&lt;p&gt;The default consumer polled with a blocking &lt;code&gt;MQGET-WAIT&lt;/code&gt; on the IBM.WMQ managed client, which carries an internal ~500 ms tick that is &lt;strong&gt;independent&lt;/strong&gt; of &lt;code&gt;waitInterval&lt;/code&gt;. On an idle queue a message arrived 250–500 ms after it was actually there. The managed client exposes no public async-consume API in any 9.4.x version.&lt;/p&gt;

&lt;p&gt;Receive was moved to an event-driven model via the XMS &lt;code&gt;MessageListener&lt;/code&gt;, cutting latency to single-digit milliseconds. It is opt-in; polling remains the default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Identity: e-mail uniqueness held by an index, not by a check
&lt;/h2&gt;

&lt;p&gt;Registration used to work like this: look up whether a user with this address exists → if so, return 409 → if not, create. Between the check and the insert, two concurrent registrations both see "free" and both write the same address. So &lt;code&gt;RequireUniqueEmail&lt;/code&gt; did not hold under concurrency. Login never had this problem — it is backed by a relational UNIQUE.&lt;/p&gt;

&lt;p&gt;The guarantee is now in the database: a &lt;strong&gt;partial unique index&lt;/strong&gt; &lt;code&gt;UX_users_email&lt;/code&gt; on &lt;code&gt;_users(_email)&lt;/code&gt; with &lt;code&gt;WHERE _email IS NOT NULL&lt;/code&gt;. Partial is not cosmetic — null e-mails are common, and SQL Server's plain UNIQUE permits only one NULL. The address is normalised (trim + lower-invariant) before both the check and the insert, otherwise the index would treat &lt;code&gt;A@x.com&lt;/code&gt; and &lt;code&gt;a@x.com&lt;/code&gt; as two different people. An index violation surfaces as &lt;code&gt;409 duplicate&lt;/code&gt;, not as a &lt;code&gt;500&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The pre-insert check stayed — but it is now what it should always have been: a fast, deterministic early error rather than the guarantee itself.&lt;/p&gt;

&lt;p&gt;The registration hot path also lost its debug scaffolding: a stopwatch with two dozen no-op marker calls, and a VERIFY block that fired two extra queries after every create just to feed those no-ops. Two fewer database round-trips per registration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tsak: a dead-letter store that did not work on PostgreSQL
&lt;/h2&gt;

&lt;p&gt;The dead-letter queue is written and read through the &lt;code&gt;Sql&lt;/code&gt; component, and on PostgreSQL it broke twice.&lt;/p&gt;

&lt;p&gt;Capture never persisted at all: the &lt;code&gt;replayable&lt;/code&gt; flag was bound as an &lt;code&gt;int&lt;/code&gt; &lt;code&gt;1/0&lt;/code&gt; into a &lt;code&gt;BOOLEAN&lt;/code&gt; column, and Postgres has no implicit &lt;code&gt;integer → boolean&lt;/code&gt; cast, so the whole INSERT threw &lt;code&gt;42804&lt;/code&gt;. Capture's own &lt;code&gt;catch&lt;/code&gt; swallowed it, so dead letters were dropped on the floor.&lt;/p&gt;

&lt;p&gt;Second: timestamps were bound as ISO-8601 strings against native &lt;code&gt;timestamptz&lt;/code&gt; columns. There is no implicit &lt;code&gt;text → timestamptz&lt;/code&gt; cast in a comparison operator either, so &lt;code&gt;DELETE ... WHERE occurred_at &amp;lt; @cutoff&lt;/code&gt; threw &lt;code&gt;42883&lt;/code&gt; — taking out the daily retention job and every date-filtered dashboard query.&lt;/p&gt;

&lt;p&gt;Both fixes are the same idea: bind types, not text. &lt;code&gt;bool&lt;/code&gt; → &lt;code&gt;boolean&lt;/code&gt;/&lt;code&gt;bit&lt;/code&gt;/&lt;code&gt;0-1&lt;/code&gt;, &lt;code&gt;DateTimeOffset&lt;/code&gt; → a native timestamp.&lt;/p&gt;

&lt;h2&gt;
  
  
  Smaller things you would notice
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;SumRedbAsync&lt;/code&gt; and &lt;code&gt;AverageRedbAsync&lt;/code&gt; threw &lt;code&gt;InvalidOperationException&lt;/code&gt; on an empty selection. A &lt;code&gt;SUM&lt;/code&gt;/&lt;code&gt;AVG&lt;/code&gt; over no rows is &lt;code&gt;NULL&lt;/code&gt; in SQL, and the result was read through &lt;code&gt;JsonElement.GetDecimal&lt;/code&gt;, which requires a &lt;code&gt;Number&lt;/code&gt;. This path only became reachable after 3.5.0 made base-field aggregations honour the filter: before that &lt;code&gt;WhereRedb(...)&lt;/code&gt; was dropped, the aggregate always spanned the whole scheme, and &lt;code&gt;NULL&lt;/code&gt; never happened. One fix opened the road to another bug — the usual consequence of a filter that used to be silently ignored.&lt;/p&gt;

&lt;p&gt;The fluent DSL and the URI parser disagreed on encoding: the builder encoded values differently from how the parser read them back, so values containing spaces did not survive a round trip. Both sides now use &lt;code&gt;Uri.EscapeDataString&lt;/code&gt;.&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.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 key&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Pro stays proprietary but free and key-less across the whole 3.x line. Everything targets .NET 9.&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://github.com/redbase-app/" rel="noopener noreferrer"&gt;github.com/redbase-app&lt;/a&gt;. About the store itself: &lt;a href="https://redbase.app/" rel="noopener noreferrer"&gt;redbase.app&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;We keep working through what comes in. The feedback journal lives in the repository, and every external signal lands there — valid, arguable and rejected alike, with our assessment and a link to wherever it was filed. If you have hit something resembling the two defects above, write in: an analysis with a reproduction is worth more than any wish list.&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;

&lt;p&gt;If this was useful — a ⭐ on GitHub helps others find it.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>esb</category>
      <category>postgres</category>
      <category>llm</category>
    </item>
    <item>
      <title>AS2 in .NET without a separate Java gateway: EDI with trading partners, right inside the route</title>
      <dc:creator>rinat kozin</dc:creator>
      <pubDate>Wed, 12 Aug 2026 18:45:36 +0000</pubDate>
      <link>https://dev.to/rinat_kozin/as2-in-net-without-a-separate-java-gateway-edi-with-trading-partners-right-inside-the-route-3638</link>
      <guid>https://dev.to/rinat_kozin/as2-in-net-without-a-separate-java-gateway-edi-with-trading-partners-right-inside-the-route-3638</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%2F343od0g0q1ykeo8bdk1b.jpg" 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%2F343od0g0q1ykeo8bdk1b.jpg" alt="redb.Route.AS2" width="800" height="1200"&gt;&lt;/a&gt;&lt;br&gt;
AS2 — signed, encrypted S/MIME document exchange with trading partners — is now a native redb.Route connector, not an external gateway.&lt;/p&gt;

&lt;p&gt;If you ship product to a big-box retailer, move freight for a 3PL, send payment advices to a bank, or exchange X12 healthcare transactions, you almost certainly move those documents over &lt;strong&gt;AS2&lt;/strong&gt;. The purchase order (EDI 850), the invoice (810), the ship notice (856), the payment order (820) don't go out by email or REST — they go as a signed, encrypted S/MIME envelope over HTTP, with a signed receipt coming back. That's how regulated B2B document exchange has worked in retail, logistics, finance, manufacturing and healthcare for twenty years: Walmart, Amazon and their supplier networks, banks with a host-to-host channel, automotive, distributors — all require AS2.&lt;/p&gt;

&lt;p&gt;In .NET, there have been two ways to do this. Either a commercial AS2 gateway — Cleo, Seeburger, BizTalk — a separate box, a separate license, a separate team to run it. Or an open-source Java server — OpenAS2, Mendelson Community — a separate JVM process next to your .NET backend, with its own inbox directory you still have to poll a document out of. Either way, AS2 lives &lt;strong&gt;beside&lt;/strong&gt; your integration, not inside it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;redb.Route.As2&lt;/code&gt; closes that gap: AS2 becomes an ordinary step of a route in your own .NET process. Receive an envelope from a partner, decrypt it, verify the signature, hand the document to the pipeline — validate it, transform it, drop it into Kafka or SQL — and return a signed receipt to the partner. One process, one deployment, one observability plane. Let's walk through what that looks like in code, where it's used, and why a native connector inside the ESB beats a standalone gateway.&lt;/p&gt;
&lt;h2&gt;
  
  
  AS2 in one minute
&lt;/h2&gt;

&lt;p&gt;AS2 (Applicability Statement 2, &lt;a href="https://datatracker.ietf.org/doc/html/rfc4130" rel="noopener noreferrer"&gt;RFC 4130&lt;/a&gt;) is a protocol for guaranteed delivery of business documents between two parties over the internet. Mechanically, it's an envelope:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The payload (usually EDI: X12 or EDIFACT, but it can be XML, JSON, anything) is &lt;strong&gt;compressed&lt;/strong&gt; (optional), &lt;strong&gt;signed&lt;/strong&gt; with your private key and &lt;strong&gt;encrypted&lt;/strong&gt; with the partner's public certificate.&lt;/li&gt;
&lt;li&gt;The finished S/MIME envelope goes to the partner as a plain HTTP POST.&lt;/li&gt;
&lt;li&gt;The partner decrypts it with their key, verifies your signature with your certificate, and answers with an &lt;strong&gt;MDN&lt;/strong&gt; (Message Disposition Notification) — a receipt. A signed MDN carries a &lt;code&gt;Received-Content-MIC&lt;/code&gt;: a cryptographic hash of what the partner actually received.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The point of the MDN is legally meaningful &lt;strong&gt;non-repudiation&lt;/strong&gt;. You sent the order, the partner returned a signed receipt whose MIC matches what you sent: you now have proof that this exact document was delivered, not some other one. That's why AS2 became the de-facto standard wherever a document carries money and obligation.&lt;/p&gt;
&lt;h2&gt;
  
  
  "Why not just HTTPS"
&lt;/h2&gt;

&lt;p&gt;Fair question: if the channel is already TLS-protected, why sign and encrypt the document on top? Because TLS protects the &lt;strong&gt;channel&lt;/strong&gt;, and AS2 protects the &lt;strong&gt;document&lt;/strong&gt;. TLS lives from your socket to the partner's socket and vanishes the moment the bytes hit disk — in a proxy log, in an inbox directory, on a load balancer the document is already in the clear. The AS2 S/MIME envelope stays signed and encrypted the whole way and at rest — only the private-key holder can decrypt it, and the signature proves the author. And crucially, TLS gives you no receipt: HTTPS has no MDN with a MIC, and that's what makes delivery non-repudiable. AS2 usually runs over HTTPS (&lt;code&gt;as2s&lt;/code&gt;) anyway — the two don't compete: TLS encrypts the channel, S/MIME provides the document and the non-repudiation.&lt;/p&gt;
&lt;h2&gt;
  
  
  A readable route: the endpoint is a string
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;as2s://partner.example.com/as2?connectionFactory=walmart          # who we send to
as2:/inbound/orders?host=0.0.0.0&amp;amp;port=4080&amp;amp;connectionFactory=walmart   # where we receive
as2:/as2/mdn?host=0.0.0.0&amp;amp;port=4081&amp;amp;mode=mdn&amp;amp;connectionFactory=walmart # where the partner posts an async MDN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The URI states the intent up front: where we're going, what port we listen on, which partner. Certificates and algorithms don't live in the string — they don't belong there. There's a dedicated object for that.&lt;/p&gt;

&lt;h2&gt;
  
  
  A partner is one object, not a scatter of parameters
&lt;/h2&gt;

&lt;p&gt;An AS2 exchange is always an agreement between two sides: whose certificates, which AS2 identifiers, what to sign and encrypt with, which MDN mode. All of that is an &lt;code&gt;As2ConnectionFactory&lt;/code&gt;, registered once by name:&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;"walmart"&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;As2ConnectionFactory&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;OurCertificate&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;PartnerCertificate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;theirCer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// partner's PUBLIC cert — encrypts outgoing, verifies their signature&lt;/span&gt;
    &lt;span class="n"&gt;As2From&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"OUR-AS2-ID"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;As2To&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"WALMART-AS2-ID"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;PartnerUrl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"https://partner.example.com/as2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="c1"&gt;// Profile — what both sides agreed on&lt;/span&gt;
    &lt;span class="n"&gt;Sign&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;Encrypt&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;Compress&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;span class="n"&gt;SignAlg&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"sha-256"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EncryptAlg&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"aes-128-cbc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;SignedMdn&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;MdnMode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;As2MdnMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sync&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;Routes reference the partner by name — &lt;code&gt;?connectionFactory=walmart&lt;/code&gt; in the URI, or &lt;code&gt;.ConnectionFactory("walmart")&lt;/code&gt; in the fluent DSL. Add a second partner and you register another object; the route URI doesn't change. Certificates are versioned with your application, not sitting in a gateway's keystore that one person on the team knows about.&lt;/p&gt;

&lt;h2&gt;
  
  
  One route, three environments
&lt;/h2&gt;

&lt;p&gt;The endpoint URI is a string, and &lt;code&gt;{{key}}&lt;/code&gt; placeholders work inside it — redb.Route resolves them from &lt;code&gt;IConfiguration&lt;/code&gt; when the route is built. The partner's address differs across dev, staging and prod, but the route stays the same:&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://outbound"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;As2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{{walmart.as2.url}}"&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;"walmart"&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 plaintext"&gt;&lt;code&gt;# appsettings.Development.json → "walmart.as2.url": "https://sandbox.partner/as2"
# appsettings.Production.json  → "walmart.as2.url": "https://edi.partner.com/as2"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Host, receiver port, partner name — all externalized to configuration instead of hard-coded into the route. Secrets — the PFX password — come from the same layer (environment variables, user-secrets) and never settle into source. One built image ships from dev to prod; only the config changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sending (producer)
&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://outbound"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;As2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://partner.example.com/as2"&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;"walmart"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Behind that single line: compression (if enabled), signing with your key, encryption with the partner's certificate, the POST, and parsing the MDN that comes back. The connector puts the outcome on &lt;code&gt;exchange.Out&lt;/code&gt; so the route can act on it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Header on &lt;code&gt;exchange.Out&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redbAs2.mdnDisposition&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the partner's verdict, e.g. &lt;code&gt;automatic-action/MDN-sent-automatically; processed&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redbAs2.signatureValid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;bool&lt;/code&gt; — the MDN's signature verified&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redbAs2.mdnMicMatch&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;bool&lt;/code&gt; — the partner received exactly what we sent, intact&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And right there, in the same route, you branch on the result:&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://outbound"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;As2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://partner/as2"&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;"walmart"&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;Out&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="n"&gt;As2Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MdnMicMatch&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;"delivered &amp;amp; verified"&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="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://delivery-alert"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// MIC mismatch or negative MDN — escalate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The connector does &lt;strong&gt;not&lt;/strong&gt; throw on a negative MDN or a MIC mismatch — it surfaces the fact and lets the route decide. For one partner a MIC mismatch is cause to raise an alert immediately; for another, log it and move on. That's your process's policy, not behavior wired into a gateway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Receiving (consumer / AS2 server)
&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="n"&gt;As2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Receive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/inbound/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;4080&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;"walmart"&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="c1"&gt;// your EDI parsing&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"direct://process-order"&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;From(As2.Receive(...))&lt;/code&gt; stands up an AS2 server. The incoming envelope is decrypted with your private key, its signature verified with the partner's certificate, unpacked — and the route receives the &lt;strong&gt;clean business document&lt;/strong&gt;. Its real content type is on &lt;code&gt;Message.ContentType&lt;/code&gt; (e.g. &lt;code&gt;application/edi-x12&lt;/code&gt;), and the exchange metadata sits under &lt;code&gt;redbAs2.*&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Header&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;redbAs2.mic&lt;/code&gt; / &lt;code&gt;redbAs2.micalg&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the computed Message Integrity Check and its algorithm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redbAs2.signatureValid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the inbound signature verified against the partner cert&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redbAs2.remoteAddress&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the sender's IP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redbAs2.partner&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the resolved connection-factory name&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The connector builds and returns a synchronous MDN itself. The AS2 wire headers (&lt;code&gt;AS2-From&lt;/code&gt;, &lt;code&gt;AS2-To&lt;/code&gt;, &lt;code&gt;Message-ID&lt;/code&gt;, &lt;code&gt;Subject&lt;/code&gt;) are copied onto the message as-is. The S/MIME &lt;strong&gt;wrapper&lt;/strong&gt; &lt;code&gt;Content-Type&lt;/code&gt; is deliberately &lt;em&gt;not&lt;/em&gt; leaked into the headers — the route sees the type of the real document, not the transport envelope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Asynchronous MDN
&lt;/h2&gt;

&lt;p&gt;Large partners often require an asynchronous receipt: accept the message, answer 200, and post the signed MDN separately later. The sender registers the outgoing &lt;code&gt;Message-ID&lt;/code&gt;, and the inbound MDN is correlated by &lt;code&gt;Original-Message-ID&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="c1"&gt;// In the partner profile&lt;/span&gt;
&lt;span class="n"&gt;MdnMode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;As2MdnMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Async&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="n"&gt;AsyncMdnUrl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"https://our-host:4081/as2/mdn"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

&lt;span class="c1"&gt;// Routes&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;As2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Receive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/inbound"&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;4080&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;"walmart"&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://process"&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;As2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReceiveMdn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/as2/mdn"&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;4081&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;"walmart"&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;original&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;As2Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MessageId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// which of our documents is acknowledged&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ok&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;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;As2Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MdnMicMatch&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;         &lt;span class="c1"&gt;// and acknowledged intact&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A dedicated endpoint for inbound MDNs is just another &lt;code&gt;From&lt;/code&gt; in a route, and the arriving receipt becomes a message you can act on: update the order status, clear a retry, close a saga.&lt;/p&gt;

&lt;h2&gt;
  
  
  Algorithm matrix
&lt;/h2&gt;

&lt;p&gt;Partners agree on specific algorithms, and the connector supports the standard set:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Knob&lt;/th&gt;
&lt;th&gt;Values&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SignAlg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sha-1&lt;/code&gt;, &lt;code&gt;sha-256&lt;/code&gt; (default), &lt;code&gt;sha-384&lt;/code&gt;, &lt;code&gt;sha-512&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EncryptAlg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;aes-128-cbc&lt;/code&gt; (default), &lt;code&gt;aes-192-cbc&lt;/code&gt;, &lt;code&gt;aes-256-cbc&lt;/code&gt;, &lt;code&gt;3des&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Compress&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;true&lt;/code&gt; / &lt;code&gt;false&lt;/code&gt; (RFC 3274)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;An unsupported algorithm fails fast &lt;strong&gt;when the route is built&lt;/strong&gt;, not at run time on the first message to a partner. A configuration error is visible at startup, not at 3 a.m. in the dead-letter logs.&lt;/p&gt;

&lt;p&gt;The crypto underneath is MimeKit (on Bouncy Castle) — the same cryptographic foundation the entire AS2 world interoperates on: Apache camel-as2, OpenAS2 and Mendelson all sit on Bouncy Castle. We land on the shared foundation the parties actually interoperate over, rather than inventing our own S/MIME.&lt;/p&gt;

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

&lt;p&gt;.NET projects have covered AS2 three ways. The difference isn't "can or can't" — everyone can, it's one protocol. The difference is where AS2 lives relative to your logic.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Commercial gateway&lt;/th&gt;
&lt;th&gt;Java server (OpenAS2/Mendelson)&lt;/th&gt;
&lt;th&gt;redb.Route.As2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Process&lt;/td&gt;
&lt;td&gt;separate box&lt;/td&gt;
&lt;td&gt;separate JVM next door&lt;/td&gt;
&lt;td&gt;your .NET process&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Received document&lt;/td&gt;
&lt;td&gt;file in an inbox dir&lt;/td&gt;
&lt;td&gt;file in an inbox dir&lt;/td&gt;
&lt;td&gt;a message in the route&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How to pick it up&lt;/td&gt;
&lt;td&gt;a poller job&lt;/td&gt;
&lt;td&gt;a poller job&lt;/td&gt;
&lt;td&gt;straight into the pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deployment&lt;/td&gt;
&lt;td&gt;its own install&lt;/td&gt;
&lt;td&gt;its own install&lt;/td&gt;
&lt;td&gt;with your app&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observability&lt;/td&gt;
&lt;td&gt;its own panel&lt;/td&gt;
&lt;td&gt;JVM logs&lt;/td&gt;
&lt;td&gt;shared traces and stats&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Further processing&lt;/td&gt;
&lt;td&gt;outside the gateway, by hand&lt;/td&gt;
&lt;td&gt;outside the server, by hand&lt;/td&gt;
&lt;td&gt;the same EIPs in the same route&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;License&lt;/td&gt;
&lt;td&gt;paid&lt;/td&gt;
&lt;td&gt;open&lt;/td&gt;
&lt;td&gt;open, no key&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A standalone gateway is justified when the AS2 boundary is deliberately isolated — in a DMZ run by a different team, say. But when the document goes to your .NET backend for processing anyway, the intermediate box is an extra hop, an extra directory, and an extra component in the audit diagram.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it's used, and why a native connector
&lt;/h2&gt;

&lt;p&gt;AS2 is needed wherever a document carries an obligation and the partner dictates the channel. A few typical situations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Supplier to a big-box retailer.&lt;/strong&gt; To ship to Walmart, Target or Amazon Vendor, a supplier must accept orders (850) and send invoices (810) and ASNs (856) over AS2 with a signed MDN. This used to mean a standalone gateway; now &lt;code&gt;From(As2.Receive(...))&lt;/code&gt; accepts the order straight into the route that validates it and lands it in your ERP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3PL and logistics.&lt;/strong&gt; A warehouse and a carrier exchange shipment statuses, receipt confirmations, inventory. The volume is streaming, and keeping a separate Java box with an inbox directory that a cron job scoops documents out of is an extra link. The document should flow, not sit in a folder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Healthcare.&lt;/strong&gt; X12 HIPAA transactions (claims, remittance) between payers and providers go over AS2 with strict signing and encryption requirements. Same profile — sign, encrypt, signed MDN.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Finance and payments.&lt;/strong&gt; EDI payment and settlement documents — the payment order and remittance advice (X12 820/824) — travel between corporate clients and their banks over AS2 where the bank offers a host-to-host channel. AS2 doesn't replace SWIFT or EBICS on the interbank rails here; it covers the EDI layer: corporate-to-bank document flow and payment advices in the supply chain, where payment is just another EDI document alongside the order and the invoice. What matters to fintech is exactly AS2's non-repudiation: a signed MDN with a MIC is proof the bank received this exact payment order, not another.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manufacturing and supply chain.&lt;/strong&gt; Automotive and industrial supply-chain networks push orders, delivery schedules and ship notices (EDIFACT/X12) between OEMs and their tiered suppliers. The exchange is mandatory: without electronic document exchange, a supplier simply isn't onboarded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Insurance.&lt;/strong&gt; Enrollment, claims, remittance (X12) between payers, brokers and providers — the same signing-and-encryption profile, the same receipt requirements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consolidating a gateway.&lt;/strong&gt; You already have a commercial AS2 gateway, but it's a separate box with its own license, its own patch cycle and its own monitoring, running alongside the .NET backend that does all the business logic. The AS2 connector folds that boundary into the application.&lt;/p&gt;

&lt;p&gt;Why a native connector in the ESB rather than a gateway beside it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The document flows, it doesn't sit.&lt;/strong&gt; With a standalone gateway, a received file lands in an inbox directory you then have to pick up. With the connector, a received document is a message in a route: validation, transformation, routing right away. No intermediate folder and no scoop-up job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One process, one deployment.&lt;/strong&gt; No second box to install, patch, monitor and explain to an auditor. The AS2 endpoint stands up on the process's shared Kestrel host along with the rest of its HTTP routes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One observability plane.&lt;/strong&gt; AS2 endpoints emit statistics and health (visible in the redb.Tsak dashboard) and distributed traces alongside every other connector: the producer opens a &lt;code&gt;Client&lt;/code&gt; span, the consumer a &lt;code&gt;Consumer&lt;/code&gt; span linked over the inbound W3C &lt;code&gt;traceparent&lt;/code&gt;. The exchange with a partner shows up in the same Jaeger trace as the document's onward path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Composition with EIPs.&lt;/strong&gt; This is the main thing. AS2 isn't an island, it's a step. A received document can go through the full redb.Route pattern catalog: validate against a schema, transform with XSLT, split, enrich, tee off to an archive with WireTap, land in Kafka and SQL at once.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AS2 as one step of an end-to-end route
&lt;/h2&gt;

&lt;p&gt;The value of the connector shows when AS2 stands not by itself but in a chain. Accept an order from a partner, transform it, split it and route it onward — in one 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;As2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Receive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/inbound/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;4080&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;"walmart"&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="c1"&gt;// document matches the schema&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Xslt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"styles/x12-to-canonical.xsl"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;// X12 → your canonical model&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WireTap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sftp://archive/edi?..."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;           &lt;span class="c1"&gt;// a copy to the archive with retention&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?brokers=..."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;// onto the bus for processing&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 inbound_orders ..."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// and into the audit DB&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same document that arrived in an encrypted AS2 envelope ends up — in one pass — validated, transformed, archived, in Kafka and in SQL, and all of it visible in a single trace. A standalone gateway would have handed you a file in a folder; here you get a full pipeline whose entry point happens to be AS2.&lt;/p&gt;

&lt;p&gt;The outbound direction is symmetric: assemble a document from your system, send it to the partner, parse the MDN, update the status — one route again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full loop: order in, invoice out
&lt;/h2&gt;

&lt;p&gt;Onboarding a supplier to a retailer is two AS2 directions, living as two routes in one process.&lt;/p&gt;

&lt;p&gt;Inbound — the order (EDI 850) from the retailer:&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;As2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Receive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/inbound"&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;4080&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;"walmart"&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="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X12OrderValidator&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;          &lt;span class="c1"&gt;// 850 structure is valid&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="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X12Format&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;Order&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;// X12 → domain model&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;async&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;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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;Order&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;Body&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="nf"&gt;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"kafka://orders-inbound?brokers={{kafka.brokers}}"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order is accepted, validated, parsed, stored and published to the bus in one pass, and a synchronous MDN went back to the retailer automatically. Your system then processes the order at its own pace.&lt;/p&gt;

&lt;p&gt;Outbound — the invoice (EDI 810) back to the partner once the shipment is ready:&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://invoices-ready?brokers={{kafka.brokers}}"&lt;/span&gt;&lt;span class="p"&gt;)&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="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X12Format&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;                   &lt;span class="c1"&gt;// domain model → X12 810&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;As2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{{walmart.as2.url}}"&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;"walmart"&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;Out&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="n"&gt;As2Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MdnMicMatch&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;_invoices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MarkDelivered&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="nf"&gt;Otherwise&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://edi-ops-alert"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both directions use one &lt;code&gt;connectionFactory("walmart")&lt;/code&gt; — the same certificates, the same profile. The whole EDI relationship with a partner is two routes and one partner object, inside your application, under your observability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Visible in one trace
&lt;/h2&gt;

&lt;p&gt;AS2 in a standalone gateway is a black box: the partner says "it didn't arrive," and you go digging in someone else's logs on someone else's box. Here, AS2 endpoints are part of redb.Route's shared observability. The producer opens a &lt;code&gt;Client&lt;/code&gt; span, the consumer a &lt;code&gt;Consumer&lt;/code&gt; span linked to the inbound document over the W3C &lt;code&gt;traceparent&lt;/code&gt;. In one Jaeger trace you see the whole thing: envelope arrived → decrypted and signature verified → validated → transformed → sent to Kafka. Endpoint statistics — how many received, how many sent, errors — sit in the redb.Tsak dashboard next to every other connector. When a partner disputes delivery, you have the trace for a specific &lt;code&gt;Message-ID&lt;/code&gt;, not "somewhere in the gateway logs."&lt;/p&gt;

&lt;h2&gt;
  
  
  What to know about AS2 in practice
&lt;/h2&gt;

&lt;p&gt;A few things you step on exactly once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The MIC is computed over the canonical form, byte for byte.&lt;/strong&gt; The Message Integrity Check is a hash of the signed part in CRLF-canonical form, computed exactly the way the sending side computed it. That's where all of AS2's interop pain lives: if canonicalization or the algorithm diverges by even a byte, the MIC in the MDN won't match and the partner decides it got the wrong thing. The connector computes the MIC per RFC 4130 and checks it on receive and in MDN parsing — and that's exactly what's verified against an external server, not against itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sync or async is the partner's call, not yours.&lt;/strong&gt; A synchronous MDN is simpler: the receipt is in the same response, the route knows the outcome immediately. But at volume a synchronous MDN holds the HTTP connection until processing finishes, and large partners require async: accept, answer 200, post the MDN separately. The connector supports both; the choice is a line in the partner profile, not a route rewrite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A signed MDN is about non-repudiation, not a checkbox.&lt;/strong&gt; &lt;code&gt;SignedMdn = true&lt;/code&gt; means the receipt is signed with the receiving side's key and carries a MIC. For a document that carries an obligation, that's the proof: you hold a signed confirmation that the partner received this exact document, intact. An unsigned MDN is just "it arrived," which is worth little in a dispute.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Content-Transfer-Encoding: binary.&lt;/strong&gt; EDI documents are binary in spirit, and partners typically exchange in &lt;code&gt;binary&lt;/code&gt; rather than &lt;code&gt;base64&lt;/code&gt; — less overhead at volume. A profile detail, but exactly the one home-grown implementations trip on.&lt;/p&gt;

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

&lt;p&gt;AS2 rests on a key pair: your private key (signs outgoing, decrypts incoming) and the partner's public certificate (encrypts outgoing, verifies their signature). In the connector that's an &lt;code&gt;X509Certificate2&lt;/code&gt; on the &lt;code&gt;As2ConnectionFactory&lt;/code&gt; — load it from a PKCS#12/PFX however suits you: a file, the Windows certificate store, a secrets manager.&lt;/p&gt;

&lt;p&gt;Certificates are part of the partner's configuration in the registry, not strings in a URI. The PFX password, if it comes through an endpoint parameter, is marked &lt;code&gt;[Sensitive]&lt;/code&gt; and redacted from logs and the dashboard — the secret doesn't leak into a trace. Rotating a partner certificate is swapping the object in the registry; the route isn't touched.&lt;/p&gt;

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

&lt;p&gt;It's easy to write "AS2 supported" and attach unit tests that run the connector against itself. That proves internal consistency, but not that your envelope is accepted by software you didn't write. The real test is exchanging with an independent implementation.&lt;/p&gt;

&lt;p&gt;The connector is verified against a live &lt;strong&gt;OpenAS2 v4.9.0&lt;/strong&gt; (a mature, Bouncy-Castle-based server) in Docker, in &lt;strong&gt;both directions&lt;/strong&gt;, with the profile "SHA-256 sign + AES-128-CBC encrypt + signed MDN":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;redb → OpenAS2.&lt;/strong&gt; Our producer sent a document; OpenAS2's own logs confirmed it decrypted our envelope, verified our signature, stored the document and returned a positive signed MDN. Our MDN parser then verified that MDN's signature and confirmed the &lt;code&gt;Received-Content-MIC&lt;/code&gt; matched what we sent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenAS2 → redb.&lt;/strong&gt; OpenAS2 built a signed, encrypted document and sent it to our consumer. We decrypted it with our private key, verified OpenAS2's signature, handed the EDI to the route, and returned a signed MDN that OpenAS2 accepted and correlated with its pending message.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That closes AS2's "hard part" in both directions: MIC computation per RFC 4130, S/MIME structure and MDN handling — send and receive — are correct against a real, independent partner, not merely self-consistent.&lt;/p&gt;

&lt;p&gt;The tests are open. How exactly it was verified — three layers (crypto round-trips and MIC, an end-to-end producer→consumer loop over a live Kestrel, interop against OpenAS2) — is laid out in the connector's &lt;a href="https://github.com/redbase-app/redb-route/tree/main/redb.Route.As2" rel="noopener noreferrer"&gt;TESTING.md on GitHub&lt;/a&gt;. The interop harness (a docker-compose with OpenAS2, its config and generated certificates) reproduces with one command; the interop test is gated, so an ordinary run is green without the container.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Multiple partners on one port?&lt;/strong&gt; Yes. Different partners' receive endpoints live on the shared Kestrel host, separated by path (&lt;code&gt;/inbound/walmart&lt;/code&gt;, &lt;code&gt;/inbound/target&lt;/code&gt;), each with its own &lt;code&gt;connectionFactory&lt;/code&gt;. One server, several partnerships.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Large files?&lt;/strong&gt; The message body is bytes, and it flows through the normal HTTP pipeline rather than being assembled into a string. For genuinely heavy transfers partners usually move to SFTP — which in redb.Route is also a connector, and sits in the same route.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;mTLS / client certificates on the transport?&lt;/strong&gt; AS2 itself authenticates the document by signature, but some partners additionally require TLS client-auth. The &lt;code&gt;as2s&lt;/code&gt; scheme runs on the same Kestrel host as the HTTP connector, with its TLS settings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about restarts?&lt;/strong&gt; An async MDN is correlated by &lt;code&gt;Original-Message-ID&lt;/code&gt;; outgoing messages awaiting a receipt are your route's state, held where you keep saga or idempotency state, not in the connector's memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&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;AddRedbRouteAs2&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddRouteBuilder&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MyRoutes&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AddRedbRouteAs2()&lt;/code&gt; registers the &lt;code&gt;as2&lt;/code&gt; / &lt;code&gt;as2s&lt;/code&gt; schemes and reuses the process's shared Kestrel host — an AS2 route and a plain HTTP route in the same worker share one server and never fight over a port.&lt;/p&gt;

&lt;p&gt;The package is &lt;a href="https://www.nuget.org/packages/redb.Route.As2/" rel="noopener noreferrer"&gt;redb.Route.As2 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.As2" rel="noopener noreferrer"&gt;connector README&lt;/a&gt;. AS2 is one more transport in the redb.Route family, alongside Kafka, RabbitMQ, IBM MQ, SFTP 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 input and output are a signed, encrypted envelope your trading partner is waiting for.&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;

&lt;p&gt;If this was useful — a ⭐ on GitHub helps others find it.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>security</category>
      <category>integration</category>
    </item>
    <item>
      <title>A real local database in Blazor WebAssembly and MAUI: no EF DbContext, no Include, no migrations</title>
      <dc:creator>rinat kozin</dc:creator>
      <pubDate>Sun, 09 Aug 2026 14:33:56 +0000</pubDate>
      <link>https://dev.to/rinat_kozin/a-real-local-database-in-blazor-webassembly-and-maui-no-ef-dbcontext-no-include-no-migrations-102b</link>
      <guid>https://dev.to/rinat_kozin/a-real-local-database-in-blazor-webassembly-and-maui-no-ef-dbcontext-no-include-no-migrations-102b</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%2F6xjwpvoakh1tds5gxz21.webp" 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%2F6xjwpvoakh1tds5gxz21.webp" alt="redb.SQLite" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Offline storage for complex graphs and an outbox queue: quick start for MAUI and the browser, everyday operations, and why EF Core just gets in the way here.&lt;/p&gt;

&lt;p&gt;A delivery driver walks into a basement loading dock and the signal dies. A warehouse clerk spends&lt;br&gt;
three hours in the one corner where Wi-Fi comes and goes. A user fills in a four-screen form and hits&lt;br&gt;
Save at the exact moment your API goes down for a deploy.&lt;/p&gt;

&lt;p&gt;The app has to keep working. Which means it needs &lt;strong&gt;its own database on the device&lt;/strong&gt; — not a response&lt;br&gt;
cache, but real local storage holding half-finished documents, local state, and a queue of changes&lt;br&gt;
waiting to go out. The outbox that flushes itself once the connection comes back.&lt;/p&gt;

&lt;p&gt;And that is where the fun starts.&lt;/p&gt;
&lt;h2&gt;
  
  
  Sound familiar?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"We'll just use key-value, it's simple."&lt;/strong&gt; &lt;code&gt;localStorage&lt;/code&gt;, Preferences, a JSON file. Works&lt;br&gt;
beautifully right up until someone asks for "unfinished orders from the last week, sorted by&lt;br&gt;
priority." Answering that means loading everything into memory and looping. Fine at two hundred&lt;br&gt;
records. At ten thousand, the phone gets warm.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Let's use EF Core, everyone knows it."&lt;/strong&gt; And now you have migrations on the client. The app&lt;br&gt;
updates, and a migration has to run on a user's device, against their data, with nobody watching. If&lt;br&gt;
it goes sideways, you find out from an app store review. Worse, it is not a one-time tax: local state&lt;br&gt;
churns far more than server entities, because it is drafts, wizard steps, sync flags.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"The graph is complex anyway."&lt;/strong&gt; An order with line items, each with attachments and a status&lt;br&gt;
history, plus a customer with an address. On the server you normalized that and wrote &lt;code&gt;Include&lt;/code&gt; /&lt;br&gt;
&lt;code&gt;ThenInclude&lt;/code&gt;. On the client you need the whole thing every time — the user opened a draft, show me&lt;br&gt;
everything. Miss an &lt;code&gt;Include&lt;/code&gt; and you get &lt;code&gt;null&lt;/code&gt; where data should be, or an N+1 out of nowhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Fine, serialize the graph into a JSON column."&lt;/strong&gt; The classic escape hatch: complex stuff goes to&lt;br&gt;
text, simple stuff stays in columns. The graph persists, but querying is over — "status = draft and&lt;br&gt;
total &amp;gt; 10,000" is now a full scan in memory. And your type safety is reduced to hoping&lt;br&gt;
&lt;code&gt;JsonSerializer&lt;/code&gt; never meets a field it does not recognize.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Now multiply that by the size of a real app.&lt;/strong&gt; It is not one entity, it is hundreds of classes, and&lt;br&gt;
each one has local state of its own: form drafts, wizard position, active filters, a snapshot for&lt;br&gt;
undo, a cached response under a specific key. And that state is not flat — it has nested objects,&lt;br&gt;
collections, statuses.&lt;/p&gt;

&lt;p&gt;A table per state means hundreds of tables and hundreds of migrations shipping to users' phones. One&lt;br&gt;
"key → JSON" table instead is fast to write and comfortably familiar, except:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;type safety is gone: rename a property, and the old JSON quietly deserializes with a &lt;code&gt;null&lt;/code&gt;, and the
bug shows up a week later on someone else's device;&lt;/li&gt;
&lt;li&gt;search is impossible: "show me unfinished drafts above the limit" means pulling everything out and
sifting through it in memory;&lt;/li&gt;
&lt;li&gt;and reading that pile with your own eyes is not a great time either.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Familiar bottom line: half the local-storage code is not business logic, it is bookkeeping for how you&lt;br&gt;
store things.&lt;/p&gt;
&lt;h2&gt;
  
  
  What it looks like instead
&lt;/h2&gt;

&lt;p&gt;The schema is a plain C# class. No &lt;code&gt;DbContext&lt;/code&gt;, no migration files, no &lt;code&gt;Include&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;RedbScheme&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Order"&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;OrderProps&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;OrderStatus&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Total&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt; &lt;span class="n"&gt;CreatedAt&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Customer&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Customer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;               &lt;span class="c1"&gt;// nested object&lt;/span&gt;
    &lt;span class="k"&gt;public&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;OrderItem&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Items&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// nested collection&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]?&lt;/span&gt; &lt;span class="n"&gt;Tags&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save the whole graph in one line. Load the whole graph in one line. Query nested fields with LINQ that&lt;br&gt;
runs in the database, not in memory:&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;await&lt;/span&gt; &lt;span class="n"&gt;redb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                       &lt;span class="c1"&gt;// the whole graph, Items and Customer included&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;draft&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;LoadAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderProps&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// the whole graph back, no Include&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;pending&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;OrderProps&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;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;Status&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;OrderStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Draft&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&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;Total&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;10000m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OrderByDescending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;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;CreatedAt&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;Add a property to the class and it simply shows up. Nothing to migrate: no migration files, no&lt;br&gt;
&lt;code&gt;ALTER TABLE&lt;/code&gt;, and objects saved earlier keep loading fine.&lt;/p&gt;

&lt;p&gt;And this is &lt;strong&gt;not a JSON blob&lt;/strong&gt;: every property lives in a typed, indexed column, so the condition&lt;br&gt;
above is a real SQL filter rather than a scan. Strong typing survives all the way down — nested&lt;br&gt;
objects, collections, dictionaries.&lt;/p&gt;

&lt;p&gt;As for those hundreds of classes: you never list them anywhere. Tag them with &lt;code&gt;[RedbScheme]&lt;/code&gt; and&lt;br&gt;
startup finds them in the assembly and sets up the schemas:&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;// one line for the entire app — for the first class and for the three-hundredth&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="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="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OrderProps&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;Assembly&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A new kind of state is a new class in your code and nothing else. No table, no migration, no registry&lt;br&gt;
entry.&lt;/p&gt;

&lt;p&gt;Underneath it is ordinary SQLite — the same file you were going to ship anyway. And the same code runs&lt;br&gt;
on a server over PostgreSQL or SQL Server, which means the client and the backend end up sharing one&lt;br&gt;
model.&lt;/p&gt;

&lt;p&gt;What follows is a quick start for both, the everyday operations, and an honest comparison. Not a word&lt;br&gt;
about how the provider works inside — this is a piece about using it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The model for the examples
&lt;/h2&gt;

&lt;p&gt;To keep the code short, everything below uses a note. It all works the same on the graph from the&lt;br&gt;
first example, it just reads better this way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;RedbScheme&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Note"&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;NoteProps&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Title&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;Priority&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt; &lt;span class="n"&gt;CreatedAt&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]?&lt;/span&gt; &lt;span class="n"&gt;Tags&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;RedBase ships three providers: PostgreSQL, SQL Server and SQLite. On the client it is SQLite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which package to install
&lt;/h2&gt;

&lt;p&gt;The SQLite provider comes in two editions, and on the client there is effectively no choice to make.&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;&lt;code&gt;redb.SQLite&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;redb.SQLite.Pro&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Implementation&lt;/td&gt;
&lt;td&gt;part of the logic in a native SQLite extension&lt;/td&gt;
&lt;td&gt;pure C#&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server, desktop&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blazor WebAssembly&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android, iOS&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Free edition keeps part of its logic in a native SQLite extension, and browsers cannot load those;&lt;br&gt;
on mobile that extension is not built at all. Pro is C# from top to bottom, so it runs everywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pro is free and needs no license key&lt;/strong&gt; — the whole 3.x line, commercial production included. The&lt;br&gt;
package is closed-source, but there is nothing to pay for and nothing to activate: install it and go.&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Nothing else to add — &lt;code&gt;redb.Core&lt;/code&gt;, SQLite itself and the rest come in transitively. You need .NET 8, 9&lt;br&gt;
or 10. Everything below was verified on &lt;strong&gt;3.5.0&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Quick start: mobile (MAUI)
&lt;/h2&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%2F3cy2ptdxa4562ey44o3p.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%2F3cy2ptdxa4562ey44o3p.png" alt="redb.SQLite" width="768" height="1707"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Mobile first, because it is the easy one: the database is a regular file that survives restarts on its&lt;br&gt;
own.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 1. Project and package
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet workload &lt;span class="nb"&gt;install &lt;/span&gt;maui-android
dotnet new maui &lt;span class="nt"&gt;-n&lt;/span&gt; MyApp
&lt;span class="nb"&gt;cd &lt;/span&gt;MyApp
dotnet add package redb.SQLite.Pro
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Building Android-only from Windows? Drop the &lt;code&gt;ios&lt;/code&gt; and &lt;code&gt;maccatalyst&lt;/code&gt; entries from &lt;code&gt;&amp;lt;TargetFrameworks&amp;gt;&lt;/code&gt;,&lt;br&gt;
or restore will demand a workload you do not have.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 2. Registration in &lt;code&gt;MauiProgram.cs&lt;/code&gt;
&lt;/h3&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.Core.Models.Configuration&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;redb.Core.Pro.Extensions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// AddRedbPro&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;redb.SQLite.Pro.Extensions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// UseSqlite&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;MauiApp&lt;/span&gt; &lt;span class="nf"&gt;CreateMauiApp&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;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MauiApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBuilder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UseMauiApp&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;App&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// AppDataDirectory is the app's private folder: the file survives restarts&lt;/span&gt;
    &lt;span class="c1"&gt;// and updates, and goes away when the app is uninstalled.&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;dbPath&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Combine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FileSystem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AppDataDirectory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"app.db"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&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;AddRedbPro&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="n"&gt;options&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=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;dbPath&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&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;Configure&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;PropsSaveStrategy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PropsSaveStrategy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ChangeTracking&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&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;AddSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;RedbBootstrap&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&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;AddSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MainPage&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&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;PropsSaveStrategy.ChangeTracking&lt;/code&gt; means "only write the properties that actually changed" instead of&lt;br&gt;
rewriting the whole object. On a phone that saves both time and flash wear.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 3. Initialize exactly once
&lt;/h3&gt;

&lt;p&gt;Here is the first thing that trips people up. On a server this happens by itself when the host starts.&lt;br&gt;
&lt;strong&gt;MAUI does not run hosted services&lt;/strong&gt;, so you have to call it yourself:&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;redb&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;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="c1"&gt;// Creates the database structure if it is missing, and registers schemas for every&lt;/span&gt;
&lt;span class="c1"&gt;// [RedbScheme] class in the given assembly. You do not enumerate them.&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="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="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NoteProps&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;Assembly&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can omit the assembly and let it scan everything loaded. On a client, name it explicitly — faster&lt;br&gt;
and more predictable.&lt;/p&gt;

&lt;p&gt;The second trap: Android recreates the Activity on rotation and when you come back from the&lt;br&gt;
background. Tie initialization to a page event and it runs several times. Tie it to the process&lt;br&gt;
instead — &lt;code&gt;Lazy&amp;lt;Task&amp;gt;&lt;/code&gt; does that in one line and behaves under concurrent callers:&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;RedbBootstrap&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;Lazy&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;RedbBootstrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IServiceProvider&lt;/span&gt; &lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_init&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;Lazy&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;()&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;redb&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;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="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="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="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NoteProps&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;Assembly&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="c1"&gt;/// Call before touching the database. Actually runs once per process.&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;EnsureInitializedAsync&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;_init&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4. The page
&lt;/h3&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;partial&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MainPage&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ContentPage&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;IRedbService&lt;/span&gt; &lt;span class="n"&gt;_redb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;RedbBootstrap&lt;/span&gt; &lt;span class="n"&gt;_bootstrap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;MainPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IRedbService&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;RedbBootstrap&lt;/span&gt; &lt;span class="n"&gt;bootstrap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;InitializeComponent&lt;/span&gt;&lt;span class="p"&gt;();&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;redb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;_bootstrap&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bootstrap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;OnAppearing&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OnAppearing&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;_bootstrap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EnsureInitializedAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="n"&gt;CountLabel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;$"Notes: &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;NoteProps&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;().&lt;/span&gt;&lt;span class="nf"&gt;CountAsync&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s"&gt;"&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;That is the whole setup. Run &lt;code&gt;dotnet build -f net10.0-android -t:Run&lt;/code&gt;, the app opens, the database is&lt;br&gt;
created on first launch and stays on the device until the app is uninstalled.&lt;/p&gt;

&lt;p&gt;Release builds use trimming and AOT — the provider handles that, nothing extra to configure. If you&lt;br&gt;
crank trimming past the defaults, root the assembly holding your schema classes: they are read through&lt;br&gt;
reflection and the linker does not know about them.&lt;/p&gt;
&lt;h2&gt;
  
  
  Quick start: Blazor WebAssembly
&lt;/h2&gt;

&lt;p&gt;The same code runs in a browser, with three wrinkles. All three fail the same unhelpful way — the&lt;br&gt;
project builds clean and then breaks in the browser — so it is worth knowing all of them.&lt;/p&gt;
&lt;h3&gt;
  
  
  Wrinkle 1. The build needs an extra tool
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet workload &lt;span class="nb"&gt;install &lt;/span&gt;wasm-tools
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;PropertyGroup&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;WasmBuildNative&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/WasmBuildNative&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/PropertyGroup&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Why: browsers have no OS library loader, so SQLite has to be &lt;em&gt;compiled into&lt;/em&gt; the runtime at build&lt;br&gt;
time rather than shipped next to it. Release turns this on by itself; &lt;code&gt;dotnet run&lt;/code&gt; and Debug need the&lt;br&gt;
flag. Without it you get a stock runtime with no SQLite in it, and a crash on the first database call.&lt;/p&gt;

&lt;p&gt;The first build after this gets noticeably slower — that is the native link step, and it is a one-off.&lt;br&gt;
Incremental builds stay quick.&lt;/p&gt;
&lt;h3&gt;
  
  
  Wrinkle 2. Initialization is manual here too
&lt;/h3&gt;

&lt;p&gt;Same as MAUI, same reason: &lt;code&gt;WebAssemblyHost&lt;/code&gt; does not run hosted services.&lt;/p&gt;
&lt;h3&gt;
  
  
  Wrinkle 3. Persistence is on you
&lt;/h3&gt;

&lt;p&gt;The browser's file system in .NET is memory. While the tab is open the database behaves normally; hit&lt;br&gt;
reload and it is gone. RedBase does not hand you a persistence mechanism — deliberately, because the&lt;br&gt;
right answer depends on the app: IndexedDB, the Cache API, or OPFS.&lt;/p&gt;

&lt;p&gt;Here is a working IndexedDB version. It needs no special build flags and uses the ordinary &lt;code&gt;File&lt;/code&gt; API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One catch that makes naive implementations look fine while they lose data.&lt;/strong&gt; SQLite in the browser&lt;br&gt;
runs in WAL mode: recent commits land in the &lt;code&gt;app.db-wal&lt;/code&gt; companion while the main file stays nearly&lt;br&gt;
empty. Save just &lt;code&gt;app.db&lt;/code&gt; and you get a database that "restores" and turns out empty. Both files have&lt;br&gt;
to travel.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;wwwroot/js/dbPersistence.js&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DB_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;myapp-db&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STORE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;files&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;openIdb&lt;/span&gt;&lt;span class="p"&gt;()&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="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;indexedDB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DB_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onupgradeneeded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;STORE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onsuccess&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onerror&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&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="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;openIdb&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;try&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;STORE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;readonly&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;objectStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;STORE&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onsuccess&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onerror&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&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="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&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="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;openIdb&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;STORE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;readwrite&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;objectStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;STORE&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;oncomplete&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onerror&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onabort&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&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="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&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;&lt;code&gt;Services/SqliteFilePersistence.cs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.JSInterop&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;redb.Core.Data&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;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SqliteFilePersistence&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;IJSRuntime&lt;/span&gt; &lt;span class="n"&gt;_js&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;_dbPath&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="n"&gt;IJSObjectReference&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;_module&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;SqliteFilePersistence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IJSRuntime&lt;/span&gt; &lt;span class="n"&gt;js&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;dbPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_js&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;js&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;_dbPath&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dbPath&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;private&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="n"&gt;IJSObjectReference&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ModuleAsync&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;_module&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;_js&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InvokeAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IJSObjectReference&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"import"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"./js/dbPersistence.js"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;/// Pull the database out of IndexedDB. Must happen before anything touches the&lt;/span&gt;
    &lt;span class="c1"&gt;/// database, or SQLite creates an empty file and there is nothing left to restore.&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;RestoreAsync&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;module&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ModuleAsync&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;path&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;_dbPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_dbPath&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"-wal"&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;bytes&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;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InvokeAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;[]?&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"load"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&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;bytes&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&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;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteAllBytesAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bytes&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="c1"&gt;/// Push the current state back into IndexedDB.&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;PersistAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IRedbContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// PASSIVE matters. TRUNCATE wants an exclusive lock, and on a single-threaded&lt;/span&gt;
        &lt;span class="c1"&gt;// browser there is nobody to release it — the call just hangs.&lt;/span&gt;
        &lt;span class="k"&gt;try&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;ExecuteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"PRAGMA wal_checkpoint(PASSIVE);"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&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;module&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ModuleAsync&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;path&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;_dbPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_dbPath&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"-wal"&lt;/span&gt; &lt;span class="p"&gt;})&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;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&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;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;InvokeVoidAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"save"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&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;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReadAllBytesAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Program.cs&lt;/code&gt; — order matters here:&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;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;DbPath&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/app.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;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WebAssemblyHostBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RootComponents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;App&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"#app"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RootComponents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HeadOutlet&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"head::after"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&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;AddSingleton&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;=&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;SqliteFilePersistence&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;IJSRuntime&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(),&lt;/span&gt; &lt;span class="n"&gt;DbPath&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&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;AddRedbPro&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="n"&gt;options&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=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;DbPath&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&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;host&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// 1. Restore the files first...&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;host&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;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SqliteFilePersistence&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;().&lt;/span&gt;&lt;span class="nf"&gt;RestoreAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// 2. ...and only then touch the database.&lt;/span&gt;
&lt;span class="kt"&gt;var&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;host&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;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="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="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="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NoteProps&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;Assembly&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;host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RunAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Persisting ships the whole file, so do not call it on every write. Sensible moments: after a&lt;br&gt;
meaningful user action, on a timer, on &lt;code&gt;beforeunload&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="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="nf"&gt;SaveAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;note&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;Persistence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PersistAsync&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="c1"&gt;// debounce this in a real app&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Everyday operations
&lt;/h2&gt;

&lt;p&gt;From here on it is identical on mobile and in the browser. Get the service from DI:&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;@inject&lt;/span&gt; &lt;span class="n"&gt;IRedbService&lt;/span&gt; &lt;span class="n"&gt;Redb&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Create
&lt;/h3&gt;

&lt;p&gt;An object is a &lt;code&gt;RedbObject&amp;lt;T&amp;gt;&lt;/code&gt; wrapper plus your data in &lt;code&gt;Props&lt;/code&gt;. The wrapper has service fields; the&lt;br&gt;
only one you need on day one is &lt;code&gt;name&lt;/code&gt;, a human-readable label.&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;note&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;RedbObject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;NoteProps&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;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Buy milk"&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="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;NoteProps&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Title&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Buy milk"&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;"And bread"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Priority&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="n"&gt;CreatedAt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"home"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"groceries"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;id&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="nf"&gt;SaveAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;note&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;SaveAsync&lt;/code&gt; hands back the id, and also stamps it onto the object, so &lt;code&gt;note.Id&lt;/code&gt; is populated after the&lt;br&gt;
call.&lt;/p&gt;

&lt;p&gt;Got several objects? Do not loop. The same method takes a collection and writes it in one batch,&lt;br&gt;
returning the ids:&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;notes&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;RedbObject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;NoteProps&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;note1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;note2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;note3&lt;/span&gt; &lt;span class="p"&gt;};&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="kt"&gt;long&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ids&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="nf"&gt;SaveAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Read by id
&lt;/h3&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;loaded&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;LoadAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;NoteProps&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;id&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;loaded&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;loaded&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;Title&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// "Buy milk"&lt;/span&gt;
    &lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;loaded&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;Tags&lt;/span&gt;&lt;span class="p"&gt;![&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;   &lt;span class="c1"&gt;// "home"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The object comes back whole, arrays and nested objects included. There is no "forgot to load the&lt;br&gt;
related data" failure mode here — properties are always there.&lt;/p&gt;

&lt;p&gt;If no object has that id you get &lt;code&gt;null&lt;/code&gt;, hence the check.&lt;/p&gt;
&lt;h3&gt;
  
  
  Update
&lt;/h3&gt;

&lt;p&gt;There is no separate &lt;code&gt;Update&lt;/code&gt;. Change the loaded object and save it again:&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;note&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;LoadAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;NoteProps&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;note&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;Priority&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;note&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;Body&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"And bread, and yogurt"&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="nf"&gt;SaveAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;PropsSaveStrategy.ChangeTracking&lt;/code&gt;, only those two properties hit the database.&lt;/p&gt;

&lt;h3&gt;
  
  
  Delete
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&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="nf"&gt;DeleteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Queries
&lt;/h3&gt;

&lt;p&gt;Plain LINQ. Conditions run in the database, not in memory:&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;// high-priority notes, newest first&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;important&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;NoteProps&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;n&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Priority&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OrderByDescending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreatedAt&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;span class="c1"&gt;// substring search&lt;/span&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;NoteProps&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;n&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Title&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;"milk"&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;span class="c1"&gt;// date range plus a compound condition&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;lastWeek&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddDays&lt;/span&gt;&lt;span class="p"&gt;(-&lt;/span&gt;&lt;span class="m"&gt;7&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;recent&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;NoteProps&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;n&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreatedAt&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;lastWeek&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Priority&lt;/span&gt; &lt;span class="p"&gt;&amp;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;ToListAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Paging, counts, existence checks:&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;page&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;NoteProps&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;OrderByDescending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreatedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Skip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;20&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;20&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;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;total&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;NoteProps&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;().&lt;/span&gt;&lt;span class="nf"&gt;CountAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;any&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;NoteProps&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;().&lt;/span&gt;&lt;span class="nf"&gt;AnyAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Priority&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you do not need the whole object, take just the fields you want — less data off disk:&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;titles&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;NoteProps&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;n&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Priority&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;n&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;Title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&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;CreatedAt&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;Worth noticing, because it catches everyone once: in &lt;code&gt;Where&lt;/code&gt; and &lt;code&gt;OrderBy&lt;/code&gt; you write properties&lt;br&gt;
directly — &lt;code&gt;n.Priority&lt;/code&gt; — while in &lt;code&gt;Select&lt;/code&gt; you go through &lt;code&gt;Props&lt;/code&gt;: &lt;code&gt;n.Props.Title&lt;/code&gt;. In a condition&lt;br&gt;
the parameter is your data; in a projection you get the whole object including service fields&lt;br&gt;
(&lt;code&gt;n.Id&lt;/code&gt;, &lt;code&gt;n.name&lt;/code&gt;), so &lt;code&gt;Props&lt;/code&gt; has to be explicit.&lt;/p&gt;
&lt;h3&gt;
  
  
  Arrays
&lt;/h3&gt;

&lt;p&gt;An array property is not a delimited string — you can query 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;home&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;NoteProps&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;n&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tags&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;"home"&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;h3&gt;
  
  
  Add a field
&lt;/h3&gt;

&lt;p&gt;The most common thing you will do as the app grows. Add the property:&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;class&lt;/span&gt; &lt;span class="nc"&gt;NoteProps&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...everything that was here&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;IsDone&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// new&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Done. The same &lt;code&gt;InitializeAsync&lt;/code&gt; at startup picks it up. No migration files, no &lt;code&gt;ALTER TABLE&lt;/code&gt;, and&lt;br&gt;
objects saved earlier keep loading — the new property simply comes back as its default.&lt;/p&gt;

&lt;p&gt;Compare that to the migration workflow: create a migration, review the generated SQL, think about the&lt;br&gt;
rollback, ship it to devices and hope it lands cleanly on data you have never seen. That step just&lt;br&gt;
does not exist here.&lt;/p&gt;
&lt;h2&gt;
  
  
  The outbox queue
&lt;/h2&gt;

&lt;p&gt;This is usually the whole reason a local database exists, so here it is end to end. The job: while&lt;br&gt;
there is no connection, changes pile up on the device; once it is back, they go out in order with a&lt;br&gt;
status you can show.&lt;/p&gt;

&lt;p&gt;The queue is a normal class. Note that it holds a &lt;strong&gt;typed payload with its own nested structure&lt;/strong&gt;, not&lt;br&gt;
a JSON 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="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;RedbScheme&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"OutboxEntry"&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;OutboxEntryProps&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Operation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// "order.create", "order.update"&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;OutboxState&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;           &lt;span class="c1"&gt;// Pending, Sending, Failed, Sent&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;Attempts&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt; &lt;span class="n"&gt;CreatedAt&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;LastTriedAt&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;LastError&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;OrderProps&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Payload&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;         &lt;span class="c1"&gt;// the whole graph&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enqueueing is just a save:&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;await&lt;/span&gt; &lt;span class="n"&gt;redb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveAsync&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;RedbObject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OutboxEntryProps&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;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;$"outbox &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&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="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;OutboxEntryProps&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Operation&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"order.create"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;State&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;OutboxState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;CreatedAt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Payload&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;          &lt;span class="c1"&gt;// the nested graph is stored with the entry&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;Flushing once you are back online. This is where querying earns its keep: picking the right entries is&lt;br&gt;
LINQ, not "load everything and filter in memory."&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;batch&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;OutboxEntryProps&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;State&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;OutboxState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pending&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;Attempts&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OrderBy&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;CreatedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// strictly in the order they appeared&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;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                           &lt;span class="c1"&gt;// in chunks, so you never stall&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;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;entry&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&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;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SendAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&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;Operation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entry&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;Payload&lt;/span&gt;&lt;span class="p"&gt;!);&lt;/span&gt;
        &lt;span class="n"&gt;entry&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;State&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;OutboxState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;entry&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;Attempts&lt;/span&gt;&lt;span class="p"&gt;++;&lt;/span&gt;
        &lt;span class="n"&gt;entry&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;State&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;OutboxState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Failed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;entry&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;LastError&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;Message&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;entry&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;LastTriedAt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// The whole batch in one call, not one write per entry.&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="nf"&gt;SaveAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line matters: &lt;code&gt;SaveAsync&lt;/code&gt; takes a collection and writes it as a batch. The loop keeps only&lt;br&gt;
what is genuinely one-at-a-time — the network call — and the results go to the database in a single&lt;br&gt;
trip. At twenty entries you will not notice. At two thousand you will.&lt;/p&gt;

&lt;p&gt;Showing the user what is happening is a query too, not a tally in a loop:&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;int&lt;/span&gt; &lt;span class="n"&gt;waiting&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;OutboxEntryProps&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;State&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;OutboxState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pending&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CountAsync&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;problems&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;OutboxEntryProps&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;Attempts&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToListAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Try the same on "key → JSON": finding stuck entries means reading the entire queue, deserializing&lt;br&gt;
every item and looping. And renaming a field means praying the old records still parse.&lt;/p&gt;
&lt;h2&gt;
  
  
  Making queries fast: filter on the base fields
&lt;/h2&gt;

&lt;p&gt;A habit worth picking up on day one rather than when the list starts crawling.&lt;/p&gt;

&lt;p&gt;Besides your &lt;code&gt;Props&lt;/code&gt;, every object has the &lt;code&gt;RedbObject&lt;/code&gt;'s own fields: &lt;code&gt;Id&lt;/code&gt;, &lt;code&gt;ParentId&lt;/code&gt;, &lt;code&gt;DateCreate&lt;/code&gt;,&lt;br&gt;
plus fast slots — &lt;code&gt;value_string&lt;/code&gt;, &lt;code&gt;value_long&lt;/code&gt;, &lt;code&gt;value_datetime&lt;/code&gt; and friends. They live right in the&lt;br&gt;
object's row, which makes filtering on them the cheapest thing available: it narrows the set &lt;strong&gt;before&lt;/strong&gt;&lt;br&gt;
properties come into play.&lt;/p&gt;

&lt;p&gt;You filter them with &lt;code&gt;WhereRedb&lt;/code&gt;, which composes happily with regular &lt;code&gt;Where&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The idea is simple: whatever you look things up by most often — a situation key, an external id, a&lt;br&gt;
timestamp — put it in a fast slot as well as in &lt;code&gt;Props&lt;/code&gt;. Then fetching state for a given key becomes&lt;br&gt;
an indexed hit.&lt;/p&gt;

&lt;p&gt;Writing — fill the slots along with the data:&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;key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;$"&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;stateType&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// "order-draft:12345"&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="nf"&gt;SaveAsync&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;RedbObject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DraftStateProps&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;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;$"Draft &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="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;value_string&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="c1"&gt;// the situation key&lt;/span&gt;
    &lt;span class="n"&gt;value_long&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                 &lt;span class="c1"&gt;// external id&lt;/span&gt;
    &lt;span class="n"&gt;value_datetime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// timestamp&lt;/span&gt;
    &lt;span class="n"&gt;Props&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;DraftStateProps&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&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;Reading — narrow by slot first, refine by properties after:&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;// state for a specific key — a hit, not a scan&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;draft&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;DraftStateProps&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;WhereRedb&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;ValueString&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="nf"&gt;FirstOrDefaultAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// everything belonging to a document&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;byDocument&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;DraftStateProps&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;WhereRedb&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;ValueLong&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;documentId&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;span class="c1"&gt;// cut by date: clean out what went stale&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddDays&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;stale&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;DraftStateProps&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;WhereRedb&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;ValueDatetime&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;threshold&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;span class="c1"&gt;// cheap slot filter first, property condition second&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;actual&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;OutboxEntryProps&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;WhereRedb&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;ValueDatetime&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&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;State&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;OutboxState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pending&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;Grouping is &lt;code&gt;ParentId&lt;/code&gt;. Worth being precise here: this is &lt;strong&gt;not just a number, it is a real foreign&lt;br&gt;
key to another object in the same database&lt;/strong&gt; — to its &lt;code&gt;Id&lt;/code&gt;. You cannot stash an identifier from some&lt;br&gt;
other system in it; that is what &lt;code&gt;value_long&lt;/code&gt; is for. What you get in return is database-level&lt;br&gt;
integrity and cascade: delete the parent and the children go with it, no manual cleanup.&lt;/p&gt;

&lt;p&gt;So the parent has to exist: save the session object (or document, or route) first, take its &lt;code&gt;Id&lt;/code&gt;, then&lt;br&gt;
put that &lt;code&gt;Id&lt;/code&gt; into &lt;code&gt;parent_id&lt;/code&gt; on the children. After which fetching the whole group is one 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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;groupItems&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;DraftStateProps&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;WhereRedb&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;ParentId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;sessionId&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;span class="c1"&gt;// or several groups at once&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;manyGroups&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;DraftStateProps&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;WhereRedb&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;ParentId&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;sessionIds&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="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParentId&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="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;h3&gt;
  
  
  Many-to-many without a join table
&lt;/h3&gt;

&lt;p&gt;The same trick removes the reason you would normally introduce a junction table. Say you track&lt;br&gt;
membership: users belong to groups, a document belongs to several categories.&lt;/p&gt;

&lt;p&gt;Store the link as an object and fill two slots at once: &lt;code&gt;ParentId&lt;/code&gt; for one side, &lt;code&gt;value_long&lt;/code&gt; for the&lt;br&gt;
other. Now &lt;strong&gt;both directions are a single indexed query with no JOIN&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="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="nf"&gt;SaveAsync&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;RedbObject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MembershipProps&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;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;$"member &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// one side of the link is the parent&lt;/span&gt;
    &lt;span class="n"&gt;parent_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;groupId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// the other goes into a fast slot&lt;/span&gt;
    &lt;span class="n"&gt;value_long&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// and into key: a unique index on it stops the same link&lt;/span&gt;
    &lt;span class="c1"&gt;// from being written twice&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userId&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="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;MembershipProps&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;AssignedAt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// everyone in the group&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;members&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;MembershipProps&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;WhereRedb&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;ParentId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;groupId&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;span class="c1"&gt;// every group a user belongs to — the reverse query, also JOIN-free&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;groups&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;MembershipProps&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;WhereRedb&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;ValueLong&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;userId&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;This is not invented for the article: it is exactly how role assignments work in RedBase Identity,&lt;br&gt;
where the link class carries a comment stating that &lt;code&gt;parent_id&lt;/code&gt; points at the role, &lt;code&gt;value_long&lt;/code&gt;&lt;br&gt;
mirrors the user id for the reverse lookup, and &lt;code&gt;key&lt;/code&gt; provides the unique index that makes assigning a&lt;br&gt;
role idempotent without a separate existence check. Adopt the convention in your own classes early and&lt;br&gt;
you will not be rewriting queries later.&lt;/p&gt;

&lt;p&gt;Small gotcha: you write the lowercase names (&lt;code&gt;value_string&lt;/code&gt;, &lt;code&gt;parent_id&lt;/code&gt;, &lt;code&gt;key&lt;/code&gt;) and read the&lt;br&gt;
PascalCase ones in &lt;code&gt;WhereRedb&lt;/code&gt; (&lt;code&gt;o.ValueString&lt;/code&gt;, &lt;code&gt;o.ParentId&lt;/code&gt;, &lt;code&gt;o.Key&lt;/code&gt;). Same fields.&lt;/p&gt;
&lt;h3&gt;
  
  
  While we are here: trees
&lt;/h3&gt;

&lt;p&gt;Since &lt;code&gt;ParentId&lt;/code&gt; is a link to another object, objects naturally form a hierarchy — and you do not have&lt;br&gt;
to hand-roll it. There is an API for it: load a whole subtree, take direct children only, build the&lt;br&gt;
path to the root for breadcrumbs, move a node with everything under it, ask whether A is a descendant&lt;br&gt;
of B, walk depth-first or breadth-first, select roots only, leaves only, or filter by depth.&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;// the whole branch in one query&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;subtree&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;TreeQuery&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CategoryProps&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;rootId&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;span class="c1"&gt;// moving a node — children come along&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="nf"&gt;MoveObjectAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;newParent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a client that is usually a catalogue, a folder tree, an org chart or threaded comments — the stuff&lt;br&gt;
you would otherwise assemble with hand-written recursive queries.&lt;/p&gt;
&lt;h3&gt;
  
  
  And there is quite a bit more
&lt;/h3&gt;

&lt;p&gt;Not to turn this into a reference: beyond what is shown here, RedBase has aggregations and &lt;code&gt;GroupBy&lt;/code&gt;,&lt;br&gt;
window functions, lookup lists, polymorphic queries over a class hierarchy, soft delete with&lt;br&gt;
background purge, built-in audit fields (who changed what, when), object ownership and permissions,&lt;br&gt;
and database export/import. All of it behaves the same across all three providers — client-side SQLite&lt;br&gt;
included.&lt;/p&gt;

&lt;p&gt;The repository ships a &lt;code&gt;redb.Examples&lt;/code&gt; project with &lt;strong&gt;148 runnable examples&lt;/strong&gt; grouped by topic:&lt;br&gt;
queries, analytics, trees, lists, CRUD. Fastest way to see how a specific thing is done without&lt;br&gt;
reading the docs end to end.&lt;/p&gt;
&lt;h2&gt;
  
  
  Syncing with a server that also runs RedBase
&lt;/h2&gt;

&lt;p&gt;A pleasant side effect: RedBase is not only SQLite. The same classes run on a server over PostgreSQL&lt;br&gt;
or SQL Server. Put the schemas in a shared project that both the client and the backend reference, and&lt;br&gt;
the data model becomes literally one model for the whole system.&lt;/p&gt;

&lt;p&gt;What changes in practice: there is &lt;strong&gt;no translation layer&lt;/strong&gt; between client and server. No DTOs, no&lt;br&gt;
mappers, no separate "sync contract" you have to edit on both sides every time a field changes. The&lt;br&gt;
object you pulled out of the local database is the same type the server puts into its own:&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;// client: pulled from the queue&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;...;&lt;/span&gt;

&lt;span class="c1"&gt;// server: received the same type and saved it&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="nf"&gt;SaveAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a field to the shared class and it appears in the local database, in the server database, and in&lt;br&gt;
what goes over the wire. No keeping three places in sync, no matching a server migration to a client&lt;br&gt;
version.&lt;/p&gt;
&lt;h2&gt;
  
  
  How it compares
&lt;/h2&gt;

&lt;p&gt;Comparing in one specific role: private local storage for a client app. Not a server database, not an&lt;br&gt;
analytics warehouse — the thing that sits on a user's device.&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;EF Core + SQLite&lt;/th&gt;
&lt;th&gt;Key → JSON&lt;/th&gt;
&lt;th&gt;RedBase&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Schema for a new kind of state&lt;/td&gt;
&lt;td&gt;entity + migration&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hundreds of state classes&lt;/td&gt;
&lt;td&gt;hundreds of tables and migrations&lt;/td&gt;
&lt;td&gt;one table, no types&lt;/td&gt;
&lt;td&gt;tag with &lt;code&gt;[RedbScheme]&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App update&lt;/td&gt;
&lt;td&gt;migration runs on the user's device&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;nothing to migrate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nested graph&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Include&lt;/code&gt; / &lt;code&gt;ThenInclude&lt;/code&gt; per level&lt;/td&gt;
&lt;td&gt;all at once, as a blob&lt;/td&gt;
&lt;td&gt;all at once, in one line&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Querying nested fields&lt;/td&gt;
&lt;td&gt;JOINs&lt;/td&gt;
&lt;td&gt;not possible, memory scan only&lt;/td&gt;
&lt;td&gt;LINQ down to SQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strong typing&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;gone&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Raw SQL when you want it&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rows worth expanding on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Migrations.&lt;/strong&gt; On a server a migration is a controlled procedure: you apply it, watch it, roll it&lt;br&gt;
back. On a client it ships to someone else's device and runs against data you have never seen. The&lt;br&gt;
more your local state churns — and it churns more than server entities do — the more often you roll&lt;br&gt;
those dice. That step is simply absent here: a new property appears on its own, old objects keep&lt;br&gt;
loading.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The graph.&lt;/strong&gt; With EF, completeness is your job on every single query: miss an &lt;code&gt;Include&lt;/code&gt; and you get&lt;br&gt;
an empty collection instead of data; add too many and you drag half the database into memory. On a&lt;br&gt;
client, where you almost always want the whole graph (the user opened a draft), that is a daily tax.&lt;br&gt;
&lt;code&gt;LoadAsync&lt;/code&gt; returns the object assembled.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Typing vs JSON.&lt;/strong&gt; The "key → JSON" route wins exactly once — on day one. After that: rename a field&lt;br&gt;
and lose data silently; need a search and enjoy your full scan; want to eyeball what is actually&lt;br&gt;
stored and good luck. RedBase gives you the same "just save the object" feeling, but the properties&lt;br&gt;
sit in typed columns and take part in queries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Raw SQL.&lt;/strong&gt; Worth stating plainly, because the question comes up immediately: what if I want a flat&lt;br&gt;
table for trends or aggregates? Nobody took it away — the same context runs arbitrary SQL, including&lt;br&gt;
against your own tables:&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;@inject&lt;/span&gt; &lt;span class="n"&gt;IRedbContext&lt;/span&gt; &lt;span class="n"&gt;Context&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;total&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="n"&gt;ExecuteScalarAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;long&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"SELECT COUNT(*) FROM my_metrics WHERE bucket = '2026-08'"&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;ExecuteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"CREATE TABLE IF NOT EXISTS my_metrics (bucket TEXT, value REAL)"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So it is not "objects or SQL" — it is objects by default, SQL where SQL fits better.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When sticking with EF Core makes more sense.&lt;/strong&gt; If the app already has an EF model shared with the&lt;br&gt;
server and there is no reason to rewrite it. Or if the local database has to have a specific physical&lt;br&gt;
schema because something other than your app reads it. Outside of those, you are paying in migrations&lt;br&gt;
and &lt;code&gt;Include&lt;/code&gt; for a schema nobody outside will ever see.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about MongoDB?&lt;/strong&gt; There is no MongoDB on a client — it is a server. If what appeals to you is&lt;br&gt;
the document model ("just save the object"), RedBase gives you that feeling on top of ordinary SQLite:&lt;br&gt;
transactions, strong typing, and LINQ instead of a bespoke query language. Local document stores like&lt;br&gt;
LiteDB are closer in spirit, but there you are back to choosing between "I store documents" and "I can&lt;br&gt;
search."&lt;/p&gt;

&lt;h2&gt;
  
  
  Things worth knowing upfront
&lt;/h2&gt;

&lt;p&gt;A few things that are better learned here than in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SQLite is single-writer.&lt;/strong&gt; That is SQLite, not the wrapper. Rarely an issue for a client app, but if&lt;br&gt;
you plan to write from several threads, keep transactions short.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One tab per database in the browser.&lt;/strong&gt; Two tabs are two independent instances of your app, each with&lt;br&gt;
its own copy of the file in memory. Last one to save wins. If you need multi-tab, coordinate through&lt;br&gt;
&lt;code&gt;BroadcastChannel&lt;/code&gt; or lock the second tab out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The browser is single-threaded.&lt;/strong&gt; A heavy query freezes the UI, so do not pull everything onto the&lt;br&gt;
page: &lt;code&gt;Take&lt;/code&gt; and paging are about responsiveness here, not aesthetics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Download size.&lt;/strong&gt; The managed assemblies come to roughly two megabytes, plus SQLite inside the&lt;br&gt;
runtime. Fine for internal tools and offline-first apps, not for a landing page. Brotli is mandatory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First launch in the browser takes a second or two&lt;/strong&gt; while the database structure is created. Show a&lt;br&gt;
spinner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exact money arithmetic.&lt;/strong&gt; &lt;code&gt;decimal&lt;/code&gt; is stored approximately in SQLite. If you need to-the-cent&lt;br&gt;
precision, that is a SQLite constraint rather than a wrapper one — factor it into your choice of&lt;br&gt;
storage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;On mobile it comes down to three things: install the package, point it at a file in the app's data&lt;br&gt;
directory, and call initialization once. After that it is ordinary C# with LINQ.&lt;/p&gt;

&lt;p&gt;In the browser you add three more: &lt;code&gt;wasm-tools&lt;/code&gt; with a build flag, the same manual initialization, and&lt;br&gt;
your own IndexedDB persistence layer — where the things to remember are "move both database files" and&lt;br&gt;
"never use the &lt;code&gt;TRUNCATE&lt;/code&gt; checkpoint."&lt;/p&gt;

&lt;p&gt;What you get for that is one data model across both clients, and queries instead of looping over&lt;br&gt;
collections in memory.&lt;/p&gt;

&lt;p&gt;Docs and examples: &lt;a href="https://redbase.app" rel="noopener noreferrer"&gt;redbase.app&lt;/a&gt;. Sources, templates and the issue tracker:&lt;br&gt;
&lt;a href="https://github.com/redbase-app/redb" rel="noopener noreferrer"&gt;github.com/redbase-app/redb&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;More of my writing: &lt;a href="https://dev.to/rinat_kozin"&gt;dev.to/rinat_kozin&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If this was useful — a ⭐ on GitHub helps others find it.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>webdev</category>
      <category>mobile</category>
      <category>sqlite</category>
    </item>
    <item>
      <title>A TMS backend, architecturally: cluster, coordinate streams and a typed object store</title>
      <dc:creator>rinat kozin</dc:creator>
      <pubDate>Thu, 30 Jul 2026 22:07:45 +0000</pubDate>
      <link>https://dev.to/rinat_kozin/a-tms-backend-architecturally-cluster-coordinate-streams-and-a-typed-object-store-n8f</link>
      <guid>https://dev.to/rinat_kozin/a-tms-backend-architecturally-cluster-coordinate-streams-and-a-typed-object-store-n8f</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%2Fnm60wylnwr8smoczvcjx.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%2Fnm60wylnwr8smoczvcjx.png" alt="TMS on redb" width="799" height="447"&gt;&lt;/a&gt;&lt;br&gt;
This is a walkthrough of one system's architecture — no timelines, no budgets, no feature-matrix comparisons. Just how it is built: what the pieces are, how they spread across a cluster, where the data goes and why it goes that way.&lt;/p&gt;

&lt;p&gt;The subject, briefly. It's a &lt;strong&gt;transport management system&lt;/strong&gt;. Orders arrive from SAP; a driver and a vehicle get matched against requirements for both the vehicle and the stops. A trip runs through waypoints with time windows, checklists at each stop, and handover reports with damage records when a vehicle goes out and comes back. On top of that: a stream of GPS coordinates, tracking for trips and places, a mobile app for drivers, and a public zone where the customer watches their delivery.&lt;/p&gt;

&lt;p&gt;Fifteen modules. Three application nodes behind a balancer, RabbitMQ and Kafka three nodes each, PostgreSQL, and Redis with six.&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%2F651bhvbapaf2h64kpr00.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%2F651bhvbapaf2h64kpr00.png" alt="the whole thing: actors, module cluster, stores, the SAP bus, the public zone" width="800" height="615"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Standalone read — the earlier posts aren't required. If the economics interest you more than the wiring, they're covered separately: &lt;a href="https://dev.to/rinat_kozin/what-an-integration-layer-really-costs-a-tms-backend-counted-line-by-line-dl6"&gt;what this stack costs to keep&lt;/a&gt; and &lt;a href="https://dev.to/rinat_kozin/we-ported-a-module-off-an-esb-and-the-code-grew-17x-that-was-the-right-outcome-1acd"&gt;what changed when one module moved off the ESB&lt;/a&gt;. This one is only about the wiring.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What the stack is made of
&lt;/h2&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%2F8gfo5b7g7uhgc79im8m8.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%2F8gfo5b7g7uhgc79im8m8.png" alt="the four layers and how each one enters the system" width="800" height="555"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Four levels, split on one principle: &lt;strong&gt;your own code lives only at the top&lt;/strong&gt;, everything below arrives as a dependency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A module&lt;/strong&gt; is what you write in the project: route declarations, business rules, entities carrying schema attributes. Its entry point registers transport components, named connection factories and route builders. It ships as a package, lands in a directory, and the container picks it up live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;redb.Route&lt;/strong&gt; is the integration engine — roughly forty patterns from the enterprise integration catalogue, 27 transports outward and four channels inward. It arrives as NuGet packages; if you need to go inside, you can reference the sources instead and step through with a debugger.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;redb.Tsak&lt;/strong&gt; is the module container and runtime: lifecycle, isolation, hot-swap, clustering with leader election, a dashboard and REST API, a scheduler, a route watchdog, a dead-letter queue. It deploys as a container image or a signed archive and is configured through environment variables.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;redb&lt;/strong&gt; is the typed store. Schema derives from classes; there are no migrations. Two doors: an object-level one for business entities, and direct SQL for what deliberately does not go into the object model.&lt;/p&gt;

&lt;p&gt;The storage provider is chosen by the host — the module code never names it. PostgreSQL, MS SQL or SQLite, same schemas and same contracts.&lt;/p&gt;




&lt;h2&gt;
  
  
  The unit of composition is a context, not a service
&lt;/h2&gt;

&lt;p&gt;Getting the scale right matters here. The deployment unit in this system is &lt;strong&gt;not a microservice and not a process&lt;/strong&gt; — it's a module with its own route context.&lt;/p&gt;

&lt;p&gt;A module is an assembly with an entry point that receives a context and fills it: transport components, named connection factories into a registry, services, sets of routes. The container then starts the context and runs the routes.&lt;/p&gt;

&lt;p&gt;A context gives you three things at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;an isolation boundary&lt;/strong&gt; — each module gets its own assembly load context, so neighbours' dependencies don't collide;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;a unit of distribution&lt;/strong&gt; — contexts spread across cluster nodes and migrate between them;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;a unit of control&lt;/strong&gt; — a context can be stopped, started and restarted independently of the others.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Hence the practical consequence the whole design exists for: &lt;strong&gt;shipping one integration doesn't disturb the rest.&lt;/strong&gt; The module package lands in a directory, the container picks it up and restarts only that context. Neighbours keep running; their in-flight messages survive.&lt;/p&gt;




&lt;h2&gt;
  
  
  Physical topology
&lt;/h2&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%2Frftf7t49c2fvykncb94q.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%2Frftf7t49c2fvykncb94q.png" alt="the cluster in full" width="799" height="484"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three application nodes behind a balancer. Node coordination runs through the shared database: leader election, route locks, redistribution of contexts when a node fails or is drained on purpose.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A word on responsibility boundaries, because one runs straight through here. The architecture and the application layer are mine. &lt;strong&gt;Deploying and operating the infrastructure is the DevOps engineers' territory:&lt;/strong&gt; nodes, broker clusters, the database cluster, build pipelines, delivering secrets into the runtime.&lt;/p&gt;

&lt;p&gt;That's not a formality, it's a working split. The architecture &lt;strong&gt;states requirements to the infrastructure&lt;/strong&gt; — three Redis masters so a quorum exists; Kafka partitions so a consumer group can spread across nodes; database replicas. Meeting those requirements isn't my job. So what follows describes &lt;strong&gt;what&lt;/strong&gt; the system needs and why, not how it's rolled out: that's someone else's work and someone else's decisions.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Each infrastructure component is clustered &lt;strong&gt;separately and on its own terms&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RabbitMQ — three nodes.&lt;/strong&gt; Audit events, SAP integration, request-reply exchanges, outbound traffic. The public zone has its own separate broker; more on that below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kafka — three nodes.&lt;/strong&gt; The GPS coordinate stream and the unplanned-stops topic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PostgreSQL — three nodes&lt;/strong&gt;, primary and replicas. It holds the object store, the partitioned tables, and the application cluster's coordination state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Redis — six nodes.&lt;/strong&gt; That one is worth explaining, because six isn't superstition.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Redis is exactly six
&lt;/h3&gt;

&lt;p&gt;Redis cluster mode requires &lt;strong&gt;at least three masters&lt;/strong&gt;. The reason is quorum: the decision that a master has failed and a replica should take over is made by a majority of masters. With two masters a majority is unreachable — one node cannot be a majority of two — so automatic failover is impossible in principle.&lt;/p&gt;

&lt;p&gt;Three masters give a two-of-three majority. Each needs a replica, otherwise losing a master means losing its slots along with the data.&lt;/p&gt;

&lt;p&gt;Three masters plus three replicas is six. Fewer than that and you either have no automatic failover or no data redundancy.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two zones
&lt;/h2&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%2Fs83yirwzwhe33t90nnhe.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%2Fs83yirwzwhe33t90nnhe.png" alt="internal and public zones, one-way link" width="800" height="471"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The system is cut not only into modules but into &lt;strong&gt;zones with different trust levels&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The internal zone&lt;/strong&gt; holds the system's verticals, the store, the brokers and the cache. Drivers work here through the mobile app, operators and administrators through the control panel — each with their own facade and their own permissions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The public zone&lt;/strong&gt; sits outside the company perimeter, in the cloud. It has &lt;strong&gt;its own database, its own broker, its own cache&lt;/strong&gt; — nothing shared with the internal infrastructure. Customers work here: they look up where their delivery is.&lt;/p&gt;

&lt;p&gt;And the key property: &lt;strong&gt;there are no return connections.&lt;/strong&gt; The company backend reaches into the zone and writes data. From inside the zone outward is forbidden.&lt;/p&gt;

&lt;p&gt;From which follows an architectural requirement that would otherwise look arbitrary: &lt;strong&gt;access validation inside the public zone must be local.&lt;/strong&gt; You cannot ask the company whether a key is valid — no return connection exists. So the check is self-contained and runs on data already present in the zone.&lt;/p&gt;

&lt;p&gt;Separately about the internal zone's external boundaries. &lt;strong&gt;The SAP bus&lt;/strong&gt; is not our layer but someone else's system: orders, drivers, places and counterparties arrive from it. External routing is the source of planned geometry. Both stay put through any change inside — they're boundaries, not parts.&lt;/p&gt;




&lt;h2&gt;
  
  
  Transports and channels
&lt;/h2&gt;

&lt;p&gt;The engine distinguishes two fundamentally different kinds of connection, and it pays to keep them apart on any diagram.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Outward — 27 transports.&lt;/strong&gt; In catalogue terms that's the Channel Adapter: brokers (RabbitMQ, Kafka, AMQP, IBM MQ, Azure Service Bus, SQS, MQTT), protocols (HTTP, gRPC, TCP, WebSocket, SignalR), files (File, FTP, SFTP, S3), data (SQL, Redis, Elasticsearch), plus mail, LDAP, Telegram, a scheduler and process execution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inward — four channels&lt;/strong&gt;, each solving a different problem:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Channel&lt;/th&gt;
&lt;th&gt;Semantics&lt;/th&gt;
&lt;th&gt;When you need it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;direct&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;synchronous, inside a route&lt;/td&gt;
&lt;td&gt;factor out a segment without changing control flow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;direct-vm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;synchronous, &lt;strong&gt;across contexts&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;call a neighbouring module with no network and no serialisation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;asynchronous, across contexts&lt;/td&gt;
&lt;td&gt;decouple modules without standing up a broker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;seda&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;asynchronous in-memory queue&lt;/td&gt;
&lt;td&gt;separate processing stages with backpressure&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last one deserves a note. &lt;code&gt;seda&lt;/code&gt; is a bounded queue with several competing consumers and an enqueue timeout. Need to decouple fast intake from slow processing? Put one between them and set a worker count. No broker required — and the queue being bounded gives you natural backpressure instead of unbounded growth in memory.&lt;/p&gt;

&lt;p&gt;In catalogue terms that's a Point-to-Point Channel together with Competing Consumers: a pattern closed by a kernel primitive rather than external infrastructure.&lt;/p&gt;




&lt;h2&gt;
  
  
  The high-frequency stream: where the object model ends
&lt;/h2&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%2Fs0bdu6hozcg7ildrc98q.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%2Fs0bdu6hozcg7ildrc98q.png" alt="one source, several aggregators, plus the audit road" width="799" height="542"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here the architecture makes a deliberate exception, and it deserves to be stated plainly.&lt;/p&gt;

&lt;p&gt;Business entities — trips, waypoints, orders, drivers, vehicles — live in the object store, load as a graph in one call, and derive their schema from classes. But &lt;strong&gt;coordinates and audit events do not go there.&lt;/strong&gt; They have their own road: partitioned tables and direct SQL.&lt;/p&gt;

&lt;p&gt;The reason is simple: these are streams of a different order of frequency. The object model is optimised for a typed, linked graph; here you need bulk insertion of tens of thousands of uniform rows and later deletion by whole months. Different jobs, different tools — and forcing one onto the other ends badly for both.&lt;/p&gt;

&lt;h3&gt;
  
  
  One source, several aggregators
&lt;/h3&gt;

&lt;p&gt;Coordinates arrive on Kafka. And this is where the interesting part starts: &lt;strong&gt;one stream carries several aggregation stages with different correlation keys and different completion rules.&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;Stage&lt;/th&gt;
&lt;th&gt;Correlation key&lt;/th&gt;
&lt;th&gt;Completion&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Persistence&lt;/td&gt;
&lt;td&gt;trip&lt;/td&gt;
&lt;td&gt;batch size or timeout&lt;/td&gt;
&lt;td&gt;one bulk insert instead of a thousand singles&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unplanned stops&lt;/td&gt;
&lt;td&gt;trip and vehicle&lt;/td&gt;
&lt;td&gt;dwell time window&lt;/td&gt;
&lt;td&gt;a stop event&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stop grouping&lt;/td&gt;
&lt;td&gt;stop&lt;/td&gt;
&lt;td&gt;end of a series&lt;/td&gt;
&lt;td&gt;a coherent series instead of scattered points&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Point deduplication&lt;/td&gt;
&lt;td&gt;point&lt;/td&gt;
&lt;td&gt;window&lt;/td&gt;
&lt;td&gt;a stream without repeats&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Plus point metrics, arrival and departure detection, and driver position broadcast.&lt;/p&gt;

&lt;p&gt;That's the Aggregator pattern from the catalogue, applied several times with different settings. And it has one property that matters: a group completes &lt;strong&gt;on size or on timeout&lt;/strong&gt;. Without the timeout, the last partial batch would sit in memory until the next message — which on a sparse stream means data not being persisted for hours.&lt;/p&gt;

&lt;h3&gt;
  
  
  How the stream scales across nodes
&lt;/h3&gt;

&lt;p&gt;Kafka's own mechanism does the work here, and the code carries an explicit comment about it: &lt;strong&gt;do not pin a partition in the endpoint address&lt;/strong&gt;, so the consumer-group mechanism engages.&lt;/p&gt;

&lt;p&gt;The point is that Kafka itself distributes a topic's partitions among consumers sharing a group id. A node appears — partitions rebalance toward it. A node drops — its partitions spread across the survivors. No coordinator of your own to write.&lt;/p&gt;

&lt;p&gt;In catalogue terms that's Competing Consumers, implemented at the broker rather than in the application.&lt;/p&gt;

&lt;h3&gt;
  
  
  The consequence everything else grows from
&lt;/h3&gt;

&lt;p&gt;And now the crux. Partitions migrate between consumers. Contexts migrate between nodes. Which means &lt;strong&gt;coordinates for one and the same trip may be processed by any node in the cluster&lt;/strong&gt;, and not necessarily the one that handled them a minute ago.&lt;/p&gt;

&lt;p&gt;Hence a hard requirement: &lt;strong&gt;GPS processing state cannot live in a node's memory.&lt;/strong&gt; It's shared, in Redis. Trip-point cache, accumulated processing state, planned and actual geometry. Anything needed to continue working on a trip has to be reachable from any node.&lt;/p&gt;

&lt;p&gt;That's exactly why the system has a dedicated shared-cache manager rather than a dictionary in process memory.&lt;/p&gt;




&lt;h2&gt;
  
  
  Planned route and actual route
&lt;/h2&gt;

&lt;p&gt;A trip has two geometries, and they're produced in completely different ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The planned one&lt;/strong&gt; comes from external routing — how the trip is supposed to run. It's built once: on the first GPS point, or when an operator saves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The actual one&lt;/strong&gt; accumulates from the coordinate stream — how the trip really went. It has a lot of points, so it gets simplified with the Ramer–Douglas–Peucker algorithm: the line keeps its shape while the point count drops by an order of magnitude.&lt;/p&gt;

&lt;p&gt;Both live in the shared cache, because both are needed by any node.&lt;/p&gt;

&lt;h3&gt;
  
  
  The broker carries a key, not geometry
&lt;/h3&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%2Fte9g824euvmy38p1acse.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%2Fte9g824euvmy38p1acse.png" alt="Claim Check and the three-level read fallback" width="799" height="434"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Route geometry is a large object. Pushing it through a broker means loading the queues, inflating consumer memory and eventually hitting the message-size limit.&lt;/p&gt;

&lt;p&gt;So it's done differently: &lt;strong&gt;the geometry stays in the store and the cache, and the message carries only a key.&lt;/strong&gt; The consumer takes the key and reads the data itself — when it actually needs it.&lt;/p&gt;

&lt;p&gt;In catalogue terms that's the Claim Check: a reference in the message, the payload stored separately. An old and well-known trick, which systems nonetheless keep reinventing after first getting burned by large messages.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reads fall back through three levels
&lt;/h3&gt;

&lt;p&gt;When a planned route is requested, the lookup walks a chain:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Redis&lt;/strong&gt; — the cluster-wide cache. A hit answers immediately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL&lt;/strong&gt; — the geometry is persisted and survives cache eviction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External routing&lt;/strong&gt; — the last resort: a paid, slow call.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And the reverse chain: got it from the external service, save to the database, put in the cache, return. The next request for the same route is served from cache.&lt;/p&gt;

&lt;p&gt;Net effect: &lt;strong&gt;the external call happens once per route, not once per request.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Audit: one event, two roads
&lt;/h2&gt;

&lt;p&gt;Audit here isn't "logs just in case" but a proper layer with its own architecture. And it's built so that a single data change produces &lt;strong&gt;two independent flows&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Road one — into the store.&lt;/strong&gt; An entity change is intercepted, the audit event lands in a queue, a background collector assembles a batch from it and performs a bulk insert into a partitioned table. The batch completes &lt;strong&gt;on size or on interval&lt;/strong&gt; — both parameters live in configuration, not in code.&lt;/p&gt;

&lt;p&gt;Note that this is the same pattern as on the coordinate stream. Different data, different frequency, different keys — one mechanism.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Road two — to subscribers.&lt;/strong&gt; A copy of the event goes out through the broker to whoever needs it. Today that's at least two: GPS-zone cache refresh, and outbound exchange to external systems.&lt;/p&gt;

&lt;p&gt;Crucially, &lt;strong&gt;the main flow does not wait for subscribers.&lt;/strong&gt; The change persists at its own pace; the fan-out runs in parallel and its latency doesn't affect the write. In catalogue terms that's a Wire Tap plus a Publish-Subscribe Channel.&lt;/p&gt;

&lt;p&gt;That construction delivers the thing audit layers are built for in the first place: &lt;strong&gt;any system can subscribe to changes without touching the one producing them.&lt;/strong&gt; A new consumer of events shows up as a subscriber, and the source code doesn't change at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  Partition maintenance
&lt;/h3&gt;

&lt;p&gt;Partitioned tables need new sections created regularly and old ones dropped. That runs on a schedule, as scheduler jobs — and the scheduler is built into the container and cluster-aware, so the job doesn't fire three times on three nodes.&lt;/p&gt;

&lt;p&gt;A detail people usually remember at the exact moment next month's partition failed to appear.&lt;/p&gt;




&lt;h2&gt;
  
  
  Observability
&lt;/h2&gt;

&lt;p&gt;Once a system is spread over three nodes and several brokers, "where exactly is it slow" stops being answerable by reading logs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Metrics are defined per pattern, not per engine.&lt;/strong&gt; The aggregator reports groups completed, groups in flight and groups that fell apart on timeout. The splitter — how many parts. The filter — how many dropped. The idempotent receiver — how many passed and how many were rejected as duplicates. The circuit breaker — times tripped and calls rejected. And so on through the catalogue.&lt;/p&gt;

&lt;p&gt;Plus end-to-end: processed, failed, currently in flight, and duration histograms for the whole exchange and for each route step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tracing follows OpenTelemetry&lt;/strong&gt;, with the conventional attribute names: messaging system, destination name, operation, HTTP method, database type. Exceptions decompose into the standard fields. On top of those, its own: correlation id, exchange pattern, route, step, endpoint.&lt;/p&gt;

&lt;p&gt;Practical consequence: &lt;strong&gt;trace collectors read this with no adapter.&lt;/strong&gt; The chain "consumed from Kafka → aggregated → written as a batch → fanned out to subscribers" shows up as one trace with every step and duration.&lt;/p&gt;

&lt;h3&gt;
  
  
  The dashboard is a control plane, not a display
&lt;/h3&gt;

&lt;p&gt;Eleven pages: overview, routes and single-route view, endpoints, cluster and node detail, scheduler, logs, audit, dead-letter queue, watchdog, access control.&lt;/p&gt;

&lt;p&gt;And you &lt;strong&gt;act&lt;/strong&gt; from them, not just look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an individual route — stop and start it without touching the others;&lt;/li&gt;
&lt;li&gt;a whole context — stop, start, restart;&lt;/li&gt;
&lt;li&gt;a hung route — force-stop it;&lt;/li&gt;
&lt;li&gt;a node — drain it gracefully: it finishes what it holds, takes no new work, hands its locks to peers; then bring it back;&lt;/li&gt;
&lt;li&gt;a failed exchange — inspect and replay after a fix;&lt;/li&gt;
&lt;li&gt;a scheduled job — fire it immediately;&lt;/li&gt;
&lt;li&gt;a node's effective configuration — read it with secrets redacted, without SSH.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next to each route are its own numbers: processed, currently in flight, throughput, history. Not one process-wide graph but per route — because in operations the question is always "which integration is stuck."&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the boundaries run
&lt;/h2&gt;

&lt;p&gt;More useful than any feature list is the set of places where the architecture deliberately departs from its own rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Partitioned tables bypass the object model.&lt;/strong&gt; Coordinates and audit events are written with direct SQL. A conscious choice, not an omission: streams at that frequency have different economics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Planned geometry is stored as a large object, not a graph of points.&lt;/strong&gt; There's nothing to gain from decomposing it into entities — it's read whole and returned whole.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Processing state lives in Redis, not the store.&lt;/strong&gt; It's hot, short-lived and needed by every node. Putting it in the database would mean paying for durability where none is required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Access validation is duplicated locally in the public zone.&lt;/strong&gt; Not for convenience, but because there are no return connections and nobody to ask.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The SAP bus and external routing stay external.&lt;/strong&gt; They aren't rewritten or "integrated more deeply" — they're reached through channel adapters, and that's the correct boundary.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's worth taking away
&lt;/h2&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%2Fv8k9tiyf25f63r0xjwvw.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%2Fv8k9tiyf25f63r0xjwvw.png" alt="the same architecture by zones, with the three defining properties" width="800" height="663"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Strip the domain specifics and a few decisions transfer to any system of comparable shape.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Split flows by frequency, not by layer.&lt;/strong&gt; Business entities and telemetry are fundamentally different loads. One storage mechanism for both usually means one of them is served badly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An Aggregator isn't "accumulate and write" — it's a pattern with two completion conditions.&lt;/strong&gt; Size and timeout, simultaneously. Without the second, a sparse stream simply stalls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If state might be needed by any node, it isn't in memory.&lt;/strong&gt; The moment a broker or a container starts redistributing work, local state turns into a source of hard-to-trace inconsistencies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Large objects don't travel through the broker.&lt;/strong&gt; A reference in the message, the payload in the store — and the queues stay light.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A fallback cache ends with a back-fill.&lt;/strong&gt; Otherwise the expensive external call repeats on every request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An event must be able to gain subscribers without editing its source.&lt;/strong&gt; That's the property audit layers are built as layers for, rather than as a pile of log lines.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;Transition status, so nothing is left unsaid: the public zone already runs on the stack described here; the remaining verticals are moving. A module-by-module before-and-after is in &lt;a href="https://dev.to/rinat_kozin/we-ported-a-module-off-an-esb-and-the-code-grew-17x-that-was-the-right-outcome-1acd"&gt;the second post&lt;/a&gt;; the economics of the "before" stack are in &lt;a href="https://dev.to/rinat_kozin/what-an-integration-layer-really-costs-a-tms-backend-counted-line-by-line-dl6"&gt;the first&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Sources and releases: &lt;a href="https://github.com/redbase-app/" rel="noopener noreferrer"&gt;github.com/redbase-app&lt;/a&gt;. About the redb store: &lt;a href="https://redbase.app/" rel="noopener noreferrer"&gt;redbase.app&lt;/a&gt;.&lt;/p&gt;

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

</description>
      <category>dotnet</category>
      <category>architecture</category>
      <category>kafka</category>
      <category>distributedsystems</category>
    </item>
    <item>
      <title>We ported a module off an ESB and the code grew 1.7 . That was the right outcome</title>
      <dc:creator>rinat kozin</dc:creator>
      <pubDate>Thu, 30 Jul 2026 19:03:56 +0000</pubDate>
      <link>https://dev.to/rinat_kozin/we-ported-a-module-off-an-esb-and-the-code-grew-17x-that-was-the-right-outcome-1acd</link>
      <guid>https://dev.to/rinat_kozin/we-ported-a-module-off-an-esb-and-the-code-grew-17x-that-was-the-right-outcome-1acd</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%2F2fxt3nd72zvirce9r7ol.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%2F2fxt3nd72zvirce9r7ol.png" alt="Transportation Management System" width="800" height="537"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We're taking apart a transport management system — a TMS.&lt;/strong&gt; Orders arrive from SAP, a driver and vehicle get matched against requirements, trips run through waypoints with time windows, checklists at each stop, handover reports with damage records. On top: a GPS stream into partitioned tables, trip and place tracking, a driver mobile app, and a public zone where customers watch their delivery. Fifteen modules, three nodes behind a load balancer, RabbitMQ and Kafka three nodes each, Redis, PostgreSQL, a separate identity server.&lt;/p&gt;

&lt;p&gt;It was built because buying the TMS as a service from outside vendors got too risky — building it in-house was a way to get control of a core process back. And it was built fast: &lt;strong&gt;the system is sixteen months old&lt;/strong&gt;, first commit April 2025.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://dev.to/rinat_kozin/what-an-integration-layer-really-costs-a-tms-backend-counted-line-by-line-dl6"&gt;part one&lt;/a&gt; I took this system apart line by line and priced its infrastructure layer on WSO2 Micro Integrator and Entity Framework Core. It came to 3,700–6,800 hours one-off and 750–1,400 per year — &lt;strong&gt;2.2–4 person-years up front and 0.45–0.85 of a permanent headcount annually&lt;/strong&gt;, before a single line of business logic.&lt;/p&gt;

&lt;p&gt;This post is what happens next. The system is leaving that state: &lt;strong&gt;the integration bus, the identity server and the ORM are all being removed.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I'll start with the module that has already moved — a line-by-line before-and-after. The headline result of that comparison was the opposite of what you expect from a migration post: &lt;strong&gt;there is now almost twice as much code.&lt;/strong&gt; Why that's correct is the first half.&lt;/p&gt;

&lt;p&gt;The second half is the other three layers, where the numbers run into person-years rather than hours.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Disclosure.&lt;/strong&gt; I'm the architect and developer of the backend under analysis, and the author of the ecosystem it relies on. So this isn't a critique of someone else's decisions — it's my own bill. Which is also why measurements are kept apart from conclusions and numbers are broken out line by line: argue with the lines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope: backend only.&lt;/strong&gt; Web frontend is a separate team, mobile is a separate developer. Neither is in these numbers.&lt;/p&gt;

&lt;p&gt;The team was complete: a systems analyst analysing requirements, a QA engineer verifying the work, a project manager running budget, deadlines, priorities, work order and risk. Worth remembering as you read — the accumulated infrastructure can't be explained away by "they had no process." And every hour quoted is development only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Part of the redb / redb.Route series&lt;/strong&gt; — recent posts first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://dev.to/rinat_kozin/what-an-integration-layer-really-costs-a-tms-backend-counted-line-by-line-dl6"&gt;What an integration layer really costs: a TMS backend counted line by line&lt;/a&gt; — part one of this pair&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/rinat_kozin/a-payment-platform-on-net-what-it-actually-costs-and-how-much-of-it-you-never-have-to-write-2lfb"&gt;A payment platform on .NET: what it actually costs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/rinat_kozin/leaving-masstransit-for-a-camel-state-of-mind-the-kafka-connector-scatter-gather-and-what-really-106h"&gt;Leaving MassTransit for a Camel state of mind&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/rinat_kozin/redbroute-apache-camel-for-net-22-transports-30-eip-patterns-compiled-dsl-11m0"&gt;redb.Route — Apache Camel for .NET&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The timeline that explains everything else
&lt;/h2&gt;

&lt;p&gt;Before counting anything, look at the dates. I pulled them out of the repository history.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;th&gt;What appeared&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;April 2025, day 1&lt;/td&gt;
&lt;td&gt;first commit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;April 2025, &lt;strong&gt;day 2&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;ORM with migrations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;April 2025, &lt;strong&gt;day 8&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;in-house routing engine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;June 2025, month 3&lt;/td&gt;
&lt;td&gt;in-house module container&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;June 2026, month 15&lt;/td&gt;
&lt;td&gt;first module on the new stack, no bus&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;July 2026, month 16&lt;/td&gt;
&lt;td&gt;current state&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three observations, each worth stating.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The routing engine appeared on day eight.&lt;/strong&gt; Not in year three after "hitting the limits of the platform" — immediately.&lt;/p&gt;

&lt;p&gt;You do not write a 27,000-line routing framework in eight days. You bring it with you, finished. Which is where I should say plainly who and why: &lt;strong&gt;the architecture was designed before the project started, together with the tool that implements it.&lt;/strong&gt; The engine wasn't shopped for on the market or picked from a comparison table.&lt;/p&gt;

&lt;p&gt;What follows from that matters for reading the rest: &lt;strong&gt;the system is not accidental.&lt;/strong&gt; Not one of the layers I take apart showed up by drift — each was chosen deliberately, and there's someone to answer for each choice. Everything called a cost below is my own bill, not a critique over someone's shoulder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The bus stayed anyway.&lt;/strong&gt; Both integration layers stood side by side from month one and still do. Not "bus first, then ours replacing it" — two runtimes simultaneously, all sixteen months.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dismantling started in month fifteen.&lt;/strong&gt; Not after five years of ossification, but almost as soon as the cost of what was chosen at the start became clear.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which gives the conclusion I want at the top rather than the bottom. &lt;strong&gt;The infrastructure layer in this system is not an inheritance. It was created in sixteen months by decisions taken in the first week.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And that changes who this post is for. If you're reading someone else's tech-debt post thinking "my project is new, I don't have this" — here's a system sixteen months old with 105,000 lines of infrastructure in it. Tech debt doesn't accrue with time. It accrues with the stack you pick on day one.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  First, how we count — because we count dishonestly
&lt;/h2&gt;

&lt;p&gt;Before comparing anything, deal with the bias built into the counting method. It's in part one, it's in most migration posts, and I fell into it myself.&lt;/p&gt;

&lt;p&gt;When we say "integration bus," we count &lt;strong&gt;our artefacts&lt;/strong&gt;: 206 XML files, 4,099 lines of Java. We never count the platform's own internals — and that's a large Java product: runtime, mediation engine, dozens of transports, a management console. Hundreds of thousands of lines that somebody wrote, debugs and versions. Not one line of it enters the estimate.&lt;/p&gt;

&lt;p&gt;When we say "identity server," we count 227 tables, because tables are visible in a database. The code serving those tables we don't count, though it exists and somebody maintains it.&lt;/p&gt;

&lt;p&gt;And when it's our own engine, we count &lt;strong&gt;everything&lt;/strong&gt;: 27,529 lines of routing engine, 11,117 lines of module container, and we invoice 1,940–3,400 hours for it.&lt;/p&gt;

&lt;p&gt;The difference isn't economics. The difference is &lt;strong&gt;what happens to live in our repository.&lt;/strong&gt; Someone else's internals are invisible, so they feel free. Ours are visible, so they feel like a line item.&lt;/p&gt;

&lt;h3&gt;
  
  
  This bias runs against the in-house stack, not for it
&lt;/h3&gt;

&lt;p&gt;Let me state the counter-intuitive part directly: &lt;strong&gt;this way of counting inflates the price of your own solution and deflates the price of the bought one.&lt;/strong&gt; Not the other way round.&lt;/p&gt;

&lt;p&gt;Counted symmetrically, there are exactly two honest options.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option one — count nobody's internals.&lt;/strong&gt; Then the "free" bus is an external dependency you added. And an engine pulled in as a package is an external dependency you added. Both are zero in the budget. What you compare is what you wrote on top: 861 lines of XML plus 4,099 lines of Java, against 212 lines of route declarations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option two — count everyone's internals.&lt;/strong&gt; Then next to 38,646 lines of engine and container stand the hundreds of thousands in the bus platform and the identity server. And the in-house engine turns out to be &lt;strong&gt;an order of magnitude smaller&lt;/strong&gt; than what it replaces.&lt;/p&gt;

&lt;p&gt;Both options give the same answer, and it's the opposite of the intuitive one. &lt;strong&gt;The asymmetric count — the one we apply by default — is the only one under which the in-house solution looks more expensive.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The part that isn't about line counts
&lt;/h3&gt;

&lt;p&gt;There's one property that makes those two options unequal, though, and it's decisive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The engine's internals never enter your repository at all.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It ships the way any normal platform ships: libraries as NuGet packages, the runtime as a container image or a signed standalone archive, releases on GitHub. You add a dependency and write your module. No sources in your tree, no building them in your pipeline, no upgrading them by hand.&lt;/p&gt;

&lt;p&gt;Which is why those 38,646 lines are, in the new arrangement, &lt;strong&gt;not a cost line but zero lines in the repository.&lt;/strong&gt; Exactly the sense in which a ready-made bus's internals were always zero. It's just symmetric now.&lt;/p&gt;

&lt;p&gt;And on top of that there's something the vendor platform doesn't offer: &lt;strong&gt;if you need to go inside, you can.&lt;/strong&gt; Reference the sources instead of the package and step through with a debugger all the way down. Not "fork it and live with the fork" — look, fix, offer it back.&lt;/p&gt;

&lt;p&gt;The vendor platform gives you no such choice. There's a binary, and exactly one way in: &lt;strong&gt;fork it.&lt;/strong&gt; Which is what happened in this project — the transport connector misbehaved, waiting for a vendor fix wasn't viable, and 2,429 lines of someone else's Java landed in the repository permanently, off the upgrade path.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;That's the real difference, not the line count. &lt;strong&gt;By default you carry nothing — and if you need to, the door inside is open.&lt;/strong&gt; A fork isn't a benefit of an open platform; it's the price of having no sane way in. Paid once, then again at every upgrade.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What goes and what stays
&lt;/h2&gt;

&lt;p&gt;The layout from part one. The system had four infrastructure layers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;th&gt;Fate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Integration bus: 195 artefacts of XML + Java&lt;/td&gt;
&lt;td&gt;904–1,708 h&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;removed&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ORM: 1,500 lines of configuration, 54 models, repositories&lt;/td&gt;
&lt;td&gt;440–820 h&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;removed&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Identity server: 4 databases, 227 service tables&lt;/td&gt;
&lt;td&gt;part of 450–850 h&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;removed&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Own routing engine and module container&lt;/td&gt;
&lt;td&gt;1,940–3,400 h&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;stays, becomes the foundation&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three of four get dismantled. What remains is the one that cost more than the other three combined.&lt;/p&gt;

&lt;p&gt;That's probably the main conclusion of both posts, and it isn't about technology. &lt;strong&gt;The cheap-at-the-door things left. The expensive-at-the-door thing stayed and keeps earning.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Where we started: the public zone
&lt;/h2&gt;

&lt;p&gt;The first thing to move was the customer-facing zone — a public area pushed outside the company perimeter into the cloud.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   INSIDE THE COMPANY               CLOUD (public zone)
   ──────────────────               ───────────────────
     backend ──── writes ────────►  its own RabbitMQ
              (inbound only)              │
                                          ▼
                                    its own PostgreSQL
                                    its own Redis
                                          │
                                          ▼
                                    HTTP API → customer site
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key property: &lt;strong&gt;there are no return connections.&lt;/strong&gt; The company backend reaches into the zone and writes data. From inside the zone outward is forbidden. Own database, own broker, own cache; the zone is self-sufficient.&lt;/p&gt;

&lt;p&gt;Why start here becomes obvious if you think about risk. The boundary is &lt;strong&gt;already drawn architecturally&lt;/strong&gt;, not sketched on a diagram. The zone can be replaced wholesale without touching anything inside the company: outward it talks only to the customer site, inward it doesn't call at all. The cost of a mistake is capped at one zone.&lt;/p&gt;

&lt;p&gt;If you're planning a similar move — look for the same thing in your own system. Not the most important part, not the hardest. &lt;strong&gt;The most severed one.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  One module, before and after
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Before
&lt;/h3&gt;

&lt;p&gt;Ten synapse-XML artefacts plus a Java mediator:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Artefact&lt;/th&gt;
&lt;th&gt;Lines&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Client API&lt;/td&gt;
&lt;td&gt;345&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Driver position proxy service&lt;/td&gt;
&lt;td&gt;312&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delivery info proxy service&lt;/td&gt;
&lt;td&gt;147&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error handling sequence&lt;/td&gt;
&lt;td&gt;57&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis configuration sequence&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token validation sequence&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two Redis connection entries&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RabbitMQ endpoint&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mediator library descriptor&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;XML total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;936&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Java token validation mediator&lt;/td&gt;
&lt;td&gt;183&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total across two languages&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,119&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  After
&lt;/h3&gt;

&lt;p&gt;One C# module, eighteen files:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Lines&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Client API routes&lt;/td&gt;
&lt;td&gt;407&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Driver position route&lt;/td&gt;
&lt;td&gt;343&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delivery info route&lt;/td&gt;
&lt;td&gt;289&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token validator&lt;/td&gt;
&lt;td&gt;238&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Configuration loader&lt;/td&gt;
&lt;td&gt;172&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Module entry point&lt;/td&gt;
&lt;td&gt;120&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exception handler&lt;/td&gt;
&lt;td&gt;83&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API error model&lt;/td&gt;
&lt;td&gt;46&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scheduled partition maintenance&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Six typed configuration files&lt;/td&gt;
&lt;td&gt;168&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two models and a validation result&lt;/td&gt;
&lt;td&gt;33&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,941&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  1,119 → 1,941. Growth of 1.7×.
&lt;/h3&gt;

&lt;p&gt;This is usually where a migration post ends, because the number is inconvenient. Let's take it apart honestly — starting with method, because counting lines across languages is easy to rig in either direction.&lt;/p&gt;

&lt;h3&gt;
  
  
  How I counted
&lt;/h3&gt;

&lt;p&gt;Three ways to count the same thing, all on the same code:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;Growth&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Raw lines, build files excluded&lt;/td&gt;
&lt;td&gt;1,119&lt;/td&gt;
&lt;td&gt;1,941&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;×1.73&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blank lines and comments excluded&lt;/td&gt;
&lt;td&gt;921&lt;/td&gt;
&lt;td&gt;1,330&lt;/td&gt;
&lt;td&gt;×1.44&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build files included&lt;/td&gt;
&lt;td&gt;1,582&lt;/td&gt;
&lt;td&gt;1,971&lt;/td&gt;
&lt;td&gt;×1.25&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All three show growth, so it isn't an artefact of the method. I'll use the first row from here on, as the least flattering to the new code: if a conclusion survives that one, it survives the others.&lt;/p&gt;

&lt;p&gt;The third row hides its own story. &lt;strong&gt;Packaging this module for the bus requires a 463-line build descriptor. The new module needs thirty.&lt;/strong&gt; Fifteen times the size, in a file that does nothing but explain how to build everything else. I keep build files out of the main count to compare code with code — but dropping the fact entirely would be dishonest, because somebody maintains that too.&lt;/p&gt;

&lt;p&gt;And the caveat without which the table is worthless: &lt;strong&gt;a line of XML and a line of C# are different units.&lt;/strong&gt; Adding them into one metric is a rough volume approximation, not a complexity measurement. Which is why what follows takes apart the composition, not the total.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why these two things are comparable at all
&lt;/h2&gt;

&lt;p&gt;Let me justify the comparison itself, or the rest is a matter of taste.&lt;/p&gt;

&lt;p&gt;Neither side is an arbitrary invention. Both implement &lt;strong&gt;the same industry catalogue&lt;/strong&gt; — the one described in Gregor Hohpe and Bobby Woolf's &lt;em&gt;Enterprise Integration Patterns&lt;/em&gt;, which formalises 65 messaging patterns: routers, splitters, aggregators, enrichers, translators and the rest. That's the industry's shared vocabulary. Apache Camel is built on it. So is WSO2 MI. So is the engine in this post.&lt;/p&gt;

&lt;p&gt;Which means &lt;strong&gt;this isn't "someone's product versus my hobby project" — it's two implementations of one specification.&lt;/strong&gt; The same pattern is a mediator tag on one side and a method call on the other. That's exactly why comparing declarations line for line is meaningful: both describe the same set of concepts.&lt;/p&gt;

&lt;p&gt;Since we're here, let me check my own coverage claim against the code rather than the project page. And note the counting mistake I nearly made myself: &lt;strong&gt;the book is not just about routing.&lt;/strong&gt; More than half the catalogue is channels, endpoints and message construction — implemented by transports and the kernel, not by the routing language.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Group in the book&lt;/th&gt;
&lt;th&gt;What exists in the engine&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Routing&lt;/td&gt;
&lt;td&gt;Content-Based Router (&lt;code&gt;Choice&lt;/code&gt;/&lt;code&gt;When&lt;/code&gt;/&lt;code&gt;Otherwise&lt;/code&gt;), Message Filter, Dynamic Router, Recipient List, Splitter, Aggregator, Resequencer, Scatter-Gather, Multicast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transformation&lt;/td&gt;
&lt;td&gt;Message Translator, Content Enricher (&lt;code&gt;Enrich&lt;/code&gt;/&lt;code&gt;PollEnrich&lt;/code&gt;), Content Filter, Normalizer, Envelope Wrapper (&lt;code&gt;Marshal&lt;/code&gt;/&lt;code&gt;Unmarshal&lt;/code&gt;), Claim Check, Datatype Channel (&lt;code&gt;OfType&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Channels&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Channel Adapter — 27 transports&lt;/strong&gt;, Point-to-Point Channel (&lt;code&gt;direct&lt;/code&gt;, &lt;code&gt;vm&lt;/code&gt;, &lt;code&gt;direct-vm&lt;/code&gt;, &lt;code&gt;seda&lt;/code&gt;, queues), Publish-Subscribe Channel, Message Bus, Messaging Bridge, Dead Letter Channel, Invalid Message Channel, Guaranteed Delivery&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;Polling Consumer (&lt;code&gt;timer&lt;/code&gt;, &lt;code&gt;cron&lt;/code&gt;, file and FTP polling), Event-Driven Consumer, Messaging Gateway (&lt;code&gt;ProducerTemplate&lt;/code&gt;), Competing Consumers, Message Dispatcher (&lt;code&gt;LoadBalance&lt;/code&gt;), Selective Consumer, Idempotent Receiver, Transactional Client, Service Activator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Message construction&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Request-Reply (&lt;code&gt;InOut&lt;/code&gt;), Return Address (&lt;code&gt;ReplyTo&lt;/code&gt;), Correlation Identifier, Message Expiration (TTL)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Management&lt;/td&gt;
&lt;td&gt;Wire Tap, Message Store (&lt;code&gt;Replayable&lt;/code&gt;), Message History (tracing), Control Bus, Process Manager (&lt;code&gt;Saga&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's &lt;strong&gt;around forty catalogue patterns&lt;/strong&gt;, and not by stretching — each is backed by a DSL method, a transport, or a kernel primitive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Channel Adapter&lt;/strong&gt; deserves singling out, because the book treats it as central and real life proves it: connecting to things is the most laborious part of any integration. Twenty-seven transports: brokers (RabbitMQ, Kafka, AMQP, IBM MQ, Azure Service Bus, SQS, MQTT), protocols (HTTP, gRPC, TCP, WebSocket, SignalR), files (File, FTP, SFTP, S3), data (SQL, Redis, Elasticsearch), plus mail, LDAP, Telegram, a scheduler and process execution.&lt;/p&gt;

&lt;p&gt;And second, the &lt;strong&gt;in-process channels where no broker is involved at all.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There are four, solving different problems. &lt;code&gt;direct&lt;/code&gt; — a synchronous call inside a route. &lt;code&gt;direct-vm&lt;/code&gt; — a synchronous call &lt;strong&gt;between different contexts&lt;/strong&gt; in the same process; that's what module-to-identity-server traffic runs on, more on which below. &lt;code&gt;vm&lt;/code&gt; — asynchronous handoff between contexts. And &lt;code&gt;seda&lt;/code&gt; — an in-memory queue with a bounded size, N competing consumers, an enqueue timeout and queue-depth counters.&lt;/p&gt;

&lt;p&gt;The value of that last one is that &lt;strong&gt;classic stage decoupling costs you no broker&lt;/strong&gt;. Need to separate fast intake from slow processing? Put an in-memory queue between them and set a worker count. You don't stand up RabbitMQ to get an async stage inside one process — and the bounded queue gives you natural backpressure instead of unbounded growth.&lt;/p&gt;

&lt;p&gt;In the book's terms that's Point-to-Point Channel plus Competing Consumers, and it's a case of a catalogue pattern being closed by a kernel primitive rather than external infrastructure. In the "before" stack the same decoupling meant a broker queue — with the connection, topology, monitoring and network hop that implies.&lt;/p&gt;

&lt;h3&gt;
  
  
  Every pattern is visible in telemetry
&lt;/h3&gt;

&lt;p&gt;Here's what comes with that catalogue and usually gets lost in EIP discussions. The patterns aren't merely implemented — &lt;strong&gt;each one is instrumented separately&lt;/strong&gt;, and not with home-grown statistics in a private format but with standard OpenTelemetry.&lt;/p&gt;

&lt;p&gt;Metrics are defined per pattern, not per engine:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;What's measured&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Aggregator&lt;/td&gt;
&lt;td&gt;groups completed, groups in flight, groups timed out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Splitter&lt;/td&gt;
&lt;td&gt;parts produced&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Filter&lt;/td&gt;
&lt;td&gt;messages dropped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idempotent Receiver&lt;/td&gt;
&lt;td&gt;passed, rejected as duplicates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multicast&lt;/td&gt;
&lt;td&gt;branch count, failed branches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recipient List&lt;/td&gt;
&lt;td&gt;recipients selected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Circuit Breaker&lt;/td&gt;
&lt;td&gt;times tripped, calls rejected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retry&lt;/td&gt;
&lt;td&gt;attempts, successes after retry, exhaustions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Saga&lt;/td&gt;
&lt;td&gt;completed, failed, compensated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Throttle / Timeout / Debounce&lt;/td&gt;
&lt;td&gt;delayed, expired, discarded and flushed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wire Tap&lt;/td&gt;
&lt;td&gt;copies dispatched, copies failed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dead Letter&lt;/td&gt;
&lt;td&gt;messages dead-lettered&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Plus end-to-end: processed, failed, currently in flight, and duration histograms — for the whole exchange and for each route step.&lt;/p&gt;

&lt;p&gt;Tracing isn't home-grown either. Spans come from a standard activity source, and attributes follow &lt;strong&gt;OpenTelemetry semantic conventions&lt;/strong&gt; — &lt;code&gt;messaging.system&lt;/code&gt;, &lt;code&gt;messaging.destination.name&lt;/code&gt;, &lt;code&gt;messaging.operation&lt;/code&gt;, &lt;code&gt;http.method&lt;/code&gt;, &lt;code&gt;db.system&lt;/code&gt;, &lt;code&gt;file.system&lt;/code&gt;, with exceptions broken into &lt;code&gt;exception.type&lt;/code&gt; / &lt;code&gt;message&lt;/code&gt; / &lt;code&gt;stacktrace&lt;/code&gt;. On top of those, its own: correlation id, exchange pattern, route, step, endpoint.&lt;/p&gt;

&lt;p&gt;The practical consequence: &lt;strong&gt;Jaeger, Grafana or Tempo read this with no adapter&lt;/strong&gt; — subscribe to the source and you're done. The module container knows about telemetry and wires it up itself, so "consumed from queue → enriched → hit the database → replied" shows up as one trace with every step and duration.&lt;/p&gt;

&lt;p&gt;The difference from the "before" stack isn't the presence of logs — everybody has logs. The difference is that &lt;strong&gt;"which step is slow and why" is answered by a trace, rather than by correlating journal entries from two runtimes by timestamp.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Beyond the catalogue there are nine more patterns absent from the 2003 book but now de-facto standard in Camel: Circuit Breaker, Throttle, Debounce, Delay, Loop, Threads, Sample, Validate, TryCatch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the project is moving fast.&lt;/strong&gt; Twelve releases in three months — from 1.0.4 in early May to 3.4.0 in late July. In that window: concurrency handoff as a first-class &lt;code&gt;.Threads(N)&lt;/code&gt; pattern, replay checkpoints for failed exchanges, an Amazon queue connector, and a shared runtime layer where patching the framework is a file swap with no rebuild. The catalogue keeps closing; transports keep landing.&lt;/p&gt;

&lt;p&gt;Worth saying plainly in a post about migrating off a mature platform: &lt;strong&gt;the engine's youth is a real factor in the decision&lt;/strong&gt;, and pretending otherwise would be dishonest. The flip side is equally real — what sat in a vendor's tracker for years here closes in weeks, because the author is in the room and priorities come from operations rather than someone else's roadmap.&lt;/p&gt;




&lt;h2&gt;
  
  
  The correction that matters: I was comparing the wrong things
&lt;/h2&gt;

&lt;p&gt;Now I have to hand back my own number, because as stated it misleads.&lt;/p&gt;

&lt;p&gt;Adding "all the XML" to "all the C#" is wrong, because each stack has &lt;strong&gt;two distinct layers&lt;/strong&gt;: the declarative route description, and the imperative logic a declaration can't express. Count them separately or the multiplier means nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer one — the route declaration.&lt;/strong&gt; On the bus that's synapse-XML; in the new module it's a block of the embedded language. Comparing like with like:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Route declaration&lt;/th&gt;
&lt;th&gt;XML&lt;/th&gt;
&lt;th&gt;DSL&lt;/th&gt;
&lt;th&gt;Density&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Client API (three resources)&lt;/td&gt;
&lt;td&gt;345&lt;/td&gt;
&lt;td&gt;72&lt;/td&gt;
&lt;td&gt;×4.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Driver position&lt;/td&gt;
&lt;td&gt;312&lt;/td&gt;
&lt;td&gt;52&lt;/td&gt;
&lt;td&gt;×6.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delivery info&lt;/td&gt;
&lt;td&gt;147&lt;/td&gt;
&lt;td&gt;53&lt;/td&gt;
&lt;td&gt;×2.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error handling&lt;/td&gt;
&lt;td&gt;57&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;×1.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;861&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;212&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;×4.1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;861 lines of XML against 212 lines of DSL.&lt;/strong&gt; On the layer where comparison is actually valid, the new stack is &lt;strong&gt;four times denser&lt;/strong&gt;. And that layer — "declarativeness" — is precisely what ESB posts usually name as the ready-made bus's main advantage. It lost fourfold.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer two — imperative logic.&lt;/strong&gt; Both stacks need it: parse the payload, compare statuses, build the response. The difference is &lt;strong&gt;where it lives.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On the bus there's nowhere for it but Java. Here's how much Java sits in the bus project overall, not in one module:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Java in the bus project&lt;/th&gt;
&lt;th&gt;Files&lt;/th&gt;
&lt;th&gt;Lines&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Forked transport connector — someone else's code, permanently maintained&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;2,429&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Own class mediators — what the XML couldn't express&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;1,670&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;18&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4,099&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four thousand lines of Java hold up the "declarative" platform. Half a fork of someone else's connector; half in-house mediators written where XML ran out.&lt;/p&gt;

&lt;p&gt;In the new module that layer didn't disappear — it &lt;strong&gt;stayed in the same file, in the same language&lt;/strong&gt; as the declaration. That's the entire source of the headline growth: what used to be a separate project in a separate language with a separate build now sits next to the route.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which restates the conclusion. Not "code grew 1.7× when leaving the ESB." Rather: &lt;strong&gt;the declaration shrank fourfold, and the imperative logic moved out of an invisible Java project into the file next to the route.&lt;/strong&gt; The net increase is the price of that move from invisible to visible, and it's an honest price.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  And second: the new code isn't written densely — deliberately
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The new module is written in a plain, spread-out style, for the debugger.&lt;/strong&gt; In practice that means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every route step is a &lt;strong&gt;named private method with a doc comment&lt;/strong&gt; — extract the data, resolve saved state from cache, resolve from the database, compare statuses, build the response. Not because there's no alternative, but so each step can take a breakpoint and show its state;&lt;/li&gt;
&lt;li&gt;exchange property names are hoisted into &lt;strong&gt;thirteen constants&lt;/strong&gt; instead of inline literals;&lt;/li&gt;
&lt;li&gt;retry parameters are named constants of their own;&lt;/li&gt;
&lt;li&gt;each step carries a numbered comment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And crucially: &lt;strong&gt;the framework offers something this code didn't use.&lt;/strong&gt; It has an expression subsystem with JSONPath, compiled and typed variants. The module extracts data by hand instead: parse the JSON, reach for fields one at a time, try/catch around it, scatter the results across six properties. Forty-odd lines where a handful of expressions would do.&lt;/p&gt;

&lt;p&gt;How much tighter it could be, I haven't measured and won't quote. But &lt;strong&gt;several times over is realistic&lt;/strong&gt;, and I'll assert that as the author of both sides of the comparison.&lt;/p&gt;

&lt;p&gt;Now combine both corrections and the asymmetry becomes visible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Synapse-XML has exactly one compression mechanism: move the logic into a Java mediator.&lt;/strong&gt; That's how token validation became eighteen lines — not density, delegation. You cannot compress the XML without evicting logic from it; every mediation step is a tag.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The XML in this comparison sits &lt;strong&gt;at its floor&lt;/strong&gt; — it gets no denser, and can only shrink by hiding. The new code sits &lt;strong&gt;above its floor by choice&lt;/strong&gt; — for breakpoints, readability and line-by-line verification against the original.&lt;/p&gt;

&lt;p&gt;So ×1.73 is not the price of the stack. It's the price of &lt;strong&gt;the style chosen for a one-to-one port.&lt;/strong&gt; The same result on the same framework could have been several times denser — and would have lost exactly what the move was for.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Where the growth actually went
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;Lines&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;Lines&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Client API (XML)&lt;/td&gt;
&lt;td&gt;345&lt;/td&gt;
&lt;td&gt;Client API routes&lt;/td&gt;
&lt;td&gt;407&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Driver position (XML)&lt;/td&gt;
&lt;td&gt;312&lt;/td&gt;
&lt;td&gt;Driver position route&lt;/td&gt;
&lt;td&gt;343&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delivery info (XML)&lt;/td&gt;
&lt;td&gt;147&lt;/td&gt;
&lt;td&gt;Delivery info route&lt;/td&gt;
&lt;td&gt;289&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error handling (XML)&lt;/td&gt;
&lt;td&gt;57&lt;/td&gt;
&lt;td&gt;Exception handler&lt;/td&gt;
&lt;td&gt;83&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token validation (XML + Java)&lt;/td&gt;
&lt;td&gt;201&lt;/td&gt;
&lt;td&gt;Token validator + result&lt;/td&gt;
&lt;td&gt;255&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis and RabbitMQ connections (XML)&lt;/td&gt;
&lt;td&gt;51&lt;/td&gt;
&lt;td&gt;Typed configuration&lt;/td&gt;
&lt;td&gt;168&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mediator library descriptor (XML)&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;API error model, two domain models&lt;/td&gt;
&lt;td&gt;62&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;Scheduled partition maintenance&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;Configuration loader, entry point&lt;/td&gt;
&lt;td&gt;292&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,119&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,941&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three sources of growth, none of them verbosity.&lt;/p&gt;

&lt;h3&gt;
  
  
  One: things that didn't exist before
&lt;/h3&gt;

&lt;p&gt;Monthly partition maintenance on a schedule — forty-two lines that had no counterpart in the old version. Previously somebody else did it, or nobody did.&lt;/p&gt;

&lt;p&gt;Configuration loader and module entry point — nearly three hundred lines. In the old arrangement the platform played that role: configuration arrived from its files, lifecycle was its business. Now it's explicit code you can read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That's 334 lines of &lt;em&gt;added&lt;/em&gt;, not &lt;em&gt;rewritten&lt;/em&gt;.&lt;/strong&gt; Subtract them and ×1.73 becomes ×1.44 — the exact figure you get counting without blanks and comments. Two independent methods landing on the same number is a decent check on both.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two: configuration became typed
&lt;/h3&gt;

&lt;p&gt;Fifty-one lines of XML connection descriptions became 168 lines of settings classes.&lt;/p&gt;

&lt;p&gt;Sounds like a regression until you look at what you got. Before: factory names as strings, parameters mixed together, a typo in a parameter name discovered at startup at best and at first failure at worst. Now: typed classes, defaults, compile-time checking.&lt;/p&gt;

&lt;p&gt;A hundred and seventeen extra lines is the price of a connection misconfiguration no longer being a production discovery.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three: the hidden came out
&lt;/h3&gt;

&lt;p&gt;This is the important one.&lt;/p&gt;

&lt;p&gt;Token validation in the old version is eighteen lines of XML. Eighteen, because the actual work happened &lt;strong&gt;somewhere else&lt;/strong&gt;: the XML only invoked a 183-line Java mediator. One piece of logic, two languages, two places to look for it.&lt;/p&gt;

&lt;p&gt;Now it's 255 lines of C# in one file that opens in a debugger, takes tests, and turns up in a code search.&lt;/p&gt;

&lt;p&gt;Same story with error handling and the delivery info route: what looked short in XML was short because of what had been moved out of it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What actually changed
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Three languages collapsed into one
&lt;/h3&gt;

&lt;p&gt;Before: XML for routes, Java for what XML can't express, settings files for configuration. Three build pipelines, three debugging stories, three places to look for a cause.&lt;/p&gt;

&lt;p&gt;After: one language, one pipeline, one debugger.&lt;/p&gt;

&lt;p&gt;It's worth registering what that lost "line saving" actually meant. Eighteen lines of XML looked cheaper than two hundred lines of C# right up until somebody asked: &lt;strong&gt;where exactly does the validation happen?&lt;/strong&gt; And answering required knowing that a Java mediator exists, where it lives, how it's wired, and who builds it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Exactly one service is still our own
&lt;/h3&gt;

&lt;p&gt;The new module has precisely one class with logic of its own — the token validator. The comment at the entry point explains why:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Only custom logic not covered by the DSL: the token validator. SQL, Redis and RabbitMQ run in the routes; there are no separate repositories.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Everything else — consuming from queues, writing to the database, cache access, the HTTP facade, partition maintenance — is expressed as routes. The HTTP facade is built &lt;strong&gt;with no controllers at all&lt;/strong&gt;: three resources declared directly in routes.&lt;/p&gt;

&lt;p&gt;The validator stayed custom not out of preference but out of necessity: the public zone cannot ask the company whether a key is valid — there are no return connections. Validation has to be local and self-contained.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two details that say more than the numbers
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The port is documented in a comment
&lt;/h3&gt;

&lt;p&gt;The header of one new route says it outright: &lt;strong&gt;"one-to-one port of the XML proxy"&lt;/strong&gt; — followed by the original's algorithm, step by step: what to pull from headers, what from the body, where to look up saved status, under what condition to update database and cache, what to return.&lt;/p&gt;

&lt;p&gt;That's not documentation for its own sake. It's how you prove behaviour didn't drift: while the original algorithm is written down alongside, anyone can check.&lt;/p&gt;

&lt;p&gt;Worth stealing for anyone rewriting something that works. &lt;strong&gt;Port one-to-one, pin the behaviour in a comment, improve later as a separate step.&lt;/strong&gt; The temptation to "make it nice while we're here" is the main reason migrations fail.&lt;/p&gt;

&lt;h3&gt;
  
  
  The cache keys still remember the platform
&lt;/h3&gt;

&lt;p&gt;Redis key names in the new module start with a prefix containing the name of the integration platform. The one that no longer exists in this zone.&lt;/p&gt;

&lt;p&gt;It happened because the key format couldn't change — data had to stay compatible during the transition. The platform was switched off; its name stayed in the data structure.&lt;/p&gt;

&lt;p&gt;Small, but telling: &lt;strong&gt;a migration doesn't end the day the old code is deleted.&lt;/strong&gt; Traces linger in formats, names and schemas for a long time.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the move cost
&lt;/h2&gt;

&lt;p&gt;I was going to give a model with assumptions here, as in part one. I broke it into stages — study the original, port the routes, the token validator, configuration, testing — and got &lt;strong&gt;193–335 hours&lt;/strong&gt;, i.e. a week and a half to two weeks for the module.&lt;/p&gt;

&lt;p&gt;Then I looked at the repository history.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;30 June, 21:22&lt;/td&gt;
&lt;td&gt;first commit of the module&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 July, 11:21&lt;/td&gt;
&lt;td&gt;second&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 July, 12:25&lt;/td&gt;
&lt;td&gt;third&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7 and 9 July&lt;/td&gt;
&lt;td&gt;two fixes from testing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The port took a day.&lt;/strong&gt; The evening of 30 June and the first half of the next day — after that, only refinement from test results, two commits over nine days.&lt;/p&gt;

&lt;p&gt;My model was off by roughly twenty times. I'm leaving that in the post exactly as it happened, because the discrepancy is more interesting than the estimate: &lt;strong&gt;an expert estimate built on familiar yardsticks missed reality by more than an order of magnitude.&lt;/strong&gt; Had I not gone into git, I'd have published plausible and entirely wrong numbers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why a day and not two weeks
&lt;/h3&gt;

&lt;p&gt;Three reasons, and the first is one that migration posts don't yet discuss.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The port was done in a pair with a language model.&lt;/strong&gt; And it worked not because "LLMs can write code now" but for a specific reason: &lt;strong&gt;the engine's DSL follows Apache Camel's idiom&lt;/strong&gt; — the same catalogue patterns, the same &lt;code&gt;from → process → to&lt;/code&gt; shape, the same URI endpoint addressing.&lt;/p&gt;

&lt;p&gt;And language models know Camel very well. Twenty years in production, thousands of projects, mountains of examples, docs and forum questions — all of it in the training data. Which means &lt;strong&gt;following an industry standard had an unexpected side effect: the model already knows your API even though it has never seen it.&lt;/strong&gt; It recognises the idiom.&lt;/p&gt;

&lt;p&gt;That's worth absorbing if you design libraries today. The old argument for a familiar idiom was that developers onboard faster. There's now a second one, and it's heavier: &lt;strong&gt;the tool the developer writes with knows it too.&lt;/strong&gt; Your own original idiom forfeits that — and the price of originality just went up.&lt;/p&gt;

&lt;p&gt;I checked the reverse on the same task: synapse-XML with platform-specific extensions and artefacts referencing each other by name takes assistance noticeably worse. The format is narrow and there's little of it in the world.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second reason — a one-to-one port.&lt;/strong&gt; The original algorithm is known; there's nothing to invent. The task reduces to translating between two description languages, which is exactly what models do best.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third — the same person wrote both sides.&lt;/strong&gt; Both the source system and the target engine. That's an advantage nobody else has, and crediting the tool for what's explained by domain knowledge would be dishonest.&lt;/p&gt;

&lt;h3&gt;
  
  
  And deploying it — an hour by hand, fifteen minutes with a model
&lt;/h3&gt;

&lt;p&gt;Second number in the same series, and it maps straight onto part one.&lt;/p&gt;

&lt;p&gt;The "before" estimate has a line: &lt;strong&gt;"deploying and configuring the bus, clustering, training the team — 300–500 hours."&lt;/strong&gt; That isn't padding: standing up a bus, clustering it, configuring the configuration stores and teaching a team to live with it is a couple of months of work.&lt;/p&gt;

&lt;p&gt;There's something to compare against. Take the deployment of another system on the same stack: the topology is described in &lt;strong&gt;a single container compose file&lt;/strong&gt;, and here's what comes up from it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;three worker nodes in a cluster&lt;/strong&gt;, coordinating through a shared database, one group name;&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;management web console&lt;/strong&gt; across all three, reaching them with a service key;&lt;/li&gt;
&lt;li&gt;a search engine with its UI, and object storage;&lt;/li&gt;
&lt;li&gt;the application web frontend.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plus the cross-cutting configuration: database connection string, queue factory parameters in the default context inherited by every module, authentication with signing and roles, secrets externalised to environment variables.&lt;/p&gt;

&lt;p&gt;That file was &lt;strong&gt;assembled with a language model in about a quarter of an hour&lt;/strong&gt;. By hand, without an assistant, around an hour: it's a ten-kilobyte file and it needs writing carefully.&lt;/p&gt;

&lt;p&gt;Both numbers are honest and both are worth holding: &lt;strong&gt;an hour if you write it yourself, fifteen minutes paired with a model.&lt;/strong&gt; Against three hundred hours the difference between them stops mattering, but claiming fifteen minutes as the general result would be sleight of hand.&lt;/p&gt;

&lt;p&gt;The reason a model is effective here is exactly the reason it was effective on the routes: &lt;strong&gt;there is no proprietary deployment format.&lt;/strong&gt; An ordinary container compose file plus configuration through environment variables on a predictable scheme. The model has seen thousands of those and writes them confidently.&lt;/p&gt;

&lt;p&gt;Contrast with the alternative: the platform has its own deployment descriptor, its own build artefact, its own configuration wizards — and &lt;em&gt;that&lt;/em&gt; a model won't write, because examples are scarce and the specifics are vendor-bound. There you're alone with the vendor's documentation, same as ten years ago.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And shipping the modules themselves is a file copy.&lt;/strong&gt; Built packages go into a directory mounted into the container, which picks them up on the fly. No image rebuild, no process restart, no deploying the whole system for one integration.&lt;/p&gt;

&lt;p&gt;The level isn't "development-grade" either. Everything that usually makes up those three hundred hours arrives pre-assembled:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;three operating modes&lt;/strong&gt; — standalone process with no database, single node with storage, or a cluster with leader election and automatic context redistribution;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;observability&lt;/strong&gt; — process and per-route metrics, ring-buffer logs, OpenTelemetry traces, optional metrics scraping;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;a watchdog&lt;/strong&gt; — detects suspect and hung routes and can restart them itself;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;a scheduler&lt;/strong&gt; — schema auto-created on first start, cluster-safe;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;security&lt;/strong&gt; — signed API keys, roles, expiry, revocation, user binding;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;module isolation&lt;/strong&gt; — each gets its own load context; dependencies don't collide;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;management&lt;/strong&gt; — REST API, CLI and a web dashboard, in the box rather than "write your own."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing here is assembled piecemeal. A module goes into a directory and the container picks it up live, without stopping its neighbours.&lt;/p&gt;

&lt;p&gt;And separately about the dashboard, because it's usually the thing people pay a commercial bus for.&lt;/p&gt;

&lt;p&gt;Eleven pages: overview, routes and single-route view, endpoints, cluster and node detail, scheduler, logs, audit, dead-letter queue, watchdog, access control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And it's a control plane, not a viewer&lt;/strong&gt; — a distinction worth stating.&lt;/p&gt;

&lt;p&gt;An individual route can be &lt;strong&gt;stopped and started from the dashboard&lt;/strong&gt;, leaving the others alone. A whole context too: stop, start, restart. A hung route can be force-stopped. So the answer to "turn off that integration, it's taking down the neighbouring system" is two clicks, not a redeploy and not a ticket to DevOps.&lt;/p&gt;

&lt;p&gt;Next to each route are its own numbers: processed, currently in flight, throughput, history. Not one process-wide graph but &lt;strong&gt;per route&lt;/strong&gt;, because the question is always "which integration is stuck," not "how is the service feeling."&lt;/p&gt;

&lt;p&gt;The watchdog gets its own page: suspect and hung routes, automatic restart, toggled right there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cluster handling isn't just a node list.&lt;/strong&gt; You can see who the current leader is and when each node last reported. A node can be drained gracefully: it finishes what it's holding, takes no new work, hands its locks to peers — then comes back. You can rebalance everything, or evict a node hard. Node detail shows CPU, memory, threads, GC with history, its contexts and its logs.&lt;/p&gt;

&lt;p&gt;Also from the dashboard: &lt;strong&gt;failed exchanges can be inspected and replayed&lt;/strong&gt; after a fix, a scheduled job fired immediately, and a node's effective configuration read with secrets redacted — without SSH.&lt;/p&gt;

&lt;p&gt;Line that up against the real failure modes from part one: consumers that silently stopped reading a queue, a hung request-reply exchange, manual restarts after a cluster rebuild. &lt;strong&gt;Every one of them is precisely the case this dashboard exists for&lt;/strong&gt; — and each was being handled by walking onto a node instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is where the DevOps person-hours actually are.&lt;/strong&gt; Not in shorter routes, but in a management layer that doesn't take weeks to stand up and isn't hand-written for the third time this decade.&lt;/p&gt;

&lt;h3&gt;
  
  
  The limits of that number
&lt;/h3&gt;

&lt;p&gt;A day is the port, not the whole thing. Outside it: studying the original, done earlier and separately; testing and behaviour verification against the old zone; two fixes from test results. Full elapsed time from first commit to last is nine days.&lt;/p&gt;

&lt;p&gt;And to repeat the third reason, because it's decisive: &lt;strong&gt;going from two weeks to one day is not a property of the tool but the sum of three circumstances&lt;/strong&gt;, of which two are reproducible. If you have a one-to-one port and a framework with a standard idiom, the first two are available to you. Both sides of the system in one head, probably not.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it saves per year
&lt;/h3&gt;

&lt;p&gt;The saving isn't in lines, it's in what stops happening:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What goes away&lt;/th&gt;
&lt;th&gt;Hours/year&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Maintaining XML artefacts for this zone&lt;/td&gt;
&lt;td&gt;15–25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Diagnosing request-reply hangs on a shared channel&lt;/td&gt;
&lt;td&gt;20–40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual restarts after cluster rebuilds&lt;/td&gt;
&lt;td&gt;10–20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maintaining the Java mediator: separate pipeline, separate competence&lt;/td&gt;
&lt;td&gt;15–30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;60–115&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Payback measured against the fact rather than my failed estimate: &lt;strong&gt;a day of porting against 60–115 hours saved per year&lt;/strong&gt; — the investment returns inside the first month.&lt;/p&gt;

&lt;p&gt;What isn't in the calculation is what can't be counted: &lt;strong&gt;silent failures.&lt;/strong&gt; A consumer that stopped reading and didn't say so. An exchange hung on a shared channel. A night-time restart after a routine upgrade. Part one covered those in detail — each costs more than any row in a table, and none can be estimated in advance.&lt;/p&gt;

&lt;p&gt;The real reason for the move isn't hours. It's that &lt;strong&gt;failures became visible and diagnosable.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Two runtimes on one node
&lt;/h2&gt;

&lt;p&gt;Only one module has moved so far. But something important is already visible if you look not at code but at where all this physically runs.&lt;/p&gt;

&lt;p&gt;The cluster topology, per the system's architecture documentation: three nodes, and &lt;strong&gt;each of them runs the in-house module container and WSO2 MI side by side.&lt;/strong&gt; Not "the bus lives in some separate zone" — two different runtimes shoulder to shoulder, on the same hardware, for the whole life of the project.&lt;/p&gt;

&lt;p&gt;That's the real cost of a heterogeneous stack — not the abstract "two build pipelines" line from part one, but literally: each of three nodes needs two runtimes serviced with different tools from scratch.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WSO2 MI is packaged into a Carbon Application (&lt;code&gt;.car&lt;/code&gt;) via Maven — its own build pipeline, its own artefact versioning, its own rollout process per node.&lt;/li&gt;
&lt;li&gt;A module on the new container is an ordinary .NET assembly dropped into a container volume. Which modules load on a given node is one environment variable with wildcard patterns — no image repackaging, no separate release per module.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a DevOps engineer running three nodes, that isn't "a bit more work" — it's a second full area of expertise alongside the first: Java, Maven, Carbon packaging, its own logs and metrics. And all of it is needed &lt;strong&gt;as long as a single node in the cluster still runs the bus&lt;/strong&gt;, no matter how many modules have moved.&lt;/p&gt;

&lt;p&gt;That's the key non-linearity of this transition's economics. Savings on DevOps competence don't scale with the number of migrated modules — they land in one step, when the last Carbon Application comes off the last node and the cluster stops being a two-runtime cluster. Until then you carry the second competence in full, not "half, since half the modules moved."&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer two: the ORM whose migrations disappear
&lt;/h2&gt;

&lt;p&gt;Analysis of the application layer produced this. All measured, not estimated:&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;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;DbSet&lt;/code&gt; declarations&lt;/td&gt;
&lt;td&gt;51&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lines of model configuration in one method&lt;/td&gt;
&lt;td&gt;~1,500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Files with eager-loading chains&lt;/td&gt;
&lt;td&gt;70 (40 + 30 nested)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Files calling &lt;code&gt;SaveChanges&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ORM models with navigation properties&lt;/td&gt;
&lt;td&gt;54&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQL views assembling graphs into JSON&lt;/td&gt;
&lt;td&gt;5, of which &lt;strong&gt;one is used&lt;/strong&gt;, four dead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generic repository + specifications&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migration files&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And the question all of it was counted for: &lt;strong&gt;what here genuinely requires an ORM?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;By the code: two partitioned tables, five views and three raw SQL calls. Everything else is servicing the ORM itself.&lt;/p&gt;

&lt;p&gt;Fifteen hundred lines of model description and a migration system exist to serve two partitioned entities and three queries. Partitions and raw SQL need no ORM — they work over a direct connection.&lt;/p&gt;

&lt;h3&gt;
  
  
  The five views that were a workaround
&lt;/h3&gt;

&lt;p&gt;They assemble object graphs into JSON — doing on the database side what the ORM couldn't do acceptably. Four of five are used by nobody: dead code nobody dared delete because it's unclear who might be watching.&lt;/p&gt;

&lt;p&gt;An object store assembles the graph in one call natively. The views don't get "rewritten" — they stop being necessary.&lt;/p&gt;

&lt;h3&gt;
  
  
  What losing migrations means — not for the developer, for the business
&lt;/h3&gt;

&lt;p&gt;This is the part technical posts mention in passing, and it's the most expensive one.&lt;/p&gt;

&lt;p&gt;A migration isn't a repository artefact. It's an &lt;strong&gt;operational event&lt;/strong&gt;. Every migration against a live system means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a release window agreed with the business;&lt;/li&gt;
&lt;li&gt;a rollback plan, and an answer for what happens if rollback fails;&lt;/li&gt;
&lt;li&gt;someone with production database rights;&lt;/li&gt;
&lt;li&gt;three nodes where application order matters;&lt;/li&gt;
&lt;li&gt;a freeze on parallel schema changes in other branches until this one lands.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In an object store the schema derives from types: add a property to a class and it exists at the next initialisation. No migration file, no window, no rollback, no person with rights.&lt;/p&gt;

&lt;p&gt;That isn't "more convenient for developers." That's &lt;strong&gt;an entire class of operational events disappearing from the company calendar&lt;/strong&gt; — along with the approvals, the night windows, and the releases pushed because a migration didn't apply on the second node.&lt;/p&gt;

&lt;h3&gt;
  
  
  The measured size of what gets scrapped
&lt;/h3&gt;

&lt;p&gt;I counted the data layer whole, separating human-written from tool-generated — they can't be mixed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What goes&lt;/th&gt;
&lt;th&gt;Files&lt;/th&gt;
&lt;th&gt;Lines&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Data layer: 55 models, reference data, interceptors, services&lt;/td&gt;
&lt;td&gt;88&lt;/td&gt;
&lt;td&gt;7,547&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database provider: a 2,049-line context, key generator, migration bodies&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;5,125&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Written by humans&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;96&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;12,672&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Generated by tooling: model snapshots&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;17,407&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total leaving&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30,079&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Thirty thousand lines. A hundred files. But the interesting part is the second-to-last row.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generated model snapshots — 17,407 lines across four files, four thousand-odd each.&lt;/strong&gt; Schema descriptions at each migration point plus the current snapshot. The tooling writes them itself.&lt;/p&gt;

&lt;p&gt;Those files live in version control. They show up in pull requests. They conflict on branch merges — and nobody wants to resolve a conflict inside a four-thousand-line generated snapshot. Nobody reads them, everybody has to commit them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Seventeen thousand lines no human wrote and no human reads, versioned, reviewed and conflicting like ordinary code.&lt;/strong&gt; That's what "a migration system" physically means in a repository, on top of the night windows in production.&lt;/p&gt;

&lt;p&gt;Plus 2,877 lines of hand-written migration bodies — those were written by hand, 2,748 of them in a single schema-creation file.&lt;/p&gt;

&lt;p&gt;With a schema derived from types, neither exists. Not "there's less of them" — there are none.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer three: integrations collapse — but not on their own
&lt;/h2&gt;

&lt;p&gt;Here's the causal link worth not losing. &lt;strong&gt;Integrations get simpler not because they were rewritten better. They get simpler because the ORM left them.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There are four integration modules: transforming data from SAP, persisting the result, outbound exchange via outbox, and a host application.&lt;/p&gt;

&lt;p&gt;The first is pure transformation and never touches the database. The ORM never touched it, and it won't change at all.&lt;/p&gt;

&lt;p&gt;All the bulk sits in the second. What it does today, verbatim from the code: look up an entity by external identifier, create or update a graph of related objects, manually synchronise collections, save changes. Once per entity type arriving from SAP — and each drags two to four related objects behind it.&lt;/p&gt;

&lt;p&gt;What remains with an object store: &lt;strong&gt;save the object in one call.&lt;/strong&gt; The graph goes as a whole.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What the integration did&lt;/th&gt;
&lt;th&gt;What it becomes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lookup by external key → create/update graph → save changes&lt;/td&gt;
&lt;td&gt;Query by external key → save object&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual synchronisation of nested collections&lt;/td&gt;
&lt;td&gt;Part of the graph; no separate code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom reference-data cache on a dictionary&lt;/td&gt;
&lt;td&gt;Built-in list provider with TTL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reading an external identifier via a separate no-tracking context&lt;/td&gt;
&lt;td&gt;An ordinary query&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Why code shrinks here and grew in the bus module
&lt;/h3&gt;

&lt;p&gt;This is the important reversal, and it explains both numbers at once.&lt;/p&gt;

&lt;p&gt;In the bus case the code &lt;strong&gt;grew 1.7×&lt;/strong&gt; — because logic hidden inside a Java mediator came out into the open. Volume rose; nothing stayed concealed.&lt;/p&gt;

&lt;p&gt;In the ORM case the code &lt;strong&gt;shrinks&lt;/strong&gt; — because nothing comes out into the open here. What disappears is ceremony: eager-loading chains, manual object assembly from separate queries, a generic repository with specifications. That isn't business logic you can lose. It's the overhead of talking to an ORM.&lt;/p&gt;

&lt;p&gt;The analysis author's estimate for the three heaviest spots — flagged as an estimate, not a measurement: an 810-line processor spending roughly 60% of its code on loading and manual graph assembly compresses to about 300; another at 495 lines with six separate queries to about 150; a 163-line filtering service to about 60.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fewer lines here isn't the goal, it's the consequence.&lt;/strong&gt; The goal is that six database round-trips to assemble one object become one. And &lt;em&gt;that&lt;/em&gt; is about hardware: fewer queries for the same answer means less load on the same nodes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer four: identity on someone else's stack, and HTTP where a call would do
&lt;/h2&gt;

&lt;p&gt;Now the architecturally interesting part — and probably the most underrated in money.&lt;/p&gt;

&lt;p&gt;What exists today: an external identity server. Four separate databases, &lt;strong&gt;227 service tables&lt;/strong&gt; — against the 53 that hold the actual business of this system. Roughly four to one in favour of authentication infrastructure.&lt;/p&gt;

&lt;p&gt;Nine integration points with the bus are confirmed in the code: token issue, refresh, revoke, JWT decode and validation, password recovery and reset, user binding, group sync, adding a user to a group.&lt;/p&gt;

&lt;p&gt;And here's the detail worth stopping on. Among the confirmed scenarios is &lt;strong&gt;service-account token retrieval for two channels&lt;/strong&gt; — mobile and the control panel.&lt;/p&gt;

&lt;p&gt;A service account is machine-to-machine. No user, no browser, no redirect. One part of the system proving to another that it is what it says it is.&lt;/p&gt;

&lt;p&gt;The path of that request, per the architecture documentation, runs &lt;strong&gt;through several layers: client → bus → identity server → backend.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So to get a token that a machine needs to talk to a machine inside one perimeter, the system goes over HTTP through two intermediate layers. With serialisation, TLS establishment, timeouts, retries, and the diagnostics of "which of the four hops just failed."&lt;/p&gt;

&lt;h3&gt;
  
  
  What changes with an in-house identity server
&lt;/h3&gt;

&lt;p&gt;In the in-house solution every entry point is registered on an internal transport that works &lt;strong&gt;in-process, without the network&lt;/strong&gt; — synchronously, zero-copy, between modules of one runtime.&lt;/p&gt;

&lt;p&gt;A module needing a service-account token gets it with a method call. No HTTP listener, no TLS handshake, no JSON over loopback. The exchange goes from the route straight into the handler and back, on the same thread.&lt;/p&gt;

&lt;p&gt;The principled split that follows:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Needs a browser&lt;/th&gt;
&lt;th&gt;Works over any transport&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Service account (machine-to-machine)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redirect to the sign-in page&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exchanging a code for a token&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refresh, revoke, introspect, userinfo&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Management, user directory, audit&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A browser is fundamentally required by &lt;strong&gt;exactly two&lt;/strong&gt; interactions in the whole standard: the authorisation redirect, and the verification page for input-constrained devices. Everything else is transport-neutral.&lt;/p&gt;

&lt;p&gt;Hence the main conclusion for this layer. The network between our own services appeared not because the protocol needs it, but because &lt;strong&gt;the identity server was someone else's, and HTTP was the only way to talk to it.&lt;/strong&gt; Once the stack stops being someone else's, the network on that hop disappears as a category.&lt;/p&gt;

&lt;p&gt;Plus 227 service tables of a foreign schema are replaced by two dozen typed schemas derived from classes. With no migrations, for the same reason as the previous section.&lt;/p&gt;




&lt;h2&gt;
  
  
  The system isn't sliced only in layers — and that multiplies everything
&lt;/h2&gt;

&lt;p&gt;So far I've talked about horizontal layers: facade, integrations, data, identity. But that's how the system looks only on a presentation slide.&lt;/p&gt;

&lt;p&gt;In reality a &lt;strong&gt;vertical slicing&lt;/strong&gt; runs across the horizontal layers: control panel, mobile channel, GPS zone, geometry, SAP integration, outbound exchange, reference data, scheduling, key issuance. Fifteen module entry points in the codebase.&lt;/p&gt;

&lt;p&gt;And here's what matters: &lt;strong&gt;each vertical drags every horizontal layer through itself.&lt;/strong&gt; Its own facade, its own data access, its own models, its own database context wiring. An abstract context, a provider context, a context factory, a provider selection service, a design-time factory — all of that exists once per system, but every vertical is threaded into it.&lt;/p&gt;

&lt;p&gt;So thirty thousand lines of data layer isn't "one layer out of four." It's what &lt;strong&gt;cuts across all fifteen verticals at once&lt;/strong&gt;. Removing the ORM isn't extracting a module — it's pulling out the wiring that runs through every module.&lt;/p&gt;

&lt;p&gt;Adding up what's measured — what leaves the repository:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Files&lt;/th&gt;
&lt;th&gt;Lines&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bus artefacts in XML plus build descriptors&lt;/td&gt;
&lt;td&gt;213&lt;/td&gt;
&lt;td&gt;17,799&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Java: connector fork and class mediators&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;4,099&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data layer and database provider — written by humans&lt;/td&gt;
&lt;td&gt;96&lt;/td&gt;
&lt;td&gt;12,672&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Routing engine and module container — &lt;strong&gt;not deleted, externalised as a dependency&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;227&lt;/td&gt;
&lt;td&gt;38,646&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total written code leaving the repository&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;554&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;≈ 73,200&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;On top: generated migration artefacts&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;17,407&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Against roughly 175,200 lines written by humans. &lt;strong&gt;About forty-two percent of the codebase stops being something this team maintains&lt;/strong&gt; — or about forty-seven counting the generated artefacts.&lt;/p&gt;

&lt;p&gt;A word on those percentages, because they're easy to spin. I deliberately keep human-written and generated in &lt;strong&gt;separate rows&lt;/strong&gt;: mixing the bases would be double counting. And "forty-two percent" is not "we deleted forty-two percent of the system." It's the share of code &lt;strong&gt;this team no longer answers for in this repository&lt;/strong&gt;: some genuinely deleted, some turned into an external dependency.&lt;/p&gt;

&lt;h3&gt;
  
  
  What this does to development speed
&lt;/h3&gt;

&lt;p&gt;Here's the cost line nobody puts in a budget and everybody pays.&lt;/p&gt;

&lt;p&gt;When one vertical needs a change, how many competences do you call in? In the "before" stack: routing XML, Java for what XML can't express, Maven and artefact packaging, an ORM with migrations, a foreign identity schema of 227 tables. Even if one person knows all of it, they're &lt;strong&gt;switching between five contexts&lt;/strong&gt;; if they don't, the task travels between people with losses at each seam.&lt;/p&gt;

&lt;p&gt;In the "after" stack — &lt;strong&gt;C#. One.&lt;/strong&gt; Routes, logic, data access, configuration, tests, debugging. One language, one pipeline, one debugger, one breakpoint through the whole chain from HTTP to database.&lt;/p&gt;

&lt;p&gt;That changes not just the hourly rate but &lt;strong&gt;the time to enter a task&lt;/strong&gt;. A new vertical can be written off the cuff: take the neighbouring one as a template, repeat the structure, change the content. Less up-front analysis is needed, because there are fewer decisions of the form "which language does this part live in" and "where will it run."&lt;/p&gt;

&lt;p&gt;And separately — the thing enterprise-migration posts don't discuss yet, though it's already part of the working day. &lt;strong&gt;Uniform code in a mainstream language with recognisable patterns is written several times faster paired with a language model than heterogeneous code is.&lt;/strong&gt; The reasons are prosaic: the model knows the mainstream language and the standard patterns; repeated module structure means a template sits right next door; typing and the compiler catch mistakes immediately rather than at runtime on the third node. Each of those cuts both time and the amount of context you have to hold — in your head or in the tool.&lt;/p&gt;

&lt;p&gt;The reverse holds too, and you find out on the first attempt: synapse-XML with platform-specific extensions and name-based artefact links takes that help noticeably worse. Not because the format is bad, but because it's narrow and rare.&lt;/p&gt;




&lt;h2&gt;
  
  
  So where's the business benefit
&lt;/h2&gt;

&lt;p&gt;All four layers, along the axes a business actually measures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you don't write and don't carry.&lt;/strong&gt; The routing engine, the module container with dashboard and clustering, the identity server, the data access layer — none of it gets written in the project and none of it lands in the repository. Libraries arrive as packages, the runtime as an image or a signed archive. What stays yours is route declarations, business rules and entities. In the module we took apart: &lt;strong&gt;one class with custom logic in the whole module&lt;/strong&gt;, everything else routes and configuration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hardware.&lt;/strong&gt; One runtime per node instead of two side by side. One database round-trip to assemble an object instead of six. The network between our own services on the identity hop disappears entirely — four network hops become a method call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Competences.&lt;/strong&gt; Before: routing XML, Java for the gaps, Maven and bus artefact packaging, an ORM with migrations, a foreign 227-table identity schema. After: one language, one pipeline, one debugger. And — more importantly — that saving arrives &lt;strong&gt;in one step&lt;/strong&gt;, when the last artefact comes off the last node, not proportionally to migrated modules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Development hours.&lt;/strong&gt; Where ORM ceremony leaves, code shrinks by multiples. Where logic emerges from hidden mediators, it grows — and that's the right kind of growth. Total volume is the wrong thing to watch. The right thing: &lt;strong&gt;about forty-two percent of the codebase stops being maintained here&lt;/strong&gt; — some 73,200 lines of 175,200 written by humans, plus 17,407 lines of generated snapshots nobody reads but everybody commits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time to enter a task.&lt;/strong&gt; One competence instead of five means a new vertical gets written off the cuff from a neighbouring template, with no prior decision about which language a part lives in. Uniform code in a mainstream language also pairs far better with a language model than a narrow declarative format with name-based links — and that's the working day now, not the future.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Operations hours.&lt;/strong&gt; An entire class of operational events disappears: migrations with windows and rollbacks; manual restarts after cluster rebuilds; trips to a node to answer "what settings is it on"; picking apart last night's failure by hand in a broker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Money on what no estimate covers.&lt;/strong&gt; Silent failures. A consumer that quietly stopped reading a queue. An exchange hung on a shared channel. Four dead views nobody deletes because it's unclear who's watching them. None of these enter an estimate in advance, and each costs more than the ones that do.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The one number I won't give is what this is worth in currency. It depends on rates, team size, the local market for specific competences, and whether those competences sit in one pair of hands. I built an explicit model for the bus layer in part one; extending it across all four layers before three of them have happened would be selling an estimate as a measurement. What's measured here is code volume, table counts, integration points and topology. The conclusions from them are mine.&lt;/p&gt;
&lt;/blockquote&gt;




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

&lt;p&gt;&lt;strong&gt;Three of the four layers haven't happened yet.&lt;/strong&gt; This is the main caveat and it goes first. One module on the bus is done. Removing the ORM is a measured analysis of the codebase plus a three-phase pilot plan, not past tense: simple isolated entities first, then hierarchy, then the complex graph, and only then a decision about full removal. Moving to an in-house identity server is a chosen target. Everything named as a number is measured in current code; everything named as a consequence is a conclusion still to be proven by work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One module isn't the whole system.&lt;/strong&gt; The zone that moved had its boundary drawn from the start. Modules wired into internal zones, SAP and the identity server move harder, and these numbers don't extrapolate linearly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The author is the architect and developer of this backend and the author of the stack it moves to.&lt;/strong&gt; Stated at the top, repeated here: the store, the routing engine, the container and the identity server are mine. That's simultaneously the source of numbers nobody else has, and the reason to read the conclusions critically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code volume growth is real.&lt;/strong&gt; ×1.73 raw, ×1.44 without blanks and comments. Some is explained by additions and typing, but not all: explicit code is inherently wordier than a declarative description. If volume matters to you, that's a price and you should know it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ORM comparison is incomplete on one axis.&lt;/strong&gt; Some of the object store's query advantages — expression compilation to SQL, tree traversal via recursive CTE, change tracking — belong to the paid edition. The free one goes through JSON facets and is slower. If you're costing this move, pick the edition before you count, not after.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A storage paradigm change hits everything around the database.&lt;/strong&gt; Data doesn't live in one table per entity. Anything looking at the schema from outside the application — reports, extracts, ETL, an analyst's hands in pgAdmin — will need adapting. The data stays in an ordinary relational database and is reachable by raw SQL, but familiar "select from the trips table" queries get rewritten. That's real work and it isn't in the estimates above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ESBs keep their strengths.&lt;/strong&gt; One administration point, out-of-the-box adapters, a model a team already fluent in it understands. If you have that competence and the failure modes from part one don't affect you, the move may not pay back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An in-house engine isn't free.&lt;/strong&gt; It cost 1,940–3,400 hours in part one. Those were spent earlier and elsewhere; a team without one doesn't start where this one started. Though that estimate is exactly what the accounting-asymmetry section at the top is about: it's computed under rules where a foreign platform's internals aren't counted at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I have no measurements on development speed.&lt;/strong&gt; Not per vertical before and after, not for pairing with a language model. I consider the direction obvious and argue it from properties of the languages, not from a stopwatch. If you have numbers, they're more interesting than my reasoning.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The one-to-one comparison produced the opposite of what you'd expect: &lt;strong&gt;there is 1.7× more code&lt;/strong&gt; (1,119 → 1,941 lines; 1.44× without blanks and comments).&lt;/p&gt;

&lt;p&gt;But broken out by layer, that multiplier says the reverse of what it seems to. &lt;strong&gt;The route declaration shrank fourfold&lt;/strong&gt; — 861 lines of XML against 212 of embedded language. What grew was the imperative logic, which in the bus lived as a separate Java project (4,099 lines across the whole project, 2,429 of them a fork of someone else's connector) and now sits in the same file as the route. On top of that, the new code is deliberately written spread out for the debugger and doesn't use the framework's own expression support — it could have been several times denser, whereas the XML was already at its floor.&lt;/p&gt;

&lt;p&gt;What we got in exchange: &lt;strong&gt;three languages collapsed into one&lt;/strong&gt;, all logic findable by code search, debuggable with an ordinary debugger, testable with ordinary tests. Exactly one class stayed custom — the one that had to.&lt;/p&gt;

&lt;h3&gt;
  
  
  And on the other three layers the picture inverts
&lt;/h3&gt;

&lt;p&gt;Worth putting side by side, because the numbers went opposite ways and that isn't a contradiction.&lt;/p&gt;

&lt;p&gt;Where logic was &lt;strong&gt;hidden&lt;/strong&gt; — in Java mediators behind eighteen lines of XML — the code grew on porting. Hidden became visible, and volume honestly reflected it.&lt;/p&gt;

&lt;p&gt;Where there was no logic at all but &lt;strong&gt;ceremony&lt;/strong&gt; — fifteen hundred lines of model description for two partitioned tables, seventy files of eager-loading chains, a generic repository with specifications, five graph-assembly views of which four are dead — the code shrinks. There's nothing there to lose.&lt;/p&gt;

&lt;p&gt;The difference between those two cases is the main practical takeaway of both posts. &lt;strong&gt;Before counting savings in lines, work out which one you're looking at: hidden logic, or overhead.&lt;/strong&gt; In the first case volume will grow, and that's fine. In the second it will shrink — and that's not a reward for rewriting either, it's a sign you were paying to talk to your tooling.&lt;/p&gt;

&lt;p&gt;And the largest saving isn't measured in lines on any of the four layers. It's the disappearance of operational events: migrations with windows and rollbacks, manual restarts after cluster rebuilds, four network hops where a machine asks a machine for a token inside one process, and a second area of expertise you must keep on staff as long as a single node still runs someone else's runtime.&lt;/p&gt;

&lt;p&gt;If you've been down a similar road, post your ratio in the comments. Two things interest me: whether volume growth on the way from declarative description to explicit code is a rule or a quirk of our zone, and whether your numbers for code reduction after dropping an ORM match the estimate above. I'm happy to revise mine.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Sources and releases: &lt;a href="https://github.com/redbase-app/" rel="noopener noreferrer"&gt;github.com/redbase-app&lt;/a&gt;. About the redb store: &lt;a href="https://redbase.app/" rel="noopener noreferrer"&gt;redbase.app&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If this was useful — a ⭐ on GitHub helps others find it.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>architecture</category>
      <category>eip</category>
      <category>refactoring</category>
    </item>
    <item>
      <title>What an integration layer really costs: a TMS backend counted line by line</title>
      <dc:creator>rinat kozin</dc:creator>
      <pubDate>Thu, 30 Jul 2026 18:56:57 +0000</pubDate>
      <link>https://dev.to/rinat_kozin/what-an-integration-layer-really-costs-a-tms-backend-counted-line-by-line-dl6</link>
      <guid>https://dev.to/rinat_kozin/what-an-integration-layer-really-costs-a-tms-backend-counted-line-by-line-dl6</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%2F5sitsi6cnni879rv799p.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%2F5sitsi6cnni879rv799p.png" alt="Logistics tracker" width="800" height="537"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Most cost-of-software posts are written about someone else's system, from the outside, by someone who never had to keep it running. This one is written from the inside.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We're taking apart a transport management system — a TMS.&lt;/strong&gt; Orders arrive from SAP. A driver and a vehicle get matched against requirements for both the vehicle and the stops. A trip runs through waypoints with time windows, checklists at each stop, and handover reports with damage records when a vehicle goes out and comes back. On top of that: a GPS stream into partitioned tables, tracking for trips, places and counterparties, WiFi-based presence detection, a mobile app for drivers, and a public zone where the customer can see where their delivery is.&lt;/p&gt;

&lt;p&gt;Fifteen modules. Three nodes behind a load balancer, RabbitMQ and Kafka three nodes each, Redis, PostgreSQL, a separate identity server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it was built at all.&lt;/strong&gt; The TMS used to be bought as a service from outside vendors, and the risk grew. When a core business process runs on an external service, risk converts directly into money: downtime you can't influence, fix timelines set by someone else's priorities, dependency on a single supplier when terms change. While the risk is small, buying beats building. When it grows, the arithmetic flips — and building your own becomes a way to get control back, not a way to save money.&lt;/p&gt;

&lt;p&gt;So it was brought in-house.&lt;/p&gt;

&lt;p&gt;And here's the thing worth saying immediately, because it breaks the usual frame of every tech-debt discussion. &lt;strong&gt;This system is sixteen months old.&lt;/strong&gt; First commit April 2025, 931 commits so far.&lt;/p&gt;

&lt;p&gt;This is not inherited 2000s legacy whose author left years ago. It's a young greenfield project. &lt;strong&gt;175,000 lines of backend in sixteen months&lt;/strong&gt; — and it has already accumulated an infrastructure layer that now has to be dismantled.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Disclosure.&lt;/strong&gt; I'm the architect and developer of the backend under analysis, and also the author of the ecosystem it relies on. So what follows isn't a critique of someone else's decisions — it's my own bill. And that's exactly why measurements are kept separate from conclusions and the numbers are broken out line by line: argue with the lines, not with my good faith.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope: backend only.&lt;/strong&gt; The web frontend is built by a separate team, the mobile app by a separate developer. Neither appears in these numbers by a single line. Everything below accumulated inside one server-side codebase.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Part of the redb / redb.Route series&lt;/strong&gt; — recent posts first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/rinat_kozin/a-payment-platform-on-net-what-it-actually-costs-and-how-much-of-it-you-never-have-to-write-2lfb"&gt;A payment platform on .NET: what it actually costs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/rinat_kozin/leaving-masstransit-for-a-camel-state-of-mind-the-kafka-connector-scatter-gather-and-what-really-106h"&gt;Leaving MassTransit for a Camel state of mind&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/rinat_kozin/redbroute-apache-camel-for-net-22-transports-30-eip-patterns-compiled-dsl-11m0"&gt;redb.Route — Apache Camel for .NET&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  About the team, because it changes how you read the numbers
&lt;/h2&gt;

&lt;p&gt;The backend wasn't written solo, and it wasn't written in a vacuum. The team had:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;strong&gt;systems analyst&lt;/strong&gt; — requirements arrived analysed, not paraphrased in chat;&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;QA engineer&lt;/strong&gt; — what got built was verified by someone other than its author;&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;project manager&lt;/strong&gt; — the full classic role: budget, deadlines, priorities, work order, risk assessment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This matters in two ways.&lt;/p&gt;

&lt;p&gt;First: &lt;strong&gt;the overhead counted below is not the result of a broken process.&lt;/strong&gt; The easiest explanation for 105,000 lines of infrastructure is "they had no analysis, no testing, nobody minding the shop." That explanation doesn't work here. All of it was in place. The infrastructure layer didn't grow &lt;em&gt;instead of&lt;/em&gt; process — it grew &lt;em&gt;alongside&lt;/em&gt; it.&lt;/p&gt;

&lt;p&gt;Second: &lt;strong&gt;every hour in the tables below is development only.&lt;/strong&gt; Analyst, QA and management time isn't in there by a single hour, though the project wouldn't have shipped without them. Total cost of ownership is higher than my numbers, not lower.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's actually in there
&lt;/h2&gt;

&lt;p&gt;Counted from the repository, vendor libraries excluded.&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;Files&lt;/th&gt;
&lt;th&gt;Lines&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;C# — application layer, own engine, container&lt;/td&gt;
&lt;td&gt;851&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;121,466&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQL — schema, migrations, reporting, reference data&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;17,341&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;synapse-XML — WSO2 MI artefacts&lt;/td&gt;
&lt;td&gt;206&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14,564&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAPI contracts&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;5,235&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Razor — operator dashboard and host boilerplate&lt;/td&gt;
&lt;td&gt;54&lt;/td&gt;
&lt;td&gt;5,224&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Java — bus connector and class mediators&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;4,099&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Own JS and CSS&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;3,999&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maven — build descriptors for bus modules&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;3,235&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total written by humans&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,210&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;≈ 175,200&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;On top: generated migration artefacts&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;17,407&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Those last two rows need explaining, because they usually get shovelled into one pile and the difference between them is the whole point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generated migration artefacts — 17,407 lines across four files.&lt;/strong&gt; No human wrote them and no human reads them: they're model snapshots the ORM tooling produces by itself. But they live in version control, they show up in pull requests, and they conflict on merge. They're excluded from the "written" count deliberately — a separate row is more honest. We'll come back to them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build descriptors for the bus — 3,235 lines across seven modules&lt;/strong&gt;, averaging 460 lines each. That's not routing logic, it's Maven configuration: how to package a module into an installable artefact. For comparison, the .NET build descriptor in the same system is 30 lines. A fifteen-fold difference in a file that does nothing except explain how to build everything else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Six languages describing one system&lt;/strong&gt;, nearly all with their own build pipeline, their own debugging story, their own release cycle.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer one: WSO2 MI and synapse-XML
&lt;/h2&gt;

&lt;p&gt;Let's start with the layer that usually escapes cost accounting entirely, because people habitually file it under "configuration."&lt;/p&gt;

&lt;p&gt;That's wrong. &lt;strong&gt;Synapse-XML is code.&lt;/strong&gt; Declarative, narrow, limited in expressive power — but code: it has branching, error handling, transformations, external calls, and message flow control. The difference from C# isn't whether it's code. It's the &lt;strong&gt;length of the feedback loop&lt;/strong&gt;: artefacts reference each other by name, expressions over the message evaluate at runtime, and a meaningful share of mistakes surface not at build time but in production.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;One thing I should correct up front, because it's a common misconception — &lt;strong&gt;WSO2 MI does have a debugger.&lt;/strong&gt; You can set breakpoints on mediators in Integration Studio and inspect the message context. "You debug it with logs" is false, and I wrote exactly that in the first edition of this post until someone corrected me. The tooling exists. What's expensive here is something else.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The deployment unit is not a file but a whole module: projects are packaged by Maven into a &lt;strong&gt;Carbon Application&lt;/strong&gt;, a &lt;code&gt;.car&lt;/code&gt; archive, and that's what ships to the bus. You can see it in the repo as &lt;code&gt;&amp;lt;packaging&amp;gt;car&amp;lt;/packaging&amp;gt;&lt;/code&gt; across six build descriptors.&lt;/p&gt;

&lt;h3&gt;
  
  
  195 artefacts, and what they cost
&lt;/h3&gt;

&lt;p&gt;A model with explicit assumptions. Rates are per artefact, covering &lt;strong&gt;writing plus debugging&lt;/strong&gt; — not typing speed. Adjust for your reality; the table is broken out so you can edit it row by row.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Artefact type&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;th&gt;Hours each&lt;/th&gt;
&lt;th&gt;Hours&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sequences&lt;/td&gt;
&lt;td&gt;96&lt;/td&gt;
&lt;td&gt;4–8&lt;/td&gt;
&lt;td&gt;384–768&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Endpoints&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;td&gt;2–4&lt;/td&gt;
&lt;td&gt;84–168&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Proxy services&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;6–12&lt;/td&gt;
&lt;td&gt;60–120&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;APIs (routing + definition)&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;8–16&lt;/td&gt;
&lt;td&gt;48–96&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scheduled tasks&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;3–6&lt;/td&gt;
&lt;td&gt;12–24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local entries, templates&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;1–2&lt;/td&gt;
&lt;td&gt;12–24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forking the Kafka connector: reading foreign code, fixing, extending&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;60–120&lt;/td&gt;
&lt;td&gt;60–120&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Java class mediators (1,670 lines)&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;25–50&lt;/td&gt;
&lt;td&gt;125–250&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploying and configuring the bus, clustering, training the team&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;300–500&lt;/td&gt;
&lt;td&gt;300–500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;One-off total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;904–1,708&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The Java sitting underneath
&lt;/h3&gt;

&lt;p&gt;Artefacts aren't the end of it. Next to them sit &lt;strong&gt;4,099 lines of Java across 18 files&lt;/strong&gt;, and they split into two stories.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A fork of the official Kafka connector — 2,429 lines across 12 files.&lt;/strong&gt; This one deserves telling in full, because it's more typical than it looks.&lt;/p&gt;

&lt;p&gt;The vendor &lt;em&gt;has&lt;/em&gt; a Kafka connector. It's in their repository, it installs normally, it's part of the box. The problem is that it didn't work properly: it had bugs.&lt;/p&gt;

&lt;p&gt;Then comes the fork in the road familiar to anyone living on someone else's platform — wait for the vendor, or fix it yourself. Waiting wasn't an option, so the project &lt;strong&gt;forked the connector and fixed it in-house&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The price looks low in the moment: reading foreign code and patching it is cheaper than writing a transport from scratch. But the price arrives later and never leaves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A fork takes you off the upgrade path.&lt;/strong&gt; The version is pinned to a snapshot — an unpublished state of the vendor's branch. Every platform upgrade now means: take the new connector, re-apply your patches, verify nothing drifted. Or don't upgrade, and accumulate divergence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And you now maintain Java you didn't design.&lt;/strong&gt; Nearly two and a half thousand lines of someone else's architecture to re-learn every time something breaks.&lt;/p&gt;

&lt;p&gt;This isn't about a bad vendor. It's a property of the model: &lt;strong&gt;when the platform is someone else's, any defect in it becomes your permanent line item&lt;/strong&gt; — either you wait, or you fork and pay for the fork forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Class mediators — 1,670 lines.&lt;/strong&gt; These are Java classes wired directly into XML sequences wherever the XML ran out of expressive power. Their list describes the boundaries of the language better than any argument:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mediator&lt;/th&gt;
&lt;th&gt;Lines&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;Image scaling&lt;/td&gt;
&lt;td&gt;433&lt;/td&gt;
&lt;td&gt;Resizing and thumbnails — binary data handling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OAuth token cache&lt;/td&gt;
&lt;td&gt;402&lt;/td&gt;
&lt;td&gt;State that outlives a single request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unique key pool&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;Thread-safe block allocation of identifiers with auto-refill&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Thread pool diagnostics&lt;/td&gt;
&lt;td&gt;250&lt;/td&gt;
&lt;td&gt;Runtime introspection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token validation&lt;/td&gt;
&lt;td&gt;183&lt;/td&gt;
&lt;td&gt;Custom security logic&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every line is a place where a developer hit "XML can't do that" and switched languages. Binary data, cross-request state, concurrency, runtime introspection, non-trivial security — all outside what a declarative routing format was designed for.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The third row is worth a pause. A block-allocating identifier pool with auto-refill is the classic answer to "hitting the database for every key is too expensive." Here it had to be implemented as a Java mediator living inside the integration bus. The same mechanism shows up elsewhere in this story — but that's the next post.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Two real failure modes
&lt;/h3&gt;

&lt;p&gt;Not hypotheticals. These are the ones that actually cost nights.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sender and consumer on one connection — mutual deadlock.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The mechanics: request-reply means the sender, having posted a message, &lt;strong&gt;waits for the answer&lt;/strong&gt;. If a consumer is sitting on the same RabbitMQ connection, that wait occupies the shared channel and everything stops. The exchange doesn't proceed; the connection hangs.&lt;/p&gt;

&lt;p&gt;What it looks like from outside is worse: consumers &lt;strong&gt;silently stop reading the queue&lt;/strong&gt;. They don't crash, don't log an error, don't raise an alert. You find out from the consequences — messages piling up.&lt;/p&gt;

&lt;p&gt;The cure is separate connections: distinct factories for sending and receiving, identical parameters, declared separately. You can see it directly in the system's configuration — every factory has separate sender and listener blocks with identical settings. Duplication that exists purely to work around platform behaviour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Temporary RPC queues don't survive a cluster rebuild.&lt;/strong&gt; When the container nodes get rebuilt, the temporary reply queues on the bus-side producers don't come back. The usual fix is restarting the bus node — by hand.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer two: EF Core
&lt;/h2&gt;

&lt;p&gt;Here's a picture familiar to anyone who has run a project on Entity Framework. With one caveat worth holding onto for this whole section: everything below accumulated &lt;strong&gt;in sixteen months&lt;/strong&gt;, not over years.&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;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;DbSet&lt;/code&gt; declarations&lt;/td&gt;
&lt;td&gt;51&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lines of model configuration in one method&lt;/td&gt;
&lt;td&gt;~1,500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Files with eager-loading chains&lt;/td&gt;
&lt;td&gt;70 (40 + 30 nested)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Files calling &lt;code&gt;SaveChanges&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ORM models with navigation properties&lt;/td&gt;
&lt;td&gt;54&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQL views assembling object graphs into JSON&lt;/td&gt;
&lt;td&gt;5, of which &lt;strong&gt;one is used&lt;/strong&gt;, four are dead code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generic repository + specifications&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migration files&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And now the question all that counting was for: &lt;strong&gt;what of this actually requires an ORM?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The answer from the code: two partitioned tables, five views, and three raw SQL calls. Everything else is servicing the ORM itself.&lt;/p&gt;

&lt;p&gt;Fifteen hundred lines of model description and a migration system exist to serve two partitioned entities and three queries. Partitions and raw SQL require no ORM at all — they work through a direct connection.&lt;/p&gt;

&lt;h3&gt;
  
  
  The five views that were a workaround
&lt;/h3&gt;

&lt;p&gt;The five SQL views deserve their own note. They assemble object graphs into JSON — that is, they do on the database side the work the ORM couldn't do acceptably. Four of the five are used by nobody: dead code that nobody dared delete, because it's unclear who might be looking at it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Work&lt;/th&gt;
&lt;th&gt;Hours&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Modelling and configuration: 54 models, ~1,500 lines of mapping&lt;/td&gt;
&lt;td&gt;120–220&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Eager-loading chains and manual graph assembly across 70 files&lt;/td&gt;
&lt;td&gt;180–340&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generic repository + specifications&lt;/td&gt;
&lt;td&gt;40–80&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Five SQL views as a workaround for graph loading&lt;/td&gt;
&lt;td&gt;40–80&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migrations, snapshots, resolving their merge conflicts&lt;/td&gt;
&lt;td&gt;60–100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;One-off total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;440–820&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Layer three: what we wrote ourselves
&lt;/h2&gt;

&lt;p&gt;Now the most interesting thing in this system, and the reason it's worth taking apart at all.&lt;/p&gt;

&lt;p&gt;Running &lt;strong&gt;alongside&lt;/strong&gt; WSO2 MI was a &lt;strong&gt;home-grown integration engine&lt;/strong&gt;. Not a wrapper, not helpers — a full routing framework in the spirit of Apache Camel, written for .NET.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Routing engine&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;173 files, 27,529 lines&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Module container with dashboard and REST API&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;54 files, 11,117 lines&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;≈ 38,600 lines of infrastructure code&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Inside the engine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;eight transports&lt;/strong&gt; — Kafka, RabbitMQ, Redis, Cron, Timer, SEDA, Direct, Validator, each with its own consumer, producer and endpoint;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;43 abstraction files&lt;/strong&gt; — &lt;code&gt;IComponent&lt;/code&gt;, &lt;code&gt;IConsumer&lt;/code&gt;, &lt;code&gt;IProducer&lt;/code&gt;, &lt;code&gt;IEndpoint&lt;/code&gt;, &lt;code&gt;IProducerTemplate&lt;/code&gt;: Camel's vocabulary, verbatim;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;41 files of expression language&lt;/strong&gt; — expressions and predicates, compiled rather than interpreted;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;19 route definition files&lt;/strong&gt; — i.e. its own DSL;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;16 processors&lt;/strong&gt; and &lt;strong&gt;4 files of transaction handling&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the container isn't "a host that starts modules." By inventory it has module lifecycle management, hot-reload, leader election, per-route metrics, a REST API and a web dashboard.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Work&lt;/th&gt;
&lt;th&gt;Hours&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Routing engine: transports, DSL, expressions, processors&lt;/td&gt;
&lt;td&gt;1,200–2,100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Module container: lifecycle, hot-reload, clustering, dashboard, API&lt;/td&gt;
&lt;td&gt;740–1,300&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;One-off total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,940–3,400&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's &lt;strong&gt;1.2–2 person-years, and the single most expensive line in the entire estimate.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The obvious question is why a system that already had an enterprise bus needed a second integration layer. The answer is in the previous section: because the bus covered the declarative part and stopped there. Everything past a branch went into Java, every missing transport went into a fork, every stateful thing went into a mediator.&lt;/p&gt;

&lt;p&gt;Add up the workarounds and you find you've written half an engine anyway — just smeared across someone else's extension points.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer four: the identity server
&lt;/h2&gt;

&lt;p&gt;Authentication runs on WSO2 Identity Server. It brings &lt;strong&gt;four databases&lt;/strong&gt; that have to be created, seeded, versioned, backed up and upgraded along with the product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;227 service tables.&lt;/strong&gt; For scale: the business schema of this TMS — trips, orders, drivers, vehicles, places, counterparties, tracking, everything — is &lt;strong&gt;53 tables&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Four to one in favour of authentication infrastructure.&lt;/p&gt;

&lt;p&gt;The bus talks to it through nine confirmed integration points: token issue, refresh, revoke, JWT decode and validation, password recovery and reset, user binding, group sync, adding a user to a group.&lt;/p&gt;

&lt;p&gt;And one detail worth stopping on. Among the confirmed flows is &lt;strong&gt;service-account token retrieval for two channels&lt;/strong&gt; — mobile and the control panel.&lt;/p&gt;

&lt;p&gt;A service account is machine-to-machine. No user, no browser, no redirect. One part of the system proving to another that it is what it says it is.&lt;/p&gt;

&lt;p&gt;The path of that request, per the architecture documentation, goes &lt;strong&gt;through several layers: client → bus → identity server → backend.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There's also a token cache implemented as a Java mediator inside the bus — 400 lines that wouldn't exist if the traffic were lower. The first validation goes to the identity server, which issued the token; after that, validation happens bus-side without touching it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Work&lt;/th&gt;
&lt;th&gt;Hours&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Deployment, four databases, backup and upgrade procedures&lt;/td&gt;
&lt;td&gt;150–300&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nine integration points on the bus side&lt;/td&gt;
&lt;td&gt;120–250&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token cache as a Java mediator&lt;/td&gt;
&lt;td&gt;60–120&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User and group synchronisation&lt;/td&gt;
&lt;td&gt;120–180&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;One-off total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;450–850&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




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

&lt;p&gt;One-off is half the story. The other half arrives annually.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two stacks instead of one
&lt;/h3&gt;

&lt;p&gt;The application layer is .NET, the integration layer is Java with Maven. That's two build pipelines, two dependency sets, two upgrade cycles and &lt;strong&gt;two areas of expertise inside one team&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In practice it means "change how this integration behaves" needs a person who knows both — or a handoff between two people, with losses at the seam.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Recurring work&lt;/th&gt;
&lt;th&gt;Hours/year&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Maintaining synapse-XML artefacts&lt;/td&gt;
&lt;td&gt;200–350&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keeping the connector fork in sync with upstream&lt;/td&gt;
&lt;td&gt;60–120&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Java mediators: separate pipeline, separate competence&lt;/td&gt;
&lt;td&gt;80–150&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ORM: migrations, snapshot conflicts, query tuning&lt;/td&gt;
&lt;td&gt;150–300&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Identity server: upgrades, four databases, certificates&lt;/td&gt;
&lt;td&gt;120–250&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Diagnosing silent failures (see the two failure modes above)&lt;/td&gt;
&lt;td&gt;140–230&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Annual total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;750–1,400&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;One-off hours&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;WSO2 MI: 195 artefacts, Java, fork, deployment&lt;/td&gt;
&lt;td&gt;904–1,708&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EF Core: models, mapping, chains, migrations&lt;/td&gt;
&lt;td&gt;440–820&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Own engine and container&lt;/td&gt;
&lt;td&gt;1,940–3,400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Identity server: databases, integration, caching&lt;/td&gt;
&lt;td&gt;450–850&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3,734–6,778&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Round it: &lt;strong&gt;3,700–6,800 hours one-off.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's &lt;strong&gt;2.2–4 person-years&lt;/strong&gt; — before a single line of business logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Annually: 750–1,400 hours&lt;/strong&gt;, i.e. &lt;strong&gt;0.45–0.85 of a permanent headcount&lt;/strong&gt;, purely to keep the layer running.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The rate to apply is the &lt;strong&gt;fully loaded hourly cost to the company&lt;/strong&gt;: salary, taxes, workspace, management, holidays and idle time. Not the offer number — that's the usual mistake, and it understates the result two- or threefold.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Infrastructure against application code
&lt;/h3&gt;

&lt;p&gt;One more cut worth taking. Out of the 175,000 lines written by humans:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Lines&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Own routing engine and module container&lt;/td&gt;
&lt;td&gt;38,646&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQL: schema, migrations, reporting, reference data&lt;/td&gt;
&lt;td&gt;17,341&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WSO2 MI artefacts plus build descriptors&lt;/td&gt;
&lt;td&gt;17,799&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contracts, dashboard, own JS and CSS&lt;/td&gt;
&lt;td&gt;14,458&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ORM configuration, models, repositories, workaround views&lt;/td&gt;
&lt;td&gt;12,672&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Java: connector fork and class mediators&lt;/td&gt;
&lt;td&gt;4,099&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Infrastructure total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;≈ 105,000&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Application code: rules, calculations, processing, module routes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;≈ 70,000&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Rules, calculations, validation, processing — the part anyone actually pays for — is &lt;strong&gt;about forty percent of what was written&lt;/strong&gt;. The rest is data transport, configuration, plumbing and manoeuvres around tool limitations.&lt;/p&gt;

&lt;p&gt;Roughly &lt;strong&gt;three to two against the application code&lt;/strong&gt;: for every two lines doing the job, three lines exist to make doing the job possible. In my experience that's not an anomaly — it's the norm for enterprise systems on a ready-made integration platform. And those three lines are what this post is about.&lt;/p&gt;

&lt;p&gt;Worth repeating: this ratio is &lt;strong&gt;inside a single backend&lt;/strong&gt;. Web and mobile are other teams and aren't in the numbers.&lt;/p&gt;




&lt;h2&gt;
  
  
  What these numbers don't cover
&lt;/h2&gt;

&lt;p&gt;The section without which the estimate would be dishonest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is a model, not a measurement.&lt;/strong&gt; Nobody kept parallel time tracking per artefact. Hours-per-item is expert judgement and every row can be disputed separately. That's exactly why the tables are broken out rather than given as a total.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Some of this work would have been necessary anyway.&lt;/strong&gt; Business logic — rules, calculations, validation — doesn't depend on the stack it's written in. It isn't in the numbers above and shouldn't be: only the infrastructure layer is counted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Development hours only.&lt;/strong&gt; Analysis, testing and project management aren't in the tables, though the team had all three. Total cost of ownership is higher than my figures — by how much depends on how your roles are split, so I'm not inventing a multiplier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ESBs have real strengths.&lt;/strong&gt; A ready-made bus gives you out-of-the-box adapters you'd otherwise write by hand, one uniform administration point, and a model a team already fluent in it understands. If you have that competence in-house, some of my estimates are too high for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The organisation could have bought some of this.&lt;/strong&gt; Then the saving converts into licences and into not developing your own, rather than into person-hours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And most importantly: the system works.&lt;/strong&gt; It holds production, serves the SAP integration and the mobile clients — and, again, it's sixteen months old. In sixteen months the team stood up from scratch what used to be bought as a service. This isn't a post-mortem of a failure. It's an accounting of the price paid for a specific set of architectural decisions. The price may well be justified; the question is whether you know it.&lt;/p&gt;




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

&lt;p&gt;Everything above is the "before" state. The system is leaving it: &lt;strong&gt;the integration bus, the identity server and the ORM are all being removed.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Three of the four layers get dismantled. The one that stays is the one that cost more than the other three combined.&lt;/p&gt;

&lt;p&gt;The next post is about that transition — a module-for-module comparison, the same zone, the same job, bus artefacts and a Java mediator on the left, a rewritten module on the right. And the headline result of that comparison was the opposite of what you'd expect from a migration post: &lt;strong&gt;there is now more code, not less.&lt;/strong&gt; Why that's the correct outcome is the next one.&lt;/p&gt;

&lt;p&gt;If you have a similar stack and different numbers, post yours in the comments. I'm happy to revise mine — converging on a realistic model is more interesting than defending the first draft.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Sources and releases: &lt;a href="https://github.com/redbase-app/" rel="noopener noreferrer"&gt;github.com/redbase-app&lt;/a&gt;. About the redb store: &lt;a href="https://redbase.app/" rel="noopener noreferrer"&gt;redbase.app&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If this was useful — a ⭐ on GitHub helps others find it.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>architecture</category>
      <category>eip</category>
      <category>csharp</category>
    </item>
    <item>
      <title>Reference architecture for a payment platform on .NET: the ledger, transaction boundaries, modules, and the cluster</title>
      <dc:creator>rinat kozin</dc:creator>
      <pubDate>Tue, 28 Jul 2026 22:46:52 +0000</pubDate>
      <link>https://dev.to/rinat_kozin/reference-architecture-for-a-payment-platform-on-net-the-ledger-transaction-boundaries-modules-2neh</link>
      <guid>https://dev.to/rinat_kozin/reference-architecture-for-a-payment-platform-on-net-the-ledger-transaction-boundaries-modules-2neh</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%2F4rep8diwozoo6petwkrz.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%2F4rep8diwozoo6petwkrz.png" alt="redb fintech" width="800" height="1200"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the &lt;a href="https://dev.to/rinat_kozin/a-payment-platform-on-net-what-it-actually-costs-and-how-much-of-it-you-never-have-to-write-2lfb"&gt;previous post&lt;/a&gt; I took a payment platform apart by cost: three layers, where the person-years go, what you can avoid writing. It left one question honestly open: &lt;strong&gt;fine, but how do you actually assemble it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the answer. A reference architecture: the ledger model, transaction boundaries, module split, cluster topology, integration routes, and recovery points. With code and diagrams.&lt;/p&gt;

&lt;p&gt;Caveat up front — this is a &lt;strong&gt;reference model&lt;/strong&gt;, not a dump from one production system. The code shows the real API and patterns that work, but your accounting model will differ, and it should. More on that at the end.&lt;/p&gt;

&lt;p&gt;The stack: &lt;strong&gt;redb&lt;/strong&gt;, a typed store on PostgreSQL; &lt;strong&gt;redb.Route&lt;/strong&gt;, an integration engine; &lt;strong&gt;redb.Tsak&lt;/strong&gt;, a runtime; and &lt;strong&gt;redb.Identity&lt;/strong&gt;, an OIDC/OAuth 2.1 server. All Pro, all free across the 3.x line.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Part of the redb / redb.Route series&lt;/strong&gt; — recent posts first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/rinat_kozin/a-payment-platform-on-net-what-it-actually-costs-and-how-much-of-it-you-never-have-to-write-2lfb"&gt;A payment platform on .NET: what it actually costs, and how much of it you never have to write&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/rinat_kozin"&gt;redb 3.4.0: day-two operations for a .NET stack&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/rinat_kozin/leaving-masstransit-for-a-camel-state-of-mind-the-kafka-connector-scatter-gather-and-what-really-106h"&gt;Leaving MassTransit for a Camel state of mind: the Kafka connector, Scatter-Gather, and transactions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/rinat_kozin/a-homegrown-apache-camel-for-net-dissected-the-http-connector-with-no-aspnet-mvc-the-56bd"&gt;Apache Camel for .NET, dissected: the HTTP connector with no ASP.NET MVC + the Content-Based Router&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/rinat_kozin/redbroute-apache-camel-for-net-22-transports-30-eip-patterns-compiled-dsl-11m0"&gt;redb.Route — Apache Camel for .NET: 22 transports, 30+ EIP patterns, compiled DSL&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sources: &lt;a href="https://github.com/redbase-app/" rel="noopener noreferrer"&gt;github.com/redbase-app&lt;/a&gt;. Docs: &lt;a href="https://redbase.app/" rel="noopener noreferrer"&gt;redbase.app&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The whole thing on one screen
&lt;/h2&gt;

&lt;p&gt;Top down first. Here's the platform in one picture; we'll take each block apart below.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                        OUTSIDE WORLD
   browser/mobile   acquirer     bank      partner   regulator
        │              │           │          │          │
     HTTPS          HTTPS      IBM MQ      SFTP      exports
        │              │           │          │          │
╔═══════▼══════════════▼═══════════▼══════════▼══════════▼═══════╗
║                      TSAK WORKER (clustered)                   ║
║                                                                ║
║  ┌──────────────┐  ┌───────────────────────────────────────┐   ║
║  │  identity    │  │            payments                   │   ║
║  │  .tpkg       │  │                                       │   ║
║  │              │  │  payments.Api     ← HTTP facade       │   ║
║  │  OAuth 2.1   │◄─┼─ payments.Acq     ← acquirers         │   ║
║  │  OIDC        │  │  payments.Bank    ← IBM MQ            │   ║
║  │  SCIM        │  │  payments.Files   ← SFTP registries   │   ║
║  │  audit       │  │  payments.Recon   ← reconciliation    │   ║
║  │              │  │  payments.Core    ← LEDGER + SCHEMAS  │   ║
║  └──────┬───────┘  └───────────────┬───────────────────────┘   ║
║         │   direct-vm://           │                           ║
║         │   (no network)           │                           ║
╚═════════╪══════════════════════════╪═══════════════════════════╝
          │                          │
     ┌────▼─────┐               ┌────▼────────┐
     │ identity │               │  payments   │
     │    DB    │               │     DB      │
     └──────────┘               └─────────────┘
       separate                   PostgreSQL
       named store                   (Pro)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four things worth noticing immediately, because they drive everything else:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Identity runs in the same worker but on its own database.&lt;/strong&gt; Payment modules reach it over &lt;code&gt;direct-vm://&lt;/code&gt; — an in-process transport, no socket and no TLS. From the outside it stays a normal OIDC server on HTTPS for browsers and mobile apps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;payments.Core&lt;/code&gt; has no external transport at all.&lt;/strong&gt; It's pure ledger: schemas, posting rules, balance computation. Every entrance to it is &lt;code&gt;direct-vm://&lt;/code&gt; from a neighbouring module.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Each external protocol is its own module.&lt;/strong&gt; The acquirer changed something and its module needs redeploying — the bank rail doesn't notice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Many modules, one worker.&lt;/strong&gt; Or several — but that's an operations decision, not an architectural one, and it's a config change. We'll come back to it in the cluster section.&lt;/p&gt;




&lt;h2&gt;
  
  
  The ledger: a posting that never changes
&lt;/h2&gt;

&lt;p&gt;Start with the data model, because everything else follows from it — transaction boundaries, idempotency, reconciliation, and what you'll be able to tell a regulator three years from now.&lt;/p&gt;

&lt;h3&gt;
  
  
  Decision #1: postings are append-only
&lt;/h3&gt;

&lt;p&gt;The single most important architectural decision fits in one sentence: &lt;strong&gt;a posting object is created once and never modified.&lt;/strong&gt; No status field, no cancellation, no amount correction. Made a mistake? Write a reversing posting; don't edit the old one.&lt;/p&gt;

&lt;p&gt;Why this matters specifically here: redb has no built-in object versioning. If you update postings, you'll be building the change history yourself. If you don't — the history &lt;strong&gt;is&lt;/strong&gt; the posting journal, and there's nothing to build.&lt;/p&gt;

&lt;p&gt;That's not an engine limitation, that's correct accounting. Bookkeeping has worked this way for two hundred years.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;RedbScheme&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"payments.posting"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Alias&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Posting"&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;PostingProps&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;/// &amp;lt;summary&amp;gt;Business operation key. One operation → several postings sharing it.&amp;lt;/summary&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;OperationId&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;/// &amp;lt;summary&amp;gt;Account. A flat id, not a reference — see below.&amp;lt;/summary&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;AccountId&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;/// &amp;lt;summary&amp;gt;Signed delta: positive is a credit, negative is a debit.&amp;lt;/summary&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Delta&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;RedbListItem&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Currency&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;RedbListItem&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Kind&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt; &lt;span class="n"&gt;OccurredAt&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;/// &amp;lt;summary&amp;gt;Set only on reversals — points at the posting being reversed.&amp;lt;/summary&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;ReversesPostingId&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Reference&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details, each a deliberate choice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;decimal Delta&lt;/code&gt; lands in a &lt;code&gt;NUMERIC(38,18)&lt;/code&gt; column.&lt;/strong&gt; Nothing to configure: any &lt;code&gt;decimal&lt;/code&gt; in props maps to a column with 38 digits of precision, 18 of them after the point. Fees, FX and tax compute without accumulating rounding error — and eighteen decimals happens to be exactly the precision Ether is denominated in, if a crypto line shows up next year.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;long AccountId&lt;/code&gt; instead of &lt;code&gt;RedbObject&amp;lt;AccountProps&amp;gt;&lt;/code&gt;.&lt;/strong&gt; redb supports object references natively, and for domain entities they're great — the whole graph loads in one call. But postings are read in the millions and always by account, so what's needed here is a fast scalar filter, not a graph. A flat &lt;code&gt;long&lt;/code&gt; hits a partial index on numeric values and behaves like an ordinary column. &lt;strong&gt;The rule: use a graph where the object is read whole; use a flat key where you filter by it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;RedbListItem&lt;/code&gt; for currency and operation kind.&lt;/strong&gt; These are redb's built-in lookup lists: the value is stored as a reference to a list item, and you can filter both by item identity and by its string value. No enum tables and no joins.&lt;/p&gt;

&lt;h3&gt;
  
  
  Decision #2: the account has no balance field
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;RedbScheme&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"payments.account"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Alias&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Account"&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;AccountProps&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;OwnerId&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;RedbListItem&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Currency&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;RedbListItem&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;AccountType&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt; &lt;span class="n"&gt;OpenedAt&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;ClosedAt&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// There is NO Balance field here. On purpose.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A balance field on the account is the most common and most expensive mistake in payment systems. It immediately creates two sources of truth: the balance in the field, and the balance as the sum of postings. They diverge. Always. The only question is when you notice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A balance is a function of the journal&lt;/strong&gt;, full stop:&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;balance&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;PostingProps&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;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AccountId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SumAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Delta&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One query, aggregated database-side, nothing pulled into memory.&lt;/p&gt;

&lt;h3&gt;
  
  
  Decision #3: the snapshot is an optimisation, not the truth
&lt;/h3&gt;

&lt;p&gt;On an account with a million postings you obviously can't sum everything every time. Hence snapshots:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;RedbScheme&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"payments.balance_snapshot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Alias&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Balance snapshot"&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;BalanceSnapshotProps&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;AccountId&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Amount&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;/// &amp;lt;summary&amp;gt;This snapshot accounts for every posting with id ≤ this value.&amp;lt;/summary&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;UpToPostingId&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt; &lt;span class="n"&gt;TakenAt&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The balance is then "latest snapshot plus deltas since":&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;snap&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;BalanceSnapshotProps&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;s&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AccountId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OrderByDescending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UpToPostingId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FirstOrDefaultAsync&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;baseAmount&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;snap&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;Amount&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="m"&gt;0m&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;fromId&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;snap&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;UpToPostingId&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="m"&gt;0L&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;delta&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;PostingProps&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;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AccountId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WhereRedb&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;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;fromId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SumAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Delta&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;balance&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;baseAmount&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key property: &lt;strong&gt;a snapshot can be thrown away and recomputed at any moment&lt;/strong&gt;, because the source of truth is the journal. Snapshot corrupted, stale, or computed by old logic? Delete it and take a new one. With a balance field on the account you don't get that luxury — once it diverges, you no longer know which number is right.&lt;/p&gt;

&lt;p&gt;Snapshots are taken by a scheduled background job — an ordinary route, covered in the integrations section.&lt;/p&gt;

&lt;h3&gt;
  
  
  Decision #4: idempotency belongs in the model, not in a side table
&lt;/h3&gt;

&lt;p&gt;The operation key lives &lt;strong&gt;inside the posting&lt;/strong&gt;, not in a separate "processed messages" table. Which makes "has this operation already been posted?" an ordinary query:&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;alreadyPosted&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;PostingProps&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;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OperationId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;operationId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AnyAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why this beats a side table: &lt;strong&gt;it cannot get out of sync.&lt;/strong&gt; The posting and the record that it happened are the same object, written by the same operation. The classic two-table shape permits "marker exists, posting doesn't" and vice versa — and that gets cleaned up by hand.&lt;/p&gt;

&lt;p&gt;A route-level idempotent consumer still exists, but it solves a different problem: rejecting redelivery before we ever reach the database. Two different lines of defence; neither replaces the other.&lt;/p&gt;

&lt;h3&gt;
  
  
  The model as a whole
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   ┌────────────────┐         ┌─────────────────────┐
   │    Account     │         │  BalanceSnapshot    │
   │  payments.     │◄────────┤  payments.          │
   │  account       │  1:N    │  balance_snapshot   │
   │                │         │                     │
   │ Number         │         │ AccountId           │
   │ OwnerId        │         │ Amount              │
   │ Currency       │         │ UpToPostingId  ─────┼──┐
   │ AccountType    │         │ TakenAt             │  │
   │ (no balance!)  │         └─────────────────────┘  │
   └───────┬────────┘                                  │
           │ 1:N                                       │
           │                                           │
   ┌───────▼───────────────────────────────┐           │
   │            Posting                    │           │
   │        payments.posting               │◄──────────┘
   │                                       │  snapshot "up to"
   │  OperationId  ← idempotency           │
   │  AccountId                            │
   │  Delta        ← NUMERIC(38,18)        │
   │  Currency     ← RedbListItem          │
   │  Kind         ← RedbListItem          │
   │  OccurredAt                           │
   │  ReversesPostingId ──┐                │
   │  Reference           │ reversal       │
   └──────────────────────┴────────────────┘
              APPEND-ONLY. Never updated.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what the model &lt;strong&gt;doesn't&lt;/strong&gt; have: an idempotency table, a change-history table, a status field on the posting, a balance field on the account. Each is absent for its own reason, and every reason is the same underlying one — don't create a second source of truth.&lt;/p&gt;




&lt;h2&gt;
  
  
  Transaction boundaries: where exactly the line runs
&lt;/h2&gt;

&lt;p&gt;The previous section was about what to store. This one is about what has to happen atomically. This is where people get it wrong most often, and where being wrong costs the most.&lt;/p&gt;

&lt;p&gt;There's one rule and it's rigid:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  INSIDE ONE TRANSACTION                  OUTSIDE — NEVER
  ──────────────────────                  ───────────────
  ✓ every posting of one operation        ✗ HTTP to the acquirer
  ✓ the outbox row                        ✗ publishing to a broker
  ✓ the balance snapshot update           ✗ sending an email
  ✓ the idempotency marker                ✗ any network call
    (which is the posting itself)         ✗ waiting on anyone else
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason is grade-school simple: a transaction holds locks, and a network call can take thirty seconds. An external call inside a transaction is guaranteed degradation under load plus entertaining deadlocks at 3 a.m.&lt;/p&gt;

&lt;p&gt;Which is also why the outbox isn't a luxury but a necessity: &lt;strong&gt;you cannot publish to a broker atomically with a posting, but you can write to your own database.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What it looks like in code
&lt;/h3&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="n"&gt;TransferResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;TransferAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;fromAccountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;toAccountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;operationId&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="c1"&gt;// 1. Cheap rejection BEFORE the transaction: shed retries without taking locks&lt;/span&gt;
    &lt;span class="k"&gt;if&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;PostingProps&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;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OperationId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;operationId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;AnyAsync&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;TransferResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AlreadyProcessed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;tx&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;Context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BeginTransactionAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// 2. Lock accounts STRICTLY IN ASCENDING id ORDER.&lt;/span&gt;
    &lt;span class="c1"&gt;//    Otherwise two opposing transfers A→B and B→A deadlock.&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;locked&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="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;fromAccountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toAccountId&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="n"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;locked&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="nf"&gt;LockForUpdateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;locked&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// 3. Re-check — now under the lock (TOCTOU guard)&lt;/span&gt;
    &lt;span class="k"&gt;if&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;PostingProps&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;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OperationId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;operationId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;AnyAsync&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;TransferResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AlreadyProcessed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// dispose rolls back&lt;/span&gt;

    &lt;span class="c1"&gt;// 4. Sufficient funds — evaluated under that same lock&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;GetBalanceAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fromAccountId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;amount&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;TransferResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InsufficientFunds&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;now&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// 5. Both postings in one batch, one round-trip&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="nf"&gt;AddNewObjectsAsync&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="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;Posting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;operationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fromAccountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nf"&gt;Posting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;operationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toAccountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="p"&gt;+&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// 6. The event goes to the outbox IN THE SAME TRANSACTION&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="nf"&gt;SaveAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;OutboxEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"transfer.completed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;operationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&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;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CommitAsync&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;TransferResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Posted&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 non-obvious parts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The double idempotency check isn't paranoia.&lt;/strong&gt; The first, outside the transaction, sheds mass retries cheaply: it takes no locks and blocks nobody. The second, under the lock, closes the window between check and write. Without the first the system degrades on a retry storm; without the second it lets a double posting through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sorting ids before locking is mandatory.&lt;/strong&gt; Textbook, and routinely forgotten. Two opposing transfers — A→B and B→A — lock the accounts in opposite order and wedge. A single canonical acquisition order removes an entire class of deadlocks; the store itself uses the same trick internally for batch operations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The balance check sits under the same lock as the write.&lt;/strong&gt; Check without the lock and you get the classic race that produces a double debit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Both postings go in one batch.&lt;/strong&gt; &lt;code&gt;AddNewObjectsAsync&lt;/code&gt; is a single round-trip rather than two. On a transfer the difference is small; on bulk charging it's decisive.&lt;/p&gt;

&lt;p&gt;If you don't need explicit rollback control, there's a shorter form:&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;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;Context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExecuteAtomicAsync&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="p"&gt;=&amp;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;redb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddNewObjectsAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;postings&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="nf"&gt;SaveAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;outboxEvent&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;h3&gt;
  
  
  Why this matters for sharding later
&lt;/h3&gt;

&lt;p&gt;Notice that &lt;strong&gt;the entire transaction fits inside one database&lt;/strong&gt;. That isn't an accident — it's a requirement you have to design in from day one.&lt;/p&gt;

&lt;p&gt;There is no distributed transaction across shards in cross-platform .NET; two-phase commit over two PostgreSQL instances isn't available. So if the platform ever grows sideways, &lt;strong&gt;the shard key must be chosen so both sides of a transfer land on the same shard.&lt;/strong&gt; In practice that means sharding by customer or by account group, not by payment id.&lt;/p&gt;

&lt;p&gt;You make this decision once, at the start. Redoing it on live data is a separate project.&lt;/p&gt;

&lt;h3&gt;
  
  
  The boundary, drawn
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   HTTP request
       │
       ▼
   ┌──────────────────────────────────────────────┐
   │  1. Idempotency check (no locks taken)       │  ← outside the transaction
   └───────────────────┬──────────────────────────┘
                       │
   ╔═══════════════════▼═══════════════════════════╗
   ║                TRANSACTION                    ║
   ║                                               ║
   ║   2. LockForUpdate(accounts, ascending id)    ║
   ║   3. Idempotency check, again                 ║
   ║   4. Sufficient-funds check                   ║
   ║   5. AddNewObjects(postings)                  ║
   ║   6. Save(outbox event)                       ║
   ║                                               ║
   ║        COMMIT ────────────────────────────┐   ║
   ╚═══════════════════════════════════════════╪═══╝
                                               │
                       ┌───────────────────────┘
                       │ from here on: async, outside the transaction
                       ▼
   ┌───────────────────────────────────────────────┐
   │  Sql.Poll(outbox) → Kafka → acquirer / bank   │
   │  Wait on the network as long as you like:     │
   │  locks are released, money is already posted  │
   └───────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Module split: unit of deploy = unit of failure
&lt;/h2&gt;

&lt;p&gt;A module in Tsak is a &lt;code&gt;.tpkg&lt;/code&gt;: a built package with an entry point that the runtime loads into its process, giving it its own route context, its own assembly load context, and its own DI container.&lt;/p&gt;

&lt;p&gt;The slicing criterion is simple, and it's neither "layers" nor "domains":&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A module is whatever you'll want to deploy or roll back separately from everything else.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Which naturally produces a split by external protocol rather than by business entity.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   ┌─────────────────────────────────────────────────────────┐
   │  payments.Core                                          │
   │  ────────────                                           │
   │  • schemas: Account, Posting, BalanceSnapshot, Outbox   │
   │  • ledger: TransferAsync, GetBalanceAsync, Reverse      │
   │  • lookups: currencies, operation kinds                 │
   │  • NOT ONE external transport                           │
   │                                                         │
   │  Entrances: direct-vm://payments-post                   │
   │             direct-vm://payments-balance                │
   └────▲──────▲──────────▲──────────▲──────────▲────────────┘
        │      │          │          │          │
        │      │          │          │          │  direct-vm://
        │      │          │          │          │
   ┌────┴───┐ ┌┴──────┐ ┌─┴──────┐ ┌─┴──────┐ ┌─┴────────┐
   │  .Api  │ │ .Acq  │ │ .Bank  │ │ .Files │ │  .Recon  │
   │        │ │       │ │        │ │        │ │          │
   │ HTTP   │ │ HTTPS │ │ IBM MQ │ │ SFTP   │ │ Quartz   │
   │ intake │ │acquirer││  bank  │ │registry│ │  recon   │
   └────────┘ └───────┘ └────────┘ └────────┘ └──────────┘
    facade     outbound   outbound   files     scheduled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What that buys in practice:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The acquirer changed its format — &lt;code&gt;payments.Acq&lt;/code&gt; gets redeployed.&lt;/strong&gt; The bank rail, payment intake and reconciliation don't notice: their modules never restarted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ledger changes least often and ships most carefully.&lt;/strong&gt; &lt;code&gt;payments.Core&lt;/code&gt; is the only module that touches money. Its release cadence differs from the rest, and that's fine — it also gets the fewest changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A new acquirer is a new module, not an edit to an existing one.&lt;/strong&gt; It cannot break the working one because it physically lives elsewhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A dead SFTP server won't take down payment intake.&lt;/strong&gt; Each module has its own route context and its own connections.&lt;/p&gt;

&lt;h3&gt;
  
  
  The module entry point
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;InitRoute&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;static&lt;/span&gt; &lt;span class="n"&gt;IRouteContext&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;IRouteContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Only the transports THIS module needs&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;AddComponent&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;HttpComponent&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

        &lt;span class="c1"&gt;// A named store instance: payments has its own database,&lt;/span&gt;
        &lt;span class="c1"&gt;// identity has its own — they share neither connections nor caches&lt;/span&gt;
        &lt;span class="kt"&gt;var&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;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetRedbService&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="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRouteBuilder&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;PaymentsApiRouteBuilder&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;context&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;The named store isn't a detail — it carries an important property: the instance is created &lt;strong&gt;per exchange&lt;/strong&gt; and lives exactly as long as the message is being handled. The connection never becomes a shared resource, and concurrent requests don't queue behind a single one.&lt;/p&gt;

&lt;h3&gt;
  
  
  How modules call each other
&lt;/h3&gt;

&lt;p&gt;Inside one worker, without the network:&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;class&lt;/span&gt; &lt;span class="nc"&gt;PaymentsApiRouteBuilder&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RouteBuilder&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;Configure&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="n"&gt;Http&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;"/api/payments/transfer"&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;8080&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;InOut&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;"api-transfer"&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="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JsonMessageSerializer&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;TransferRequest&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="nf"&gt;RequireScope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"payments:write"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IdempotentConsumer&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;Message&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="s"&gt;"Idempotency-Key"&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://payments-post"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// ← into the ledger, no network&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="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JsonMessageSerializer&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Respond&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;&lt;code&gt;direct-vm://&lt;/code&gt; is an in-process transport between route contexts. No socket, no serialization, no TLS: same thread, same exchange object.&lt;/p&gt;

&lt;p&gt;And here's the architectural payoff: &lt;strong&gt;if tomorrow &lt;code&gt;payments.Core&lt;/code&gt; has to move to its own worker, a URI string changes, not code.&lt;/strong&gt; &lt;code&gt;direct-vm://payments-post&lt;/code&gt; becomes &lt;code&gt;rabbitmq://payments-post&lt;/code&gt; or &lt;code&gt;grpc://.../Post&lt;/code&gt; and that's it. The decision of "monolith or distributed" is deferred to operations and changed by configuration.&lt;/p&gt;

&lt;p&gt;That's arguably the whole reason to slice modules this way.&lt;/p&gt;




&lt;h2&gt;
  
  
  Integration routes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The outbox — and why it isn't a redb object
&lt;/h3&gt;

&lt;p&gt;The one place we deliberately step away from the typed store into a flat table:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;payments_outbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;           &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;event_type&lt;/span&gt;   &lt;span class="nb"&gt;text&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;operation_id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt;      &lt;span class="n"&gt;jsonb&lt;/span&gt;       &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;   &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;processed&lt;/span&gt;    &lt;span class="nb"&gt;boolean&lt;/span&gt;     &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;processed_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;ix_outbox_pending&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;payments_outbox&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="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;processed&lt;/span&gt; &lt;span class="o"&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;Why, when the whole domain lives in redb: &lt;strong&gt;the outbox is infrastructure, not domain.&lt;/strong&gt; It's flat by nature, lives for seconds, gets read in batches by one predicate, and gets deleted. It has no use for typing, object graphs or schema evolution — and a lot of use for a partial index on unprocessed rows and batched polling.&lt;/p&gt;

&lt;p&gt;This is exactly the situation where it matters that the storage structure is open: &lt;strong&gt;you can write to redb and to plain SQL in the same transaction&lt;/strong&gt;, because the context and the connection are shared.&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;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;Context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExecuteAtomicAsync&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="p"&gt;=&amp;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;redb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddNewObjectsAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;postings&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// domain → redb&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;Context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExecuteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;                  &lt;span class="c1"&gt;// infrastructure → SQL&lt;/span&gt;
        &lt;span class="s"&gt;"INSERT INTO payments_outbox (event_type, operation_id, payload) VALUES (@t, @o, @p)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;operationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payloadJson&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 rule that falls out: &lt;strong&gt;redb for what you read, search and evolve. Flat SQL for what you pump through.&lt;/strong&gt; Mixing them in one transaction is fine and normal.&lt;/p&gt;

&lt;p&gt;Then the publisher:&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;Sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Poll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SELECT * FROM payments_outbox WHERE processed = false ORDER BY id LIMIT 200"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;DataSource&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;Delay&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;OnSuccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"UPDATE payments_outbox SET processed = true, processed_at = now() "&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt;
                   &lt;span class="s"&gt;"WHERE id = ANY(@ids)"&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;RouteId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"outbox-publisher"&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="nf"&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="n"&gt;Kafka&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Topic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"payments.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;Acks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"All"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EnableIdempotence&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;EnableTransactionalProducer&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;TransactionIdPrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"payments-outbox"&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;OnSuccess&lt;/code&gt; runs in the same transaction scope as the publish: either the event went out and is marked processed, or neither. There's no in-between state.&lt;/p&gt;

&lt;h3&gt;
  
  
  Outbound to an acquirer: where the save-point goes
&lt;/h3&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;Kafka&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Topic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"payments.events"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;GroupId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"acq"&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;"acq-charge"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Filter&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;"event_type"&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;"transfer.completed"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Replayable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"acq-charge"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;// ← SAVE-POINT&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;BuildAcquirerRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;RedeliveryDelay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;UseExponentialBackOff&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://acq.example.com/v1/charge"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;15_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;Process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ParseAcquirerResponse&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://payments-mark-charged"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EndReplayable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The save-point goes &lt;strong&gt;after&lt;/strong&gt; entering the route and &lt;strong&gt;before&lt;/strong&gt; the first external call — that is, at the boundary where state is assembled but we haven't gone outside yet. If the acquirer is down, retries don't help and the exchange fails, the snapshot lands in the dead-letter store and a support operator hits Replay from the dashboard once the acquirer is back.&lt;/p&gt;

&lt;p&gt;An important detail the engine checks for you: &lt;strong&gt;don't casually put a save-point on a route where a broker or a transaction already owns redelivery.&lt;/strong&gt; Otherwise two parties are responsible for the retry, which is the direct path to a double charge. This route deliberately isn't marked &lt;code&gt;.Transacted()&lt;/code&gt; for exactly that reason: the save-point mechanism owns the retry here, not Kafka.&lt;/p&gt;

&lt;h3&gt;
  
  
  The bank on IBM MQ: here it's the opposite
&lt;/h3&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;Wmq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"PAYMENTS.IN"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;QueueManager&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"QM.PROD"&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="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;RouteId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"bank-inbound"&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="c1"&gt;// ← ack and send commit together&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="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Iso20022Codec&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;Schemas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pacs008&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// schema validation is a built-in step&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;MapToPostingRequest&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://payments-post"&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;Wmq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"PAYMENTS.ACK"&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="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;The model here is the inverse of the previous one: the queue owns redelivery. On failure everything rolls back, the message returns to the queue, the backout counter increments, and past a threshold the message moves to a dedicated queue for triage instead of looping forever.&lt;/p&gt;

&lt;p&gt;You write the ISO 20022 parsing (no codecs ship in the box — every profile differs), but XSD validation is built in, and that's half the work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Registry files over SFTP
&lt;/h3&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;Sftp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Poll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/in/registry"&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;"*.xml"&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;60_000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Move&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/in/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;RouteId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"files-registry"&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;Schemas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Registry&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="nf"&gt;XPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"//Payment"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Threads&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="c1"&gt;// fan the parsing out&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;MapToPostingRequest&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://payments-post"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EndSplit&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;"log://registry-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;.Threads(8)&lt;/code&gt; matters here: the source polls serially, but parsing a ten-thousand-line registry fans out across a pool. Ordering within the registry is lost — acceptable for charges, not acceptable for a sequence of operations on one account. If you need that, parallelise by account, not by line.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scheduled reconciliation
&lt;/h3&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;Quartz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Cron&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"0 30 3 * * ?"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;          &lt;span class="c1"&gt;// daily at 03:30&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;"recon-daily"&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="k"&gt;async&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;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="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&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;e&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;GetRedbService&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="n"&gt;e&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;byMerchant&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;PostingProps&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;WhereRedb&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;DateCreate&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Today&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddDays&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;GroupBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MerchantId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SelectAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;g&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="n"&gt;Total&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Agg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Delta&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;Message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetBody&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;byMerchant&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;"direct-vm://recon-compare"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;// compare against the acquirer statement&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;Sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"recon_report"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The aggregation runs database-side over live data. No separate analytical stack is needed for this — you'll want one later, for something else, which we'll get to.&lt;/p&gt;

&lt;h3&gt;
  
  
  The route map
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  IN                      LEDGER                   OUT
  ──                      ──────                   ───

  HTTP /transfer ──┐
                   │
  IBM MQ PAY.IN ───┼──► direct-vm://          ┌──► payments_outbox
   (transacted)    │    payments-post ────────┤    (same transaction)
                   │         │                └──► postings in redb
  SFTP registries ─┘         │
   (Threads 8)               ▼
                        ┌─────────┐
  Quartz 03:30 ────────►│  redb   │
   (reconciliation)     │payments │
                        └─────────┘
                             ▲
                             │
       ┌─────────────────────┘
       │  Sql.Poll(outbox) ──► Kafka (EOS) ──┬──► acquirer (HTTP)
       │   .Transacted()                     │     └ .Replayable
       │                                     └──► notifications
       └─ OnSuccess: processed = true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The facade: where authorization sits
&lt;/h2&gt;

&lt;p&gt;The identity server runs in the same worker but &lt;strong&gt;on its own database&lt;/strong&gt;. That matters: payment data and OAuth records share no connections, no transactions, no caches. Splitting them onto separate database servers later is a connection-string change, not a rewrite.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   EXTERNAL CLIENTS                   INTERNAL MODULES
   ────────────────                   ────────────────
   browser, mobile app,               payments.Api
   partner                            payments.Acq
        │                                   │
        │ HTTPS                             │ direct-vm://
        │ (the spec requires                │ (no network,
        │  a browser only for               │  no TLS,
        │  /authorize)                      │  no JSON)
        ▼                                   ▼
   ┌──────────────────────────────────────────────────┐
   │              redb.Identity                       │
   │                                                  │
   │  /connect/token      direct-vm://identity-token  │
   │  /connect/introspect direct-vm://identity-...    │
   │  /connect/authorize  ← the only one needing HTTP │
   │  /scim/v2/*                                      │
   │                                                  │
   │  79 typed audit events ──► redb + SIEM           │
   └──────────────────┬───────────────────────────────┘
                      │
              ┌───────▼────────┐
              │  identity DB   │   ← separate from payments
              └────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Authorization at the payment API entrance:&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;Process&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="n"&gt;e&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;=&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;token&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;Message&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="s"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;)?[&lt;/span&gt;&lt;span class="s"&gt;"Bearer "&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt;&lt;span class="p"&gt;..];&lt;/span&gt;

    &lt;span class="c1"&gt;// Introspection WITHOUT a network call — same process&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&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;_identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequestBody&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IntrospectionResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
        &lt;span class="n"&gt;IdentityEndpoints&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Introspect&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="n"&gt;token&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;result&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Active&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="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scopes&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;"payments:write"&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;UnauthorizedException&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="nf"&gt;SetProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"subject"&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;span class="n"&gt;Subject&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 difference from the usual shape isn't cosmetic. Classically every internal call means a network hop to the identity server — and at meaningful traffic that becomes a bottleneck, a single point of failure, and a permanent latency tax. Here it's a method call.&lt;/p&gt;

&lt;p&gt;From the outside it stays a normal OIDC provider: the browser parts speak HTTPS because the spec says so, and everything else — issuance, refresh, introspection, revocation, management, SCIM — is transport-neutral.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which also implies something worth keeping in mind when designing closed segments:&lt;/strong&gt; you can have as many front doors as you have channels. An inter-branch segment with regulator-mandated crypto, a site where only the corporate bus is allowed, a partner channel with a proprietary format — those are separate adapter modules in front of one shared core. One core, one set of permissions, one audit trail; only the adapters differ.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cluster topology: nodes don't have to be identical
&lt;/h2&gt;

&lt;p&gt;Here operations begins, and here's a property that rarely gets designed in — unfairly so.&lt;/p&gt;

&lt;p&gt;Tsak's coordinator is three-level: &lt;strong&gt;cluster → group → node&lt;/strong&gt;. A group is a geographic or logical partition. And placement is tracked &lt;strong&gt;per module&lt;/strong&gt;, scoped to a group — so a node isn't a replica of its neighbour, it's a carrier of a specific module set.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   cluster: production
   │
   ├── group: acquiring          ← hot path, many nodes
   │    ├── node-acq-1   [payments.Api, payments.Acq, payments.Core]
   │    ├── node-acq-2   [payments.Api, payments.Acq, payments.Core]
   │    └── node-acq-3   [payments.Api, payments.Acq, payments.Core]
   │
   ├── group: payouts            ← bank rail, separate network
   │    ├── node-pay-1   [payments.Bank, payments.Files, payments.Core]
   │    └── node-pay-2   [payments.Bank, payments.Files, payments.Core]
   │
   └── group: reporting          ← scheduled work, one node is enough
        └── node-rep-1   [payments.Recon, identity]

   Leader: elected cluster-wide, with epoch fencing.
           A stale leader cannot corrupt state after
           losing the election.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this gives architecturally:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Contours are separated by load and criticality without being split into separate systems.&lt;/strong&gt; The acquiring contour scales horizontally for peaks, the bank rail lives on two nodes in a separate network, scheduled work sits on one. Still one cluster, one control plane, one audit trail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ledger module appears in two groups.&lt;/strong&gt; Both intake and payouts need it. That's fine — the module is stateless, state lives in the database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The scheduler is cluster-aware.&lt;/strong&gt; Scheduled reconciliation is marked as a leader-only job and doesn't fire on three nodes at once.&lt;/p&gt;

&lt;p&gt;The configuration is the same on every node; node id, address and group differ:&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ConnectionStrings"&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;"Postgres"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Host=db.cluster;Database=payments;..."&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;"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;"Storage"&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;"Type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Redb"&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;"Cluster"&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;"Enabled"&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;"ClusterName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"GroupName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"acquiring"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;              &lt;/span&gt;&lt;span class="c1"&gt;// ← differs per group&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"NodeId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node-acq-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;                &lt;/span&gt;&lt;span class="c1"&gt;// ← differs per node&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"ApiEndpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://node-acq-1:9090"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"HeartbeatIntervalSeconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"DeadNodeTimeoutSeconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"LeaderLockTtlSeconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&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;"HotReload"&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;"RollingUpdate"&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="c1"&gt;// roll node by node&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Auth"&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;"Enabled"&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;Taking a node out for maintenance is draining, not switching off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tsak cluster cordon node-acq-2     &lt;span class="c"&gt;# takes no new work, finishes what it started&lt;/span&gt;
&lt;span class="c"&gt;# ... maintenance ...&lt;/span&gt;
tsak cluster uncordon node-acq-2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Failure map: what breaks, and what handles it
&lt;/h2&gt;

&lt;p&gt;An architecture is judged not by how it works but by how it fails. Walking the scenarios:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;th&gt;Who fixes it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Process died between posting and publish&lt;/td&gt;
&lt;td&gt;Posting is committed, outbox row isn't marked. The publisher picks it up on start&lt;/td&gt;
&lt;td&gt;Itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicate message from the broker&lt;/td&gt;
&lt;td&gt;Idempotent consumer rejects by key; if it slips through, the &lt;code&gt;OperationId&lt;/code&gt; check under the lock catches it&lt;/td&gt;
&lt;td&gt;Itself, two lines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Acquirer returns 500&lt;/td&gt;
&lt;td&gt;Three retries with exponential backoff. Still failing → snapshot to the dead-letter store&lt;/td&gt;
&lt;td&gt;Support, one click&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Acquirer down for an hour&lt;/td&gt;
&lt;td&gt;Same, but snapshots pile up. Once it's back — bulk replay&lt;/td&gt;
&lt;td&gt;Support&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bank returned an error over MQ&lt;/td&gt;
&lt;td&gt;Transaction rolls back, message returns to the queue, backout counter climbs; past the threshold it moves to a triage queue&lt;/td&gt;
&lt;td&gt;Itself + triage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two opposing transfers at once&lt;/td&gt;
&lt;td&gt;Accounts locked in one canonical ascending order — no deadlock&lt;/td&gt;
&lt;td&gt;Itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node died&lt;/td&gt;
&lt;td&gt;Heartbeats stopped, the leader reassigned its modules to live nodes&lt;/td&gt;
&lt;td&gt;Itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The leader died&lt;/td&gt;
&lt;td&gt;Re-election once the lock TTL expires; the epoch increments, so the old leader can't do damage&lt;/td&gt;
&lt;td&gt;Itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Balance snapshot diverged&lt;/td&gt;
&lt;td&gt;Delete and recompute: the journal is the source of truth&lt;/td&gt;
&lt;td&gt;Scheduled job&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shipped a bad module version&lt;/td&gt;
&lt;td&gt;Upload the previous &lt;code&gt;.tpkg&lt;/code&gt;; the old context drains, the new one starts&lt;/td&gt;
&lt;td&gt;One command&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unsigned module in the directory&lt;/td&gt;
&lt;td&gt;Rejected at the load boundary, before a single line of its code runs&lt;/td&gt;
&lt;td&gt;Itself&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Look at the distribution in the right-hand column: the overwhelming majority is handled by mechanisms rather than by a person. A human is needed where the failure is &lt;strong&gt;someone else's&lt;/strong&gt; — and that isn't ours to fix.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where save-points go, and where they don't
&lt;/h3&gt;

&lt;p&gt;A rule worth pinning in your review checklist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   .Transacted()        ─── broker/transaction owns the retry
                            → do NOT add a save-point
                            (two parties owning retry = double charge)

   .Replayable("name")  ─── we own the retry
                            → place it AFTER the entrance,
                              BEFORE the first external call

   neither              ─── no retry at all
                            → a deliberate decision, not an oversight
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The engine will warn if a save-point ends up on a transactional route — but it's better caught at review.&lt;/p&gt;




&lt;h2&gt;
  
  
  The decision checklist
&lt;/h2&gt;

&lt;p&gt;Everything you'll have to decide, in the order to decide it. The first three are the most expensive to change later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The shard key — before you need sharding.&lt;/strong&gt; Both sides of a transfer must land on the same shard: there will be no distributed transaction. Shard by customer or account group, not by payment id.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Postings are append-only.&lt;/strong&gt; No statuses, no edits. Mistakes are corrected by reversal. This also disposes of the change-history question — the journal is the history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. There is no balance field.&lt;/strong&gt; Journal plus snapshot-as-cache only. The snapshot must be disposable and recomputable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Idempotency on two lines.&lt;/strong&gt; At the route, against redelivery. In the model, as the operation key inside the posting, checked under the lock.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. One canonical lock acquisition order.&lt;/strong&gt; Ascending id, always.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. No network calls inside a transaction.&lt;/strong&gt; Everything external goes behind the outbox.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Flat outbox, typed domain.&lt;/strong&gt; Mixing them in one transaction is fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. Slice modules by external protocol.&lt;/strong&gt; Unit of deploy = unit of failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9. The ledger core has no transports.&lt;/strong&gt; Every entrance is in-process — then extracting it into its own worker becomes a URI change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10. The identity server gets its own database.&lt;/strong&gt; Splitting later is a connection string.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;11. Cluster groups by contour, not by sameness.&lt;/strong&gt; Nodes carry different module sets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;12. Save-points only where we own the retry.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What's out of scope
&lt;/h2&gt;

&lt;p&gt;Honest boundaries of this reference model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your accounting model will differ.&lt;/strong&gt; The one shown is minimal: accounts, postings, snapshots. A real one grows a chart of accounts, analytical dimensions, multi-currency with revaluation, fund reservation and pricing rules. That's your domain, and no vendor will design it for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You write the financial format codecs.&lt;/strong&gt; Transport, framing and schema validation exist; parsing a specific ISO 20022 or ISO 8583 profile is yours. It would have been yours anyway — every profile differs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Regulated open banking needs work.&lt;/strong&gt; Mutual-TLS client authentication, rich authorization requests, decoupled confirmation, and step-up authentication for a transaction aren't implemented. The cryptographic groundwork is in the server; this is a scoped sprint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An analytical stack will appear eventually.&lt;/strong&gt; Built-in aggregation covers operational queries — remaining limit, today's gap, a window for a fraud rule. A yearly slice across ten dimensions doesn't get computed on a live operational database on any storage engine, and you will build data marts. Just not in year one and not as a launch precondition — and "roll up aggregates and write them flat" is an ordinary scheduled route.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sharding is a kit.&lt;/strong&gt; The mechanisms exist: block key generation with the ability to move the key source into a separate database, per-connection cache isolation, several independent stores in one module. An assembled solution doesn't. That's a post of its own, in progress — including why the key authority can be an empty database with one line of DDL, and why a failure there is fixed with one SQL statement.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The reference model in one paragraph: &lt;strong&gt;append-only postings with the balance as a function of them; a transaction containing not one network call; the outbox as the bridge into the asynchronous world; modules sliced by external protocol; a ledger core with no transports, reached in-process; a cluster whose nodes carry different module sets; and save-points exactly where we own the retry.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;None of this is invention — it's discipline familiar to anyone who has built payment systems. The difference is how much code it takes to implement: transactions, locks, exact arithmetic, idempotency, an outbox, transports, save-points, clustering and an operations console are already here, and what's left is describing your ledger.&lt;/p&gt;

&lt;p&gt;For what that layer costs if you write it yourself, see the &lt;a href="https://dev.to/rinat_kozin/a-payment-platform-on-net-what-it-actually-costs-and-how-much-of-it-you-never-have-to-write-2lfb"&gt;companion post&lt;/a&gt; with the full breakdown.&lt;/p&gt;

&lt;p&gt;If you're building something similar — tell me in the comments which decisions you made differently and why. The forks where you chose otherwise and didn't regret it are more useful than any feature list.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Sources and releases: &lt;a href="https://github.com/redbase-app/" rel="noopener noreferrer"&gt;github.com/redbase-app&lt;/a&gt;. About the store: &lt;a href="https://redbase.app/" rel="noopener noreferrer"&gt;redbase.app&lt;/a&gt;. Previous posts: &lt;a href="https://dev.to/rinat_kozin"&gt;my dev.to profile&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If this was useful — a ⭐ on GitHub helps others find it.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>fintech</category>
      <category>architecture</category>
    </item>
    <item>
      <title>A payment platform on .NET: what it actually costs, and how much of it you never have to write</title>
      <dc:creator>rinat kozin</dc:creator>
      <pubDate>Tue, 28 Jul 2026 19:30:24 +0000</pubDate>
      <link>https://dev.to/rinat_kozin/a-payment-platform-on-net-what-it-actually-costs-and-how-much-of-it-you-never-have-to-write-2lfb</link>
      <guid>https://dev.to/rinat_kozin/a-payment-platform-on-net-what-it-actually-costs-and-how-much-of-it-you-never-have-to-write-2lfb</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%2Faa7mtcuov59qsl6nsc7t.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%2Faa7mtcuov59qsl6nsc7t.png" alt="redb fintech" width="800" height="1200"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Conversations about payment platforms almost always start from the wrong end. People argue message brokers. They debate microservices versus a modular monolith. They draw the schema.&lt;/p&gt;

&lt;p&gt;Eighteen months later a team of twelve has produced a million and a half lines, of which maybe two hundred thousand are business logic. The rest is transport, retries, dashboards, deployment, and increasingly elaborate attempts to work out where a payment got stuck last night.&lt;/p&gt;

&lt;p&gt;This post is about that second number. About the layer that earns nothing and without which nothing runs — and about how much of it you can simply not write in 2026.&lt;/p&gt;

&lt;p&gt;We've spent four years building &lt;strong&gt;redb&lt;/strong&gt;: a typed store on top of PostgreSQL, MS SQL and SQLite; an integration engine; a runtime with clustering and a dashboard; and an identity server speaking OAuth 2.1 and OpenID Connect. It runs in our own production, ships as packages and images, and — relevant to everything below — &lt;strong&gt;every Pro capability is free across the whole 3.x line, no keys and no license server&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;What follows is a payment platform taken apart by layer, with an honest accounting of where the person-years go. No code: the technical deep dives live in other posts. This one is about money, timelines and risk.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Part of the redb / redb.Route series&lt;/strong&gt; — recent posts first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/rinat_kozin"&gt;redb 3.4.0: day-two operations for a .NET stack&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/rinat_kozin"&gt;redb 3.3.0: an enterprise .NET stack you actually own&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/rinat_kozin/leaving-masstransit-for-a-camel-state-of-mind-the-kafka-connector-scatter-gather-and-what-really-106h"&gt;Leaving MassTransit for a Camel state of mind: the Kafka connector, Scatter-Gather, and transactions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/rinat_kozin/a-homegrown-apache-camel-for-net-dissected-the-http-connector-with-no-aspnet-mvc-the-56bd"&gt;Apache Camel for .NET, dissected: the HTTP connector with no ASP.NET MVC + the Content-Based Router&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/rinat_kozin/redbroute-apache-camel-for-net-22-transports-30-eip-patterns-compiled-dsl-11m0"&gt;redb.Route — Apache Camel for .NET: 22 transports, 30+ EIP patterns, compiled DSL&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sources: &lt;a href="https://github.com/redbase-app/" rel="noopener noreferrer"&gt;github.com/redbase-app&lt;/a&gt;. Docs: &lt;a href="https://redbase.app/" rel="noopener noreferrer"&gt;redbase.app&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Three layers, three budgets
&lt;/h2&gt;

&lt;p&gt;Every payment platform — a marketplace's payouts, a wallet, a PSP, a corporate treasury, a SaaS billing engine — decomposes into three parts, and each has a different cost profile.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The core.&lt;/strong&gt; Holds money and operations. Postings, balances, limits, pricing, reconciliation. This is what you get paid for, and it's where a mistake costs the most: a mis-rounded cent isn't a bug, it's a complaint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The facade.&lt;/strong&gt; Decides who gets in. Who is this, what are they allowed to do, how did they prove it, and how is all of that recorded for the auditor and the security team. A layer made entirely of standards, invisible to the business — right up until the first audit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The integrations.&lt;/strong&gt; Talk to the outside world. A bank on IBM MQ, an acquirer over HTTP, payout files on SFTP, accounting on a schedule, fraud scoring on a queue. The layer that breaks most often and costs the most when it does, because the thing on the other end belongs to someone else.&lt;/p&gt;

&lt;p&gt;Layer by layer: what genuinely has to be yours, and what's already written.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer one: the core, where cents don't evaporate
&lt;/h2&gt;

&lt;p&gt;Start with something that looks like an implementation detail and is actually a first-order business risk: &lt;strong&gt;how the number that measures money is defined.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In most .NET projects an amount lands in the database as a &lt;code&gt;decimal&lt;/code&gt; with whatever precision someone set in the ORM config years ago. Usually two decimal places, sometimes four. Then: a fee is a percentage, the percentage produces a third decimal, the third decimal gets rounded, roundings accumulate — and a year later reconciliation with the acquirer is off by an amount nobody can explain. That's not a hypothetical. It's a genre, and it gets resolved by hand, by an analyst and a developer, over weeks.&lt;/p&gt;

&lt;p&gt;In redb, monetary precision isn't in application config. It's &lt;strong&gt;in the storage schema itself&lt;/strong&gt;: numeric values live in a column with 38 digits of precision, 18 of them after the decimal point. The schema comment on that column says why, in as many words: exact decimal numbers for financial calculations, lossless, for money, taxes and percentages where arithmetic accuracy matters.&lt;/p&gt;

&lt;p&gt;In business terms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fees, FX and tax compute without accumulating rounding error.&lt;/strong&gt; Eighteen decimals is headroom for any chain of calculations, not "two decimals and fingers crossed."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-currency and crypto work out of the box.&lt;/strong&gt; Eighteen decimals is exactly the precision Ether is denominated in. If a crypto line of business shows up next quarter, the storage doesn't change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Precision isn't a function of who configured the project.&lt;/strong&gt; A developer can't accidentally declare an amount with two decimals, because the decision was made one level below them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Money doesn't get double-spent
&lt;/h3&gt;

&lt;p&gt;The second baseline guarantee: one operation executes once, and never half-way. This part is standard and boring, as finance should be — real transactions with explicit control, atomic execution of a group of operations, and row-level locking while a record is being modified.&lt;/p&gt;

&lt;p&gt;That last one is worth calling out, because it's where homegrown solutions usually break. Classic scenario: two requests read the balance concurrently, both see "sufficient funds", both debit. The defence is locking the row for the duration. In redb that's a first-class mechanism, and it isn't decorative — it's what our own subsystems run on: the identity server's token store, the failed-MFA-attempt counter, the cluster's distributed lock. It carries production load for us, rather than existing "in case someone needs it."&lt;/p&gt;

&lt;h3&gt;
  
  
  Deletion you can undo
&lt;/h3&gt;

&lt;p&gt;Another thing that gets reinvented in every finance project: correct deletion. You can't hard-delete — reporting, regulators, incident forensics. So it's either an &lt;code&gt;is_deleted&lt;/code&gt; flag dragged into every query forever, or archive tables and the job of keeping them in sync.&lt;/p&gt;

&lt;p&gt;In redb this is built in, in two phases. First the object is marked — together with the whole tree of records hanging off it — and &lt;strong&gt;immediately disappears from every normal query&lt;/strong&gt;, with no change to a single select in your code. Physical removal happens later, on a background worker, in batches, with progress persisted in the database, so a restart doesn't kill it and any node in the cluster can pick it up.&lt;/p&gt;

&lt;p&gt;Two business effects: the UI can honestly offer "Deleted — undo" and have it work, and the user-facing delete doesn't start crawling as data grows.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reporting where the data already is
&lt;/h3&gt;

&lt;p&gt;Reconciliation, registries, regulatory reports, commission calculations — all aggregation. The usual answer is a separate stack: extract, warehouse, BI. A separate stack means a separate team, separate licenses, and the eternal question of why the report disagrees with the system.&lt;/p&gt;

&lt;p&gt;redb computes aggregates, groupings, window functions and having-clauses on the database side, over the same data serving operations. "Show me merchants whose reconciliation gap exceeds the threshold" is a query against live data, not an export into a third system.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fair objection — and what to do with it
&lt;/h3&gt;

&lt;p&gt;Here an experienced reader will reasonably point out that aggregating over an operational store, at volume, is slower than aggregating over a flat pre-built table. That's true, and there's nothing to argue about.&lt;/p&gt;

&lt;p&gt;Except the objection is about a different problem. &lt;strong&gt;Nobody does heavy analytics on an operational core&lt;/strong&gt;, and not because of the storage engine — because it's bad architecture on any storage engine. A yearly slice across ten dimensions doesn't get computed on the live database on plain PostgreSQL, or Oracle, or anything else: it competes for resources with payment processing, and payments win. For that, you build flat data marts. You still will.&lt;/p&gt;

&lt;p&gt;The two query classes get conflated in these arguments, so let's separate them:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Operational.&lt;/strong&gt; Narrow slice, fresh data, answer needed now: remaining limit, today's reconciliation gap, top declines in the last hour, windowed sum for a fraud rule, ranking operations inside a batch. Small volumes of hot data — and these need to compute where the data lives, or you end up with a data mart that's a day stale answering a question that needed a second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Analytical.&lt;/strong&gt; Wide period, many dimensions, tolerates latency: reporting, product analytics, finding patterns. That's a data mart, and it should be.&lt;/p&gt;

&lt;p&gt;Built-in aggregation is aimed squarely at the first class. It covers the case where a typical project either stands up a warehouse prematurely, hand-writes optimized SQL, or pulls rows into the app and sums them in memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the mart itself isn't a project.&lt;/strong&gt; "Every hour, roll up aggregates and write them flat into a table or a search index" is an ordinary route on this stack: a scheduler, a read, a sink. Configuration, not an ETL programme with its own headcount.&lt;/p&gt;

&lt;p&gt;So data marts stay exactly where they belong. They just stop being a &lt;strong&gt;precondition for launch&lt;/strong&gt; — which takes them out of year-one budget and puts them in the year they're actually justified.&lt;/p&gt;

&lt;h3&gt;
  
  
  Nothing is hidden: there's always a way down
&lt;/h3&gt;

&lt;p&gt;A separate guarantee worth stating, because it removes a whole class of risk.&lt;/p&gt;

&lt;p&gt;Any abstraction over a database eventually meets a query it can't express. Then you get two options: a workaround in application code, or a ticket to the vendor with an unknown ETA. In a financial system that moment arrives on schedule — at the first non-standard request from security, compliance, or a regulator.&lt;/p&gt;

&lt;p&gt;There's no dead end here. &lt;strong&gt;The storage structure is open and documented&lt;/strong&gt;, and you can always go at it with plain SQL — your analyst, your DBA, your existing tooling.&lt;/p&gt;

&lt;p&gt;And that isn't "technically possible but painful." The structure is designed so odd queries come out short and fast.&lt;/p&gt;

&lt;p&gt;The showcase example is a query that's nearly impossible in a classical schema: find &lt;strong&gt;every&lt;/strong&gt; object that has any field starting with a given string, regardless of what type those objects are. In a normal relational model that's a union across forty tables enumerating every text column — or a separate full-text system you now have to feed and keep in sync. Here, values for all objects of all types live in one table with a pattern-search index over it. The answer comes back immediately, across millions of rows, without caring which entity anything belongs to. Same story for "which objects have this field populated at all" — there are dedicated indexes for exactly that check.&lt;/p&gt;

&lt;p&gt;What this buys:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An unplanned request stops being a sprint.&lt;/strong&gt; Compliance wants operations matching an odd criterion, security is reconstructing an incident, a regulator asked for something in their own phrasing — that's an analyst-hour, not a backlog item for next quarter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom logic is cheap.&lt;/strong&gt; Views, stored procedures, feeds into your reporting — with ordinary database tooling, without asking us and without waiting for a release.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The data isn't locked in.&lt;/strong&gt; The database reads with standard PostgreSQL or MS SQL tools, without a single line of our code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The storage layout is written up in full at &lt;a href="https://redbase.app/architecture" rel="noopener noreferrer"&gt;redbase.app/architecture&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What you write yourself anyway — and why that's correct
&lt;/h3&gt;

&lt;p&gt;There's no double-entry, no chart of accounts, no posting rules in the box. That's not a gap, it's a boundary.&lt;/p&gt;

&lt;p&gt;The accounting model &lt;strong&gt;is&lt;/strong&gt; your product. A marketplace's is one thing, a treasury's another, a card issuer's a third. Every attempt by a vendor to hand you a "ready-made ledger" ends with the business spending six months bending its model to fit someone else's abstractions, then a year living with the workarounds where it didn't fit.&lt;/p&gt;

&lt;p&gt;What redb provides is the right foundation underneath your model: an exact number, transactions, locks, reversible deletion, and aggregation. You describe postings as ordinary C# classes, and they become tables.&lt;/p&gt;

&lt;p&gt;Which brings us to the second effect, and the one businesses consistently under-weight.&lt;/p&gt;




&lt;h2&gt;
  
  
  Changing the schema stops being an event
&lt;/h2&gt;

&lt;p&gt;In a normal project, adding one field to a payment entity looks like this: a developer writes a migration, the migration goes to review, review pulls in a DBA, the DBA asks for a rollback script, the script gets tested on staging, the deploy waits for the next window. Between "we need a field" and "the field is in production": days to weeks, in a healthy organisation. In a bank, longer.&lt;/p&gt;

&lt;p&gt;In redb the schema is &lt;strong&gt;described by ordinary C# classes&lt;/strong&gt;, and the store reconciles itself against them. Add a property, it exists. No migration, no DDL, no sign-off.&lt;/p&gt;

&lt;p&gt;Three consequences, all of them money.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One: designing as you go.&lt;/strong&gt; The classical approach demands an agreed data model before development starts. In a financial platform that model isn't knowable up front — it emerges as you discover what the partner bank actually sends and what the business actually needs on screen. So you either spend a month or two guessing, or you get a stream of migrations later. Usually both.&lt;/p&gt;

&lt;p&gt;When the schema can evolve freely, that phase never happens. The team starts with a base set of fields and grows it as understanding arrives. &lt;strong&gt;On a payment-platform scale that's 400–900 engineering hours&lt;/strong&gt; — before a single line of useful code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two: being wrong about the model stops being expensive.&lt;/strong&gt; The classic: a field was designed as text, and a month later it needs to be a reference. In a normal project that's a migration with data conversion, a backfill and a rollback plan — a day to several, plus production risk. So in practice that decision often &lt;strong&gt;doesn't get made&lt;/strong&gt;: the answer is "too expensive to change", and the workaround lives forever.&lt;/p&gt;

&lt;p&gt;Here, changing a field's type is editing a class. And the Pro tier ships a mechanism that backfills a new field from data you already have: add "amount including tax" and it populates from amount and rate across the entire operation history, expressed in C#, with no export and no script.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three, and most under-rated: the cost of testing an idea.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Work out what it costs to test a product hypothesis that needs a new field or a new entity. Think of it, write the migration, spin up a local stack or fight for the shared one, run it, and if it didn't work — write the reverse migration and clean up the data. Honestly: half a day to a day.&lt;/p&gt;

&lt;p&gt;Here: add a property, run against a local database file, didn't work, delete the property. &lt;strong&gt;Twenty minutes.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An order of magnitude. And the point isn't the saved hours, though across a project's life that's another 500-plus. The point is &lt;strong&gt;how many options the team gets to consider at all&lt;/strong&gt;. At half a day per test, you evaluate one and ship the first thing that works. At twenty minutes, you evaluate three to five and ship the best one. That's not a line item, that's product quality — and it resurfaces later in conversion, in support volume, and in what rework costs a year out.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;On the local stack specifically: the same store runs on PostgreSQL, MS SQL and SQLite with no code changes. So a developer fixing one small module doesn't need a full environment of database, broker, identity server and neighbouring services — a file will do. Onboarding drops from days to hours, and CI stops needing infrastructure spun up per pull request.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Layer two: the facade the auditor will look at
&lt;/h2&gt;

&lt;p&gt;Nobody sane writes their own authorization server anymore. You take one off the shelf — and that's where a fork in the road costs money before the first line of code.&lt;/p&gt;

&lt;p&gt;The commercial .NET product means a license, and for a financial institution that license isn't on the entry tier. Open-source Keycloak means a Java stack inside a .NET organisation: another runtime, another skill set, another on-call rotation, another upgrade cycle. Both work. Both cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;redb.Identity&lt;/strong&gt; is OAuth 2.1 and OpenID Connect inside the same stack, on the same storage, under the same runtime. What matters to the business here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Standards in the code, not on a slide.&lt;/strong&gt; The source references &lt;strong&gt;40 RFCs by number&lt;/strong&gt; — not as a wish list, but next to the implementation and the tests that check it. The full set a security reviewer asks about: mandatory PKCE, proof-of-possession token binding, pushed authorization requests, dynamic client registration, private-key-JWT client authentication, token exchange, the device grant, introspection, revocation, back-channel logout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verified by an outside arbiter.&lt;/strong&gt; The server is exercised against the &lt;strong&gt;official OpenID Foundation conformance suite&lt;/strong&gt; — the same suite the Foundation uses to certify providers. The configuration profile passes with zero findings; the authorization-code modules of the basic profile are green. Formal certification is the next step and we're not claiming it as done. But the fact of running against an external reference is something most .NET stacks, homegrown and purchased alike, never do — and on an audit that's a different quality of conversation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit and second factors included.&lt;/strong&gt; MFA: TOTP, SMS and email codes, FIDO2 hardware keys, recovery codes. User lifecycle over SCIM, so HR systems provision and deprovision automatically. And &lt;strong&gt;79 typed audit event types across seven categories&lt;/strong&gt;, persisted and optionally fanned out to your SIEM in parallel. Not "logs you could reconstruct something from" — typed events: login failed, second factor failed, token replay detected, client secret rotated, all sessions revoked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One product on three databases.&lt;/strong&gt; The server's full test suite — &lt;strong&gt;over 1,700 checks&lt;/strong&gt; — passes identically on PostgreSQL, MS SQL and SQLite by flipping one setting. If picking a database is its own conversation with infrastructure at your company, that removes a class of blockers.&lt;/p&gt;

&lt;h3&gt;
  
  
  The saving that never shows up in a budget
&lt;/h3&gt;

&lt;p&gt;There's an architectural detail that looks technical and converts directly into hardware.&lt;/p&gt;

&lt;p&gt;In the usual shape, every internal call between services is authorized, and authorizing means a network hop to the identity server. At meaningful traffic that server becomes a bottleneck, a single point of failure, and a permanent addition to your latency budget.&lt;/p&gt;

&lt;p&gt;Here, modules running in the same runtime &lt;strong&gt;reach the identity server in-process — no network at all&lt;/strong&gt;. No socket, no TLS handshake, no serializing to yourself. Externally it stays a normal server on standard protocols: the browser parts speak HTTP because the spec requires a browser, and everything else — token issuance, refresh, introspection, revocation, management, directory — is transport-neutral.&lt;/p&gt;

&lt;p&gt;Our own measurements: a three-node configuration sustains roughly five thousand token requests per second at 50–80 ms p99. Translated: &lt;strong&gt;fewer nodes doing authorization, and one less single point of failure.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  You build the front door — on your transport, with your crypto
&lt;/h3&gt;

&lt;p&gt;This is an architectural fork whose consequences reach well past finance, into any distributed system whose nodes talk over more than one kind of channel.&lt;/p&gt;

&lt;p&gt;In a typical identity server, protocol and transport are welded together: it speaks HTTPS, and that's the extent of it. Here &lt;strong&gt;transport is decoupled from protocol&lt;/strong&gt; — HTTP is just the first adapter, not part of the server. Everything except the flows the spec requires a browser for is transport-neutral.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One source of truth about permissions, many front doors.&lt;/strong&gt; A real distributed system rarely talks over one channel. Some nodes sit in a closed segment behind a tunnel. Some sites are wired to a corporate message bus because that was decided a decade ago and won't be revisited. Partner integrations go over the public internet. Edge devices don't speak modern HTTP at all.&lt;/p&gt;

&lt;p&gt;A conventional identity server assumes one channel and that it's HTTPS. Which leaves two bad options: force everything onto HTTPS — and open up the closed segment — or run several servers for several channels, and now you have several sources of truth about who may do what. The second is worse: permission drift between segments gets discovered after the fact, and unpleasantly.&lt;/p&gt;

&lt;p&gt;Here the core is single — the standard one, the one that passes conformance and reads sensibly to an auditor. And it has &lt;strong&gt;as many front doors as you have channels&lt;/strong&gt;, each built for its channel: your transport, your envelope, your encryption, your framing. Unwrap on the way in, wrap on the way out; the core neither knows nor should. Adding a front door changes nothing in the server — it's a separate module you drop in. Removing one is deleting a file.&lt;/p&gt;

&lt;p&gt;Special cases of one mechanism: an inter-branch segment with crypto your regulator mandates; a closed site where only the corporate bus is permitted; a partner channel with a proprietary format; IoT devices on a lightweight protocol. Same core, same permissions, same audit trail — only the adapters differ.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shrinking the attack surface.&lt;/strong&gt; A scenario that rarely gets discussed. An identity server has to sit on a public address: browsers, mobile apps, external provider callbacks, back-channel logout — all require a publicly resolvable URL. You can't wrap a tunnel around the whole thing.&lt;/p&gt;

&lt;p&gt;But &lt;strong&gt;administrative operations&lt;/strong&gt; — signing-key rotation, forced revocation, bootstrapping the first admin, mutating clients — don't have to be public. Those can move to a separate port, a separate network, and a format of your own: your markers, your protocol version, your authentication.&lt;/p&gt;

&lt;p&gt;The effect isn't cryptographic, it's practical: mass scanners and off-the-shelf exploit kits simply don't understand the format. An attacker has to reverse-engineer it before they can produce even a syntactically valid request. Every day of that delay is a day of lead time for your security team. The pattern is old news to banks and the public sector, but it used to require rewriting the stack around a proprietary protocol; here it's one module in front of a standard core.&lt;/p&gt;

&lt;p&gt;Mandatory caveat: &lt;strong&gt;this is a layer on top of protection, not a replacement for it.&lt;/strong&gt; Authorization and signature checks stay in place on every operation. A non-standard wire format doesn't substitute for cryptography — it adds information asymmetry to it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where we honestly don't cover you
&lt;/h3&gt;

&lt;p&gt;If your platform goes out into regulated open banking — you're a third-party provider, or a bank serving them — you'll need work. Not implemented: mutual-TLS client authentication, rich authorization requests (consent granted for one specific payment to one specific payee), decoupled confirmation through a banking app, and step-up authentication for a transaction.&lt;/p&gt;

&lt;p&gt;That's a deliberate boundary, not an oversight. The cryptographic groundwork for all of it is already in the server — proof-of-possession token binding is implemented and covered by tests — so this is a scoped sprint, not a rewrite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For everything else&lt;/strong&gt; — your own processing, a wallet, marketplace payouts, corporate treasury, B2B payments, billing — the server covers the job today and in full.&lt;/p&gt;

&lt;h3&gt;
  
  
  And yes, this works for plain billing too
&lt;/h3&gt;

&lt;p&gt;Worth stating explicitly, because after all the talk about payment cores it's easy to conclude the stack is aimed only at heavy scenarios.&lt;/p&gt;

&lt;p&gt;Billing — subscription, telecom, utility, SaaS — lands on our strengths arguably more precisely than processing does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pricing changes constantly, and that's billing's central pain.&lt;/strong&gt; A new plan means new fields and new rules. Where every price-sheet revision normally means a migration and a release window, here it's a class edit and a rollout with charging still running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proration is fractions.&lt;/strong&gt; Mid-cycle plan changes, per-day charges, partial refunds — all multiplication and division, and exactly where rounding error accumulates into invoices that don't reconcile. Precision at the storage layer removes the question.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recurring charges are a schedule.&lt;/strong&gt; The scheduler is built in; a charging run is an ordinary route, not a separate service with its own on-call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failed payments need retry rules.&lt;/strong&gt; Dunning with backoff, a dead-letter queue, manual re-run from a support screen — exactly what's below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invoices and reconciliation are aggregation&lt;/strong&gt;, computed where the data lives.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the part that matters most to a small team: &lt;strong&gt;there's no entry toll for complexity.&lt;/strong&gt; Clustering, the coordinator and the dashboard are optional — the stack runs happily as one process on one server, and everything above switches on by configuration when you grow into it. That's the opposite of enterprise platforms, where you pay for all the complexity up front regardless of whether you need it yet.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer three: integrations, where it breaks most and costs most
&lt;/h2&gt;

&lt;p&gt;Financial integration doesn't look the way architecture diagrams draw it. On the diagram: a tidy bus and events. In life: a partner bank that accepts IBM MQ only, because its core was written in the 2000s and nobody is rewriting it. A payout file landing on SFTP at four in the morning. An acquirer on plain HTTP with its own opinion of what idempotency means. Accounting that needs an export on a schedule. And fraud scoring on a queue.&lt;/p&gt;

&lt;p&gt;The key idea: &lt;strong&gt;integration is won not by whoever has the prettiest bus, but by whoever speaks the counterparty's language.&lt;/strong&gt; Because you're not going to change the counterparty.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;redb.Route ships 27 transports.&lt;/strong&gt; For comparison, the popular .NET messaging libraries offer four to seven, all of them brokers. They assume you're surrounded by modern services. In finance you are not.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What's actually out there&lt;/th&gt;
&lt;th&gt;What covers it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bank core on IBM MQ&lt;/td&gt;
&lt;td&gt;A real transport with transactions, not a hand-rolled adapter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Registries and statements as files&lt;/td&gt;
&lt;td&gt;SFTP, FTP, filesystem polling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Acquirers and partner APIs&lt;/td&gt;
&lt;td&gt;HTTP, gRPC, WebSockets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Event backbone&lt;/td&gt;
&lt;td&gt;Kafka, RabbitMQ, AMQP, Azure Service Bus, Amazon SQS/SNS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Legacy reading straight from a database&lt;/td&gt;
&lt;td&gt;Scheduled polling and writes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Notifications&lt;/td&gt;
&lt;td&gt;Email, Telegram, push&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clearing and scheduled runs&lt;/td&gt;
&lt;td&gt;Built-in scheduler&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;IBM MQ deserves a note, because it's a maturity tell. It's supported with genuine transactional semantics — including a backout counter and automatic movement of a poison message to a dedicated queue past a threshold. Anyone who has integrated with a bank core knows: without that, one malformed message loops for a day and blocks the channel.&lt;/p&gt;

&lt;h3&gt;
  
  
  The payment that won't go out twice
&lt;/h3&gt;

&lt;p&gt;The headline technical risk in financial integration is redelivery. The network blinked, the acknowledgement didn't land, the sender retried, the payment went out twice. Resolving that incident means a customer complaint, a manual reversal, and a written explanation.&lt;/p&gt;

&lt;p&gt;Three layers of defence, composable:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Transactional processing.&lt;/strong&gt; Acknowledging receipt to the broker and publishing the result happen &lt;strong&gt;together&lt;/strong&gt;: both or neither. Implemented identically for RabbitMQ, IBM MQ, AMQP and Kafka — so swapping brokers doesn't rewrite the logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Persistent duplicate suppression.&lt;/strong&gt; The operation key is claimed first and confirmed only after successful processing. If the process dies mid-way, the key stays unconfirmed and the operation is correctly retried. State lives in the database, not memory — so it works cluster-wide and survives restarts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Guaranteed publication.&lt;/strong&gt; The classic pattern where the event is written in the same transaction as the business operation and published by a separate process.&lt;/p&gt;

&lt;p&gt;We get asked why this isn't a single "enable guarantees" switch, the way some competitors package it. The answer is practical. A ready-made guarantees container brings its own table, its own schema and its own notion of how to select unsent items. In finance that table regularly has to live &lt;strong&gt;in someone else's database whose schema you don't control&lt;/strong&gt;, and the selection criterion isn't "where not sent" but a business rule with priorities, windows and caps. A welded-in implementation is a blocker in that situation, not a convenience. Here it's a few lines of configuration where the query is yours, the table is yours, and the transaction is yours.&lt;/p&gt;

&lt;h3&gt;
  
  
  About international message formats
&lt;/h3&gt;

&lt;p&gt;The question always comes: what about ISO 20022?&lt;/p&gt;

&lt;p&gt;There are no shipped codecs for financial message formats — ISO 20022, ISO 8583, SWIFT MT. That's honest, and it's a niche for whoever needs it: transport, framing and schema validation exist, parsing your specific profile is yours. And frankly you'd write it anyway — every bank's ISO 20022 profile differs, and a "ready-made codec" gets modified regardless.&lt;/p&gt;

&lt;p&gt;What helps: ISO 20022 is XML with a schema, and validating a message against XSD is a built-in pipeline step. So validating a payment instruction against the official schema is configuration, not development. Same for JSON formats via JSON Schema.&lt;/p&gt;




&lt;h2&gt;
  
  
  3 a.m.: what a stuck payment costs
&lt;/h2&gt;

&lt;p&gt;Now for the thing that appears in no presentation and determines actual cost of ownership.&lt;/p&gt;

&lt;p&gt;A payment is stuck. Not "the system is down" — a down system is visible and gets fixed. Stuck: one operation got an error from an external service somewhere mid-pipeline and never arrived. The customer calls in the morning.&lt;/p&gt;

&lt;p&gt;How that goes in a typical project: a developer reads logs, reconstructs what was in the message, hand-assembles a request, resends, and hopes it doesn't double-charge. An hour to a day, requires a &lt;strong&gt;developer&lt;/strong&gt; rather than support, and happens under pressure.&lt;/p&gt;

&lt;p&gt;How it goes here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A route can carry a &lt;strong&gt;save-point&lt;/strong&gt;. Not a log of what happened — a snapshot of the operation's state at a specific place in the pipeline.&lt;/li&gt;
&lt;li&gt;If something fails further along, the snapshot lands automatically in a &lt;strong&gt;dead-letter store&lt;/strong&gt; backed by the database — PostgreSQL, MS SQL or SQLite, your pick.&lt;/li&gt;
&lt;li&gt;A support operator opens the dashboard, sees the stuck operations with cause and timestamp, and &lt;strong&gt;clicks Replay&lt;/strong&gt;. The operation re-enters its route from the save-point, not from whatever the state decayed into on the way down.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two details that show this was built for real operations rather than for a feature list:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Capture is opt-in by construction.&lt;/strong&gt; Only what a developer marked as replayable lands in the queue. The system never claims an operation it wasn't handed — otherwise the dead-letter store becomes a landfill nobody opens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There's a guard against split ownership of delivery.&lt;/strong&gt; If a save-point is placed on a route where a broker or a transaction already owns redelivery, the engine warns. That specific mistake — two parties responsible for the retry — is what produces double charges.&lt;/p&gt;

&lt;p&gt;Alongside it: a watchdog that classifies routes and raises alerts on hung ones, and in-flight tracking, so you can see which message is sitting in which step right now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this means for budget.&lt;/strong&gt; Triage of stuck operations stops being developer work and becomes first-line work. We put that at 2–4 hours of senior time per week returned to development — but the bigger effect is that a 3 a.m. escalation to a developer stops being routine.&lt;/p&gt;




&lt;h2&gt;
  
  
  Regulation: guidance in the morning, production in the evening
&lt;/h2&gt;

&lt;p&gt;A financial organisation lives inside a stream of changes it didn't initiate. A regulator publishes guidance. A rate moves. A mandatory field appears in reporting. A partner changes their exchange format. The deadlines aren't yours.&lt;/p&gt;

&lt;p&gt;Look at what the path from "guidance published" to "running in production" is made of in a normal project:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Code change — hours.&lt;/li&gt;
&lt;li&gt;Database migration, if the structure moved — days of sign-off.&lt;/li&gt;
&lt;li&gt;Build and test — hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Getting a release window approved&lt;/strong&gt; — days or weeks.&lt;/li&gt;
&lt;li&gt;Service stop, deploy, verification, rollback plan.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Notice: the actual development is item one, and it's the shortest. Everything else is &lt;strong&gt;permission to deploy&lt;/strong&gt;, and it consumes most of the elapsed time.&lt;/p&gt;

&lt;p&gt;The same path on this stack:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Change — a new field is a class property, no migration.&lt;/li&gt;
&lt;li&gt;The module is built and &lt;strong&gt;signed with your key in your pipeline&lt;/strong&gt;. The key pair is generated with one command; the runtime only ever knows the public half.&lt;/li&gt;
&lt;li&gt;It's uploaded — through the management interface or as a file.&lt;/li&gt;
&lt;li&gt;The runtime &lt;strong&gt;verifies the signature before a single line of code from the package executes&lt;/strong&gt;. The public key decides, not who put the file there. Unsigned is rejected.&lt;/li&gt;
&lt;li&gt;Swap in place: the old version finishes the operations it started, the new one starts alongside, and only after it proves healthy is the old one retired. In a cluster this rolls node by node.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The payment flow never stops.&lt;/strong&gt; Permission to deploy collapses to "the signature is valid." Rollback is uploading the previous package.&lt;/p&gt;

&lt;p&gt;Guidance in the morning, change and build during the day, rollout across nodes in the evening. Not heroics from the on-call shift — a routine procedure.&lt;/p&gt;

&lt;p&gt;For the business those are two separate wins: &lt;strong&gt;speed of response to a regulator&lt;/strong&gt;, and &lt;strong&gt;the elimination of downtime as a category&lt;/strong&gt;. In finance the second is often worth more — an approved maintenance window isn't just unavailability, it's a change request, customer notifications, and reputational cost.&lt;/p&gt;




&lt;h2&gt;
  
  
  The platform team you don't have
&lt;/h2&gt;

&lt;p&gt;There's a layer that teams under fifty people never build. Not from incompetence — it doesn't pay for itself within a single product. The operational envelope: a management interface, a CLI, a dashboard, node coordination, metrics, a watchdog, deployment.&lt;/p&gt;

&lt;p&gt;What you get instead is the standard kit: three deploy scripts, log grep, and "SSH in and take a look." Which works while the system is small.&lt;/p&gt;

&lt;p&gt;In &lt;strong&gt;redb.Tsak&lt;/strong&gt;, that layer arrives with the product:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dashboard&lt;/strong&gt; — 10 screens: cluster overview, a three-level topology tree, per-node detail with live load charts, every route with counters and error rates, per-route drill-down with in-flight operations, the dead-letter queue with a replay button, the hung-route watchdog, log search, access-key management.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CLI&lt;/strong&gt; — 43 commands. Not just start and stop: sign a module, deploy, validate, roll back, drain a node, return it, rebalance, pull diagnostics, replay a dead letter, pause the scheduler.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Management API&lt;/strong&gt; — 16 controllers, 45-plus operations, with a typed client. Meaning all of the above embeds into your existing control plane instead of requiring a separate panel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Observability&lt;/strong&gt; — Prometheus-format metrics, a ready Grafana dashboard as a file, three distinct Kubernetes probes, a full manifest set including monitoring-operator integration. And, separately, distributed tracing — which deserves its own paragraph.&lt;/p&gt;

&lt;h3&gt;
  
  
  "Where is my payment" — the question that usually has no fast answer
&lt;/h3&gt;

&lt;p&gt;A customer calls asking where their money is. The operation crossed an inbound request, an authorization check, fraud scoring, a queue, the partner bank and back — five systems, each with its own logs in its own time format.&lt;/p&gt;

&lt;p&gt;In a normal project the answer gets assembled by correlating timestamps and fragments of identifiers. Half an hour to a day, and it needs the person who remembers how everything connects.&lt;/p&gt;

&lt;p&gt;The right solution is well known — distributed tracing: one operation gets an identifier carried through every system, and afterwards its whole path is one chain with per-step timings. The catch is that building it yourself is a project: pick a standard, thread context across every process boundary and every transport, instrument each connector, stand up a collector, teach the teams.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Here tracing is built into the integration engine and the runtime picks it up on its own.&lt;/strong&gt; Drop in a module and its routes are already in traces and metrics, with no configuration in application code. Counters for processed and failed operations, a duration histogram, a gauge of operations in flight right now — all present from first start. Export to Jaeger or any compatible collector is one setting; your own spans and counters join the same pipe.&lt;/p&gt;

&lt;p&gt;Two engineering details that show this was built for operations:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Collection always runs; export is on demand.&lt;/strong&gt; Traces are produced regardless of whether export is enabled. So attaching a collector during an incident &lt;strong&gt;doesn't require redeploying anything&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Observability may not take the system down, and may not be a hole.&lt;/strong&gt; Before starting, the metrics exporter probes whether it can bind and, if not, logs a warning and continues without metrics — because an optional subsystem must not take down a payment path. And the collector listens on loopback only; metrics go out through the main API. Telemetry physically cannot end up exposed to the network by accident.&lt;/p&gt;

&lt;p&gt;For the business, short version: &lt;strong&gt;"where did it get stuck" becomes a matter of minutes and of support staff&lt;/strong&gt; — rather than a day and a developer who remembers the architecture.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scaling out is starting another process
&lt;/h3&gt;

&lt;p&gt;Worth a separate note, because it's a direct capital line.&lt;/p&gt;

&lt;p&gt;Going from one node to three is &lt;strong&gt;the same configuration file on every node&lt;/strong&gt;, differing only in node id and address. Nodes find each other through shared storage, elect a leader, and the leader distributes modules and rolls updates one node at a time.&lt;/p&gt;

&lt;p&gt;No service discovery system, no external coordinator, no service mesh. And not one line of new code.&lt;/p&gt;

&lt;p&gt;Going back is symmetric: a node is drained with one command, finishes what it started, and leaves.&lt;/p&gt;

&lt;p&gt;An important detail for larger platforms: &lt;strong&gt;nodes don't have to be identical.&lt;/strong&gt; Placement is tracked per module, scoped to a group. So an "acquiring" group can carry one set of modules, "payouts" another, "reporting" a third — all under one coordinator. That lets you separate contours by load and by criticality without splitting them into separate systems with separate operations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And containers however you like.&lt;/strong&gt; Three image variants: a headless worker for Kubernetes, a standalone management UI, and an all-in-one for single-server installs. Ready compose sets, Kubernetes manifests, and a no-container archive option. Images are signed. Need a custom build with your own connectors baked in? An ordinary standard .NET build, no proprietary tooling.&lt;/p&gt;

&lt;h3&gt;
  
  
  What that layer is worth
&lt;/h3&gt;

&lt;p&gt;If you had to build it — component by component, conservatively:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Engineering hours&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Management API&lt;/td&gt;
&lt;td&gt;250–350&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CLI&lt;/td&gt;
&lt;td&gt;150–250&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web dashboard&lt;/td&gt;
&lt;td&gt;400–600&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node coordination: leader election, registry, distribution&lt;/td&gt;
&lt;td&gt;250–350&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zero-downtime updates with graceful drain&lt;/td&gt;
&lt;td&gt;100–150&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dead-letter store with replay&lt;/td&gt;
&lt;td&gt;100–150&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hung-process watchdog&lt;/td&gt;
&lt;td&gt;60–100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Metrics, distributed tracing, health probes&lt;/td&gt;
&lt;td&gt;310–480&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Images, deployment sets, manifests, signing&lt;/td&gt;
&lt;td&gt;210–330&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Signature verification for loaded modules&lt;/td&gt;
&lt;td&gt;40–60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,870–2,820&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's &lt;strong&gt;over a person-year&lt;/strong&gt; — before a single line of business logic. And, again, most teams simply don't build it: they live without it and pay later, in night escalations and hours of incident triage.&lt;/p&gt;




&lt;h2&gt;
  
  
  Hardware: why the same traffic needs fewer nodes
&lt;/h2&gt;

&lt;p&gt;There's a line in a platform's budget that technology posts almost never mention — the infrastructure bill. And it's driven less by traffic than by &lt;strong&gt;how much unnecessary work the system does per operation&lt;/strong&gt;. Two mechanisms here, and they pay out every month rather than once.&lt;/p&gt;

&lt;h3&gt;
  
  
  Don't rebuild what didn't change
&lt;/h3&gt;

&lt;p&gt;Standard behaviour for nearly any data-access layer: you asked for five hundred operations, so it assembled five hundred objects out of database rows. Next page, then back — assembled again. CPU work producing no value.&lt;/p&gt;

&lt;p&gt;In redb every object carries a checksum, and loading happens in two steps. First a cheap query — identifiers and checksums only, no touching field values. Then that's compared against what's already in memory, and &lt;strong&gt;only genuinely changed objects get fetched&lt;/strong&gt;. The rest come from cache without reassembly.&lt;/p&gt;

&lt;p&gt;Same on the write side: an object that didn't change never reaches the database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And this stays correct in a cluster, which is the part that matters most.&lt;/strong&gt; The checksum comes fresh from the database on every query. So if one node changed an operation, the next node sees the mismatch on its next request and re-reads it by itself. No separate cache server with its own operations and its own bill, and no invalidation broadcast between nodes — the mechanism that breaks most often and most quietly in distributed systems.&lt;/p&gt;

&lt;p&gt;The mode is a setting, and the safe one — with validation — is the default. A monolith can turn validation off and go faster still; a distributed deployment leaves it on and gets correctness for free.&lt;/p&gt;

&lt;h3&gt;
  
  
  Don't hold a thread just to wait
&lt;/h3&gt;

&lt;p&gt;The second mechanism is about what a payment system spends its time doing.&lt;/p&gt;

&lt;p&gt;Which is waiting. For the acquirer's response. For the bank's confirmation. For the fraud verdict. For a disk write. Actual computation is a fraction of a percent.&lt;/p&gt;

&lt;p&gt;In a synchronous model each of those waits pins a thread. Threads are memory and context switches, and they run out considerably earlier than CPU does. Hence the familiar picture: the server is at fifteen percent utilisation and requests are already queueing.&lt;/p&gt;

&lt;p&gt;The routing engine is &lt;strong&gt;async by construction&lt;/strong&gt;: its core holds around a hundred and fifty async methods against eight blocking calls — and all eight sit in synchronous convenience wrappers, not in the processing pipeline. While an operation waits on an external system, the thread serves others.&lt;/p&gt;

&lt;p&gt;Practical meaning: one node holds substantially more concurrent operations on the same CPU. For a payment gateway, where waiting is nearly all of the elapsed time, that's not a difference measured in percent.&lt;/p&gt;

&lt;h3&gt;
  
  
  What that means in money
&lt;/h3&gt;

&lt;p&gt;Resource savings convert down a chain, and every link is its own budget line:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fewer cores per node&lt;/strong&gt; — and enterprise database licenses, plus a chunk of commercial software, are priced per core.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fewer nodes for the same traffic&lt;/strong&gt; — a direct cloud or hardware bill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The standby environment mirrors production&lt;/strong&gt;, so any saving on the primary automatically doubles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Less load on the database itself&lt;/strong&gt; — and in a financial system the database is usually the most expensive component and the hardest to scale: you add an app node, you don't add a database.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We're deliberately not quoting percentages here. They depend on your load profile, and any tidy figure in a post like this would be a figure from nowhere. But the mechanism is verifiable, and you'll see it on your own load immediately.&lt;/p&gt;

&lt;p&gt;The important part is that this saving is &lt;strong&gt;recurring&lt;/strong&gt;. Saved engineering hours are a one-time gain. The infrastructure bill arrives every month, for the life of the system.&lt;/p&gt;




&lt;h2&gt;
  
  
  People: why one stack is cheaper to run
&lt;/h2&gt;

&lt;p&gt;There's an effect that never appears in a budget and in practice costs more than migrations.&lt;/p&gt;

&lt;p&gt;Take a normal .NET project two years and fifty features in. What you'll find: two controller styles, because the approach changed. Three validation approaches. Two background-work mechanisms — the scheduler adopted at the start, and the second one added when the first didn't fit. A hand-rolled mapper next to a library one. And a data-access layer half the team bypasses because it's faster that way.&lt;/p&gt;

&lt;p&gt;Nobody is surprised, because this is what always happens when the scaffolding is chosen fresh on every task.&lt;/p&gt;

&lt;p&gt;Our stack constrains that freedom deliberately. Three rules total: &lt;strong&gt;a data structure is a class, an entry point is a route, an integration is a connector.&lt;/strong&gt; Not out of hostility to variety, but because variety in the foundation is paid for forever.&lt;/p&gt;

&lt;p&gt;In money:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Onboarding.&lt;/strong&gt; A new developer learns one approach rather than five that "grew historically." Difference: 3–5 days per person. On a team of ten with normal attrition, &lt;strong&gt;150–250 hours a year&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Review.&lt;/strong&gt; The "but how should this be done" argument doesn't arise, because there's one shape. Half an hour to an hour per pull request, a hundred and fifty of those a year — another &lt;strong&gt;75–150 hours&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The refactor that never happens.&lt;/strong&gt; In a heterogeneous codebase, every eighteen months to two years someone launches a "let's unify everything" initiative worth 200–400 hours that gets half-finished. Here there's nothing to unify.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key-person risk.&lt;/strong&gt; No longer about hours. When the scaffolding is uniform, a departing lead doesn't take with them the knowledge of why it's done one way here and another way there. For a financial organisation, where team turnover is a genuine operational risk, that's a line of its own.&lt;/p&gt;




&lt;h2&gt;
  
  
  Procurement: what developers don't think about and directors do
&lt;/h2&gt;

&lt;p&gt;Three questions that surface at sign-off and can stop a project after the technical side has satisfied everyone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Licenses.&lt;/strong&gt; Every Pro capability — optimised query generation, partial writes, parallel materialisation, clustering, advanced analytics — is &lt;strong&gt;free across the whole 3.x line. No key, no license server, no registration, commercial production included.&lt;/strong&gt; Not "free up to a volume", not "free for development." Free. Versions you're already running stay free permanently.&lt;/p&gt;

&lt;p&gt;The open portion is Apache 2.0. Pro packages are closed-source but free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source code.&lt;/strong&gt; Companies that adopt the ecosystem can &lt;strong&gt;request the Pro sources&lt;/strong&gt; — for a security audit, escrow, or in-house builds. In enterprise procurement, source escrow for a proprietary component isn't a bonus, it's a line in the requirements, and it usually costs separate money and separate negotiation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No vendor lock-in.&lt;/strong&gt; Data sits in PostgreSQL or MS SQL — your database, your infrastructure, your control. The storage structure is open and documented, so the database reads with ordinary SQL, without our code and without our tools. There's a first-class export and import for the whole database, including across engines. No closed formats anywhere. The worst case — "the vendor disappeared" — leaves you with a running system, your data in readable form, and, if needed, the sources.&lt;/p&gt;

&lt;p&gt;For contrast: the typical alternative for the facade layer is either a commercial license priced at enterprise tier for a financial institution, or an open product on a foreign stack that needs an operations team of its own.&lt;/p&gt;




&lt;h2&gt;
  
  
  The whole bill
&lt;/h2&gt;

&lt;p&gt;Everything above, in one table. This is &lt;strong&gt;a model with stated assumptions, not a measurement of two parallel teams&lt;/strong&gt; — but the assumptions are conservative, and every line can be argued with individually.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One-time cost that doesn't occur:&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;What you don't write&lt;/th&gt;
&lt;th&gt;Engineering hours&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Transports — the 9 a payment platform actually needs, at production quality&lt;/td&gt;
&lt;td&gt;900–1,350&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Integration patterns — duplicate suppression, splitting, aggregation, routing, retries, circuit breaker, compensation&lt;/td&gt;
&lt;td&gt;400–800&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operational layer — API, CLI, dashboard, clustering, observability, deployment&lt;/td&gt;
&lt;td&gt;1,870–2,820&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Identity facade — adoption, client management, audit, MFA, federation&lt;/td&gt;
&lt;td&gt;600–1,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The data-model design phase that never happens&lt;/td&gt;
&lt;td&gt;440–880&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Testing product hypotheses — 10–15× cheaper per test&lt;/td&gt;
&lt;td&gt;~560&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4,770–7,410&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's &lt;strong&gt;2.8–4.4 person-years&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recurring cost that doesn't occur:&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;What&lt;/th&gt;
&lt;th&gt;Hours per year&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Database migrations&lt;/td&gt;
&lt;td&gt;360–600&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Preparing and running release windows&lt;/td&gt;
&lt;td&gt;~450&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Triaging stuck operations with developer time&lt;/td&gt;
&lt;td&gt;100–200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maintaining your own connectors as external systems change&lt;/td&gt;
&lt;td&gt;150–250&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Onboarding and review on heterogeneous scaffolding&lt;/td&gt;
&lt;td&gt;225–400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,285–1,900&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's &lt;strong&gt;0.8–1.1 of a permanent headcount&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Rates vary too much across markets for a single number to be useful, so substitute your own. Fully-loaded cost per engineering hour — salary plus taxes, benefits, workspace, management, holidays and idle time, &lt;strong&gt;not&lt;/strong&gt; the number in the offer, which is the most common mistake and understates the result two-to-threefold:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Fully-loaded hourly&lt;/th&gt;
&lt;th&gt;One-time (4,770–7,410 h)&lt;/th&gt;
&lt;th&gt;Annual (1,285–1,900 h)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;$60&lt;/td&gt;
&lt;td&gt;$286k – $445k&lt;/td&gt;
&lt;td&gt;$77k – $114k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;$100&lt;/td&gt;
&lt;td&gt;$477k – $741k&lt;/td&gt;
&lt;td&gt;$129k – $190k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;$150&lt;/td&gt;
&lt;td&gt;$716k – $1.11M&lt;/td&gt;
&lt;td&gt;$193k – $285k&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Plus no license fees, and source escrow already answered.&lt;/p&gt;

&lt;p&gt;One honest caveat: calling this a saving is only fair where the team would genuinely have built all of it. An organisation that would have bought instead gets the benefit in another form — absence of license fees and absence of an integration zoo. And hours spent on &lt;strong&gt;your&lt;/strong&gt; business logic — the accounting model, pricing, fraud rules, reconciliation — nobody saves and nobody should. What gets saved is the infrastructure layer underneath them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where we're a bad fit
&lt;/h2&gt;

&lt;p&gt;The section that usually doesn't get written. We'll write it, because understanding the boundary is cheaper at the start than halfway in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The accounting model is yours.&lt;/strong&gt; No double-entry, no chart of accounts, no posting rules, and none planned. We give the foundation, you describe your accounting. If you were shopping for turnkey processing, this isn't it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Regulated open banking needs work.&lt;/strong&gt; If the platform faces outward as a third-party provider or as a bank serving them, you'll need a sprint across the standards listed earlier. The groundwork is there and the scope is knowable, but it's not in the box today.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Financial message codecs are yours to write.&lt;/strong&gt; Transport, framing and schema validation exist; parsing a specific ISO 20022 or ISO 8583 profile is yours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No multi-region clustering out of the box.&lt;/strong&gt; Coordination assumes low latency to a shared database. The pattern for geographic redundancy is a cluster per region, federated a level above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Horizontal sharding is a kit, not a feature.&lt;/strong&gt; Every necessary mechanism is in the store: block key generation with the ability to move the key source to a separate database, per-connection cache isolation, several independent stores in one module. An assembled "switch it on and go" solution isn't there yet. That's a post of its own, and it's in progress.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Module isolation is not a sandbox.&lt;/strong&gt; A loaded module runs with the process's privileges: trust comes from the signature at the door, not from restricting rights after start.&lt;/p&gt;

&lt;p&gt;For enterprise practice that barely matters. The contractor delivers source, the build runs in &lt;strong&gt;your&lt;/strong&gt; pipeline and is signed with &lt;strong&gt;your&lt;/strong&gt; key — the pair is generated with one command, and the verifying side only knows the public half. Then the signature doesn't mean "we trust the contractor", it means "this is exactly the code that passed our review and our build", which is stricter than any sandbox around a third-party binary.&lt;/p&gt;

&lt;p&gt;The real limitation remains for one scenario: a module arriving as a finished binary from a supplier you don't control and whose code you haven't seen. There you'll want OS-level isolation — a process or container per module.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;You can't buy a payment platform ready-made — the part you actually get paid for is always written for you. But it's the smaller part of the code and the smaller part of the budget.&lt;/p&gt;

&lt;p&gt;The larger part is the transport to a bank that speaks IBM MQ. It's the defence against double-charging. It's an authorization server that will survive an audit. It's a dashboard that shows where an operation stopped. It's deploying without pausing payments. It's node coordination once one node stopped being enough. &lt;strong&gt;And that's roughly three person-years plus a standing headcount — if you write it yourself.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Our bet over the last four years is that this layer should be shared. One stack across all three parts of the platform, one version across the ecosystem, one set of rules for the whole team. A store that counts money without loss and changes its schema without migrations. An integration engine that speaks the counterparty's language instead of demanding they speak yours. An identity server checked against an external reference. A runtime that ships changes without a maintenance window and hands support a button instead of a phone call to a developer.&lt;/p&gt;

&lt;p&gt;And all of it free across the whole 3.x line, with sources on request.&lt;/p&gt;

&lt;p&gt;If you're working on something similar — tell me in the comments which of these hurts most. We have detailed technical write-ups per layer, and the next ones will follow real questions rather than what we happen to find interesting.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Sources and releases: &lt;a href="https://github.com/redbase-app/" rel="noopener noreferrer"&gt;github.com/redbase-app&lt;/a&gt;. About the store: &lt;a href="https://redbase.app/" rel="noopener noreferrer"&gt;redbase.app&lt;/a&gt;. Previous posts in the series: &lt;a href="https://dev.to/rinat_kozin"&gt;my dev.to profile&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If this was useful — a ⭐ on GitHub helps others find it.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>architecture</category>
      <category>fintech</category>
    </item>
    <item>
      <title>redb 3.4.0: day-two operations for a .NET stack — replay what failed, hot-patch the framework, lock down the control plane</title>
      <dc:creator>rinat kozin</dc:creator>
      <pubDate>Mon, 27 Jul 2026 21:48:33 +0000</pubDate>
      <link>https://dev.to/rinat_kozin/redb-340-day-two-operations-for-a-net-stack-replay-what-failed-hot-patch-the-framework-lock-1bm</link>
      <guid>https://dev.to/rinat_kozin/redb-340-day-two-operations-for-a-net-stack-replay-what-failed-hot-patch-the-framework-lock-1bm</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%2Fenscjfefeery7haqnthk.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%2Fenscjfefeery7haqnthk.png" alt="redb ecosystem" width="800" height="1200"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Building a system and &lt;em&gt;running&lt;/em&gt; one are two different engineering problems. The first is done when it holds up under load. The second starts with the questions someone asks while holding a pager: &lt;em&gt;what died overnight, and how do I re-run it? who hit force-stop? can I ship a one-line library fix without rebuilding the entire runtime? why is a service-account password sitting in plain sight on a dashboard page?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Previous releases of our stack answered the first question. &lt;strong&gt;3.4.0 is entirely about the second.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Quick refresher on the cast: &lt;strong&gt;redb&lt;/strong&gt;, a typed store on top of Postgres/MSSQL/SQLite; &lt;strong&gt;redb.Route&lt;/strong&gt;, an integration engine in the Apache Camel spirit with 30+ connectors; &lt;strong&gt;redb.Tsak&lt;/strong&gt;, a runtime with a dashboard, hot-reload and clustering; and &lt;strong&gt;redb.Identity&lt;/strong&gt;, an OIDC / OAuth 2.1 server. All of it runs in our own production and ships as packages, images, and standalone archives.&lt;/p&gt;

&lt;p&gt;Four things landed in 3.4.0, and every one of them is about the day after the deploy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Replay checkpoints&lt;/strong&gt; — a save-point inside a route, so the tail can be re-run later from known-good state. This one goes all the way through: a primitive in the engine, a dead-letter queue in the runtime, a button in the dashboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A shared runtime layer&lt;/strong&gt; — the framework now lives &lt;em&gt;beside&lt;/em&gt; the app instead of inside it. Patching a library is a file swap, not a release of everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets out of the logs&lt;/strong&gt; — declarative redaction of endpoint URIs, plus named connection factories so the credential never enters the route to begin with.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Roles and signed modules&lt;/strong&gt; — role enforcement on the management API, a persistent audit trail, and a cryptographic trust anchor for code the runtime loads into its own process.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Quietly but importantly: &lt;strong&gt;redb.Identity moved onto the ecosystem version&lt;/strong&gt; (1.2.2 straight to 3.4.0). One number across the stack, so "which Identity works with which Route" is a glance instead of a lookup table.&lt;/p&gt;

&lt;p&gt;And as of 3.3.0, still true here: &lt;strong&gt;every Pro package is free across the whole 3.x line.&lt;/strong&gt; No keys, no license server, no sign-up. &lt;code&gt;dotnet add package redb.Postgres.Pro&lt;/code&gt; and it works.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Part of the redb / redb.Route series&lt;/strong&gt; — recent posts first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/rinat_kozin"&gt;The Telegram connector: a bot as a route&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/rinat_kozin"&gt;redb 3.3.0: an enterprise .NET stack you actually own&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/rinat_kozin/leaving-masstransit-for-a-camel-state-of-mind-the-kafka-connector-scatter-gather-and-what-really-106h"&gt;Leaving MassTransit for a Camel state of mind: the Kafka connector, Scatter-Gather, and transactions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/rinat_kozin/a-homegrown-apache-camel-for-net-dissected-the-http-connector-with-no-aspnet-mvc-the-56bd"&gt;Apache Camel for .NET, dissected: the HTTP connector with no ASP.NET MVC + the Content-Based Router&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/rinat_kozin/redbroute-apache-camel-for-net-22-transports-30-eip-patterns-compiled-dsl-11m0"&gt;redb.Route — Apache Camel for .NET: 22 transports, 30+ EIP patterns, compiled DSL&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sources: &lt;a href="https://github.com/redbase-app/" rel="noopener noreferrer"&gt;github.com/redbase-app&lt;/a&gt;. About the database itself: &lt;a href="https://redbase.app/" rel="noopener noreferrer"&gt;redbase.app&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Part 1. Replay: a save-point inside a route
&lt;/h2&gt;

&lt;p&gt;Starting with the feature I'm happiest about — because it runs through all three layers and looks natural in each one.&lt;/p&gt;

&lt;h3&gt;
  
  
  The problem everyone solves by hand
&lt;/h3&gt;

&lt;p&gt;A five-step route. Step three charged the card. Step four blew up because the receipt service was down for ten minutes. Now what?&lt;/p&gt;

&lt;p&gt;None of the usual answers are good. Retrying the whole route charges the card twice. Retrying from the exchange's &lt;em&gt;current&lt;/em&gt; state is worse than it sounds: the body has already been reshaped by the steps in between and headers have been overwritten, so you're not replaying what happened — you're replaying whatever it decayed into on the way to the failure. Picking through it by hand at 3 a.m. isn't an answer at all.&lt;/p&gt;

&lt;p&gt;The right model is a &lt;strong&gt;save-point&lt;/strong&gt;: freeze the message exactly where things were still good, then re-run the &lt;em&gt;tail&lt;/em&gt; of the route from that frozen state.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it looks like in the DSL
&lt;/h3&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;"timer://poll?period=5000"&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;chargeCard&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Replayable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"after-charge"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;// save-point: the card is already charged&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;sendReceipt&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;"http://receipts"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EndReplayable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything after the marker is the tail. Each exchange passing through is snapshotted into &lt;code&gt;exchange.Properties["route.checkpoint"]&lt;/code&gt; (last marker wins). There's a lambda form too, if you'd rather not track matching &lt;code&gt;End&lt;/code&gt; calls:&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;Replayable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"after-charge"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;r&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;sendReceipt&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;"http://receipts"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replay is a typed in-process call:&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;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;ReplayAsync&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="s"&gt;"after-charge"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;snapshot&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Declare the marker with &lt;code&gt;exposed: true&lt;/code&gt; and it's additionally published as &lt;code&gt;direct:__replay:{routeId}:{name}&lt;/code&gt;, so another route can simply &lt;code&gt;.To(...)&lt;/code&gt; it. The default is private: a save-point shouldn't accidentally become a public entry point into the middle of your route.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;Snapshot()&lt;/code&gt; — and why it isn't &lt;code&gt;Clone()&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;This needed a new primitive in the engine, and it's the most interesting part.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;IExchange&lt;/code&gt; has had &lt;code&gt;Clone()&lt;/code&gt; for a long time. But &lt;code&gt;Clone()&lt;/code&gt; &lt;strong&gt;deliberately shares the body&lt;/strong&gt; with the original — Splitter aggregation depends on it, because the branches need to see the same object. For a save-point that's exactly backwards: if the body is shared, the next step mutates it in place and your "frozen" state was never frozen at all.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;IExchange.Snapshot()&lt;/code&gt; / &lt;code&gt;IMessage.Snapshot()&lt;/code&gt; landed: a genuinely deep, isolated copy. v1 handles immutable bodies, &lt;code&gt;byte[]&lt;/code&gt;, and &lt;code&gt;ICloneable&lt;/code&gt;, and &lt;strong&gt;throws loudly on anything else&lt;/strong&gt; rather than quietly doing a shallow share. That part isn't negotiable — a silently shared body inside a save-point mechanism is the kind of bug that surfaces a month later, in production, on the one exchange you needed.&lt;/p&gt;

&lt;p&gt;A snapshot carries no DI scope: it's dormant data headed for a database row, not a live exchange, so there's no per-message leak.&lt;/p&gt;

&lt;p&gt;While we were in there, we also fixed the doc comment on &lt;code&gt;Clone()&lt;/code&gt;, which had said "deep copy" for years while never being one. Both methods now describe what they actually do.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where a checkpoint may and may not go
&lt;/h3&gt;

&lt;p&gt;A save-point can't cross a branching composite (Choice, TryCatch) — the tail stops being well-defined, because &lt;em&gt;which&lt;/em&gt; branch is the tail? Inside a long-running transaction it's allowed but warrants a warning: replay happens &lt;strong&gt;outside&lt;/strong&gt; the original transaction.&lt;/p&gt;

&lt;p&gt;We could have hard-coded a couple of &lt;code&gt;if&lt;/code&gt;s in the validator. Instead there's now a general mechanism, because constraints like this keep showing up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ICompositeScope&lt;/code&gt; / &lt;code&gt;IDurableScope&lt;/code&gt; — scope category markers;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;IScopeNestingRule&lt;/code&gt; — a node declares &lt;code&gt;Allowed&lt;/code&gt; / &lt;code&gt;Warn&lt;/code&gt; / &lt;code&gt;Forbid&lt;/code&gt; against an ancestor category;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;IBranchingDefinition&lt;/code&gt; — definitions whose children live outside &lt;code&gt;Outputs&lt;/code&gt; (Choice When/Otherwise, TryCatch catch/finally) expose them for a generic tree walk.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The validator applies the rules uniformly: &lt;code&gt;Forbid&lt;/code&gt; is a build error, &lt;code&gt;Warn&lt;/code&gt; goes to the log. &lt;strong&gt;A new structural constraint now ships on the definition, never in the validator&lt;/strong&gt; — which is precisely what Camel-style engines tend to get wrong, letting constraints sprawl across code that has no business knowing about them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer two: the dead-letter queue in Tsak
&lt;/h3&gt;

&lt;p&gt;A primitive in the engine is nice, but the person on call doesn't want an abstraction — they want the list of what failed overnight. So Tsak 3.4.0 puts a &lt;strong&gt;DLQ with replay&lt;/strong&gt; on top of checkpoints.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CheckpointDlqHandler&lt;/code&gt; is a Tier-3 error handler installed on every module context. On failure it reads &lt;code&gt;route.checkpoint&lt;/code&gt; off the exchange and dead-letters it. The key property: &lt;strong&gt;opt-in by construction.&lt;/strong&gt; Only routes carrying a &lt;code&gt;.Replayable()&lt;/code&gt; marker leave a checkpoint, so only those get captured. The queue never fights a broker or a transaction that already owns redelivery — it picks up exactly what you explicitly marked as "this needs a human re-run."&lt;/p&gt;

&lt;p&gt;From there it's ordinary operational plumbing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tsak_dlq&lt;/code&gt; — a flat table (PG / MSSQL / SQLite), created at startup;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ExchangeSnapshotCodec&lt;/code&gt; — serializes the snapshot for durable storage. &lt;code&gt;byte[]&lt;/code&gt; and &lt;code&gt;string&lt;/code&gt; round-trip exactly; everything else goes through System.Text.Json with the exact CLR type restored when its assembly is loadable. A non-serializable body is stored &lt;strong&gt;visible but flagged not-replayable&lt;/strong&gt; — capture never breaks outright;&lt;/li&gt;
&lt;li&gt;API: &lt;code&gt;GET /api/exchanges/failed&lt;/code&gt; (filtered, paged), &lt;code&gt;POST /api/exchanges/{id}/replay&lt;/code&gt;, &lt;code&gt;DELETE /api/exchanges/{id}&lt;/code&gt; — role-gated and audited;&lt;/li&gt;
&lt;li&gt;CLI: &lt;code&gt;tsak dlq list | replay | discard&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;Dead-letter&lt;/strong&gt; dashboard page with server-side filtering and paging, so a large table never gets dragged into the browser;&lt;/li&gt;
&lt;li&gt;retention as a first-class &lt;code&gt;cron://tsak-dlq-retention&lt;/code&gt; route on the system context (&lt;code&gt;Tsak:Dlq:RetentionDays&lt;/code&gt;, default 30). Not a hidden timer buried in the runtime — a normal route, visible in the API, the dashboard, and the scheduler page. We eat our own cooking.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On semantics, plainly and without marketing: &lt;strong&gt;this is at-least-once and operator-driven.&lt;/strong&gt; The tail may run more than once, so replayed side effects need to be idempotent. It is not Temporal-style durable execution and doesn't pretend to be. It's a tool for the person on call: &lt;em&gt;show me what broke overnight, and re-run it once the fix is in.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 2. The shared runtime layer: patch the framework without a rebuild
&lt;/h2&gt;

&lt;p&gt;The second big item is architectural, and it's about how fast you can react.&lt;/p&gt;

&lt;h3&gt;
  
  
  Before
&lt;/h3&gt;

&lt;p&gt;Tsak takes your routes (&lt;code&gt;.tpkg&lt;/code&gt; modules) and turns them into a service with a dashboard, metrics, and clustering. The framework itself — &lt;code&gt;redb.Core(.Pro)&lt;/code&gt;, the providers, &lt;code&gt;redb.Route.*&lt;/code&gt; — used to sit in the app's bin folder. Which meant &lt;strong&gt;a patch to any library required rebuilding and re-releasing everything&lt;/strong&gt;. One fix in the Kafka connector: rebuild Tsak, re-cut the archives, re-push the images, re-run the whole verification pass.&lt;/p&gt;

&lt;h3&gt;
  
  
  After
&lt;/h3&gt;

&lt;p&gt;The framework moved to &lt;code&gt;Libs/shared/&lt;/code&gt; and is loaded from there at startup. Only &lt;code&gt;redb.Tsak.*&lt;/code&gt; and &lt;code&gt;redb.Licensing&lt;/code&gt; stay in the bin.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;worker/
├── redb.Tsak.Worker.dll        ← the application
├── redb.Tsak.Core.dll
└── Libs/shared/
    ├── redb.Core.dll           ← the framework — swappable
    ├── redb.Postgres.Pro.dll
    ├── redb.Route.dll
    ├── redb.Route.Kafka.dll    ← connectors too
    └── runtimes/…              ← native dependencies alongside
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The payoff:&lt;/strong&gt; a binary-compatible patch to any leaf, provider, or connector — or a brand-new beta connector — ships by &lt;strong&gt;swapping a DLL in &lt;code&gt;Libs/shared/&lt;/code&gt;&lt;/strong&gt;. No rebuild, no re-spin of the Tsak and Identity archives. A &lt;code&gt;3.4.0 → 3.4.1&lt;/code&gt; framework patch becomes a file copy.&lt;/p&gt;

&lt;p&gt;How it works, briefly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Early bootstrap.&lt;/strong&gt; &lt;code&gt;SharedRuntime.InstallEarly&lt;/code&gt; is the very first statement in &lt;code&gt;Program.cs&lt;/code&gt;, before a single redb type is touched. It installs the shared-layer resolver and &lt;strong&gt;byte-loads&lt;/strong&gt; the framework — the file is never locked, which is exactly what makes it swappable on a live box. It reuses primitives we already had: tracker unification (so &lt;code&gt;.tpkg&lt;/code&gt; modules still see one identity per redb type), a per-assembly native resolver (librdkafka, e_sqlite3), and version-tolerant forwarding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail fast.&lt;/strong&gt; A missing or corrupt framework DLL in &lt;code&gt;Libs/shared/&lt;/code&gt; &lt;strong&gt;aborts startup immediately&lt;/strong&gt;, naming the assembly and where it looked. Infinitely better than a &lt;code&gt;MissingMethodException&lt;/code&gt; a day later under load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compat gate.&lt;/strong&gt; At startup the shared redb &lt;em&gt;minor&lt;/em&gt; is checked against this Tsak build's minor. Patch drift is allowed — that's the entire point — but a minor mismatch, or a mix of minors inside &lt;code&gt;Libs/shared/&lt;/code&gt;, stops the process.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;GET /api/system/assemblies&lt;/code&gt;&lt;/strong&gt; (admin) — what redb is &lt;em&gt;actually&lt;/em&gt; loaded: name, version, and origin (shared / bin / runtime). The diagnostic counterpart to a swappable layer, answering "which redb is running right now."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tooling collapsed to one manifest (&lt;code&gt;scripts/shared-manifest.psd1&lt;/code&gt;) and one parameterized &lt;code&gt;scripts/build-shared.ps1&lt;/code&gt; that serves both dev and publish. Plus a new &lt;code&gt;scripts/refresh-shared.ps1&lt;/code&gt;: rebuild one library and drop its DLL into a target &lt;code&gt;Libs/shared/&lt;/code&gt; — your dev tree or an unpacked archive. The "patch without rebuilding Tsak" flow, as an actual command.&lt;/p&gt;

&lt;p&gt;We verified it rather than assuming: 0 framework assemblies in the bin root vs 12 in &lt;code&gt;Libs/shared&lt;/code&gt;; a clean start loads all 12 from the shared layer and boots (SQLite Pro, cluster, scheduler); Kafka with librdkafka, Mail with MailKit, and SQLite with e_sqlite3 all served through the shared layer and exercised end to end; &lt;code&gt;.tpkg&lt;/code&gt; modules unify; the negative case aborts with a clear message.&lt;/p&gt;

&lt;h3&gt;
  
  
  Planned maintenance: cordon / uncordon
&lt;/h3&gt;

&lt;p&gt;Same "day after the deploy" theme, and it's in Pro — so, free.&lt;/p&gt;

&lt;p&gt;A cluster node used to have two states: working, or gone. That's not enough for a rolling upgrade: &lt;code&gt;rebalance&lt;/code&gt; reshuffles everything and &lt;code&gt;remove-node&lt;/code&gt; is a hard eviction, when what you want is the middle state — &lt;em&gt;this node finishes what it's doing and takes on nothing new.&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tsak cluster cordon   node-3     &lt;span class="c"&gt;# takes no new work, drains its route locks to peers&lt;/span&gt;
&lt;span class="c"&gt;# … upgrade the node …&lt;/span&gt;
tsak cluster uncordon node-3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Cordoned&lt;/code&gt; flag is durable and orthogonal to status: the node stays Online, because it &lt;em&gt;is&lt;/em&gt; alive — it's just closed for new business. The per-route watch loop reads a process-local mirror of the flag, refreshed from the node's own record on each heartbeat, stops acquiring route locks, and releases the ones it holds so peers can pick them up. Work in flight drains normally. There's an API (&lt;code&gt;POST /api/cluster/nodes/{id}/cordon&lt;/code&gt; and &lt;code&gt;/uncordon&lt;/code&gt;, audited), a client method, the CLI above, and Cordon/Uncordon buttons on the dashboard's Cluster page.&lt;/p&gt;

&lt;p&gt;If you've spent time around Kubernetes, both the semantics and the word will look familiar. That's deliberate: don't invent new vocabulary where the industry already has one everybody knows.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 3. Secrets: don't mask them, don't accept them
&lt;/h2&gt;

&lt;p&gt;The third block is operational security — and the interesting part isn't what got fixed, it's &lt;strong&gt;how the approach changed.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Declaration beats guesswork
&lt;/h3&gt;

&lt;p&gt;In an engine where endpoints are URIs (&lt;code&gt;ldap://…?bindPassword=…&lt;/code&gt;), sooner or later you have to decide which parts of that URI may reach a log. The classic answer is a deny-list of parameter names: &lt;code&gt;password&lt;/code&gt;, &lt;code&gt;secret&lt;/code&gt;, &lt;code&gt;apiKey&lt;/code&gt;…&lt;/p&gt;

&lt;p&gt;A deny-list &lt;strong&gt;fails open.&lt;/strong&gt; Add a new option carrying a credential and it goes to the logs in the clear until somebody remembers to extend the list. The failure is silent: you find out by reading logs, not by failing a build.&lt;/p&gt;

&lt;p&gt;In 3.4.0 the source of truth is a &lt;strong&gt;declaration on the option itself&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LdapEndpointOptions&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;EndpointOptions&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"localhost"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// printed in logs&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Sensitive&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;BindPassword&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;           &lt;span class="c1"&gt;// always ****&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;EndpointOptions.BindFromUri&lt;/code&gt; harvests those declarations by reflection (once per options type) and feeds them to the URI sanitizer. &lt;strong&gt;The set of secret keys is derived from the code and never hand-maintained&lt;/strong&gt; — a new credential option can't be forgotten, because the only thing you could forget is the attribute, and the attribute sits right there on the property.&lt;/p&gt;

&lt;p&gt;This is exactly how Apache Camel does it: &lt;code&gt;@UriParam(secret = true)&lt;/code&gt; is the declaration, and the runtime list is generated from those annotations by a build plugin. The .NET version needs no build step — reflection is enough. All 37 credential options across 22 connectors are annotated. The name-based heuristic survives as a backstop for a URI rendered before any endpoint of that scheme exists.&lt;/p&gt;

&lt;p&gt;Formatting is preserved: the new &lt;code&gt;EndpointUri.Sanitize(string)&lt;/code&gt; keeps the scheme, &lt;code&gt;://&lt;/code&gt;, path, parameter order, and every non-secret value byte for byte, replacing secrets with a constant &lt;code&gt;****&lt;/code&gt;. Every boundary now goes through it: route-build and endpoint-start logs, the &lt;code&gt;redb.route.endpoint&lt;/code&gt; OpenTelemetry span tag and metric label, in-flight exchange and health-check metadata, and the &lt;code&gt;CompiledRoute.FromUri&lt;/code&gt; DTO the CLI and dashboard render. Unnamed routes now derive a &lt;strong&gt;sanitized&lt;/strong&gt; route id too, so a secret can't leak out through a &lt;code&gt;{RouteId}&lt;/code&gt; log line.&lt;/p&gt;

&lt;p&gt;Also in this pass: userinfo passwords are finally inside the fence (&lt;code&gt;amqp://user:pass@host&lt;/code&gt; → &lt;code&gt;user:****@host&lt;/code&gt;); &lt;code&gt;redb.Route.Elasticsearch&lt;/code&gt; sanitizes node URLs in its startup log; and &lt;code&gt;redb.Route.Exec&lt;/code&gt; logs the executable plus an argument &lt;strong&gt;count&lt;/strong&gt; — command-line argument values are secrets far too often to log on principle.&lt;/p&gt;

&lt;p&gt;There's a public API for connector authors as well: &lt;code&gt;EndpointUri.Sanitize(string)&lt;/code&gt;, &lt;code&gt;IsSensitiveKey(string)&lt;/code&gt;, and &lt;code&gt;AddSensitiveKeys(params string[])&lt;/code&gt; — the direct analogue of Camel's &lt;code&gt;addSanitizeKeywords&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Separately, &lt;code&gt;EndpointUri.RedactSecrets(string)&lt;/code&gt; handles arbitrary text: a driver exception is perfectly capable of dragging a connection string with &lt;code&gt;Password=…&lt;/code&gt; into &lt;code&gt;ex.Message&lt;/code&gt;, and &lt;code&gt;OnExceptionProcessor&lt;/code&gt; logs that message on redelivery and on retries-exhausted. Both sites now run it through the redactor. One honest limitation worth knowing: with &lt;code&gt;LogStackTrace&lt;/code&gt; enabled, the exception object itself is handed to the logger and can't be scrubbed in-process — that path needs a filter in your logging sink.&lt;/p&gt;

&lt;h3&gt;
  
  
  Better than masking: never take the secret
&lt;/h3&gt;

&lt;p&gt;Masking is defense. Offense is making sure the credential &lt;strong&gt;never enters the URI at all.&lt;/strong&gt; So 3.4.0 rolls out a named &lt;code&gt;ConnectionFactory&lt;/code&gt; across the ten connectors that had no such mechanism: &lt;strong&gt;Telegram, MQTT, HTTP, Mail, FTP, SFTP, SignalR, gRPC, TCP, WebSocket&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;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;"support-bot"&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;TelegramConnectionFactory&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetEnvironmentVariable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"TELEGRAM_TOKEN"&lt;/span&gt;&lt;span class="p"&gt;)!&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="n"&gt;r&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;"telegram://receive?connectionFactory=support-bot"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// no token in the route&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No secret in the URI means nothing to mask in logs, telemetry, or the dashboard. The factory fills in &lt;strong&gt;only&lt;/strong&gt; the options the URI didn't set explicitly, so an inline URI value always wins and existing routes are untouched; a name that isn't in the registry logs a warning and falls back to URI parameters.&lt;/p&gt;

&lt;p&gt;One detail I'm prouder of than the feature itself. For connectors whose address lives in the endpoint path (HTTP, Mail, SignalR, gRPC, TCP, WebSocket) the factory deliberately carries &lt;strong&gt;no host or port&lt;/strong&gt;. Otherwise an innocent typo in a factory name could silently redirect a route somewhere else. For the same reason the &lt;code&gt;wss&lt;/code&gt; scheme still forces TLS on regardless of what the factory says. A mechanism for credentials must not quietly become a mechanism for routing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;redb.Route.Ldap&lt;/code&gt; got an &lt;code&gt;LdapConnectionFactory&lt;/code&gt; in the same pass — and with it, a route that carries no credentials whatsoever:&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;"honest-ldap"&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;LdapConnectionFactory&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Server&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ldap.corp.local"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Port&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;636&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;BindDn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"cn=svc-reader,dc=corp,dc=local"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;BindPassword&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetEnvironmentVariable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"LDAP_BIND_PASSWORD"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="n"&gt;r&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;"ldap://SEARCH:dc=corp,dc=local?connectionFactory=honest-ldap&amp;amp;filter=(objectClass=user)"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One caveat to internalize: &lt;code&gt;CompiledRoute.FromUri&lt;/code&gt; is now a &lt;strong&gt;display value.&lt;/strong&gt; Routing identity, endpoint cache keys, and message flow didn't change by a single byte, but parsing &lt;code&gt;FromUri&lt;/code&gt; to recover credentials is no longer a thing you can do. It's a display, not a data source.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 4. Who can do what: roles, signed modules, audit
&lt;/h2&gt;

&lt;p&gt;The management API is, functionally, the keys to prod. In 3.4.0 the keys got locks.&lt;/p&gt;

&lt;h3&gt;
  
  
  The role model
&lt;/h3&gt;

&lt;p&gt;Tsak API keys have carried roles since 1.0.0. In 3.4.0 those roles &lt;strong&gt;started being required at the endpoints&lt;/strong&gt; — until now the &lt;code&gt;viewer &amp;lt; operator &amp;lt; admin&lt;/code&gt; ladder existed in the model, but no endpoint declared a requirement, so a key issued for dashboard access could do everything an admin key could.&lt;/p&gt;

&lt;p&gt;It's declarative, in the same style as everything else here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;RequiresRoleAttribute&lt;/code&gt; — on an action or a whole controller; multiple roles are OR-ed; a method-level attribute overrides the controller-level one;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NoRoleRequiredAttribute&lt;/code&gt; — for technical endpoints that must never start answering &lt;code&gt;403&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TsakRoles&lt;/code&gt; — the ladder itself, with &lt;code&gt;reader&lt;/code&gt;/&lt;code&gt;ops&lt;/code&gt; synonyms; custom roles match by exact name only;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RoleAuthorizationProcessor&lt;/code&gt; — the enforcement, wired into the system pipeline immediately after a successful auth check. It resolves the target action through the same &lt;code&gt;ControllerRegistry&lt;/code&gt; the dispatcher uses, so both agree on where the request would have landed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;How it shook out: &lt;code&gt;admin&lt;/code&gt; covers all of &lt;code&gt;/api/auth/*&lt;/code&gt; (reads included), &lt;code&gt;/api/users/*&lt;/code&gt;, deleting a context or a module, route &lt;code&gt;force-stop&lt;/code&gt;, cluster &lt;code&gt;rebalance&lt;/code&gt; and node removal. &lt;code&gt;operator&lt;/code&gt; covers diagnostics and logs (both expose internals) plus every mutating endpoint by default. &lt;code&gt;viewer&lt;/code&gt; covers every other &lt;code&gt;GET&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A specific concern here was &lt;strong&gt;not breaking probes.&lt;/strong&gt; The check runs only for authenticated exchanges, so auth-exempt Kubernetes probes pass straight through; &lt;code&gt;HealthProbeController&lt;/code&gt; is additionally marked &lt;code&gt;[NoRoleRequired]&lt;/code&gt;, so even if an operator narrows &lt;code&gt;Tsak:Api:AuthExempt&lt;/code&gt;, the probe can't start returning &lt;code&gt;403&lt;/code&gt; and take the deployment down with it. The echo and Prometheus routes have their own pipelines and never reach the check at all.&lt;/p&gt;

&lt;p&gt;Compatibility is staged, as it has to be for something like this. Nothing changes when auth is disabled. Keys with no roles keep full access and log a one-time warning — then, once every key has been reissued with explicit roles, you set &lt;code&gt;Tsak:Auth:RolelessKeysAreAdmin=false&lt;/code&gt; and close the door. Enforcement as a whole can be switched off with &lt;code&gt;Tsak:Auth:EnforceRoles=false&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Coverage: 32 tests on the processor and the ladder, plus 27 integration tests driving the real Kestrel pipeline with &lt;code&gt;viewer&lt;/code&gt;, &lt;code&gt;operator&lt;/code&gt;, and roleless keys — including proof that probes answer without a key and never return &lt;code&gt;403&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  A module is code. So: signatures
&lt;/h3&gt;

&lt;p&gt;Deploying a module used to mean filesystem access. Now modules can be uploaded and rolled back over the API — but since a module is &lt;strong&gt;code Tsak loads into its own process&lt;/strong&gt;, the whole feature is built around a trust anchor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tsak module keygen              &lt;span class="c"&gt;# generate an ECDSA key pair&lt;/span&gt;
tsak module sign  my.tpkg       &lt;span class="c"&gt;# → my.tpkg.sig&lt;/span&gt;
tsak module validate my.tpkg    &lt;span class="c"&gt;# dry run: same checks as upload, installs nothing&lt;/span&gt;
tsak module deploy my.tpkg      &lt;span class="c"&gt;# upload (signature in a header)&lt;/span&gt;
tsak module rollback my-module  &lt;span class="c"&gt;# restore the previous version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parts that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Upload is &lt;strong&gt;off by default&lt;/strong&gt; (&lt;code&gt;Tsak:Modules:Upload:Enabled=false&lt;/code&gt;) — a node that doesn't need remote deploy exposes no upload surface at all.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ModuleSignatureVerifier&lt;/code&gt; does RSA/ECDSA detached-signature verification with the BCL only. No cosign dependency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enforcement at the load boundary:&lt;/strong&gt; with &lt;code&gt;Tsak:Modules:Signature:Required=true&lt;/code&gt; and a configured public key, &lt;strong&gt;every&lt;/strong&gt; &lt;code&gt;.tpkg&lt;/code&gt; — uploaded &lt;em&gt;or&lt;/em&gt; dropped into the directory by an operator — must carry a valid &lt;code&gt;.tpkg.sig&lt;/code&gt; or it's refused before any of its code loads. The trust anchor becomes the public key, not filesystem access. That's stricter than the WSO2 MI default.&lt;/li&gt;
&lt;li&gt;Upload-time guards: a size ceiling, valid-ZIP and manifest checks, the stored name taken from the manifest and sanitized (no &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;\&lt;/code&gt;, &lt;code&gt;..&lt;/code&gt; — path-traversal and zip-slip safe), fail-fast signature verification, atomic install (temp → move), previous version archived.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Related: &lt;strong&gt;staged validation before hot-swap.&lt;/strong&gt; A package update used to tear down the running version &lt;em&gt;before&lt;/em&gt; opening the new one — so a broken package left the context with no modules at all. Now the new &lt;code&gt;.tpkg&lt;/code&gt; is first opened in a throwaway collectible ALC (without mutating the shared assembly tracker, so running modules are untouched) and checked that it loads and discovers at least one module. Only then is the old version torn down. A package that won't open, or that has no modules, is refused with a logged reason — &lt;strong&gt;and the current version keeps running.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  An audit trail that survives a restart
&lt;/h3&gt;

&lt;p&gt;Admin actions used to go to the log, with lifecycle history in a 1000-entry in-memory ring — so after a restart there was no record of who did what. Now there's &lt;code&gt;tsak_audit_log&lt;/code&gt;: a flat table (deliberately not a redb object — it's append-only, it grows, and its schema is fixed), created at startup for the configured provider, exactly the way the Quartz tables already were.&lt;/p&gt;

&lt;p&gt;It's written through a &lt;code&gt;direct://tsak-audit&lt;/code&gt; route (&lt;code&gt;Sql.Execute&lt;/code&gt; INSERT) — &lt;strong&gt;and that's a feature, not showing off&lt;/strong&gt;: because the sink is an endpoint, the same event stream can be pointed at a file, a broker, or an HTTP collector by configuration alone, with no new code. Writes are fire-and-forget through a bounded queue with a background pump: an API call never waits on the database, a broken audit backend can never take the node down, a backend failure falls back to the log sink, and sustained flooding drops the oldest queued events with a warning.&lt;/p&gt;

&lt;p&gt;Reading is &lt;code&gt;GET /api/audit&lt;/code&gt; (role &lt;code&gt;admin&lt;/code&gt;, filtering and paging entirely server-side), the &lt;code&gt;tsak audit&lt;/code&gt; CLI command, and a new &lt;strong&gt;Audit&lt;/strong&gt; dashboard page. Retention is — again — a normal &lt;code&gt;cron://tsak-audit-retention&lt;/code&gt; route (&lt;code&gt;Tsak:Audit:RetentionDays&lt;/code&gt;, default 90; &lt;code&gt;0&lt;/code&gt; keeps forever).&lt;/p&gt;

&lt;p&gt;For deployments with no database, audit stays in the log but now emits a &lt;code&gt;[tsak-audit]&lt;/code&gt;-anchored line with one JSON object right after it, so a standalone node can be grepped and parsed without any log-format wrangling.&lt;/p&gt;

&lt;h3&gt;
  
  
  Get paged, don't poll
&lt;/h3&gt;

&lt;p&gt;Last of the operational block: the watchdog detected hung and suspected exchanges, but the alerts just piled up behind &lt;code&gt;GET /api/watchdog/alerts&lt;/code&gt; — a poll nobody performs at 3 a.m. Now they're pushed.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AlertDispatcher&lt;/code&gt; fans each new alert out to every enabled channel, &lt;strong&gt;fire-and-forget&lt;/strong&gt; (bounded queue plus a background pump, same as the audit sink): the watchdog scan never blocks on a slow SMTP server. Dedup is by &lt;code&gt;context + route + exchange + level&lt;/code&gt; within &lt;code&gt;DedupWindowMinutes&lt;/code&gt; — the scan rebuilds its snapshot every cycle, so without it a single hung exchange would page you on every tick.&lt;/p&gt;

&lt;p&gt;Channels, all off by default: &lt;strong&gt;webhook&lt;/strong&gt; (Slack / Teams / PagerDuty / any collector), &lt;strong&gt;telegram&lt;/strong&gt; (Bot API over plain HTTPS, no connector), &lt;strong&gt;email&lt;/strong&gt; (SMTP via the BCL), and &lt;strong&gt;endpoint&lt;/strong&gt; — the generic one: send to any redb.Route producer URI (&lt;code&gt;kafka:&lt;/code&gt;, &lt;code&gt;rabbitmq:&lt;/code&gt;, &lt;code&gt;amqp:&lt;/code&gt;, &lt;code&gt;sqs:&lt;/code&gt;, &lt;code&gt;mqtt:&lt;/code&gt;…) through a &lt;code&gt;ProducerTemplate&lt;/code&gt;. That last channel covers every broker with zero per-connector code, and because the host supplies the component, &lt;strong&gt;no broker ever becomes a compile-time dependency of Core&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;You can verify the wiring without waiting for a real outage: &lt;code&gt;POST /api/watchdog/test-alert&lt;/code&gt; sends a synthetic alert through every enabled channel and returns the per-channel outcome, bypassing the dedup window. In the dashboard it's a "Send test alert" button on the Watchdog page's Alert Delivery panel.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 5. The core: a scheme name becomes your decision
&lt;/h2&gt;

&lt;p&gt;Down to the storage layer. The headline change is small on paper and very visible in practice.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;[RedbScheme(Name = "...")]&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;A scheme used to be named after the CLR type's &lt;code&gt;FullName&lt;/code&gt;, with the string in the attribute serving as a cosmetic alias. The consequence is familiar: &lt;strong&gt;a scheme's identity in the database was welded to your namespace and class name.&lt;/strong&gt; Rename the class or move the namespace, and you either don't refactor or you go fix the database by hand.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;RedbScheme&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Customer note"&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;"Notes.Note"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;  &lt;span class="c1"&gt;// positional arg is still the alias&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;Note&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="err"&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 positional argument is, and stays, the alias — an explicit name is only settable through the named &lt;code&gt;Name&lt;/code&gt; parameter, so &lt;strong&gt;not one existing declaration changes meaning&lt;/strong&gt;, and types without &lt;code&gt;Name&lt;/code&gt; behave exactly as before.&lt;/p&gt;

&lt;p&gt;On the first sync, a type with an explicit name has its scheme &lt;strong&gt;renamed in the database&lt;/strong&gt;. The lookup walks a three-step chain — explicit name, &lt;code&gt;FullName&lt;/code&gt;, short type name — and the first match is renamed in place. Physically that's a single-row &lt;code&gt;UPDATE&lt;/code&gt; of &lt;code&gt;_schemes._name&lt;/code&gt;: the id is preserved, objects, structures and values are untouched, and polymorphic loading (which resolves through &lt;code&gt;scheme_id&lt;/code&gt;) never notices.&lt;/p&gt;

&lt;p&gt;The honest warning, which is in the changelog and belongs here too: &lt;strong&gt;renaming requires updating every consumer of that database together.&lt;/strong&gt; An app version that predates the explicit name won't find the scheme under its new name and will create a second one, silently splitting objects across both. If that has already happened, redb now detects it and refuses to continue rather than picking one at random.&lt;/p&gt;

&lt;p&gt;Names are validated against C# identifier rules (Latin letters, digits, &lt;code&gt;_&lt;/code&gt;, &lt;code&gt;.&lt;/code&gt;, &lt;code&gt;+&lt;/code&gt;; no reserved words; 128 chars max) &lt;strong&gt;in C#, before any SQL is issued&lt;/strong&gt;, so the error names the offending type. And they're all validated up front and reported together in a single &lt;code&gt;AggregateException&lt;/code&gt; — fixing a codebase one name per restart would have been cruel. Human-readable titles still live in &lt;code&gt;Alias&lt;/code&gt;, which is free-form.&lt;/p&gt;

&lt;p&gt;Provider parity got closed in the same pass: MSSql and SQLite now carry a &lt;code&gt;_schemes&lt;/code&gt; name-validation trigger mirroring the PostgreSQL rule for rule, so a name accepted by one provider is accepted by all three. And a scheme's &lt;code&gt;_alias&lt;/code&gt; now syncs on every sync (structures always did): the attribute is the source of truth, removing it resets &lt;code&gt;_alias&lt;/code&gt; to &lt;code&gt;NULL&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Load checks the scheme
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;LoadAsync&amp;lt;TProps&amp;gt;&lt;/code&gt; now verifies the object's &lt;code&gt;_id_scheme&lt;/code&gt; against the scheme &lt;code&gt;TProps&lt;/code&gt; maps to — before anything reaches the cache.&lt;/p&gt;

&lt;p&gt;By default a mismatch returns &lt;code&gt;null&lt;/code&gt;. That's not an arbitrary pick: a soft-deleted object gets scheme &lt;code&gt;-10&lt;/code&gt;, and soft-delete callers expect exactly &lt;code&gt;null&lt;/code&gt; back. If you'd rather have a genuine type mistake be loud, set &lt;code&gt;RedbServiceConfiguration.ThrowOnSchemeMismatch = true&lt;/code&gt; and get a &lt;code&gt;RedbSchemeMismatchException&lt;/code&gt; instead. The untyped &lt;code&gt;LoadAsync(objectId)&lt;/code&gt; is unaffected either way.&lt;/p&gt;

&lt;h3&gt;
  
  
  Several nodes starting at once
&lt;/h3&gt;

&lt;p&gt;A scenario reproduced on a three-node cluster: several instances start against a database that doesn't have a given scheme yet, all of them miss the lookup, and all of them issue &lt;code&gt;INSERT INTO _schemes&lt;/code&gt;. Exactly one wins.&lt;/p&gt;

&lt;p&gt;Scheme creation now uses a conflict-free statement per dialect (&lt;code&gt;ON CONFLICT (_name) DO NOTHING&lt;/code&gt; on PostgreSQL and SQLite, &lt;code&gt;INSERT … WHERE NOT EXISTS&lt;/code&gt; with &lt;code&gt;UPDLOCK, HOLDLOCK&lt;/code&gt; on MSSql), and whoever loses the race reads back the winner's row.&lt;/p&gt;

&lt;p&gt;The detail that makes this more interesting than it looks: &lt;strong&gt;catching the unique violation wouldn't have worked.&lt;/strong&gt; On PostgreSQL a failed statement inside a transaction poisons it, so the follow-up read fails with &lt;code&gt;25P02&lt;/code&gt;. The right answer isn't "handle the exception" — it's "don't create the conflict." Both creation paths are covered, typed and untyped.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pro data migrations now run on every provider
&lt;/h3&gt;

&lt;p&gt;The Pro data-migration mechanism works across all three providers and is covered by tests (&lt;code&gt;MigrationTestsBase&lt;/code&gt;: apply, history row, idempotency, dry run, against all three Pro fixtures). Getting there sorted out four separate things: how the executor obtains the DB context; dialect-aware UPDATE generation (&lt;code&gt;ISqlDialectPro.Migration_UpdateTarget&lt;/code&gt; — SQLite forbids aliasing an UPDATE target, T-SQL wants the alias bound in a trailing &lt;code&gt;FROM&lt;/code&gt;); a SQLite-native &lt;code&gt;_migrations&lt;/code&gt; DDL (with a REAL Julian &lt;code&gt;_applied_at&lt;/code&gt;, per SQLite's own time convention in redb); and dropping &lt;code&gt;IDENTITY&lt;/code&gt; from &lt;code&gt;_migrations._id&lt;/code&gt; on MSSql, since the executor supplies ids explicitly like everywhere else in redb.&lt;/p&gt;

&lt;p&gt;Existing databases don't pick up the new &lt;code&gt;_migrations&lt;/code&gt; DDL — initialization is skipped when &lt;code&gt;_schemes&lt;/code&gt; already exists. In practice that breaks nothing, since there's no migration history there to preserve.&lt;/p&gt;

&lt;h3&gt;
  
  
  Small things you'll feel
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hot-reload no longer pins memory.&lt;/strong&gt; The process-wide &lt;code&gt;schemeName → Type&lt;/code&gt; index held strong &lt;code&gt;Type&lt;/code&gt; references, and a &lt;code&gt;Type&lt;/code&gt; keeps its &lt;code&gt;AssemblyLoadContext&lt;/code&gt; alive — so a collectible ALC (the thing Tsak's hot-swap is built on) could never be collected, and after a reload the stale instance produced a false name conflict against the fresh one. Entries are now &lt;code&gt;WeakReference&amp;lt;Type&amp;gt;&lt;/code&gt; with dead ones pruned on lookup, and two instances of the same &lt;code&gt;FullName&lt;/code&gt; from different ALCs are recognized as a reload rather than a clash. Distinct types sharing one explicit &lt;code&gt;Name&lt;/code&gt; still conflict, by design.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;redb cache options are reachable from Tsak config&lt;/strong&gt; — the &lt;code&gt;Tsak:Redb:Cache&lt;/code&gt; section (props cache and TTL, list cache, metadata cache, &lt;code&gt;AutoRecomputeHash&lt;/code&gt;, cache domain). A Tsak node used to run on redb's defaults because only two settings were passed through. Every key is optional and an absent section changes nothing, and the whole set with default values is spelled out in &lt;code&gt;appsettings.json&lt;/code&gt; so the knobs are visible and editable in place. Enabling &lt;code&gt;SkipHashValidationOnCacheCheck&lt;/code&gt; together with clustering logs a startup warning: trusting the cache without re-checking the object hash is single-writer territory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;679 lines of dead cache-interface layer removed&lt;/strong&gt; — five interfaces that referenced only each other, with no implementations and no consumers. The live caches are &lt;code&gt;GlobalMetadataCache&lt;/code&gt;, &lt;code&gt;GlobalListCache&lt;/code&gt;, &lt;code&gt;GlobalPropsCache&lt;/code&gt;, and the type index. Technically a breaking change since the types were public; practically unusable, since nothing implemented them. The caching &lt;code&gt;README.md&lt;/code&gt; was rewritten to describe the caches that actually exist.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Part 6. redb.Identity: one version, JAR, and an honest conformance answer
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why 1.2.2 → 3.4.0
&lt;/h3&gt;

&lt;p&gt;Until this release redb.Identity rode its own &lt;code&gt;1.x&lt;/code&gt; line while core, Route, and Tsak moved together on &lt;code&gt;3.x&lt;/code&gt;. Two numbering schemes turned "which Identity works with which Route" into a lookup instead of a glance.&lt;/p&gt;

&lt;p&gt;From 3.4.0 on, &lt;strong&gt;Identity shares the ecosystem version&lt;/strong&gt; and jumps straight to the shared number — the same &lt;code&gt;redb.Route&lt;/code&gt; 3.4.0 it's built against. Every future ecosystem release bumps it too.&lt;/p&gt;

&lt;p&gt;To be explicit: this is a &lt;strong&gt;version realignment, not a semver-breaking release.&lt;/strong&gt; There are no breaking API changes here, and the one new feature is off by default. The major digit changed because the number is now the ecosystem's, not because Identity's contract broke. The old &lt;code&gt;1.0.1&lt;/code&gt;–&lt;code&gt;1.2.2&lt;/code&gt; tags remain valid history.&lt;/p&gt;

&lt;h3&gt;
  
  
  JAR (RFC 9101) — signed authorization requests
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;/connect/authorize&lt;/code&gt; now accepts a signed request object, gated behind &lt;code&gt;Features.EnableJar&lt;/code&gt; and off by default, so every prior release behaves identically (a &lt;code&gt;request&lt;/code&gt; parameter still gets &lt;code&gt;request_not_supported&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;When it's on: a signed &lt;code&gt;request&lt;/code&gt; (inline) or &lt;code&gt;request_uri&lt;/code&gt; (by reference, fetched over HTTP) is verified against the client's registered keys, and the parameters inside the JWT take precedence over the query string, per §6.1.&lt;/p&gt;

&lt;p&gt;What is &lt;strong&gt;not&lt;/strong&gt; accepted: &lt;code&gt;alg: none&lt;/code&gt; (an unsigned request object destroys the exact integrity guarantee JAR exists for), a wrong-key signature, a mismatched inner &lt;code&gt;client_id&lt;/code&gt;, an expired object, and — under &lt;code&gt;Enforce&lt;/code&gt; — an algorithm that disagrees with the client's declared &lt;code&gt;RequestObjectSigningAlg&lt;/code&gt;. All of them come back as &lt;code&gt;invalid_request_object&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Rollout is staged: &lt;code&gt;Off → LogOnly → Enforce&lt;/code&gt;. Discovery advertises &lt;code&gt;request_parameter_supported&lt;/code&gt;, &lt;code&gt;request_uri_parameter_supported&lt;/code&gt;, and the algorithm list &lt;strong&gt;only when JAR is on&lt;/strong&gt; — a server shouldn't promise in metadata what the endpoint won't actually do. Configuration lives in the &lt;code&gt;Jar&lt;/code&gt; section: enforcement mode, allowed algorithms (asymmetric only), clock skew, size limits, and the SSRF knobs for &lt;code&gt;request_uri&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now the part that was most of the work. &lt;strong&gt;OpenIddict 6.3.0 does not support request objects.&lt;/strong&gt; We checked before planning: the assembly carries &lt;code&gt;ValidateRequestParameter&lt;/code&gt; and the &lt;code&gt;request_not_supported&lt;/code&gt; string, but nothing that parses a request object or verifies its signature, and &lt;code&gt;request_uri&lt;/code&gt; exists only as a PAR URN. So JAR here isn't a config flag — it's our own server handlers (&lt;code&gt;ValidateRequestObjectHandler&lt;/code&gt;) that slot in &lt;strong&gt;ahead of&lt;/strong&gt; the built-in unconditional rejection and do the work themselves. PAR-issued &lt;code&gt;urn:ietf:params:oauth:request_uri:*&lt;/code&gt; identifiers are deliberately left alone: PAR resolves those itself, and a JAR handler has no business there. &lt;code&gt;request_uri&lt;/code&gt; fetches are size- and timeout-bounded and pass the SSRF filter below.&lt;/p&gt;

&lt;p&gt;Coverage: 15 handler tests plus a live conformance demo, &lt;code&gt;demo_jar_request_object.ps1&lt;/code&gt;, wired into the &lt;code&gt;run_all&lt;/code&gt; sweep — so the scenario is exercised against a running server, not just a unit test.&lt;/p&gt;

&lt;p&gt;What's not here yet: &lt;strong&gt;request-object encryption (JWE) is deferred&lt;/strong&gt; — the client's &lt;code&gt;RequestObjectEncryptionAlg&lt;/code&gt; / &lt;code&gt;...Enc&lt;/code&gt; are stored but not applied; that's a separate phase. &lt;code&gt;RequestObjectSigningAlg&lt;/code&gt;, previously stored and ignored, is now genuinely enforced under &lt;code&gt;Enforce&lt;/code&gt;, and &lt;code&gt;JwksUri&lt;/code&gt; is genuinely resolved. The full six-phase plan, with risks (SSRF, &lt;code&gt;alg:none&lt;/code&gt;, algorithm substitution) and sizing, is in the repo at &lt;code&gt;doc/JAR_RFC9101_PLAN.md&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Resolving client keys
&lt;/h3&gt;

&lt;p&gt;Underneath all this sits &lt;code&gt;IClientKeyResolver&lt;/code&gt; — the resolver for the keys that verify what a &lt;em&gt;client&lt;/em&gt; signed: a JAR request object today, a &lt;code&gt;private_key_jwt&lt;/code&gt; assertion later. Two sources: the inline &lt;code&gt;JsonWebKeySet&lt;/code&gt; on the application, or &lt;code&gt;JwksUri&lt;/code&gt;, fetched and cached with background refresh on a TTL plus a rate-limited forced refresh on a &lt;code&gt;kid&lt;/code&gt; miss, so a client rotating its keys doesn't break sign-in until the TTL expires.&lt;/p&gt;

&lt;p&gt;Two principles, both about not letting uncertainty turn into permission:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fails closed.&lt;/strong&gt; An unreachable or malformed JWKS yields no keys, so there's nothing to verify against and the caller rejects. "Couldn't check" is never treated as "valid."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A broken inline JWKS never falls back to &lt;code&gt;jwks_uri&lt;/code&gt;.&lt;/strong&gt; A typo in a pasted key set has to stay visible instead of being papered over by a silent switch to another key source.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Asymmetric algorithms only: &lt;code&gt;ClientSecret&lt;/code&gt; is stored as a BCrypt hash and verifying an HMAC needs the original secret, so &lt;code&gt;HS*&lt;/code&gt; is impossible here on mechanics alone — and FAPI 2.0 forbids it for request objects regardless. Resolver settings live in the &lt;code&gt;ClientKeys&lt;/code&gt; section: cache lifetime, minimum refresh interval, fetch timeout, maximum document size, plus &lt;code&gt;RequireHttps&lt;/code&gt; and &lt;code&gt;AllowPrivateNetworkTargets&lt;/code&gt; — two &lt;strong&gt;development-only&lt;/strong&gt; relaxations that have no business in production.&lt;/p&gt;

&lt;h3&gt;
  
  
  The SSRF guard
&lt;/h3&gt;

&lt;p&gt;Anything arriving from outside as a URL (&lt;code&gt;jwks_uri&lt;/code&gt;, &lt;code&gt;request_uri&lt;/code&gt;) goes through &lt;code&gt;OutboundUrlGuard&lt;/code&gt; — &lt;strong&gt;before a socket is opened.&lt;/strong&gt; It rejects anything that isn't an absolute HTTPS URL, and anything resolving to a non-public address: loopback, RFC 1918, link-local — including &lt;code&gt;169.254.169.254&lt;/code&gt;, the cloud metadata endpoint that hands instance credentials to whatever can reach it — CGNAT, IPv6 unique-local, and the IPv4-mapped forms like &lt;code&gt;::ffff:10.0.0.1&lt;/code&gt; that a naive check walks straight past.&lt;/p&gt;

&lt;p&gt;40 tests, 29 of them on the guard alone — including one asserting that &lt;code&gt;172.32.*&lt;/code&gt; and &lt;code&gt;172.15.*&lt;/code&gt; &lt;strong&gt;are public and must not be blocked&lt;/strong&gt; (the &lt;code&gt;172.16/12&lt;/code&gt; boundary is a classic off-by-one). All of them stay offline: attempting a network call fails the test by itself. Stating the known limitation myself: this does not close DNS rebinding, which needs the validated address pinned onto the connection itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  About conformance — why SKIPPED is the right answer
&lt;/h3&gt;

&lt;p&gt;After enabling JAR, the local OpenID Foundation Basic OP run went from &lt;strong&gt;1 SKIPPED / 4 REVIEW&lt;/strong&gt; to &lt;strong&gt;2 SKIPPED / 3 REVIEW&lt;/strong&gt;, with &lt;strong&gt;0 FAILED&lt;/strong&gt; throughout.&lt;/p&gt;

&lt;p&gt;That deserves an explanation, because the number reads worse than the reality. Both SKIPPED modules test the &lt;strong&gt;unsigned (&lt;code&gt;alg:none&lt;/code&gt;) request object&lt;/strong&gt;. The suite skips them when the server doesn't advertise &lt;code&gt;none&lt;/code&gt; among its supported algorithms — which is precisely our position: we support signed request objects only. Better still, one module moved REVIEW → SKIPPED &lt;em&gt;because&lt;/em&gt; the server now genuinely processes signed request objects and advertises that honestly, instead of the previous no-support state that sent the test down a screenshot path.&lt;/p&gt;

&lt;p&gt;There's exactly one way to turn either skip into a pass: start accepting &lt;code&gt;alg:none&lt;/code&gt;. We're not trading a security property for a nicer line in a report. The full write-up is in &lt;code&gt;OPENID_CERTIFICATION.md&lt;/code&gt; §4.3, so anyone looking at the badge sees the same picture we do.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 7. Connector odds and ends
&lt;/h2&gt;

&lt;p&gt;Briefly, so it doesn't get lost. The Telegram connector (which got &lt;a href="https://dev.to/rinat_kozin"&gt;its own post&lt;/a&gt;) picked up some ergonomics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;replyToMessageId&lt;/code&gt;&lt;/strong&gt; as a first-class producer option, expression-capable, with &lt;code&gt;ReplyTo(long)&lt;/code&gt; / &lt;code&gt;ReplyTo(IExpression)&lt;/code&gt; / &lt;code&gt;.ReplyToIncoming()&lt;/code&gt;. Replying to the message that triggered the exchange no longer needs a hand-written &lt;code&gt;.Process&lt;/code&gt; step copying one header into another.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;messageId&lt;/code&gt;&lt;/strong&gt; for edit/delete — same story: send-then-edit is now &lt;code&gt;Tg.Edit(token).MessageId(Header(TelegramHeaders.SentMessageId))&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;showAlert&lt;/code&gt;&lt;/strong&gt; in &lt;code&gt;answer&lt;/code&gt; mode — the callback answer shows as a modal alert instead of a toast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;telegram.caption&lt;/code&gt; header&lt;/strong&gt; for &lt;code&gt;document&lt;/code&gt;/&lt;code&gt;photo&lt;/code&gt; that wins over the option, consistent with how &lt;code&gt;parseMode&lt;/code&gt; and &lt;code&gt;fileName&lt;/code&gt; already behaved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mini App payloads (&lt;code&gt;WebApp.sendData&lt;/code&gt;)&lt;/strong&gt; now reach the route: the data becomes the exchange body — same contract as text messages and callback queries — and is also exposed as headers. Works on both the long-polling and webhook paths (they share a mapper); filter on &lt;code&gt;telegram.messageType = "WebAppData"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;And a fix: &lt;code&gt;document&lt;/code&gt; / &lt;code&gt;photo&lt;/code&gt; modes now genuinely pass &lt;code&gt;telegram.replyToMessageId&lt;/code&gt; and &lt;code&gt;telegram.replyMarkup&lt;/code&gt; through, so a photo with inline buttons keeps its keyboard.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Upgrading from 3.3.x
&lt;/h2&gt;

&lt;p&gt;The release is backward compatible and existing routes are unchanged. Three things are worth a look, though.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The SQLite native extension was renamed: &lt;code&gt;redb&lt;/code&gt; → &lt;code&gt;redbsqlite&lt;/code&gt;.&lt;/strong&gt; The package now ships &lt;code&gt;runtimes/&amp;lt;rid&amp;gt;/native/redbsqlite.{dll,so}&lt;/code&gt; (win-x64, linux-x64, linux-arm64). The reason is mundane: the generic name collided with the managed &lt;code&gt;redb.*&lt;/code&gt; assemblies and, worse, matched the &lt;code&gt;redb.*&lt;/code&gt; prune globs a host applies to its own bin — a native loadable module was getting swept up as if it were one of ours. &lt;strong&gt;The C init symbol is unchanged (&lt;code&gt;sqlite3_redb_init&lt;/code&gt;) and the binaries are byte-for-byte the ones shipped in 3.3.3&lt;/strong&gt; — this is a file rename, not a rebuild.

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;Nothing to do&lt;/em&gt; if the package resolves the path for you (&lt;code&gt;SqliteDataSource.LocatePackagedExtension()&lt;/code&gt;, the default for the Free DI registration) — it looks for the new name.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Action required&lt;/em&gt; if you pin the path yourself: &lt;code&gt;REDB_SQLITE_EXTENSION&lt;/code&gt;, an explicit &lt;code&gt;NativeExtensionPath&lt;/code&gt;, a Dockerfile &lt;code&gt;COPY&lt;/code&gt;, or a deploy script that copies &lt;code&gt;redb.so&lt;/code&gt; by name. A stale path fails &lt;strong&gt;at connection open, not at build&lt;/strong&gt; — i.e. at runtime.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tsak: roles are now enforced.&lt;/strong&gt; Roleless keys keep full access by default (with a one-time warning), so nothing should break on day one. Reissue keys with explicit roles, then set &lt;code&gt;Tsak:Auth:RolelessKeysAreAdmin=false&lt;/code&gt;. Check that your CI scripts use a key with the right level: route &lt;code&gt;force-stop&lt;/code&gt; and module deletion are now &lt;code&gt;admin&lt;/code&gt;, diagnostics and logs are &lt;code&gt;operator&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tsak: the distribution build changed.&lt;/strong&gt; The framework is now built into the shared layer: &lt;code&gt;scripts/build-shared.ps1 -IncludeFramework&lt;/code&gt; (publish does this for you via &lt;code&gt;publish/build.ps1&lt;/code&gt;). Running the Worker without it hits the fail-fast, and the message tells you exactly what to run. Prebuilt &lt;code&gt;3.4.0&lt;/code&gt; images and archives are already correct.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Implementing &lt;code&gt;IModuleHealthContributor&lt;/code&gt;?&lt;/strong&gt; It moved from &lt;code&gt;redb.Tsak.Core&lt;/code&gt; to &lt;code&gt;redb.Tsak.Contracts&lt;/code&gt;: change &lt;code&gt;using redb.Tsak.Core.Contracts;&lt;/code&gt; to &lt;code&gt;using redb.Tsak.Contracts;&lt;/code&gt;. Nice side effect — your module can now reference only the lightweight contracts assembly and drop the &lt;code&gt;redb.Tsak.Core&lt;/code&gt; reference entirely.&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  Getting it
&lt;/h3&gt;

&lt;p&gt;Core and providers 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 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 key&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The engine and whichever connectors you need:&lt;br&gt;
&lt;/p&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.Telegram
dotnet add package redb.Route.Kafka
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The runtime as an image or an archive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker pull ghcr.io/redbase-app/redb-tsak-stack:3.4.0
&lt;span class="c"&gt;# or the standalone archive from the v3.4.0 GitHub release (linux-x64 / win-x64), cosign-signed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Images are cosign-signed; the public key ships with the release (&lt;code&gt;cosign.pub&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;cosign verify &lt;span class="nt"&gt;--key&lt;/span&gt; cosign.pub ghcr.io/redbase-app/redb-tsak-worker:3.4.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything targets &lt;strong&gt;.NET 9&lt;/strong&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  Wrapping up
&lt;/h3&gt;

&lt;p&gt;If 3.3.0 was about the stack holding up under load, 3.4.0 is about the stack being &lt;strong&gt;operable&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;What failed gets replayed from a save-point instead of from whatever the state decayed into on the way down — and it's a button in a dashboard, not a night spent reading logs. A library patch ships as a DLL swap, with no runtime rebuild and no re-cut archives. A node leaves the rotation gracefully instead of being evicted. Secrets stay out of logs, telemetry, and the dashboard — and better yet, never enter the route. The control plane hands out permissions by role, module code is verified against a signature before it loads, and the record of who did what survives a restart. A scheme's identity in the database stopped being hostage to your namespace. And the whole ecosystem, Identity included, finally rides one version number.&lt;/p&gt;

&lt;p&gt;This is the class of capability nobody sees in a demo and everybody feels in production.&lt;/p&gt;

&lt;p&gt;If you take it for a spin, tell me what landed and what's missing — deep dives on replay checkpoints and on the secret-redaction work are both queued up, and I'd rather aim them at real questions.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Sources and releases: &lt;a href="https://github.com/redbase-app/" rel="noopener noreferrer"&gt;github.com/redbase-app&lt;/a&gt;. About the database: &lt;a href="https://redbase.app/" rel="noopener noreferrer"&gt;redbase.app&lt;/a&gt;. Previous posts: &lt;a href="https://dev.to/rinat_kozin"&gt;my dev.to profile&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If this was useful — a ⭐ on GitHub helps others find it.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>Basic OP: 35 modules, 0 failures. Config OP: 0 failures.

Found and fixed: PII leak in id_token, missing Cache-Control: no-store.

report: https://github.com/redbase-app/redb-identity/blob/main/OPENID_CERTIFICATION.md

#dotnet #oidc #oauth</title>
      <dc:creator>rinat kozin</dc:creator>
      <pubDate>Wed, 22 Jul 2026 20:05:11 +0000</pubDate>
      <link>https://dev.to/rinat_kozin/basic-op-35-modules-0-failures-config-op-0-failures-found-and-fixed-pii-leak-in-idtoken-46j6</link>
      <guid>https://dev.to/rinat_kozin/basic-op-35-modules-0-failures-config-op-0-failures-found-and-fixed-pii-leak-in-idtoken-46j6</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__cover"&gt;
          &lt;a href="https://github.com/redbase-app/redb-identity/blob/main/OPENID_CERTIFICATION.md" 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%2Fopengraph.githubassets.com%2F4e168653422f711ccfc0e0f567d51fea23d6f15a27ca6b23b901038a818768e3%2Fredbase-app%2Fredb-identity" height="600" class="m-0" width="1200"&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/redb-identity/blob/main/OPENID_CERTIFICATION.md" rel="noopener noreferrer" class="c-link"&gt;
            redb-identity/OPENID_CERTIFICATION.md at main · redbase-app/redb-identity · GitHub
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Transport-agnostic OAuth 2.1 / OpenID Connect server for the redb ecosystem — OpenIddict on redb.Route, redb-backed stores, MFA, WebAuthn, federation, SCIM, DPoP. Green on PostgreSQL, MSSQL and SQL...
          &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>The night job that carves its own partitions: cron, a plpgsql function, and a Splitter</title>
      <dc:creator>rinat kozin</dc:creator>
      <pubDate>Tue, 21 Jul 2026 17:17:47 +0000</pubDate>
      <link>https://dev.to/rinat_kozin/the-night-job-that-carves-its-own-partitions-cron-a-plpgsql-function-and-a-splitter-6gl</link>
      <guid>https://dev.to/rinat_kozin/the-night-job-that-carves-its-own-partitions-cron-a-plpgsql-function-and-a-splitter-6gl</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%2Fijko2j9h4l5a9pxx5qdr.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%2Fijko2j9h4l5a9pxx5qdr.png" alt="redb.Route.Cron" width="784" height="1168"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Series:&lt;/strong&gt; redb ecosystem&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's a table of GPS points. Vehicles push coordinates every few seconds, a day adds up to millions of rows, and the table is partitioned by time. Which means somebody has to create tomorrow's partition ahead of time and detach the ones older than ninety days. Otherwise, one fine night, an insert dies with &lt;code&gt;no partition of relation "gps_points" found for row&lt;/code&gt; — at precisely the hour nobody is watching.&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%2F1ykj9saojbzay8gxolp0.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%2F1ykj9saojbzay8gxolp0.png" alt="redb.Tsak scheduler" width="800" height="698"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The problem is as old as the hills, and it's usually solved one of two ways: &lt;code&gt;pg_cron&lt;/code&gt; inside the database, or an &lt;code&gt;IHostedService&lt;/code&gt; with a &lt;code&gt;PeriodicTimer&lt;/code&gt; in the app. The first has an observability problem — the job lives in the database, and your application logs know nothing about it. The second has the problem that a small private infrastructure grows around &lt;code&gt;PeriodicTimer&lt;/code&gt; very quickly: retries, logging, "what if the previous run is still going", "what if there are three nodes".&lt;/p&gt;

&lt;p&gt;I'll show a third way — a route. What follows is a walkthrough of the &lt;code&gt;redb.Route.Quartz&lt;/code&gt; connector, calling a PostgreSQL function through the SQL connector, a splitter with error isolation, and an honest conversation about clustering, because that's where it actually gets interesting.&lt;/p&gt;

&lt;p&gt;All the code in this post uses string URIs. redb.Route has fluent builders too, but a URI reads without knowing the API, and you can paste it into a config file.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;redb ecosystem series.&lt;/strong&gt; This is a continuation — newest posts first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/rinat_kozin/the-amazon-sqs-sns-connector-in-redbroute-at-least-once-and-pubsub-via-sns-sqs-still-leaving-48dc"&gt;The Amazon SQS + SNS connector in redb.Route, at-least-once and pub/sub via SNS→SQS. Still leaving MassTransit&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/rinat_kozin/the-rabbitmq-connector-in-redbroute-the-whole-broker-in-one-uri-rpc-competing-consumers-18m0"&gt;The RabbitMQ connector in redb.Route: the whole broker in one URI — RPC, competing consumers, dead-lettering&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/rinat_kozin/redb-330-an-enterprise-net-stack-you-actually-own-typed-store-a-homegrown-apache-camel-and-3gd1"&gt;redb 3.3.0: an enterprise .NET stack you actually own — typed store, a homegrown Apache Camel, and a runtime with a dashboard (all free)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/rinat_kozin/two-routes-in-an-evening-from-a-debug-worker-to-an-enterprise-runtime-with-redbroute-tsak-m8k"&gt;Two routes in an evening: from a debug worker to an enterprise runtime with redb.Route + Tsak&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/rinat_kozin/leaving-masstransit-for-a-camel-state-of-mind-the-kafka-connector-scatter-gather-and-what-really-106h"&gt;Leaving MassTransit for a Camel state of mind: the Kafka connector, Scatter-Gather, and what really happens under a transaction&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full list on the &lt;a href="https://dev.to/rinat_kozin"&gt;profile&lt;/a&gt;. Sources: &lt;a href="https://github.com/redbase-app/redb-route" rel="noopener noreferrer"&gt;github.com/redbase-app/redb-route&lt;/a&gt;. About the database itself: &lt;a href="https://redbase.app/" rel="noopener noreferrer"&gt;redbase.app&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The SQL first
&lt;/h2&gt;

&lt;p&gt;There's no magic at this level, so let's start from the most honest place — the function in the database. It takes a table name and a retention window, creates tomorrow's partition if it isn't there yet, and detaches everything past the window:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;maintain_partitions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tbl&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keep_days&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;DECLARE&lt;/span&gt;
    &lt;span class="n"&gt;next_day&lt;/span&gt;   &lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'1 day'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;part_name&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'%s_%s'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tbl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_char&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next_day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'YYYYMMDD'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="n"&gt;cutoff&lt;/span&gt;     &lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;make_interval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;keep_days&lt;/span&gt;&lt;span class="p"&gt;))::&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;dropped&lt;/span&gt;    &lt;span class="nb"&gt;int&lt;/span&gt;  &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;old_part&lt;/span&gt;   &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
    &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="n"&gt;to_regclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;part_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
        &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'CREATE TABLE %I PARTITION OF %I FOR VALUES FROM (%L) TO (%L)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;part_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tbl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;next_day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;next_day&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="n"&gt;old_part&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt;
        &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt;
        &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
        &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_inherits&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inhrelid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;
        &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inhparent&lt;/span&gt;
        &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tbl&lt;/span&gt;
          &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'%s_%s'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tbl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_char&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'YYYYMMDD'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;LOOP&lt;/span&gt;
        &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DROP TABLE %I'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;old_part&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;dropped&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dropped&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;LOOP&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;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'%s: +1 partition, -%s dropped'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tbl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dropped&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The function returns a string so it shows up in the log. That "so it shows up in the log" is the single concession to convenience here; everything else is ordinary plpgsql you'd have written anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tick: &lt;code&gt;cron://&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;redb.Route.Quartz&lt;/code&gt; connector gives you two schemes. The first is &lt;code&gt;cron:&lt;/code&gt;, a regular Quartz trigger with a cron expression:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cron://[group/]jobName?schedule=&amp;lt;cron-expression&amp;gt;&amp;amp;&amp;lt;options&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second is &lt;code&gt;qtimer:&lt;/code&gt;, a plain periodic trigger for when you don't need cron:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;qtimer://[group/]jobName?period=5000&amp;amp;delay=1000&amp;amp;fixedRate=true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is deliberately no &lt;code&gt;quartz:&lt;/code&gt; scheme. That's not an oversight: scheduler setup — where jobs are stored, whether it's a cluster or a single node, what the thread pool looks like — is a property of the host, not of the route. Only the schedule belongs in a route URI. If a &lt;code&gt;quartz:&lt;/code&gt; scheme existed, job store settings would immediately start growing into it, and the route would stop being portable.&lt;/p&gt;

&lt;p&gt;The cron expression is the Quartz one, six fields, with seconds. Let's put partition maintenance at 02:30:&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;"cron://maintenance/gps-partitions?schedule=0 30 2 * * ?"&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;"gps-partitions"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The expression is validated when the endpoint is created, not when it first fires. A typo in the schedule is an &lt;code&gt;ArgumentException&lt;/code&gt; at application startup, not silence until three in the morning.&lt;/p&gt;

&lt;p&gt;Registering the component is one line at the module entry point:&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;AddComponent&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;CronComponent&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Calling the function: &lt;code&gt;sql:&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The SQL connector has a single scheme, &lt;code&gt;sql:&lt;/code&gt;, and the mode is picked with the &lt;code&gt;mode&lt;/code&gt; parameter. To call a PostgreSQL function there's &lt;code&gt;mode=Procedure&lt;/code&gt; with the &lt;code&gt;asFunction=true&lt;/code&gt; flag — the connector then assembles &lt;code&gt;SELECT maintain_partitions(@p1, @p2)&lt;/code&gt;, executes it as a scalar, and puts the result in the message body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sql:maintain_partitions
  ?mode=Procedure
  &amp;amp;dataSource=#pg
  &amp;amp;procedureName=maintain_partitions
  &amp;amp;asFunction=true
  &amp;amp;procedureParams=IN:tbl:String,IN:keep_days:Int32
  &amp;amp;param.keep_days=90
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;procedureParams&lt;/code&gt; declares the parameters in &lt;code&gt;direction:name:type&lt;/code&gt; form, and the declaration order is the argument order in the call. There are three directions — &lt;code&gt;IN&lt;/code&gt;, &lt;code&gt;OUT&lt;/code&gt;, &lt;code&gt;INOUT&lt;/code&gt; — and &lt;code&gt;OUT&lt;/code&gt; values come back into the message headers under their own names after execution.&lt;/p&gt;

&lt;p&gt;A parameter's value is resolved along a chain: first an explicit &lt;code&gt;param.name&lt;/code&gt; from the URI, then a message header with the same name, then the body if it's a dictionary. Here &lt;code&gt;keep_days&lt;/code&gt; is a constant right in the URI, and &lt;code&gt;tbl&lt;/code&gt; will arrive from a header set by the splitter.&lt;/p&gt;

&lt;p&gt;For simple cases there's a shorter path — &lt;code&gt;mode=Execute&lt;/code&gt; (the default) with &lt;code&gt;@name&lt;/code&gt; placeholders directly in the query text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sql:SELECT maintain_partitions(@tbl, @keep_days)
  ?dataSource=#pg
  &amp;amp;outputType=Scalar
  &amp;amp;param.tbl=${header.tbl}
  &amp;amp;param.keep_days=90
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note &lt;code&gt;${header.tbl}&lt;/code&gt; — that's an expression, resolved at runtime from the message header. Placeholders in SQL are &lt;code&gt;@name&lt;/code&gt; only, the colon form isn't supported, and an unsubstituted parameter silently becomes &lt;code&gt;NULL&lt;/code&gt;, so it pays not to mistype the names.&lt;/p&gt;

&lt;p&gt;Both variants work. For the rest of the post I use &lt;code&gt;mode=Procedure&lt;/code&gt; because it's the more illustrative one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The splitter: three tables, and one failure doesn't take down the rest
&lt;/h2&gt;

&lt;p&gt;There's rarely just one time-partitioned table. We have three: points, tracks, and events. The naive move would be a loop inside a processor, but then you're deciding by hand what happens when the second table fails — abort everything or carry on, and how do you find out afterwards what didn't run.&lt;/p&gt;

&lt;p&gt;This is exactly the EIP Splitter pattern. A message carrying a list is split into one message per element, each goes down its own branch, and the branches can be processed in parallel:&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;"cron://maintenance/gps-partitions?schedule=0 30 2 * * ?"&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;"gps-partitions"&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="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;"gps_points"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"gps_tracks"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"gps_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;Split&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ParallelProcessing&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MaxParallelism&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;span class="nf"&gt;SetHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"tbl"&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="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;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sql:maintain_partitions"&lt;/span&gt;
                &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"?mode=Procedure"&lt;/span&gt;
                &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"&amp;amp;dataSource=#pg"&lt;/span&gt;
                &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"&amp;amp;procedureName=maintain_partitions"&lt;/span&gt;
                &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"&amp;amp;asFunction=true"&lt;/span&gt;
                &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"&amp;amp;procedureParams=IN:tbl:String,IN:keep_days:Int32"&lt;/span&gt;
                &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"&amp;amp;param.keep_days=90"&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;"[PART] ${body}"&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;Exception&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;Log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"[PART] ${header.tbl}: ${exception.Message}"&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;Error&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;EndSplit&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;Summary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twelve lines, and they already contain everything you'd normally bolt on a week later. &lt;code&gt;MaxParallelism(2)&lt;/code&gt; means two tables get serviced at once and the third waits for a free slot; &lt;code&gt;DROP TABLE&lt;/code&gt; takes an &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock, and there's no point burying the database under parallel locks. &lt;code&gt;DoTry&lt;/code&gt;/&lt;code&gt;DoCatch&lt;/code&gt; sit &lt;strong&gt;inside&lt;/strong&gt; the split, so the exception is isolated to its own branch: a failing &lt;code&gt;gps_tracks&lt;/code&gt; won't undo the &lt;code&gt;gps_points&lt;/code&gt; that already succeeded, and won't stop &lt;code&gt;gps_events&lt;/code&gt;. After &lt;code&gt;EndSplit&lt;/code&gt;, control lands in &lt;code&gt;Summary&lt;/code&gt;, where you can count how many branches made it and decide whether to page somebody.&lt;/p&gt;

&lt;p&gt;This isn't a trick invented for the article. Exactly this shape runs in production — a job that syncs shipping points from SAP splits the list of points and processes three at a time, because one unreachable point shouldn't take down the whole tick:&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;"timer://tsum-points?period=180000&amp;amp;delay=60000"&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;"tsum-points-timer"&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;PreloadContextAsync&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="nf"&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;ParallelProcessing&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MaxParallelism&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ShippingPoint"&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="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;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sqlTo&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;DeserializeXml&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;ProcessPointsAsync&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;Exception&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;Process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AddPointSyncError&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="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&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;"[TSUM-PT] SP=${header.ShippingPoint} failed, skipping: ${exception.Message}"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EndLog&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;EndSplit&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;BuildPointSyncSummary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The splitter isn't the only EIP that docks onto a scheduler. A schedule pairs naturally with a Content-Based Router (one thing on weekdays, another on weekends), a Throttler (don't hammer an external API more than N times a second), an Aggregator (collect branch results into a single report), an Idempotent Consumer (more on that below) and a Dead Letter Channel. redb.Route implements nearly the whole Hohpe &amp;amp; Woolf catalogue — Splitter, Aggregator, Resequencer, Multicast, Recipient List, Dynamic Router, Wire Tap, Content Enricher, Claim Check, Saga, Scatter-Gather, Circuit Breaker, Load Balancer, Transactional Client and the rest. The scheduler here is just a source, not a separate world with its own rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when the server was down at 02:30
&lt;/h2&gt;

&lt;p&gt;This is where the reason you'd want Quartz rather than a &lt;code&gt;PeriodicTimer&lt;/code&gt; starts.&lt;/p&gt;

&lt;p&gt;The job didn't fire, because the node was down or a deploy ran long. What should happen when the scheduler comes back at 02:47? The answer depends on the job, and it isn't a philosophical question: for partition maintenance, a skip is a catastrophe — tomorrow's partition has to be created, at 02:47 or at 06:00. For a "send the morning report" job, running at noon is worse than not running at all.&lt;/p&gt;

&lt;p&gt;Quartz calls this misfire, and the connector passes the policy straight through the URI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cron://maintenance/gps-partitions?schedule=0 30 2 * * ?&amp;amp;misfireInstruction=CronFireOnceNow
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CronFireOnceNow&lt;/code&gt; — catch up, run once, return to the schedule. &lt;code&gt;CronDoNothing&lt;/code&gt; — skip it, wait for the next scheduled time. Simple triggers (&lt;code&gt;qtimer:&lt;/code&gt;) have more policies — five of them — differing in what to do with the accumulated repeat count. But the choice always collapses to one question: does a missed run need catching up, or has it already gone stale?&lt;/p&gt;

&lt;p&gt;The connector's default for &lt;code&gt;qtimer:&lt;/code&gt; is picked by common sense: with &lt;code&gt;fixedRate=true&lt;/code&gt;, catch up (you asked for a fixed rate); otherwise, reschedule from the next tick.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when the previous run is still going
&lt;/h2&gt;

&lt;p&gt;Partitions have piled up, &lt;code&gt;DROP TABLE&lt;/code&gt; is waiting on a lock, the job is hanging. The next fire time arrives. Now what?&lt;/p&gt;

&lt;p&gt;The standard Quartz answer is the &lt;code&gt;[DisallowConcurrentExecution]&lt;/code&gt; attribute on the job class. The connector went a different way: concurrency is governed by the consumer's own semaphore, and if every thread is busy the fire is silently skipped:&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;// QuartzConsumerBase.cs&lt;/span&gt;
&lt;span class="k"&gt;if&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;_semaphore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WaitAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ConfigureAwait&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;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// all threads busy, skip this fire&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The semaphore size is set in the URI via &lt;code&gt;threads&lt;/code&gt; (default 1). Why not the attribute: &lt;code&gt;[DisallowConcurrentExecution]&lt;/code&gt; is either one run or no control at all, with nothing in between. A semaphore lets you say "up to three concurrent runs of this job" while staying friendly to a cluster, where the limit lives at the job store level rather than in a class attribute.&lt;/p&gt;

&lt;p&gt;If you do want the Quartz semantics, there's a &lt;code&gt;stateful=true&lt;/code&gt; flag that switches the job to a class carrying &lt;code&gt;[DisallowConcurrentExecution]&lt;/code&gt; and &lt;code&gt;[PersistJobDataAfterExecution]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One more detail about shutdown: when a route is stopped, the connector unschedules the trigger and waits for runs already in flight — up to thirty seconds. A job that happens to be dropping a partition right then won't be cut off halfway. And if a job does fire when its route is already gone (the module was unloaded, say), the job notices on startup that its consumer is dead and deletes itself from the scheduler, leaving no litter behind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three nodes
&lt;/h2&gt;

&lt;p&gt;The most common question about cron in a distributed application: if there are three nodes, does the job fire three times?&lt;/p&gt;

&lt;p&gt;It does — if every node keeps its own scheduler in memory. That's exactly how the connector's fallback works: no &lt;code&gt;IScheduler&lt;/code&gt; found in the context, so it creates its own, RAM-backed, unique to that context. Fine for local development, not fine for three nodes.&lt;/p&gt;

&lt;p&gt;The right answer is one scheduler per cluster — more precisely, one shared job store. Quartz does this out of the box via &lt;code&gt;AdoJobStore&lt;/code&gt;, and the connector deliberately stays out of the way: it does not create its own scheduler if a ready one is already sitting in the route context. The host drops a clustered one in there, and the route needs no changes at all — the URI stays the same.&lt;/p&gt;

&lt;p&gt;And here's where all the plumbing that usually gets hand-waved away comes into view. &lt;code&gt;AdoJobStore&lt;/code&gt; is a set of &lt;code&gt;QRTZ_*&lt;/code&gt; tables in your database. &lt;code&gt;QRTZ_TRIGGERS&lt;/code&gt; holds the next fire time, &lt;code&gt;QRTZ_FIRED_TRIGGERS&lt;/code&gt; holds who is executing what right now, &lt;code&gt;QRTZ_LOCKS&lt;/code&gt; holds row-mutexes. The "only one node runs the job" mechanism isn't clever consensus — it's &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; on a row in &lt;code&gt;QRTZ_LOCKS&lt;/code&gt;: whoever takes the lock first takes the trigger. Each node checks in periodically in &lt;code&gt;QRTZ_SCHEDULER_STATE&lt;/code&gt;, and if a node stops checking in, another one picks up its unfinished jobs — but only those marked &lt;code&gt;recoverableJob=true&lt;/code&gt;. The flag is off by default: restarting a job when you don't know whether it's idempotent is a bad idea.&lt;/p&gt;

&lt;p&gt;The table schema is created by the host at startup; the connection string and dialect come from the application's database configuration. So you don't write the DDL, but the tables are perfectly ordinary, they sit next to yours, they're visible in any client, and when something goes wrong you go in with a plain &lt;code&gt;SELECT&lt;/code&gt; and see which trigger is stuck and on which node.&lt;/p&gt;

&lt;p&gt;And since we brought up &lt;code&gt;recoverableJob&lt;/code&gt;: recovery after a node dies means the job may run twice. For our partition maintenance function that's safe — it's written idempotently (&lt;code&gt;IF to_regclass(...) IS NULL&lt;/code&gt;), and that's a requirement, not an accident. If the job weren't idempotent — say, it credited bonuses — you'd put an Idempotent Consumer in front of it, and redb.Route has one backed by SQL with a unique index. A unique index, not a "smart cache": in a cluster, that's the only thing that saves you from double execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  What lands in the message
&lt;/h2&gt;

&lt;p&gt;A scheduler is a source with no message body. The body is &lt;code&gt;null&lt;/code&gt;, the pattern is &lt;code&gt;InOnly&lt;/code&gt;, and everything about the firing lives in the properties:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;What's in it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CamelQuartzFireTime&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;when the job actually fired&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CamelQuartzScheduledFireTime&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;when it should have fired per the schedule&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CamelQuartzNextFireTime&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;when it fires next&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CamelQuartzPreviousFireTime&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;when it fired last&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;CamelCronSchedule&lt;/code&gt; / &lt;code&gt;CamelCronName&lt;/code&gt; / &lt;code&gt;CamelCronGroup&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the expression, name and group of the job&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The gap between &lt;code&gt;FireTime&lt;/code&gt; and &lt;code&gt;ScheduledFireTime&lt;/code&gt; is that misfire, in numbers. If they're seventeen minutes apart, the job was catching up.&lt;/p&gt;

&lt;p&gt;The Camel-style names aren't nostalgia. redb.Route deliberately keeps Apache Camel's nomenclature wherever the semantics match, so someone arriving from Java integrations can read the headers without a dictionary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two jobs from production
&lt;/h2&gt;

&lt;p&gt;So this doesn't read like a piece about a spherical cron in a vacuum — here are two routes that run every night in a transport management system.&lt;/p&gt;

&lt;p&gt;Database backup at three:&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;"cron://tsum-backup?schedule=0 0 3 * * ?"&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;"tsum-backup-cron"&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;RunBackupAsync&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dead-route cleanup at four:&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;"cron://tsum-cleanup?schedule=0 0 4 * * ?"&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;"tsum-cleanup-cron"&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;CleanupDeadRoutesAsync&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing spectacular, and that's the point: the schedule is in the URI, the logic is in the processor, retries and logging come from the framework. Note that the schedule is hardcoded in the URI rather than pulled from config — that's allowed, and it's how things live for a while. When you need to change a schedule without a rebuild, you assemble the URI from config with ordinary concatenation, because it's just a string.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full option list
&lt;/h2&gt;

&lt;p&gt;So this doesn't turn into half a post of reference material — everything &lt;code&gt;cron:&lt;/code&gt; understands:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;schedule&lt;/code&gt; (required), &lt;code&gt;timeZone&lt;/code&gt; (IANA name), &lt;code&gt;threads&lt;/code&gt;, &lt;code&gt;misfireInstruction&lt;/code&gt;, &lt;code&gt;stateful&lt;/code&gt;, &lt;code&gt;recoverableJob&lt;/code&gt;, &lt;code&gt;durableJob&lt;/code&gt;, &lt;code&gt;deleteJob&lt;/code&gt;, &lt;code&gt;pauseJob&lt;/code&gt;, &lt;code&gt;startAt&lt;/code&gt;, &lt;code&gt;endAt&lt;/code&gt;, &lt;code&gt;customCalendar&lt;/code&gt;, &lt;code&gt;triggerStartDelay&lt;/code&gt;, &lt;code&gt;prefixJobNameWithEndpointId&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;qtimer:&lt;/code&gt; swaps &lt;code&gt;schedule&lt;/code&gt; for &lt;code&gt;period&lt;/code&gt;, &lt;code&gt;delay&lt;/code&gt;, &lt;code&gt;fixedRate&lt;/code&gt;, &lt;code&gt;repeatCount&lt;/code&gt;; everything else is the same, minus the time zone (a simple trigger doesn't need one).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;customCalendar&lt;/code&gt; is worth calling out: it's the Quartz exclusion calendar, registered in the context by name. That's how you get "except holidays" and "business days only" — things a cron expression cannot express at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The scheduler in redb.Route is a message source, not a subsystem with its own rules. A job is a route, which means everything available to any route is available to it: the splitter, exception handling, transactions, retries, metrics. The trigger is described by a single URI string, and there's nothing about infrastructure in that string — only the schedule.&lt;/p&gt;

&lt;p&gt;The infrastructure isn't hidden, either. The cluster runs on &lt;code&gt;QRTZ_*&lt;/code&gt; tables and a row lock in the database, idempotency in a cluster comes from a unique index, and partition maintenance is an ordinary plpgsql function that you wrote and can read. The framework here spares you the glue code, not the understanding of what's happening in your database.&lt;/p&gt;

&lt;p&gt;Code: &lt;a href="https://github.com/redbase-app/redb-route" rel="noopener noreferrer"&gt;github.com/redbase-app/redb-route&lt;/a&gt; · Docs: &lt;a href="https://redbase.app/" rel="noopener noreferrer"&gt;redbase.app&lt;/a&gt;&lt;/p&gt;




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

</description>
      <category>dotnet</category>
      <category>c</category>
      <category>opensource</category>
      <category>postgres</category>
    </item>
    <item>
      <title>The Amazon SQS + SNS connector in redb.Route, at-least-once and pub/sub via SNS SQS. Still leaving MassTransit</title>
      <dc:creator>rinat kozin</dc:creator>
      <pubDate>Thu, 16 Jul 2026 13:24:18 +0000</pubDate>
      <link>https://dev.to/rinat_kozin/the-amazon-sqs-sns-connector-in-redbroute-at-least-once-and-pubsub-via-sns-sqs-still-leaving-48dc</link>
      <guid>https://dev.to/rinat_kozin/the-amazon-sqs-sns-connector-in-redbroute-at-least-once-and-pubsub-via-sns-sqs-still-leaving-48dc</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%2F1ucok93f7rbfvfab7r6x.jpg" 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%2F1ucok93f7rbfvfab7r6x.jpg" alt="redb.Route.SQS" width="784" height="1168"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;series: "redb.Route"&lt;/p&gt;

&lt;p&gt;We've done Kafka and RabbitMQ earlier in this series. Next up: &lt;strong&gt;Amazon SQS&lt;/strong&gt; — and riding along in the same package, &lt;strong&gt;SNS&lt;/strong&gt;. Two transports, because in AWS-land they travel together. The &lt;code&gt;redb.Route.Sqs&lt;/code&gt; connector sits on the native &lt;strong&gt;AWS SDK for .NET v4&lt;/strong&gt; (&lt;code&gt;AWSSDK.SQS&lt;/code&gt;, &lt;code&gt;AWSSDK.SimpleNotificationService&lt;/code&gt;), but you don't write a "client" — you write routes, and the whole queue collapses into a single URI:&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;"sqs://orders?waitTimeSeconds=20&amp;amp;concurrentConsumers=4"&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;"Order in: ${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://process"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it once and the whole story's there: long-poll the &lt;code&gt;orders&lt;/code&gt; queue in 20-second pulls, four consumers wide, log it, pass it on. No "new up a client," no &lt;code&gt;ReceiveMessage&lt;/code&gt;, no "remember to &lt;code&gt;DeleteMessage&lt;/code&gt; after you've handled it" — the connector does all of that.&lt;/p&gt;

&lt;p&gt;About "leaving MassTransit," same disclaimer as last time: it's not that MassTransit can't do this. It's had SQS/SNS for years — &lt;a href="https://www.nuget.org/packages/MassTransit.AmazonSQS" rel="noopener noreferrer"&gt;&lt;code&gt;MassTransit.AmazonSQS&lt;/code&gt;&lt;/a&gt; — and it wires up the same SNS→SQS pairing. The difference is the &lt;strong&gt;model&lt;/strong&gt;. MassTransit is a bus — message contracts, consumers, bus config, the whole worldview bought at once. redb.Route is explicit routes in the Apache Camel spirit: an endpoint is a URI, the integration patterns are steps in a route, the transport is an abstraction underneath. This post is about what SQS and SNS look like in that second model.&lt;/p&gt;

&lt;p&gt;And the thing everyone's actually asking about right now — worth stating precisely rather than gleefully. &lt;strong&gt;MassTransit went commercial with v9&lt;/strong&gt;: the line now ships under &lt;a href="https://massient.com/" rel="noopener noreferrer"&gt;Massient, Inc.&lt;/a&gt;, and &lt;code&gt;MassTransit.AmazonSQS&lt;/code&gt; 9.0.0 on NuGet is already from there. But no spin: &lt;strong&gt;v8 stays Apache 2.0&lt;/strong&gt;, with security patches and critical fixes through at least the end of 2026; v9 isn't a closed binary — it's &lt;strong&gt;source-available&lt;/strong&gt;; and organizations under $1M USD annual revenue (plus non-profits under $1M in expenses) get it at a &lt;strong&gt;100% discount&lt;/strong&gt; — free, minus commercial support. So "MassTransit is paid now" is false for a small team and true for a large one, where it turns into a real decision: pay, ride v8 to the end of its support, or look around. &lt;code&gt;redb.Route&lt;/code&gt; is Apache 2.0, this SQS connector included — but pick it because the routing model fits your head, not because of a license line. A license is a reason to look around; it isn't an argument about architecture.&lt;/p&gt;

&lt;p&gt;All the code is in English; the examples aren't invented — they run against &lt;strong&gt;LocalStack&lt;/strong&gt; (a Docker container on &lt;code&gt;http://localhost:4566&lt;/code&gt;), the same way the connector's integration tests do.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Part of the redb / redb.Route series&lt;/strong&gt; — recent posts first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The RabbitMQ connector — the whole broker in one URI: RPC, competing consumers, dead-lettering &lt;em&gt;(previous in the series)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/rinat_kozin/leaving-masstransit-for-a-camel-state-of-mind-the-kafka-connector-scatter-gather-and-what-really-106h"&gt;Leaving MassTransit for a Camel state of mind: the Kafka connector, Scatter-Gather, and transactions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/rinat_kozin/a-homegrown-apache-camel-for-net-dissected-the-http-connector-with-no-aspnet-mvc-the-56bd"&gt;Apache Camel for .NET, dissected: the HTTP connector with no ASP.NET MVC + the Content-Based Router&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/rinat_kozin/redbroute-apache-camel-for-net-22-transports-30-eip-patterns-compiled-dsl-11m0"&gt;redb.Route — Apache Camel for .NET: 22 transports, 30+ EIP patterns, compiled DSL&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sources: &lt;a href="https://github.com/redbase-app/" rel="noopener noreferrer"&gt;github.com/redbase-app&lt;/a&gt;. About the database itself: &lt;a href="https://redbase.app/" rel="noopener noreferrer"&gt;redbase.app&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  One package, two transports
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;redb.Route.Sqs&lt;/code&gt; registers &lt;strong&gt;two schemes&lt;/strong&gt;, and they don't play the same role:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sqs://&lt;/code&gt;&lt;/strong&gt; — a queue. Works as both a consumer (in &lt;code&gt;From(...)&lt;/code&gt;) and a producer (in &lt;code&gt;.To(...)&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sns://&lt;/code&gt;&lt;/strong&gt; — a topic. &lt;strong&gt;Publisher-only.&lt;/strong&gt; SNS has no pull consumer — delivery is push (to SQS / HTTP / email / SMS). Try to &lt;code&gt;From("sns://...")&lt;/code&gt; and it throws &lt;code&gt;NotSupportedException&lt;/code&gt; on purpose: to "read" a topic, you subscribe an SQS queue to it and read &lt;em&gt;that&lt;/em&gt; with &lt;code&gt;sqs://&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why they share a package: at AWS they're a pair. SQS is a durable point-to-point queue (one message, one consumer). SNS is a publish-subscribe topic (one message, every subscriber). The canonical AWS fan-out is an SNS topic with several SQS queues hanging off it — so one connector covers both: &lt;code&gt;sqs://&lt;/code&gt; and &lt;code&gt;sns://&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Wiring it into DI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRedbRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRedbRouteSqs&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// registers both sqs:// and sns://&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;h2&gt;
  
  
  Anatomy of the URI
&lt;/h2&gt;

&lt;p&gt;An endpoint is a string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sqs://queue-name?region=us-east-1&amp;amp;waitTimeSeconds=20&amp;amp;concurrentConsumers=4
sns://topic-name?region=us-east-1&amp;amp;autoCreateTopic=true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scheme, the queue or topic name in the path, then query parameters (names are in the tables below). The fluent builder gives you the same thing: &lt;code&gt;Sqs.Queue("orders").WaitTimeSeconds(20)...&lt;/code&gt; and &lt;code&gt;Sns.Topic("events").Region("us-east-1")...&lt;/code&gt; build that exact string, URL-encoding included.&lt;/p&gt;

&lt;p&gt;Three things are AWS-specific and worth a pause:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't repeat your credentials.&lt;/strong&gt; Twenty queues on one account? Register an &lt;code&gt;AwsConnectionFactory&lt;/code&gt; in the registry and point at it with &lt;code&gt;connectionFactory=name&lt;/code&gt;. One factory builds &lt;strong&gt;both&lt;/strong&gt; the SQS and the SNS client — they share credentials, region and service URL:&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-aws"&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;AwsConnectionFactory&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"eu-west-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;UseDefaultCredentialsProvider&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;// IAM role on the instance / SSO / env vars&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;"sqs://orders?connectionFactory=prod-aws&amp;amp;concurrentConsumers=4"&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;"sns://events?connectionFactory=prod-aws"&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;LocalStack / ElasticMQ via &lt;code&gt;serviceUrl=&lt;/code&gt;.&lt;/strong&gt; An explicit URL overrides the regional endpoint (the region sticks around only to sign the request). That's your local-dev and test mode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sqs://orders?serviceUrl=http://localhost:4566&amp;amp;region=us-east-1&amp;amp;accessKey=test&amp;amp;secretKey=test
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Queues and topics get created on the spot.&lt;/strong&gt; &lt;code&gt;autoCreateQueue=true&lt;/code&gt; (or &lt;code&gt;autoCreateTopic=true&lt;/code&gt;) creates the resource on startup if it's missing. A name ending in &lt;code&gt;.fifo&lt;/code&gt; comes up as a &lt;strong&gt;FIFO&lt;/strong&gt; resource automatically, with &lt;code&gt;ContentBasedDeduplication=true&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Every parameter
&lt;/h2&gt;

&lt;p&gt;The reason to bookmark this post. Names are exactly as they appear in the URI; defaults are what you get out of the box.&lt;/p&gt;

&lt;h3&gt;
  
  
  Connection and credentials (shared by &lt;code&gt;sqs://&lt;/code&gt; and &lt;code&gt;sns://&lt;/code&gt;)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;What it's for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;region&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;us-east-1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;AWS region&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;serviceUrl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(empty)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;Explicit endpoint (LocalStack/ElasticMQ); overrides the region&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;accessKey&lt;/code&gt; / &lt;code&gt;secretKey&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(empty)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;Static keys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sessionToken&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(empty)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;Temporary (STS) credentials&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;profileName&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(empty)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;A named profile from the shared credentials file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;useDefaultCredentialsProvider&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The default AWS chain (env / IAM role / SSO)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;connectionFactory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(empty)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;A factory in the registry; overrides URI credentials&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;retryCount&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SDK retries on transient errors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;connectionTimeout&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;30000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HTTP client timeout, ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;proxyHost&lt;/code&gt; / &lt;code&gt;proxyPort&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;HTTP proxy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Resolution order: the default chain → a profile → a session token → static keys. &lt;code&gt;Validate()&lt;/code&gt; insists on at least one path — &lt;code&gt;accessKey&lt;/code&gt;+&lt;code&gt;secretKey&lt;/code&gt;, &lt;code&gt;profileName&lt;/code&gt;, &lt;code&gt;useDefaultCredentialsProvider=true&lt;/code&gt;, or a registered &lt;code&gt;connectionFactory&lt;/code&gt; — otherwise it throws at startup.&lt;/p&gt;

&lt;h3&gt;
  
  
  SQS — consumer (&lt;code&gt;From("sqs://...")&lt;/code&gt;)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;What it's for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;waitTimeSeconds&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;20&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Long-poll (0–20). Fewer empty receives, fewer API calls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;maxNumberOfMessages&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How many to pull per receive (1–10)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;visibilityTimeout&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;0&lt;/code&gt; (queue default)&lt;/td&gt;
&lt;td&gt;How long a message stays hidden while you work it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;concurrentConsumers&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Number of competing receive loops (see the concurrency recipe)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;extendMessageVisibility&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keep extending visibility while the handler runs (a heartbeat); needs &lt;code&gt;visibilityTimeout &amp;gt; 0&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;deleteAfterRead&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Delete the message after a clean pass (this is the ack)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;resetVisibilityOnFailure&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;On error, reset visibility to 0 → immediate redelivery&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;transacted&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Defer the delete into the route transaction (&lt;code&gt;.Transacted()&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;attributeNames&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;All&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Which system attributes to request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;messageAttributeNames&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;All&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Which message attributes to request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;delay&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pause (ms) after an empty receive before polling again&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;initialDelay&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Delay (ms) before the first poll&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  SQS — producer (&lt;code&gt;.To("sqs://...")&lt;/code&gt;)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;What it's for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;delaySeconds&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Delivery delay (0–900). FIFO queues reject a per-message delay&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;messageGroupId&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;FIFO group id. A constant or a &lt;code&gt;${...}&lt;/code&gt; expression (per message)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;messageDeduplicationId&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;FIFO dedup id. Skip it if the queue has content-based dedup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;enableBatch&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Send an &lt;code&gt;IEnumerable&lt;/code&gt; body as one &lt;code&gt;SendMessageBatch&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;batchMaxMessages&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Batch size (SQS's hard cap is 10)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;autoCreateQueue&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Create the queue on startup (FIFO if the name ends in &lt;code&gt;.fifo&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;queueUrl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(empty)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;An explicit queue URL instead of resolving by name&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  SNS — publisher (&lt;code&gt;.To("sns://...")&lt;/code&gt;)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;What it's for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;autoCreateTopic&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Create the topic on startup (FIFO on &lt;code&gt;.fifo&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;topicArn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(empty)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;An explicit topic ARN instead of resolving by name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;subject&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(empty)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;Subject (for email delivery). Supports &lt;code&gt;${...}&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;messageStructure&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(empty)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;json&lt;/code&gt; — per-protocol payloads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;messageGroupId&lt;/code&gt; / &lt;code&gt;messageDeduplicationId&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;For FIFO topics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;subscribeSnsToSqs&lt;/code&gt; + &lt;code&gt;subscribeQueueArn&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;On startup, subscribe an SQS queue (by ARN) to this topic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rawMessageDelivery&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;On that subscription, deliver the bare payload (see the pub/sub recipe)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Headers and tracing through the broker
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;sqs://&lt;/code&gt; consumer stamps message metadata onto the headers (prefixed &lt;code&gt;redbSqs.&lt;/code&gt;), and incoming &lt;strong&gt;message attributes&lt;/strong&gt; land under &lt;code&gt;redbSqs.attr.&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Header&lt;/th&gt;
&lt;th&gt;What's in it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redbSqs.queue&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The queue name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redbSqs.messageId&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Message id&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redbSqs.receiptHandle&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Receipt handle (needed to delete or change visibility)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redbSqs.approximateReceiveCount&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How many times this message has been delivered ("is this a retry?")&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;redbSqs.messageGroupId&lt;/code&gt; / &lt;code&gt;redbSqs.sequenceNumber&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;FIFO group + sequence number&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redbSqs.sentTimestamp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;When it was sent (epoch millis)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redbSqs.attr.&amp;lt;name&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;An incoming user message attribute&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Going the other way, the &lt;strong&gt;header → message-attribute&lt;/strong&gt; mapping on the producer goes like this: user headers ride out as attributes as-is; an incoming &lt;code&gt;redbSqs.attr.&amp;lt;name&amp;gt;&lt;/code&gt; is forwarded under the bare &lt;code&gt;&amp;lt;name&amp;gt;&lt;/code&gt; (the prefix is stripped, so sqs→sqs and sqs→sns bridges keep their attributes); the internal &lt;code&gt;redbSqs.*&lt;/code&gt; / &lt;code&gt;redbSns.*&lt;/code&gt; keys are dropped, so one hop's metadata doesn't leak into the next.&lt;/p&gt;

&lt;p&gt;Tracing comes for free: before sending, the producer injects the W3C &lt;code&gt;traceparent&lt;/code&gt;/&lt;code&gt;tracestate&lt;/code&gt; as message attributes via the standard &lt;code&gt;DistributedContextPropagator&lt;/code&gt;, and the consumer on the far end picks them up and continues the trace. Over plain SQS (&lt;code&gt;sqs://&lt;/code&gt; → &lt;code&gt;sqs://&lt;/code&gt;) the chain is unbroken with zero setup. (There's a wrinkle when SNS is in the middle — see the pub/sub recipe.)&lt;/p&gt;




&lt;h2&gt;
  
  
  Straight talk on delivery: at-least-once, visibility, transacted
&lt;/h2&gt;

&lt;p&gt;This is where the marketing likes to say "exactly-once." Let's read the code instead.&lt;/p&gt;

&lt;p&gt;The SQS consumer is &lt;strong&gt;at-least-once&lt;/strong&gt;. A message is deleted (&lt;code&gt;DeleteMessage&lt;/code&gt;) only &lt;strong&gt;after&lt;/strong&gt; it clears the route. The settle logic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;clean pass + &lt;code&gt;deleteAfterRead=true&lt;/code&gt; → &lt;code&gt;DeleteMessage&lt;/code&gt; (the ack);&lt;/li&gt;
&lt;li&gt;failed + &lt;code&gt;resetVisibilityOnFailure=true&lt;/code&gt; → &lt;code&gt;ChangeMessageVisibility(0)&lt;/code&gt; → it comes right back (fast retry);&lt;/li&gt;
&lt;li&gt;failed without that flag → it simply times out on visibility and gets redelivered.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;visibilityTimeout&lt;/code&gt;&lt;/strong&gt; is how long the message is hidden while you work it. Long handler? There's &lt;strong&gt;&lt;code&gt;extendMessageVisibility=true&lt;/code&gt;&lt;/strong&gt;: a background heartbeat bumps the visibility every &lt;code&gt;visibilityTimeout / 2&lt;/code&gt;, so slow work never triggers a redelivery out from under you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;transacted=true&lt;/code&gt;&lt;/strong&gt; rolls the ack into the route transaction. The delete doesn't happen immediately — a deferred &lt;code&gt;SqsAckAction&lt;/code&gt; is registered, whose &lt;code&gt;Commit&lt;/code&gt; deletes the message and whose &lt;code&gt;Rollback&lt;/code&gt; resets visibility to 0 (immediate redelivery). Commit and rollback ride along with your redb work at the &lt;code&gt;.Transacted()&lt;/code&gt; boundary:&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;"sqs://orders?transacted=true&amp;amp;visibilityTimeout=60"&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;redb&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="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;redb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Parse&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="c1"&gt;// write to the DB&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;// DB committed → the message is deleted; DB failed → it comes back&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What's &lt;strong&gt;not&lt;/strong&gt; here is cross-queue "read from A, write to B" atomicity at the broker level — SQS doesn't have it to give. Crash in the gap between "work done" and "delete," and after a restart you get a &lt;strong&gt;repeat&lt;/strong&gt; — at-least-once, not exactly-once. So make your handler idempotent; &lt;code&gt;redbSqs.approximateReceiveCount&lt;/code&gt; is there to help. The honest label is "at-least-once with a transactional ack at the route level" — no exactly-once fairy dust.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ordering&lt;/strong&gt; holds only on a FIFO queue (&lt;code&gt;.fifo&lt;/code&gt;) and only with &lt;code&gt;concurrentConsumers=1&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Recipe 1: producer, consumer, FIFO, batch
&lt;/h2&gt;

&lt;p&gt;The bare minimum:&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;// Consumer — long-poll, delete after success&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;Sqs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Queue&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;WaitTimeSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;20&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;span class="c1"&gt;// Producer — send the body to the queue&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;Sqs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Queue&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;Region&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"eu-west-1"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;FIFO — the name ends in &lt;code&gt;.fifo&lt;/code&gt;, and a group id is required (a constant or a per-message expression):&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;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Sqs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders.fifo"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;MessageGroupId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${header.customerId}"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Batch — an &lt;code&gt;IEnumerable&lt;/code&gt; body goes out as a single &lt;code&gt;SendMessageBatch&lt;/code&gt; (in chunks of &lt;code&gt;batchMaxMessages&lt;/code&gt;, SQS's cap being 10):&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;"timer://tick?period=5000"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetBody&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;"a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"b"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"e"&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;Sqs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Queue&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;EnableBatch&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;   &lt;span class="c1"&gt;// one call instead of five&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Recipe 2: competing consumers (concurrency)
&lt;/h2&gt;

&lt;p&gt;One parameter — &lt;code&gt;concurrentConsumers&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="nf"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Sqs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Queue&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;ConcurrentConsumers&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="nf"&gt;MaxNumberOfMessages&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;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;span class="c1"&gt;// up to 8 messages handled at once&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the &lt;strong&gt;SQS-native&lt;/strong&gt; concurrency model: the connector spins up N independent receive loops (one task each), and each pulls and handles its own message. Setting &lt;code&gt;maxNumberOfMessages=1&lt;/code&gt; alongside it makes each of the N workers hold exactly one message at a time, so the pool saturates honestly instead of one loop grabbing a batch of 10. The default is &lt;code&gt;1&lt;/code&gt; (strictly serial). Go &lt;code&gt;N &amp;gt; 1&lt;/code&gt; and per-queue ordering is off the table, and your handler had better be thread-safe.&lt;/p&gt;

&lt;p&gt;How much this actually parallelizes shows up in the &lt;code&gt;SqsRpcDemo&lt;/code&gt; (more on it below): 12 requests, roughly 300 ms of work each, a pool of 4 — the whole batch clears in about 1.2 s instead of the ~3.6 s a serial consumer would spend, and the measured peak concurrency pins at exactly 4.&lt;/p&gt;

&lt;p&gt;And if what you want to parallelize is the &lt;em&gt;processing&lt;/em&gt; inside the route rather than the &lt;em&gt;intake&lt;/em&gt; (a serial source, heavy work), there's an orthogonal EIP step, &lt;code&gt;.Threads(N)&lt;/code&gt;. &lt;code&gt;concurrentConsumers&lt;/code&gt; scales reading off the queue; &lt;code&gt;.Threads(N)&lt;/code&gt; scales the work; the &lt;code&gt;CONCURRENCY.md&lt;/code&gt; guide walks through the difference. They compose freely.&lt;/p&gt;




&lt;h2&gt;
  
  
  Recipe 3: RPC (request/reply) — and why SQS won't hand it to you
&lt;/h2&gt;

&lt;p&gt;Here's the honest contrast with the last post. On RabbitMQ, RPC flipped on with &lt;strong&gt;one flag&lt;/strong&gt; — &lt;code&gt;replyTo=true&lt;/code&gt; on the client and &lt;strong&gt;zero&lt;/strong&gt; config on the server, because the broker does reply queues itself. &lt;strong&gt;SQS has none of that&lt;/strong&gt; — no protocol-level reply-to, no built-in waiting for an answer. RPC over SQS is something you build, using the classic correlation pattern: a &lt;strong&gt;reply queue&lt;/strong&gt;, a &lt;code&gt;correlationId&lt;/code&gt;, and matching the answer back. That's exactly what the &lt;code&gt;SqsRpcDemo&lt;/code&gt; shows.&lt;/p&gt;

&lt;p&gt;The client tags each request with two message attributes — &lt;code&gt;correlationId&lt;/code&gt; and &lt;code&gt;replyTo&lt;/code&gt; (the queue to answer on) — and drops it on the request queue. The worker computes a result and sends it to the queue named in &lt;code&gt;replyTo&lt;/code&gt;; the &lt;code&gt;correlationId&lt;/code&gt; rides back on its own (the incoming &lt;code&gt;redbSqs.attr.correlationId&lt;/code&gt; is forwarded straight back out as an attribute). The worker is a plain consumer plus a dynamic &lt;code&gt;.ToD(...)&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="c1"&gt;// Worker: N competing consumers on the request queue, reply to the queue named in replyTo&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;Sqs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"rpc-requests"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ConcurrentConsumers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;MaxNumberOfMessages&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;Process&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="n"&gt;ex&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;=&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;n&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Parse&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="nf"&gt;ToString&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;Task&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;300&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;// simulated work&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="n"&gt;n&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;long&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// the reply body&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="c1"&gt;// dynamic destination — the reply queue comes from the request's replyTo attribute&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToD&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="n"&gt;Sqs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;ReplyTo&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="nf"&gt;Build&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 client side a separate route reads the reply queue and matches the &lt;code&gt;correlationId&lt;/code&gt; back to the pending call:&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;Sqs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"rpc-replies"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;MaxNumberOfMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Process&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;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="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;Attr&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="s"&gt;"correlationId"&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;id&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;Pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryRemove&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="k"&gt;out&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;tcs&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="n"&gt;tcs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TrySetResult&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="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="s"&gt;""&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;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CompletedTask&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 takeaway: RPC on SQS works, but it's &lt;strong&gt;assembled from primitives&lt;/strong&gt; (attributes + &lt;code&gt;.ToD&lt;/code&gt; + correlation), not toggled with a flag. When you want RPC out of the box, you reach for a broker with a native reply path (RabbitMQ). SQS is honest about what it is: durable queues and redelivery, and request/reply is a thing you wire up on top. The full, runnable version lives in &lt;code&gt;redb.Route/demos/SqsRpcDemo&lt;/code&gt; (it runs against LocalStack).&lt;/p&gt;




&lt;h2&gt;
  
  
  Recipe 4 (EIP): Publish-Subscribe via SNS→SQS fan-out
&lt;/h2&gt;

&lt;p&gt;Here's the headline pattern, the whole reason SQS keeps SNS company. &lt;strong&gt;Publish-Subscribe Channel&lt;/strong&gt;, straight out of Hohpe &amp;amp; Woolf: the publisher sends &lt;strong&gt;one&lt;/strong&gt; message and &lt;strong&gt;every&lt;/strong&gt; subscriber gets it — independently, into its own queue, with its own retries. At AWS you don't do this in code, you do it with topology: an SNS topic with several SQS queues subscribed to it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;publish "order" ──▶ SNS topic ──┬──▶ SQS "orders-billing"  ──▶ billing route
                                 └──▶ SQS "orders-shipping" ──▶ shipping route
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The publisher just publishes to the topic, and every subscribed queue gets a copy — each with its own independent consumer:&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;// Publisher — publish an order event; SNS fans it out to every subscribed queue:&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;"timer://orders?period=5000"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetBody&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;BuildOrder&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;Sns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Topic&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="c1"&gt;// Two independent subscribers — each its own SQS queue, each its own consumer:&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;Sqs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders-billing"&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;Charge&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;Sqs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders-shipping"&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;Ship&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The subscriptions themselves the connector can set up from the URI — &lt;code&gt;subscribeSnsToSqs&lt;/code&gt; + the queue ARN; the subscribe runs when the &lt;code&gt;sns://&lt;/code&gt; producer starts. Each &lt;code&gt;sns://&lt;/code&gt; endpoint subscribes &lt;strong&gt;one&lt;/strong&gt; queue (a single ARN), so a two-queue fan-out is two subscriptions on the one topic:&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;// Subscribe each queue to the topic with raw delivery (bare payload, no envelope):&lt;/span&gt;
&lt;span class="n"&gt;Sns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Topic&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;SubscribeSnsToSqs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;billingArn&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;RawMessageDelivery&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;Sns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Topic&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;SubscribeSnsToSqs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shippingArn&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;RawMessageDelivery&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the exact fan-out wiring (creating the queues, reading their ARNs, subscribing, publishing), see &lt;code&gt;redb.Route/demos/SqsPubSubDemo&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  ⚠️ The catch: the envelope vs. raw
&lt;/h3&gt;

&lt;p&gt;By default SNS does &lt;strong&gt;not&lt;/strong&gt; drop your payload into the queue as-is — it wraps it in a &lt;strong&gt;JSON notification envelope&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Notification"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"MessageId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"TopicArn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"Message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;your payload, as a string&amp;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;"MessageAttributes"&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="err"&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;So the subscribing queue receives the &lt;strong&gt;envelope&lt;/strong&gt;, not your message, and your SNS attributes sit inside it rather than as SQS attributes. That's standard AWS SNS behavior (the subscription's &lt;strong&gt;Raw Message Delivery = OFF&lt;/strong&gt;), and it earns its keep in exactly one case: when a single queue listens to several topics and you need the envelope's &lt;code&gt;TopicArn&lt;/code&gt; to tell them apart. For an ordinary fan-out it's just in the way — you end up unwrapping the envelope, and the SNS→SQS trace breaks (the &lt;code&gt;traceparent&lt;/code&gt; goes inside the envelope, while the SQS consumer looks for it among the SQS attributes).&lt;/p&gt;

&lt;p&gt;The fix is the subscription's &lt;strong&gt;Raw Message Delivery = true&lt;/strong&gt;: the queue gets the &lt;strong&gt;bare payload&lt;/strong&gt;, and the SNS attributes become SQS attributes (and the trace is whole again). In the connector that's &lt;code&gt;.RawMessageDelivery()&lt;/code&gt; — it sets &lt;code&gt;RawMessageDelivery=true&lt;/code&gt; on that &lt;code&gt;subscribeSnsToSqs&lt;/code&gt; subscription. The &lt;code&gt;SqsPubSubDemo&lt;/code&gt; prints the first delivered body: with &lt;code&gt;.RawMessageDelivery()&lt;/code&gt; it's &lt;code&gt;{"orderId":1,"amount":100}&lt;/code&gt;, not an envelope.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;On &lt;code&gt;rawMessageDelivery&lt;/code&gt;.&lt;/strong&gt; This option lands in the &lt;strong&gt;next NuGet release&lt;/strong&gt; of &lt;code&gt;redb.Route.Sqs&lt;/code&gt;. You can already grab it from source right now — &lt;a href="https://github.com/redbase-app/redb-route/tree/main/redb.Route.Sqs" rel="noopener noreferrer"&gt;the &lt;code&gt;redb.Route.Sqs&lt;/code&gt; connector on GitHub&lt;/a&gt; (the connector folder — &lt;code&gt;SnsEndpointOptions&lt;/code&gt; / &lt;code&gt;SnsProducer&lt;/code&gt; / the &lt;code&gt;RawMessageDelivery&lt;/code&gt; fluent method). Without it, SNS→SQS still works; you just get the JSON envelope and unwrap it yourself (&lt;code&gt;envelope.Message&lt;/code&gt;).&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Recipe 5 (a cousin): Multicast — the same fan-out, but inside the route
&lt;/h2&gt;

&lt;p&gt;Publish-Subscribe copies the message &lt;strong&gt;on the broker&lt;/strong&gt; (SNS hands a copy to each subscribed queue). There's a close cousin that copies &lt;strong&gt;inside the route&lt;/strong&gt; — &lt;strong&gt;Multicast&lt;/strong&gt;: your step sends a copy of the exchange to several destinations itself.&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;"sqs://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;Multicast&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;"sqs://orders-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;To&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sqs://orders-shipping"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks similar, but the difference is fundamental — &lt;strong&gt;where the copying happens, and who knows about whom&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;&lt;/th&gt;
&lt;th&gt;SNS→SQS (pub/sub)&lt;/th&gt;
&lt;th&gt;Multicast&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Where it copies&lt;/td&gt;
&lt;td&gt;on the broker (SNS)&lt;/td&gt;
&lt;td&gt;in the route (your process)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who knows the recipients&lt;/td&gt;
&lt;td&gt;SNS (the subscriptions)&lt;/td&gt;
&lt;td&gt;the route (the &lt;code&gt;.To&lt;/code&gt; list)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adding a recipient&lt;/td&gt;
&lt;td&gt;subscribe another queue, leave the publisher alone&lt;/td&gt;
&lt;td&gt;edit the route's code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Coupling&lt;/td&gt;
&lt;td&gt;fully decoupled&lt;/td&gt;
&lt;td&gt;recipients are baked into the route&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parallelism / aggregating replies&lt;/td&gt;
&lt;td&gt;none (fire-and-forget per subscription)&lt;/td&gt;
&lt;td&gt;yes: &lt;code&gt;.Parallel().MaxParallelism(N)&lt;/code&gt;, merge the branches&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rule of thumb: need to &lt;strong&gt;decouple the publisher from the subscribers&lt;/strong&gt; (anyone can subscribe later, the publisher never knows) — SNS→SQS. Need to &lt;strong&gt;fan out to a fixed list right here and maybe collect the answers&lt;/strong&gt; — Multicast (or its sibling Scatter-Gather from the Kafka post). One is broker topology, the other is a route step; you pick by where the fan-out logic should live.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;SQS and SNS in redb.Route are routes where the whole queue and the whole topic are one URI: &lt;code&gt;From("sqs://…")&lt;/code&gt; / &lt;code&gt;.To("sqs://…")&lt;/code&gt; / &lt;code&gt;.To("sns://…")&lt;/code&gt;, with all the AWS SDK plumbing, the receive/delete dance, visibility, subscriptions and correlation tucked behind the string's parameters. Long-poll, FIFO, batching, competing consumers on a single parameter, at-least-once with a transactional ack, SNS→SQS fan-out with a choice of "envelope or bare payload" — the set is complete, and what you're left with is a short, legible string.&lt;/p&gt;

&lt;p&gt;Straight talk on the trade-offs: there's no exactly-once (SQS itself doesn't offer it) — you get at-least-once plus idempotency; RPC isn't out of the box, it's assembled from attributes and &lt;code&gt;.ToD&lt;/code&gt;; and an SNS subscription wraps your payload in an envelope by default. All of that is AWS being AWS, not the connector cutting corners — and the connector is upfront about every bit of it.&lt;/p&gt;

&lt;p&gt;Two runnable examples — &lt;code&gt;redb.Route/demos/SqsRpcDemo&lt;/code&gt; (RPC + concurrency) and &lt;code&gt;redb.Route/demos/SqsPubSubDemo&lt;/code&gt; (SNS→SQS fan-out + raw delivery); both come up on LocalStack (&lt;code&gt;http://localhost:4566&lt;/code&gt;, &lt;code&gt;test&lt;/code&gt;/&lt;code&gt;test&lt;/code&gt;) and run as a self-test. If something bites in your scenario, say so in the comments.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Sources and releases: &lt;a href="https://github.com/redbase-app/" rel="noopener noreferrer"&gt;github.com/redbase-app&lt;/a&gt;. About the redb database: &lt;a href="https://redbase.app/" rel="noopener noreferrer"&gt;redbase.app&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




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

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>aws</category>
      <category>sqs</category>
    </item>
  </channel>
</rss>
