<?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: dotnet</title>
    <description>The latest articles tagged 'dotnet' on DEV Community.</description>
    <link>https://dev.to/t/dotnet</link>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tag/dotnet"/>
    <language>en</language>
    <item>
      <title>Stop Hardcoding Your .NET Data Provider</title>
      <dc:creator>Pengdows LLC</dc:creator>
      <pubDate>Wed, 07 Oct 2026 21:50:12 +0000</pubDate>
      <link>https://dev.to/pengdows/stop-hardcoding-your-net-data-provider-4jmo</link>
      <guid>https://dev.to/pengdows/stop-hardcoding-your-net-data-provider-4jmo</guid>
      <description>&lt;p&gt;There is a piece of advice I have been giving for a long time. I first wrote about it around 2010, before .NET Core existed:&lt;/p&gt;

&lt;p&gt;Do not hardcode your database provider into your application unless the application is deliberately a database-specific application.&lt;/p&gt;

&lt;p&gt;That sounds obvious now. It was less obvious then, when a lot of .NET code looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;connection&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;SqlConnection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;command&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;SqlCommand&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="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is nothing technically wrong with that code. &lt;code&gt;SqlConnection&lt;/code&gt; works. &lt;code&gt;SqlCommand&lt;/code&gt; works. If the application is a SQL Server application and will remain one, this is a reasonable choice.&lt;/p&gt;

&lt;p&gt;The problem begins when the application is supposed to support more than one database, or when a reusable library quietly assumes that every database behaves like SQL Server.&lt;/p&gt;

&lt;p&gt;At that point, the provider type is no longer an implementation detail. It has leaked through the data-access boundary and become part of the application.&lt;/p&gt;

&lt;p&gt;That is exactly the problem ADO.NET was designed to solve.&lt;/p&gt;

&lt;h2&gt;
  
  
  ADO.NET already has the contract
&lt;/h2&gt;

&lt;p&gt;The useful part of ADO.NET is not &lt;code&gt;SqlConnection&lt;/code&gt;. It is the abstraction underneath it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;DbConnection&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DbCommand&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DbParameter&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DbDataReader&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DbTransaction&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DbProviderFactory&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The concrete provider supplies the implementation. The application uses the contract.&lt;/p&gt;

&lt;p&gt;A provider-agnostic connection flow is therefore not mysterious:&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;DbProviderFactory&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;...;&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;DbConnection&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateConnection&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvalidOperationException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"The provider did not create a connection."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConnectionString&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;connectionString&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;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OpenAsync&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;DbCommand&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateCommand&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;parameterName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"customer_id"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CommandText&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;$"select name from customer where customer_id = @&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;parameterName&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;DbParameter&lt;/span&gt; &lt;span class="n"&gt;parameter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateParameter&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;parameter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParameterName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parameterName&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;parameter&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="n"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Parameters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parameter&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;DbDataReader&lt;/span&gt; &lt;span class="n"&gt;reader&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;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExecuteReaderAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;while&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;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReadAsync&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;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetString&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;@&lt;/code&gt; in that first SQL string is deliberately limited to named-parameter providers, not presented as universal syntax. In a real provider-agnostic implementation, the marker comes from detected provider metadata. A positional provider such as OleDb or Access must use &lt;code&gt;?&lt;/code&gt;, and the parameter order becomes part of the contract.&lt;/p&gt;

&lt;p&gt;There is no &lt;code&gt;SqlConnection&lt;/code&gt; in the object-creation path. There is no &lt;code&gt;NpgsqlConnection&lt;/code&gt;, &lt;code&gt;OracleConnection&lt;/code&gt;, or &lt;code&gt;MySqlConnection&lt;/code&gt; either.&lt;/p&gt;

&lt;p&gt;That is the first half of the solution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bother?
&lt;/h2&gt;

&lt;p&gt;Because the provider is infrastructure, not application policy.&lt;/p&gt;

&lt;p&gt;If a reusable data-access library directly references &lt;code&gt;SqlConnection&lt;/code&gt;, then the library is coupled to that provider's assembly, release cycle, configuration conventions, and behavioral assumptions. Moving to &lt;code&gt;Microsoft.Data.SqlClient&lt;/code&gt;, upgrading Npgsql, adding Oracle support, or serving a second tenant with a different database becomes a library change.&lt;/p&gt;

&lt;p&gt;With the provider behind &lt;code&gt;DbProviderFactory&lt;/code&gt;, the composition root selects the implementation and the data-access layer consumes the contract. A routine provider upgrade can then be a package and deployment change rather than a source change and rebuild of every library that talks to the database. The application still pins the provider version, and a provider that changes behavior still needs testing. Switching providers is localized to registration and dialect/capability handling.&lt;/p&gt;

&lt;p&gt;The same separation works in the other direction. A data-access library can ship an upgrade without taking ownership of the provider package or forcing every consumer onto one vendor's release schedule. Provider selection remains an application or deployment decision.&lt;/p&gt;

&lt;p&gt;That does not make upgrades magically risk-free. A provider can change defaults, remove APIs, or expose a database behavior that needs a new dialect rule. Those changes still need tests. The difference is that the dependency is isolated: the application-facing data-access contract does not have to change just because the provider assembly did.&lt;/p&gt;

&lt;p&gt;It also makes the same library usable in more than one environment. Development can use SQLite, integration tests can use a fake or containerized provider, production can use SQL Server or PostgreSQL, and a multi-tenant service can select a provider per tenant without embedding those choices in every query path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the provider at runtime
&lt;/h2&gt;

&lt;h3&gt;
  
  
  .NET Framework: &lt;code&gt;providerName&lt;/code&gt; in configuration
&lt;/h3&gt;

&lt;p&gt;In classic .NET Framework applications, a connection string entry commonly carried both the connection string and a &lt;code&gt;providerName&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;connectionStrings&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;add&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"ApplicationDatabase"&lt;/span&gt;
       &lt;span class="na"&gt;connectionString=&lt;/span&gt;&lt;span class="s"&gt;"..."&lt;/span&gt;
       &lt;span class="na"&gt;providerName=&lt;/span&gt;&lt;span class="s"&gt;"System.Data.SqlClient"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/connectionStrings&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important idea was not the XML. The important idea was that the provider identity was configuration, not source code.&lt;/p&gt;

&lt;p&gt;Once you have the provider name, ADO.NET can resolve the factory:&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;factory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DbProviderFactories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetFactory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;providerName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  .NET Core and modern .NET
&lt;/h3&gt;

&lt;p&gt;There is no machine-wide provider configuration model in .NET Core. In current .NET, the provider is normally a NuGet dependency and the application registers its factory during startup.&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;"Database"&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;"ProviderName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Npgsql"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ConnectionString"&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=localhost;Database=orders;Username=app;Password=..."&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The composition root can map the configured invariant name to the matching factory:&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;factories&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;Dictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DbProviderFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;StringComparer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrdinalIgnoreCase&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;"Npgsql"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NpgsqlFactory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Instance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Microsoft.Data.SqlClient"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SqlClientFactory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Instance&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;providerName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Database:ProviderName"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvalidOperationException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Database provider is not configured."&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;factories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;providerName&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;factory&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvalidOperationException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"Unsupported database provider '&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;providerName&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="n"&gt;DbProviderFactories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RegisterFactory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;providerName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a provider that is a compile-time dependency, the BCL also accepts its factory type:&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;DbProviderFactories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RegisterFactory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Npgsql"&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;NpgsqlFactory&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a provider that is not a compile-time dependency, use a trusted assembly-qualified factory type 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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;factoryTypeName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Database:FactoryType"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvalidOperationException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Database factory type is not configured."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;DbProviderFactories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RegisterFactory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Npgsql"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;factoryTypeName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The assembly still has to be resolvable by normal .NET probing, which usually means it ships with the application. A path-based loader can go further and load a provider DLL from a file under the application directory.&lt;/p&gt;

&lt;p&gt;For applications that support multiple providers or tenants, keyed DI is another option. &lt;code&gt;AddKeyedSingleton&lt;/code&gt; and &lt;code&gt;GetRequiredKeyedService&lt;/code&gt; require .NET 8 or later. The important architectural point is that the composition root knows the provider; the rest of the data-access code receives the ADO.NET contract.&lt;/p&gt;

&lt;p&gt;For longer-lived applications, current .NET also provides &lt;code&gt;DbDataSource&lt;/code&gt;, which gives a provider a shared place for pooling and prepared-statement setup. Register it once and open short-lived connections from the shared instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="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;DbDataSource&lt;/span&gt;&lt;span class="p"&gt;&amp;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="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateDataSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Npgsql's &lt;code&gt;NpgsqlDataSource&lt;/code&gt; is a richer provider-specific version of the same idea. &lt;code&gt;DbProviderFactory&lt;/code&gt; remains the portable construction boundary; &lt;code&gt;DbDataSource&lt;/code&gt; is the shared-resource boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Loading a provider from a registry
&lt;/h2&gt;

&lt;p&gt;Sometimes the provider is not known when the application is built. A multi-tenant service may keep each tenant's provider name, factory type, and connection string in a central registry. The registry itself still needs a bootstrap provider, but the target provider can be loaded from the registry record.&lt;/p&gt;

&lt;p&gt;Here is the essential loader pattern, simplified from pengdows.crud's provider loader:&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;static&lt;/span&gt; &lt;span class="n"&gt;DbProviderFactory&lt;/span&gt; &lt;span class="nf"&gt;LoadFactory&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;providerName&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;assemblyName&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;factoryTypeName&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;assembly&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="nf"&gt;Load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assemblyName&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;factoryType&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="nf"&gt;GetType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;factoryTypeName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;throwOnError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)!;&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="n"&gt;factoryType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Instance"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BindingFlags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Public&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;BindingFlags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Static&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;GetValue&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;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;DbProviderFactory&lt;/span&gt;
        &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="n"&gt;factoryType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Instance"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BindingFlags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Public&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;BindingFlags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Static&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;GetValue&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;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;DbProviderFactory&lt;/span&gt;
        &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvalidOperationException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;$"Factory type '&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;factoryTypeName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;' did not expose DbProviderFactory.Instance."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;DbProviderFactories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RegisterFactory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;providerName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;factory&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;factory&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 registration belongs at startup or DI composition time. Do not repeat it for every command or lookup.&lt;/p&gt;

&lt;p&gt;If the assembly comes from a configured file path, use &lt;code&gt;Assembly.LoadFrom&lt;/code&gt; only after resolving the path against the application base directory. Resolve symlinks before checking containment, and reject paths or symlink targets that escape that directory. This is a path-safety rail, not a sandbox: provider code executes inside the application process with its permissions.&lt;/p&gt;

&lt;p&gt;The registry values must be trusted deployment data. A database row that controls assembly loading is effectively code-loading configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;DataSourceInformation&lt;/code&gt;: the missing layer
&lt;/h2&gt;

&lt;p&gt;Resolving a provider tells us how to create ADO.NET objects. It does not tell us every rule of the database behind the connection.&lt;/p&gt;

&lt;p&gt;ADO.NET exposes a standard metadata path through &lt;code&gt;GetSchema("DataSourceInformation")&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;static&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;ReadMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DbConnection&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;DataTable&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetSchema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;DbMetaDataCollectionNames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DataSourceInformation&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;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Count&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="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// This provider does not publish the standard row; probe instead.&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;DataRow&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Rows&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="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;DbMetaDataColumnNames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DataSourceProductName&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;string&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;string&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;DbMetaDataColumnNames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DataSourceProductVersion&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;string&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;string&lt;/span&gt; &lt;span class="n"&gt;markerFormat&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;DbMetaDataColumnNames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParameterMarkerFormat&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;string&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;string&lt;/span&gt; &lt;span class="n"&gt;markerPattern&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;DbMetaDataColumnNames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParameterMarkerPattern&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;string&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;string&lt;/span&gt; &lt;span class="n"&gt;namePattern&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;DbMetaDataColumnNames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParameterNamePattern&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;string&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;int&lt;/span&gt; &lt;span class="n"&gt;maxNameLength&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;DbMetaDataColumnNames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParameterNameMaxLength&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="kt"&gt;int&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;n&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem is that providers fill this table inconsistently. Some report useful values. Some leave fields empty. Some expose a base product but not a meaningful version. Some require a product-specific query before you can distinguish PostgreSQL from a compatible engine.&lt;/p&gt;

&lt;p&gt;In this model, &lt;code&gt;0&lt;/code&gt; means the provider did not report a limit. Unknown limits should be treated as unknown, not as zero capacity.&lt;/p&gt;

&lt;p&gt;A real data-access layer therefore does three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Reads the standard metadata when it is available.&lt;/li&gt;
&lt;li&gt;Runs product and version probes when the metadata is incomplete.&lt;/li&gt;
&lt;li&gt;Normalizes the result into an immutable object containing facts and capability flags.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The shape is small and boring by design. This is trimmed from pengdows.crud's &lt;code&gt;DataSourceInformation&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;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;DataSourceInformation&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;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;ProductName&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;init&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;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;ProductVersion&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;init&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;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;ParameterMarker&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;init&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;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;ParameterMarkerPattern&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;init&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;required&lt;/span&gt; &lt;span class="n"&gt;Regex&lt;/span&gt; &lt;span class="n"&gt;ParameterNamePattern&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;init&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;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;QuotePrefix&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;init&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;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;QuoteSuffix&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;init&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;required&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;ParameterNameMaxLength&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;init&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;required&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;MaxParameterLimit&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;init&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;required&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;SupportsNamedParameters&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;init&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;required&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;SupportsRepeatedNamedParameters&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;init&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;required&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;SupportsMerge&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;init&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;required&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;SupportsInsertOnConflict&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;init&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;required&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;SupportsOnDuplicateKey&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;init&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;required&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;IsFallbackDialect&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;init&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;This is the useful abstraction. Code can ask what the connected database supports instead of switching on a database-name enum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SupportsRepeatedNamedParameters&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Allocate distinct parameter names for repeated values.&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;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxParameterLimit&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;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxParameterLimit&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;requestedParameterCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Split the command or batch the operation.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parameter helper is equally small:&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;static&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nf"&gt;MakeParameterName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DataSourceInformation&lt;/span&gt; &lt;span class="n"&gt;info&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;name&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;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SupportsNamedParameters&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// With ?, binding order equals parameter-add order. Add parameters in SQL order.&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParameterMarker&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// positional: ?, for example&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;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParameterNamePattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsMatch&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="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;ArgumentException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Invalid parameter name."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;nameof&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="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;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParameterNameMaxLength&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;&amp;amp;&amp;amp;&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParameterNameMaxLength&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ArgumentException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Parameter name is too long."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;nameof&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="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParameterMarker&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the idea behind a dialect's parameter-name logic. It also explains why a generic implementation cannot blindly write &lt;code&gt;@p0&lt;/code&gt;. Oracle commonly uses &lt;code&gt;:p0&lt;/code&gt;; positional providers use &lt;code&gt;?&lt;/code&gt;; Npgsql supports named forms and native positional &lt;code&gt;$1&lt;/code&gt;; and ODP.NET binds by position by default unless &lt;code&gt;BindByName&lt;/code&gt; is enabled.&lt;/p&gt;

&lt;p&gt;Capability flags are not necessarily mutually exclusive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SupportsInsertOnConflict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Default preference order; a specific operation may choose differently.&lt;/span&gt;
    &lt;span class="c1"&gt;// Build INSERT ... ON CONFLICT for this operation.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;else&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;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SupportsOnDuplicateKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Build INSERT ... ON DUPLICATE KEY UPDATE.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;else&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;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SupportsMerge&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Build a MERGE-based implementation.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Use a transaction containing an insert/update decision.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostgreSQL 15+ supports both &lt;code&gt;MERGE&lt;/code&gt; and &lt;code&gt;ON CONFLICT&lt;/code&gt;; the correct choice depends on the operation being generated. Query the capability required by the SQL you are building, not a database name as a shortcut.&lt;/p&gt;

&lt;p&gt;Identifier quoting follows the same rule. Keep it in one helper rather than scattering concatenation through the application:&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;static&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nf"&gt;QuoteIdentifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DataSourceInformation&lt;/span&gt; &lt;span class="n"&gt;info&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;name&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="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QuoteSuffix&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="n"&gt;name&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="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QuotePrefix&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="nf"&gt;Replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QuoteSuffix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QuoteSuffix&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QuoteSuffix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;StringComparison&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ordinal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QuoteSuffix&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;Composite identifiers, reserved words, and provider-specific rules may require a richer dialect helper, but the ownership is the same: SQL generation needs facts about the connected database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detection happens once, then the facts are reused
&lt;/h2&gt;

&lt;p&gt;Database detection should not be repeated for every command. It belongs to context initialization.&lt;/p&gt;

&lt;p&gt;The broad flow is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;configuration
    -&amp;gt; provider name
    -&amp;gt; DbProviderFactory
    -&amp;gt; DbConnection
    -&amp;gt; open connection
    -&amp;gt; ADO.NET metadata and product probes
    -&amp;gt; database dialect
    -&amp;gt; immutable datasource facts
    -&amp;gt; commands generated with the right rules
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your data-access layer owns the detected dialect and metadata. Query builders, repositories, transactions, and higher-level libraries all use the same answer.&lt;/p&gt;

&lt;p&gt;If the provider loads but the engine is not recognized, the layer can use a conservative SQL-92 fallback, expose that it is operating in fallback mode, and provide a compatibility warning. Loading a provider does not mean that every database behind that provider is fully supported.&lt;/p&gt;

&lt;h2&gt;
  
  
  The point is not to hide the database
&lt;/h2&gt;

&lt;p&gt;I am not interested in pretending that all relational databases are interchangeable. They are not.&lt;/p&gt;

&lt;p&gt;The point is to put the differences where they belong. Configuration chooses the provider. &lt;code&gt;DbProviderFactory&lt;/code&gt; creates provider objects. The open connection tells you what is behind it. Metadata and probes become immutable facts. A dialect turns those facts into correct SQL behavior.&lt;/p&gt;

&lt;p&gt;That is how you support multiple databases without hardcoding your data provider into every layer of the application.&lt;/p&gt;

&lt;p&gt;That's a lot of code to own: provider loading, metadata normalization, dialects, capability detection, fallback handling, connection modes for file-based databases like SQLite, and the edge cases around them.&lt;/p&gt;

&lt;p&gt;Or, if you don't want to handcode all this... use &lt;a href="https://www.nuget.org/packages/pengdows.crud" rel="noopener noreferrer"&gt;pengdows.crud&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>database</category>
      <category>sql</category>
    </item>
    <item>
      <title>We Optimized Everything. Azure Was Still Freezing Our .NET API with Gen2 Pauses</title>
      <dc:creator>Miloš Panić</dc:creator>
      <pubDate>Wed, 07 Oct 2026 18:14:31 +0000</pubDate>
      <link>https://dev.to/panky98/we-optimized-everything-azure-was-still-freezing-our-net-api-with-gen2-pauses-1187</link>
      <guid>https://dev.to/panky98/we-optimized-everything-azure-was-still-freezing-our-net-api-with-gen2-pauses-1187</guid>
      <description>&lt;h2&gt;
  
  
  Some context
&lt;/h2&gt;

&lt;p&gt;Our backend is a .NET API that a large fleet of devices talks to. The devices constantly report their status, and the API takes in those updates, processes them and serves regular user requests at the same time. On a normal day it handles about &lt;strong&gt;400 requests every 15 seconds&lt;/strong&gt; (roughly 25–30 requests per second), steadily, around the clock.&lt;/p&gt;

&lt;p&gt;That load isn't huge, but it never stops. Because the traffic is so constant, the API has no quiet periods to catch up after a slowdown. If it stalls for even a few seconds, hundreds of requests are waiting when it comes back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;This API had a problem we couldn't get rid of. At random moments it would &lt;strong&gt;freeze&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Our monitoring dashboards showed the same pattern every time. The &lt;strong&gt;thread pool queue length suddenly jumped to around 200&lt;/strong&gt;, the &lt;strong&gt;thread pool size grew sharply&lt;/strong&gt;, and then recovery was &lt;strong&gt;slow and painful&lt;/strong&gt;. While the API was frozen, requests piled up, and once it came back it had to work through the backlog. Maximum response times spiked to &lt;strong&gt;20, 50, sometimes 100 seconds&lt;/strong&gt;.&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%2F2gb2q7oekghqq068ftuf.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%2F2gb2q7oekghqq068ftuf.png" alt=" " width="798" height="86"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The direct cause wasn't hard to find: a &lt;strong&gt;large, stop-the-world Gen2 garbage collection pause&lt;/strong&gt;. While the GC blocks every managed thread, nothing makes progress. Work keeps arriving and the queue keeps growing. The thread pool then injects new threads only gradually, which is why recovery took so long.&lt;/p&gt;

&lt;p&gt;What we couldn't work out was &lt;strong&gt;why&lt;/strong&gt; the Gen2 collections were happening.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 1: Fixing our own code
&lt;/h2&gt;

&lt;p&gt;Our first assumption was that the problem was in our code, so we went through it carefully.&lt;/p&gt;

&lt;h3&gt;
  
  
  Removing blocking code
&lt;/h3&gt;

&lt;p&gt;First we removed all &lt;strong&gt;sync-over-async&lt;/strong&gt; code, meaning places where a thread blocks while it waits for an async operation instead of using &lt;code&gt;await&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;// ❌ Before: blocks a thread pool thread while waiting&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="n"&gt;_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetDataAsync&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;_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ProcessAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Wait&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ After: frees the thread while waiting&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;_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetDataAsync&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;_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ProcessAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Blocking calls like these are a well-known cause of thread pool starvation, so they were worth removing anyway.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reducing memory pressure on hot paths
&lt;/h3&gt;

&lt;p&gt;Next we tuned our hot paths to allocate less:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Object pooling&lt;/strong&gt; for frequently created objects, so they're reused instead of collected&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;RecyclableMemoryStream&lt;/code&gt;&lt;/strong&gt; instead of &lt;code&gt;MemoryStream&lt;/code&gt;, to avoid repeatedly allocating large buffers that end up on the Large Object Heap
&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="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;RecyclableMemoryStreamManager&lt;/span&gt; &lt;span class="n"&gt;StreamManager&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;// ✅ Pooled buffers instead of a fresh allocation every time&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;stream&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;StreamManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetStream&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All of these were good improvements. &lt;strong&gt;None of them fixed the freezes.&lt;/strong&gt; The Gen2 pauses kept coming.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 2: Looking inside the process
&lt;/h2&gt;

&lt;p&gt;Next I wanted to see what the live process was doing. I got console (CMD/PowerShell) access to the Azure machine hosting the API, which is roughly the Windows equivalent of SSH-ing into the box, and used the standard .NET diagnostic tools:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Live GC, heap and thread pool metrics&lt;/span&gt;
dotnet-counters monitor &lt;span class="nt"&gt;-p&lt;/span&gt; &amp;lt;PID&amp;gt; &lt;span class="nt"&gt;--counters&lt;/span&gt; System.Runtime

&lt;span class="c"&gt;# Snapshot of the managed heap&lt;/span&gt;
dotnet-gcdump collect &lt;span class="nt"&gt;-p&lt;/span&gt; &amp;lt;PID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I watched the counters and analyzed heap dumps, looking for anything unusual: Large Object Heap growth, huge collections, leaking caches.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I found nothing.&lt;/strong&gt; Nothing stood out and nothing was leaking. After all the earlier optimization work, the allocation patterns looked healthy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 3: Looking outside the process
&lt;/h2&gt;

&lt;p&gt;At this point I asked a different question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What if the problem isn't &lt;em&gt;in&lt;/em&gt; our process, but something &lt;em&gt;acting on&lt;/em&gt; it?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Azure keeps its own logs of what the platform does to your app. When I went through them, I found entries showing that &lt;strong&gt;Azure was periodically profiling our process&lt;/strong&gt;.&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%2Fspnixi2gi1ae7z4voxwf.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%2Fspnixi2gi1ae7z4voxwf.png" alt=" " width="388" height="84"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I lined up the timestamps of those profiling sessions with our freezes, and they &lt;strong&gt;matched&lt;/strong&gt;. Every time Azure profiled the process, the API froze and showed the same thread pool spike and long Gen2 pause.&lt;/p&gt;

&lt;p&gt;My working theory: these profiling sessions &lt;strong&gt;trigger blocking, foreground Gen2 collections&lt;/strong&gt; that pause all application threads, which produces exactly the thread pool pile-up we had been chasing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: two settings
&lt;/h2&gt;

&lt;p&gt;The profiling came from two &lt;strong&gt;Application Insights&lt;/strong&gt; features enabled on the app:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;[Feature 1 – e.g. Application Insights Profiler]&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;[Feature 2 – e.g. Snapshot Debugger]&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb70vd0bgp7syhowk9wwm.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%2Fb70vd0bgp7syhowk9wwm.png" alt=" " width="799" height="240"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We weren't really using them. Our existing logging and tracing already gave us stack traces and per-method timings. So I disabled both, and the profiling entries in the Azure logs stopped.&lt;/p&gt;

&lt;p&gt;Then I let the API run for 4+ hours and checked the dashboards.&lt;/p&gt;

&lt;h2&gt;
  
  
  The results
&lt;/h2&gt;

&lt;h3&gt;
  
  
  No more freezes, stable thread pool
&lt;/h3&gt;

&lt;p&gt;The queue spikes stopped and the thread pool stayed flat and stable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt;&lt;br&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%2F2gb2q7oekghqq068ftuf.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%2F2gb2q7oekghqq068ftuf.png" alt=" " width="798" height="86"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt;&lt;br&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%2Fg1bfa596pluyhh48jl4u.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%2Fg1bfa596pluyhh48jl4u.png" alt=" " width="800" height="86"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Memory: 2.2 GB → 1.4 GB
&lt;/h3&gt;

&lt;p&gt;The working set dropped from &lt;strong&gt;2.2 GB to 1.4 GB&lt;/strong&gt;. My guess is that the profiling and debugging tooling was also adding significant memory overhead.&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%2F0s2tgtic28h430kejmbb.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%2F0s2tgtic28h430kejmbb.png" alt=" " width="800" height="147"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Worst-case response times
&lt;/h3&gt;

&lt;p&gt;Maximum response times that used to reach 20–100 seconds are now consistent: &lt;strong&gt;99% of requests complete in under 4–5 seconds&lt;/strong&gt;, even in the worst case. Typical response times are much lower. What changed is that the extreme outliers are gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt;&lt;br&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%2F19ep6jdcpu8vwa0x8fzg.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%2F19ep6jdcpu8vwa0x8fzg.png" alt=" " width="800" height="334"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt;&lt;br&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%2Fgj7tcyg7x38k7jlogjgz.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%2Fgj7tcyg7x38k7jlogjgz.png" alt=" " width="800" height="332"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Background processing: under one second
&lt;/h3&gt;

&lt;p&gt;The flow that processes device status updates had been slow and spiky enough that we were considering moving it out of the API entirely. It now finishes in &lt;strong&gt;under one second&lt;/strong&gt; almost every time, with no peaks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt;&lt;br&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%2Fie33q8mlnshzrf76ojwn.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%2Fie33q8mlnshzrf76ojwn.png" alt=" " width="800" height="84"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt;&lt;br&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%2F9fyonw6ro4phwpqhd55d.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%2F9fyonw6ro4phwpqhd55d.png" alt=" " width="797" height="84"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Look beyond your own code.&lt;/strong&gt; We fixed blocking calls, pooled objects and reduced allocations. All of that was worth doing, but the real cause was the platform acting on our process from outside.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Correlate timestamps across every source.&lt;/strong&gt; The breakthrough came from lining up platform logs with application metrics, not from going deeper into one tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability has a cost.&lt;/strong&gt; Profilers and snapshot debuggers are useful, but they can add memory overhead and pause your application. Turn them on when you need them, not permanently "just in case."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit features you aren't using.&lt;/strong&gt; If another tool already gives you that insight, a redundant feature is all cost and no benefit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't accept "that's just how it is."&lt;/strong&gt; Changing the question we were asking is what finally solved it.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;To be clear, this is a strong correlation backed by large before/after improvements, not a proven root cause at the CLR level. &lt;strong&gt;Have you seen Azure profiling or snapshot features cause GC pauses in your apps?&lt;/strong&gt; Let me know in the comments. I'd like to hear if others have run into this.&lt;/p&gt;

</description>
      <category>azure</category>
      <category>dotnet</category>
      <category>performance</category>
    </item>
    <item>
      <title>SQL Pagination</title>
      <dc:creator>Rhuturaj Takle</dc:creator>
      <pubDate>Wed, 07 Oct 2026 15:27:56 +0000</pubDate>
      <link>https://dev.to/rhuturaj_takle/sql-pagination-1i50</link>
      <guid>https://dev.to/rhuturaj_takle/sql-pagination-1i50</guid>
      <description>&lt;h1&gt;
  
  
  SQL Pagination
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;A deep-dive walkthrough of pagination in SQL Server — covering &lt;code&gt;OFFSET&lt;/code&gt;/&lt;code&gt;FETCH&lt;/code&gt;, why &lt;code&gt;ORDER BY&lt;/code&gt; is mandatory and why it must be deterministic, calculating page numbers, getting the total row count without a second round trip, why deep pages get slower (and how keyset pagination fixes it), the indexing that makes pagination fast, wrapping it in a stored procedure, and how EF Core's &lt;code&gt;Skip&lt;/code&gt;/&lt;code&gt;Take&lt;/code&gt; maps onto all of this.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Introduction&lt;/li&gt;
&lt;li&gt;What Pagination Actually Is&lt;/li&gt;
&lt;li&gt;OFFSET / FETCH: The Core Syntax&lt;/li&gt;
&lt;li&gt;Why ORDER BY Is Mandatory (and Must Be Deterministic)&lt;/li&gt;
&lt;li&gt;Calculating Page Numbers&lt;/li&gt;
&lt;li&gt;Getting the Total Row Count&lt;/li&gt;
&lt;li&gt;The Hidden Cost: Deep Pages Get Slower&lt;/li&gt;
&lt;li&gt;Keyset (Seek) Pagination&lt;/li&gt;
&lt;li&gt;Indexing for Pagination&lt;/li&gt;
&lt;li&gt;Pagination Inside a Stored Procedure&lt;/li&gt;
&lt;li&gt;Older Approaches: TOP and ROW_NUMBER()&lt;/li&gt;
&lt;li&gt;Pagination in EF Core&lt;/li&gt;
&lt;li&gt;Common Pitfalls&lt;/li&gt;
&lt;li&gt;Quick Reference Table&lt;/li&gt;
&lt;li&gt;Conclusion&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Pagination means retrieving data in smaller chunks instead of loading all records at once. Instead of asking the database for every row in a table and letting the application (or the user's browser) sort out what to show, you ask for exactly one "page" at a time — say, rows 21 through 30 — and fetch the next page only when it's needed. This is especially important when working with large datasets, where loading everything wastes memory, network bandwidth, and database resources on rows nobody will ever look at.&lt;/p&gt;

&lt;p&gt;The standard SQL Server approach (SQL Server 2012 and later) is the &lt;code&gt;OFFSET&lt;/code&gt;/&lt;code&gt;FETCH&lt;/code&gt; clause:&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;SELECT&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;OrderDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Total&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;Orders&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;OrderDate&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;OFFSET&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="k"&gt;ROWS&lt;/span&gt;
&lt;span class="k"&gt;FETCH&lt;/span&gt; &lt;span class="k"&gt;NEXT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="k"&gt;ROWS&lt;/span&gt; &lt;span class="k"&gt;ONLY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- skip the first 20 rows, then return the next 10 — i.e. page 3 at a page size of 10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This guide goes beyond the syntax. It covers the mechanics that matter in production: why the &lt;code&gt;ORDER BY&lt;/code&gt; has to be deterministic or pages will silently overlap, why &lt;code&gt;OFFSET&lt;/code&gt; gets slower the deeper you page, the keyset alternative that stays fast at any depth, and the indexing that underpins both.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. What Pagination Actually Is
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Returning a bounded slice of an ordered result set
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Without pagination:
  SELECT * FROM Orders;          -- 5,000,000 rows -&amp;gt; memory, network, UI all suffer

With pagination:
  Page 1 -&amp;gt; rows   1-10
  Page 2 -&amp;gt; rows  11-20
  Page 3 -&amp;gt; rows  21-30          -- only the slice that is actually displayed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pagination solves three distinct problems at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Database and network load&lt;/strong&gt; — only the requested rows are read, shaped, and sent over the wire.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Application memory&lt;/strong&gt; — the client never has to materialize millions of rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User experience&lt;/strong&gt; — a screen that renders 10–50 rows is fast and usable; one that renders 5 million is neither.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Conceptually, pagination is always two things combined: a &lt;strong&gt;stable ordering&lt;/strong&gt; of the full result set, and a &lt;strong&gt;window&lt;/strong&gt; (skip N, take M) over that ordering. Everything in this guide is about getting one or both of those right.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. OFFSET / FETCH: The Core Syntax
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;OFFSET&lt;/code&gt; skips rows; &lt;code&gt;FETCH NEXT&lt;/code&gt; limits how many come back
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&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;CustomerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OrderDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Total&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;Orders&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;OrderDate&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;OFFSET&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="k"&gt;ROWS&lt;/span&gt;            &lt;span class="c1"&gt;-- how many rows to SKIP&lt;/span&gt;
&lt;span class="k"&gt;FETCH&lt;/span&gt; &lt;span class="k"&gt;NEXT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="k"&gt;ROWS&lt;/span&gt; &lt;span class="k"&gt;ONLY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- how many rows to RETURN after skipping&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few precise syntax rules worth knowing up front:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- OFFSET ... FETCH is part of the ORDER BY clause — it cannot exist without ORDER BY.
- FETCH requires OFFSET. (To start from the first row, use OFFSET 0 ROWS.)
- OFFSET is optional on its own: OFFSET 20 ROWS with no FETCH
  returns EVERYTHING after the first 20 rows.
- ROW and ROWS are interchangeable; FIRST and NEXT are interchangeable.
  OFFSET 1 ROW FETCH FIRST 1 ROW ONLY is valid.
- It cannot be combined with TOP in the same query.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Using variables instead of literals
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DECLARE&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="k"&gt;Offset&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;DECLARE&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;PageSize&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CustomerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OrderDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Total&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;Orders&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;OrderDate&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;OFFSET&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="k"&gt;Offset&lt;/span&gt; &lt;span class="k"&gt;ROWS&lt;/span&gt;
&lt;span class="k"&gt;FETCH&lt;/span&gt; &lt;span class="k"&gt;NEXT&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;PageSize&lt;/span&gt; &lt;span class="k"&gt;ROWS&lt;/span&gt; &lt;span class="k"&gt;ONLY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both values can be variables, parameters, or even expressions — which is what makes &lt;code&gt;OFFSET&lt;/code&gt;/&lt;code&gt;FETCH&lt;/code&gt; practical inside stored procedures and parameterized application queries (Section 9 and Section 11). The first page is simply &lt;code&gt;OFFSET 0 ROWS&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Why ORDER BY Is Mandatory (and Must Be Deterministic)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  A relational table has no inherent order — "page 2" is meaningless without one
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SQL Server does NOT guarantee any row order unless you specify ORDER BY.
Without one, "skip 20, take 10" has no defined meaning — the engine would
be free to return ANY 10 rows, and a different 10 next time.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is why &lt;code&gt;OFFSET&lt;/code&gt;/&lt;code&gt;FETCH&lt;/code&gt; is syntactically bound to &lt;code&gt;ORDER BY&lt;/code&gt; — the language refuses to let you paginate over an undefined ordering at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  But a &lt;em&gt;non-unique&lt;/em&gt; ORDER BY is a subtler, silent bug
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- ❌ DANGEROUS — many orders can share the same OrderDate&lt;/span&gt;
&lt;span class="k"&gt;SELECT&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;OrderDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Total&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;Orders&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;OrderDate&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;OFFSET&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="k"&gt;ROWS&lt;/span&gt; &lt;span class="k"&gt;FETCH&lt;/span&gt; &lt;span class="k"&gt;NEXT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="k"&gt;ROWS&lt;/span&gt; &lt;span class="k"&gt;ONLY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If multiple rows share the same &lt;code&gt;OrderDate&lt;/code&gt;, SQL Server is free to return those tied rows in &lt;em&gt;any&lt;/em&gt; order, and that order can legitimately differ between two executions of the same query — particularly once the plan changes, parallelism kicks in, or data shifts. The result: &lt;strong&gt;a row can appear on page 2 and again on page 3, while a different row is skipped entirely and never appears on any page.&lt;/strong&gt; There's no error, no warning — just quietly wrong pages.&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="c1"&gt;-- ✅ SAFE — add a unique tie-breaker (typically the primary key) as the LAST sort column&lt;/span&gt;
&lt;span class="k"&gt;SELECT&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;OrderDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Total&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;Orders&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;OrderDate&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;OFFSET&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="k"&gt;ROWS&lt;/span&gt; &lt;span class="k"&gt;FETCH&lt;/span&gt; &lt;span class="k"&gt;NEXT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="k"&gt;ROWS&lt;/span&gt; &lt;span class="k"&gt;ONLY&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;Rule: the ORDER BY for paginated queries must produce a TOTAL order.
  Whatever column(s) the user sorts by, ALWAYS append a unique column
  (usually the primary key) at the end so no two rows ever tie.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the single most common correctness bug in hand-written pagination, and it's worth treating as non-negotiable.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Calculating Page Numbers
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Converting a 1-based page number into an offset
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OFFSET = (PageNumber - 1) * PageSize
FETCH  = PageSize

Page size 10:
  Page 1 -&amp;gt; OFFSET  0
  Page 2 -&amp;gt; OFFSET 10
  Page 3 -&amp;gt; OFFSET 20      &amp;lt;-- the example at the top of this guide
  Page 4 -&amp;gt; OFFSET 30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DECLARE&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;PageNumber&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;DECLARE&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;PageSize&lt;/span&gt;   &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OrderDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Total&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;Orders&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;OrderDate&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;OFFSET&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;PageNumber&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="o"&gt;*&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;PageSize&lt;/span&gt; &lt;span class="k"&gt;ROWS&lt;/span&gt;
&lt;span class="k"&gt;FETCH&lt;/span&gt; &lt;span class="k"&gt;NEXT&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;PageSize&lt;/span&gt; &lt;span class="k"&gt;ROWS&lt;/span&gt; &lt;span class="k"&gt;ONLY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Validate inputs — never trust the caller's page number
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- PageNumber &amp;lt; 1  -&amp;gt; a NEGATIVE offset, which raises an error
- PageSize = 0 or huge -&amp;gt; an empty page, or defeats the whole purpose of paging
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In real code, clamp the values (&lt;code&gt;PageNumber&lt;/code&gt; at least 1; &lt;code&gt;PageSize&lt;/code&gt; within a sensible range such as 1–100). An API that lets a caller request &lt;code&gt;PageSize = 10,000,000&lt;/code&gt; has quietly reintroduced the exact problem pagination exists to solve.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Getting the Total Row Count
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Most UIs need "Page 3 of 47" — which requires knowing the total
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Option A: a second query (simple, but runs the filter twice)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;Orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Shipped'&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 sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Option B: COUNT(*) OVER() — total returned alongside every row in ONE query&lt;/span&gt;
&lt;span class="k"&gt;SELECT&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;OrderDate&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="k"&gt;COUNT&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;OVER&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;TotalRows&lt;/span&gt;      &lt;span class="c1"&gt;-- same value repeated on every row of the page&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;Orders&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Shipped'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;OrderDate&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;OFFSET&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="k"&gt;ROWS&lt;/span&gt; &lt;span class="k"&gt;FETCH&lt;/span&gt; &lt;span class="k"&gt;NEXT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="k"&gt;ROWS&lt;/span&gt; &lt;span class="k"&gt;ONLY&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;COUNT(*) OVER()&lt;/code&gt; is evaluated over the &lt;em&gt;entire filtered set before&lt;/em&gt; the &lt;code&gt;OFFSET&lt;/code&gt;/&lt;code&gt;FETCH&lt;/code&gt; window is applied, so every returned row carries the full total. The application reads &lt;code&gt;TotalRows&lt;/code&gt; from the first row and computes the page count:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TotalPages = CEILING(TotalRows / PageSize)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth knowing precisely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- If the requested page is PAST the end, zero rows come back — so there is
  no row to read TotalRows from. Handle the empty-page case explicitly
  (e.g. fall back to a separate COUNT, or return TotalRows = 0).
- Computing the total is NOT free: it must visit every row matching the
  filter. On very large tables this count can cost more than the page itself.
  Many large systems avoid exact totals and show "Next / Previous" only,
  or cache an approximate count.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  6. The Hidden Cost: Deep Pages Get Slower
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;OFFSET&lt;/code&gt; does not jump to row N — it reads and discards N rows
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OFFSET 1000000 ROWS FETCH NEXT 10 ROWS ONLY
  -&amp;gt; SQL Server must still PRODUCE the first 1,000,000 rows in order,
     THROW THEM AWAY, and only then return the next 10.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the central performance fact about &lt;code&gt;OFFSET&lt;/code&gt; pagination: the cost grows with the offset. Page 1 is nearly instant; page 100,000 does a million rows' worth of work to hand you ten. You can see it directly in the execution plan (per this series' Execution Plans guide): the operator feeding the &lt;code&gt;Top&lt;/code&gt; has to read far more rows than the query ultimately returns.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Page 1        -&amp;gt; reads ~10 rows        -&amp;gt; fast
Page 1,000    -&amp;gt; reads ~10,000 rows    -&amp;gt; noticeably slower
Page 100,000  -&amp;gt; reads ~1,000,000 rows -&amp;gt; slow, and gets worse as data grows
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For most business applications — where users rarely go beyond the first few pages — this is perfectly acceptable. It becomes a real problem for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;very large tables,&lt;/li&gt;
&lt;li&gt;"infinite scroll" feeds,&lt;/li&gt;
&lt;li&gt;APIs or export jobs that walk through &lt;em&gt;every&lt;/em&gt; page sequentially.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A second, separate weakness: because &lt;code&gt;OFFSET&lt;/code&gt; counts rows from the start each time, &lt;strong&gt;inserts or deletes between page requests shift the window.&lt;/strong&gt; A new row inserted at the top while the user is on page 2 pushes one row from page 2 onto page 3 — so the user sees a duplicate on the next page. Keyset pagination (next section) avoids both problems.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Keyset (Seek) Pagination
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Instead of "skip N rows," say "give me rows AFTER the last one I saw"
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Page 1: no anchor yet&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;TOP&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&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="n"&gt;OrderDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Total&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;Orders&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;OrderDate&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- The application remembers the LAST row's values from this page:&lt;/span&gt;
&lt;span class="c1"&gt;--   @LastOrderDate = '2026-03-14 09:30', @LastId = 48213&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Next page: seek directly to the position after the last row seen&lt;/span&gt;
&lt;span class="k"&gt;DECLARE&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;LastOrderDate&lt;/span&gt; &lt;span class="n"&gt;DATETIME2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-03-14 09:30'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;DECLARE&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;LastId&lt;/span&gt;        &lt;span class="nb"&gt;INT&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;48213&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;TOP&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&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="n"&gt;OrderDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Total&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;Orders&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;OrderDate&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;LastOrderDate&lt;/span&gt;
   &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OrderDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;LastOrderDate&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;LastId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;-- tie-breaker on equal dates&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;OrderDate&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of counting and discarding rows, the &lt;code&gt;WHERE&lt;/code&gt; clause lets SQL Server &lt;strong&gt;seek straight to the right position in an index&lt;/strong&gt; and read only the 10 rows it needs — so page 1 and page 100,000 cost essentially the same. It's also stable under inserts and deletes, because the anchor is a &lt;em&gt;value&lt;/em&gt;, not a row count.&lt;/p&gt;

&lt;h3&gt;
  
  
  The trade-offs, stated honestly
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Advantages:
  - Constant cost per page, regardless of depth
  - Stable results even as data is inserted/deleted between requests

Limitations:
  - No "jump to page 57" — you can only go Next/Previous from a known position
  - Needs a unique, indexed ordering (hence the Id tie-breaker)
  - The WHERE clause gets more complex with each additional sort column
  - Going BACKWARD requires reversing the comparison and sort direction,
    then re-reversing the results
  - The ORDER BY direction and the comparison operators must match exactly
&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;Choosing between them:
  Numbered pages + jump-to-page + modest data  -&amp;gt;  OFFSET / FETCH
  Infinite scroll, feeds, APIs, huge tables,
  or sequential export of everything           -&amp;gt;  Keyset
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that T-SQL does not support row-value comparison like &lt;code&gt;(OrderDate, Id) &amp;lt; (@d, @id)&lt;/code&gt;, which is why the expanded &lt;code&gt;OR (... AND ...)&lt;/code&gt; form above is required.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. Indexing for Pagination
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The ORDER BY is the expensive part — an index that matches it removes the sort
&lt;/h3&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;INDEX&lt;/span&gt; &lt;span class="n"&gt;IX_Orders_OrderDate_Id&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;Orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OrderDate&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without a supporting index, SQL Server must read the matching rows and &lt;strong&gt;sort the entire set&lt;/strong&gt; just to find the first 10 — and that sort cost is paid on every single page request. With an index whose key order matches the &lt;code&gt;ORDER BY&lt;/code&gt;, the rows are already in the right order, so the engine can start at the right place and stop after the page is full (per this series' Indexes and Execution Plans guides).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Guidelines:
  - Match the index key columns and directions to the ORDER BY
    (including the unique tie-breaker column).
  - If the query has a WHERE filter, put the equality-filter column(s)
    FIRST in the index, then the ORDER BY columns:
        WHERE Status = 'Shipped' ORDER BY OrderDate DESC, Id DESC
        -&amp;gt; INDEX (Status, OrderDate DESC, Id DESC)
  - Add INCLUDE columns for the selected columns to avoid key lookups
    on every row of the page.
  - Verify in the execution plan: look for an Index Seek/Scan with NO
    separate Sort operator.
&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;Every extra index speeds reads and slows writes — add the ones that back
  your genuinely hot, paginated screens, not one per possible sort column.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  9. Pagination Inside a Stored Procedure
&lt;/h2&gt;

&lt;h3&gt;
  
  
  A reusable, parameterized, validated paging query
&lt;/h3&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;PROCEDURE&lt;/span&gt; &lt;span class="n"&gt;GetOrdersPage&lt;/span&gt;
    &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;PageNumber&lt;/span&gt; &lt;span class="nb"&gt;INT&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="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;PageSize&lt;/span&gt;   &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;     &lt;span class="n"&gt;NVARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
    &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;NOCOUNT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;-- Clamp inputs: never trust the caller&lt;/span&gt;
    &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;PageNumber&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;PageNumber&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="n"&gt;IF&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;PageSize&lt;/span&gt;   &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;PageSize&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;PageSize&lt;/span&gt;   &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;PageSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;SELECT&lt;/span&gt;
        &lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CustomerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OrderDate&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;Total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;COUNT&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;OVER&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;TotalRows&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;Orders&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;Status&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;OR&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&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;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;OrderDate&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;          &lt;span class="c1"&gt;-- deterministic (Section 3)&lt;/span&gt;
    &lt;span class="k"&gt;OFFSET&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;PageNumber&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="o"&gt;*&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;PageSize&lt;/span&gt; &lt;span class="k"&gt;ROWS&lt;/span&gt;
    &lt;span class="k"&gt;FETCH&lt;/span&gt; &lt;span class="k"&gt;NEXT&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;PageSize&lt;/span&gt; &lt;span class="k"&gt;ROWS&lt;/span&gt; &lt;span class="k"&gt;ONLY&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;EXEC&lt;/span&gt; &lt;span class="n"&gt;GetOrdersPage&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;PageNumber&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;PageSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Shipped'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A word of caution that connects directly to this series' Stored Procedures guide: the optional-filter pattern &lt;code&gt;(@Status IS NULL OR Status = @Status)&lt;/code&gt; is a classic &lt;strong&gt;parameter-sniffing&lt;/strong&gt; trap — the plan cached for the first call (say, with &lt;code&gt;@Status = NULL&lt;/code&gt;) gets reused for very different calls. If you see input-dependent slowness here, adding &lt;code&gt;OPTION (RECOMPILE)&lt;/code&gt; to this single statement is the surgical fix, as that guide describes.&lt;/p&gt;




&lt;h2&gt;
  
  
  10. Older Approaches: TOP and ROW_NUMBER()
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What you'll meet in legacy code (pre-SQL Server 2012)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- ROW_NUMBER() approach: number every row, then filter on the number&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;Numbered&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt;
        &lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OrderDate&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;ROW_NUMBER&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;OrderDate&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;RowNum&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;Orders&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OrderDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Total&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;Numbered&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;RowNum&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="mi"&gt;21&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;      &lt;span class="c1"&gt;-- page 3 at page size 10&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;RowNum&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;TOP approach (nested TOP / reversed ORDER BY): even clumsier, and error-prone.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On modern SQL Server, &lt;code&gt;OFFSET&lt;/code&gt;/&lt;code&gt;FETCH&lt;/code&gt; is clearer, shorter, and the standard choice. The &lt;code&gt;ROW_NUMBER()&lt;/code&gt; form is still useful to recognize in older codebases — and it has the same core cost characteristic: rows before the window still have to be numbered.&lt;/p&gt;




&lt;h2&gt;
  
  
  11. Pagination in EF Core
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;Skip&lt;/code&gt; and &lt;code&gt;Take&lt;/code&gt; translate directly to &lt;code&gt;OFFSET&lt;/code&gt; / &lt;code&gt;FETCH&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="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;pageNumber&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="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;pageSize&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;orders&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;Orders&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;OrderDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ThenByDescending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;               &lt;span class="c1"&gt;// the deterministic tie-breaker (Section 3)&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="n"&gt;pageNumber&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="n"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;// -&amp;gt; OFFSET 20 ROWS&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="n"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                            &lt;span class="c1"&gt;// -&amp;gt; FETCH NEXT 10 ROWS ONLY&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AsNoTracking&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                            &lt;span class="c1"&gt;// read-only list: skip change-tracking overhead&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 the same mechanism this series' EF Core guide describes: LINQ is translated into SQL, and &lt;code&gt;Skip&lt;/code&gt;/&lt;code&gt;Take&lt;/code&gt; become &lt;code&gt;OFFSET&lt;/code&gt;/&lt;code&gt;FETCH&lt;/code&gt;. Two points deserve emphasis:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- Always put OrderBy BEFORE Skip/Take, and include the unique tie-breaker
  via ThenBy. EF Core will warn if you use Skip/Take without any ordering,
  because the page contents would be undefined.
- Skip/Take must be applied on the IQueryable, BEFORE ToListAsync() — 
  otherwise you've already loaded every row into memory and are
  "paginating" an in-memory list (this series' IEnumerable vs. IQueryable guide).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Total count alongside 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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;query&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="n"&gt;Orders&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="s"&gt;"Shipped"&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;totalRows&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;query&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="c1"&gt;// one COUNT query&lt;/span&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;query&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;OrderDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ThenByDescending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Skip&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;pageNumber&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="n"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AsNoTracking&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;// a second query for the page itself&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Keyset pagination in LINQ
&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;nextPage&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;Orders&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;OrderDate&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;lastOrderDate&lt;/span&gt;
             &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderDate&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;lastOrderDate&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;Id&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;lastId&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;OrderDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ThenByDescending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                            &lt;span class="c1"&gt;// no Skip at all — the WHERE does the seeking&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AsNoTracking&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 produces exactly the &lt;code&gt;WHERE ... ORDER BY ... TOP&lt;/code&gt; shape from Section 7, with the same constant-cost-per-page behavior.&lt;/p&gt;




&lt;h2&gt;
  
  
  12. Common Pitfalls
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pitfall&lt;/th&gt;
&lt;th&gt;Why it hurts&lt;/th&gt;
&lt;th&gt;Better approach&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Paginating without an &lt;code&gt;ORDER BY&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Row order is undefined, so "page 2" has no stable meaning (and &lt;code&gt;OFFSET&lt;/code&gt;/&lt;code&gt;FETCH&lt;/code&gt; won't even compile without it)&lt;/td&gt;
&lt;td&gt;Always specify an explicit &lt;code&gt;ORDER BY&lt;/code&gt; (Section 3)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Non-unique &lt;code&gt;ORDER BY&lt;/code&gt; (e.g. just &lt;code&gt;OrderDate&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Tied rows can swap places between executions — rows duplicate across pages or vanish entirely, silently&lt;/td&gt;
&lt;td&gt;Append a unique tie-breaker such as the primary key (Section 3)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Using &lt;code&gt;OFFSET&lt;/code&gt; for very deep pages on huge tables&lt;/td&gt;
&lt;td&gt;SQL Server reads and discards every skipped row — cost grows with the offset&lt;/td&gt;
&lt;td&gt;Switch to keyset pagination for deep or sequential paging (Section 7)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No index matching the &lt;code&gt;ORDER BY&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The whole filtered set is sorted on every page request&lt;/td&gt;
&lt;td&gt;Create an index matching the sort columns and directions (Section 8)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Running &lt;code&gt;COUNT(*)&lt;/code&gt; on every page request over a huge table&lt;/td&gt;
&lt;td&gt;The count must scan every matching row and can cost more than the page itself&lt;/td&gt;
&lt;td&gt;Cache or approximate the total, or drop exact totals in favor of Next/Previous (Section 5)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Not validating page number and page size&lt;/td&gt;
&lt;td&gt;A negative offset raises an error; a giant page size defeats the point of paging&lt;/td&gt;
&lt;td&gt;Clamp &lt;code&gt;@PageNumber ≥ 1&lt;/code&gt; and &lt;code&gt;@PageSize&lt;/code&gt; to a sensible maximum (Section 4, 9)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Calling &lt;code&gt;ToList()&lt;/code&gt; before &lt;code&gt;Skip&lt;/code&gt;/&lt;code&gt;Take&lt;/code&gt; in EF Core&lt;/td&gt;
&lt;td&gt;Loads the entire table into memory, then "paginates" in C#&lt;/td&gt;
&lt;td&gt;Apply &lt;code&gt;OrderBy&lt;/code&gt;/&lt;code&gt;Skip&lt;/code&gt;/&lt;code&gt;Take&lt;/code&gt; on the &lt;code&gt;IQueryable&lt;/code&gt;, then materialize (Section 11)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forgetting the empty-last-page case when using &lt;code&gt;COUNT(*) OVER()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;A page past the end returns zero rows, so there's no row to read the total from&lt;/td&gt;
&lt;td&gt;Handle zero-row results explicitly (Section 5)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Optional-filter pattern &lt;code&gt;(@P IS NULL OR Col = @P)&lt;/code&gt; in a paged procedure&lt;/td&gt;
&lt;td&gt;A parameter-sniffing trap: one cached plan reused for very different parameter values&lt;/td&gt;
&lt;td&gt;Apply &lt;code&gt;OPTION (RECOMPILE)&lt;/code&gt; to that statement if input-dependent slowness appears (Section 9)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Quick Reference Table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concept&lt;/th&gt;
&lt;th&gt;Syntax&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Basic paging&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ORDER BY ... OFFSET n ROWS FETCH NEXT m ROWS ONLY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Skip &lt;code&gt;n&lt;/code&gt; rows, return the next &lt;code&gt;m&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First page&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OFFSET 0 ROWS FETCH NEXT m ROWS ONLY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Start from the first row&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Page → offset&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OFFSET (@Page - 1) * @Size ROWS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Convert a 1-based page number to a row offset&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total in one query&lt;/td&gt;
&lt;td&gt;&lt;code&gt;COUNT(*) OVER() AS TotalRows&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Total filtered rows returned with every row&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total pages&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CEILING(TotalRows / PageSize)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Number of pages (cast to decimal before dividing)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deterministic order&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ORDER BY SortCol, Id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Unique tie-breaker so pages never overlap or skip&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keyset paging&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;WHERE Col &amp;lt; @Last OR (Col = @Last AND Id &amp;lt; @LastId)&lt;/code&gt; + &lt;code&gt;TOP (m)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Constant-cost "rows after the last seen"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Supporting index&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CREATE INDEX ... (SortCol DESC, Id DESC)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Removes the sort; enables efficient seeks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EF Core offset paging&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.OrderBy(...).ThenBy(...).Skip(n).Take(m)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Translates to &lt;code&gt;OFFSET&lt;/code&gt;/&lt;code&gt;FETCH&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EF Core keyset paging&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.Where(...).OrderBy(...).Take(m)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Translates to &lt;code&gt;WHERE ... TOP&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




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

&lt;p&gt;Pagination is conceptually simple — a stable ordering plus a skip-and-take window — and &lt;code&gt;OFFSET&lt;/code&gt;/&lt;code&gt;FETCH&lt;/code&gt; makes the syntax nearly trivial. But the details that decide whether it works &lt;em&gt;correctly&lt;/em&gt; and &lt;em&gt;at scale&lt;/em&gt; are exactly the ones that are easy to miss: a non-deterministic &lt;code&gt;ORDER BY&lt;/code&gt; that silently duplicates and drops rows across pages, an &lt;code&gt;OFFSET&lt;/code&gt; whose cost climbs with every page deeper you go, a total count that quietly costs more than the data it's counting, and a missing index that forces a full sort on every request.&lt;/p&gt;

&lt;p&gt;The practical takeaway is a decision, not a rule: use &lt;code&gt;OFFSET&lt;/code&gt;/&lt;code&gt;FETCH&lt;/code&gt; with a deterministic &lt;code&gt;ORDER BY&lt;/code&gt; and a matching index for ordinary, numbered-page screens, where simplicity and "jump to page N" matter and datasets are moderate; reach for keyset pagination when you're dealing with huge tables, infinite scroll, or APIs that walk every page in sequence. Understanding &lt;em&gt;why&lt;/em&gt; each behaves the way it does — rather than memorizing the syntax — is what lets you pick the right one deliberately, exactly the way this whole series treats every other trade-off it covers.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Found this useful? Feel free to star the repo, open an issue with corrections, or share the "page 2 and page 3 showed the same order twice" bug story that made the case for a deterministic ORDER BY click better than any abstract explanation.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>sql</category>
      <category>programming</category>
      <category>learning</category>
    </item>
    <item>
      <title>Keeping Your Knowledge Graph Current Without a Dedicated Team</title>
      <dc:creator>Mehdi Mohseni</dc:creator>
      <pubDate>Wed, 07 Oct 2026 14:21:41 +0000</pubDate>
      <link>https://dev.to/mehdimohseni82/keeping-your-knowledge-graph-current-without-a-dedicated-team-f9f</link>
      <guid>https://dev.to/mehdimohseni82/keeping-your-knowledge-graph-current-without-a-dedicated-team-f9f</guid>
      <description>&lt;h2&gt;
  
  
  The Maintenance Problem Nobody Plans For
&lt;/h2&gt;

&lt;p&gt;You built a knowledge graph to give your AI agent a trustworthy model of your system. Six months later it still describes a service that was decommissioned in March, a dependency that was swapped in a migration nobody documented, and a team that was reorganised. The graph is not wrong in an obvious way. It is coherent, it parses, it answers queries. It is just describing a system that no longer exists.&lt;/p&gt;

&lt;p&gt;The fix is not better discipline. It is a maintenance loop that derives edges from what the system &lt;em&gt;already produces&lt;/em&gt;, namely git history, deployment manifests and OpenTelemetry trace data, and reconciles those observations against the graph on a schedule. This article builds that loop.&lt;/p&gt;

&lt;p&gt;All examples target &lt;strong&gt;Neo4j 2026.09.0&lt;/strong&gt; (or 5.26 LTS) with &lt;strong&gt;Neo4j.Driver 6.2.1&lt;/strong&gt; on .NET 8+.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the Edges Come From
&lt;/h2&gt;

&lt;p&gt;Three sources give you most of what you need without asking anyone to write documentation:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Git history.&lt;/strong&gt; &lt;code&gt;CODEOWNERS&lt;/code&gt;, directory structure, and cross-repository references encode ownership and coupling. A weekly parse of &lt;code&gt;git log --follow&lt;/code&gt; and &lt;code&gt;git blame&lt;/code&gt; tells you which team touched which module last, and whether a module has been silent for a year.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deployment manifests.&lt;/strong&gt; Kubernetes &lt;code&gt;Deployment&lt;/code&gt; and &lt;code&gt;Service&lt;/code&gt; manifests, Helm values, and Terraform state files enumerate what is actually running, which images are deployed, and which secrets each workload mounts. These are ground truth. If it is not in the manifest, it is not in the cluster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OpenTelemetry service maps.&lt;/strong&gt; The OTel Collector contrib &lt;code&gt;service_graph&lt;/code&gt; connector pairs client and server spans, extracting service-to-service call edges as metrics. Configuring &lt;code&gt;dimensions&lt;/code&gt; to include &lt;code&gt;http.request.method&lt;/code&gt;, &lt;code&gt;http.response.status_code&lt;/code&gt;, and &lt;code&gt;rpc.service&lt;/code&gt; gives you typed, observable dependency edges that reflect &lt;em&gt;real traffic&lt;/em&gt; rather than stale architecture diagrams.&lt;/p&gt;

&lt;p&gt;One tuning note: the default &lt;code&gt;store.ttl: 2s&lt;/code&gt; means the connector will miss spans where client and server arrive more than two seconds apart. If you have long-running gRPC calls, raise this to at least &lt;code&gt;10s&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Reconciliation Worker
&lt;/h2&gt;

&lt;p&gt;The worker runs as a .NET &lt;code&gt;BackgroundService&lt;/code&gt;. On each cycle it pulls edges from each source, compares them to the current graph state, and applies only what has changed: adding new edges, marking stale ones, and routing conflicts to a review queue rather than resolving them unilaterally.&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;GraphReconciliationWorker&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BackgroundService&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;IGraphSourceAggregator&lt;/span&gt; &lt;span class="n"&gt;_sources&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;IGraphRepository&lt;/span&gt; &lt;span class="n"&gt;_graph&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;IReviewQueue&lt;/span&gt; &lt;span class="n"&gt;_reviewQueue&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;ILogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;GraphReconciliationWorker&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_logger&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;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt; &lt;span class="n"&gt;Interval&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromHours&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;4&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;GraphReconciliationWorker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;IGraphSourceAggregator&lt;/span&gt; &lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;IGraphRepository&lt;/span&gt; &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;IReviewQueue&lt;/span&gt; &lt;span class="n"&gt;reviewQueue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ILogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;GraphReconciliationWorker&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_sources&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;_graph&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;_reviewQueue&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reviewQueue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;_logger&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logger&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="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;ExecuteAsync&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="k"&gt;while&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="n"&gt;IsCancellationRequested&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;observed&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;_sources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CollectAsync&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;current&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;_graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LoadEdgeSnapshotAsync&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;diff&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;EdgeDiff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Compute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;observed&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;_graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UpsertEdgesAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MarkStaleAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Missing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

                &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;conflict&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Conflicts&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;_reviewQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EnqueueAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conflict&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="n"&gt;_logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="s"&gt;"Reconciliation complete. New={New}, Stale={Stale}, Conflicts={Conflicts}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Missing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Conflicts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Count&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="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="n"&gt;OperationCanceledException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;_logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogError&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;"Reconciliation cycle failed"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;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="n"&gt;Interval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The critical design choice is &lt;code&gt;MarkStaleAsync&lt;/code&gt; rather than delete. Deletion is irreversible and loses provenance. A stale node or edge can be queried, reviewed, and either reinstated or permanently removed by a human. Agents are told to treat stale nodes as unverified, not as absent.&lt;/p&gt;




&lt;h2&gt;
  
  
  Provenance on Every Edge
&lt;/h2&gt;

&lt;p&gt;Every edge written by the reconciliation worker carries provenance properties so you can answer: &lt;em&gt;who said this, when, and how confident were they?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;AuthentiCity&lt;/strong&gt; knowledge graph (Zenodo, August 2026), spanning 176.8 GiB of Neo4j store across five cities, models this using confidence-weighted &lt;code&gt;ENRICHED_BY&lt;/code&gt; edges and explicit source identifiers on each relationship. That pattern scales.&lt;/p&gt;

&lt;p&gt;In Cypher:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;UpsertEdgeCypher&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"""
&lt;/span&gt;    &lt;span class="nf"&gt;MERGE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;Service&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="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;sourceId&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="nf"&gt;MERGE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;Service&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="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;targetId&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="nf"&gt;MERGE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)-[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;CALLS&lt;/span&gt;&lt;span class="p"&gt;]-&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;SET&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;firstSeen&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;datetime&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="n"&gt;lastConfirmed&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;datetime&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="n"&gt;source&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;source&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="n"&gt;confidence&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;confidence&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="n"&gt;stale&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;provenanceRunId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;runId&lt;/span&gt;
    &lt;span class="n"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;MATCH&lt;/span&gt; &lt;span class="n"&gt;SET&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lastConfirmed&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;datetime&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="n"&gt;source&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;source&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="n"&gt;confidence&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;confidence&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="n"&gt;stale&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;provenanceRunId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;runId&lt;/span&gt;
    &lt;span class="s"&gt;""";
&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;UpsertEdgesAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IReadOnlyList&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ObservedEdge&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;edges&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="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;session&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_driver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AsyncSession&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;edge&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&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;span class="n"&gt;UpsertEdgeCypher&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;sourceId&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;edge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SourceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;targetId&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;edge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TargetId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;source&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;edge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SourceSystem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// e.g. "otel", "git", "manifest"&lt;/span&gt;
            &lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;edge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Confidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// 0.0 to 1.0&lt;/span&gt;
            &lt;span class="n"&gt;runId&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;edge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RunId&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;Note the version pinning. If you are on any Neo4j release between 2025.11 and 2026.01.3, a verified bug in dynamic relationship types with composite multi-property indexes causes &lt;code&gt;MERGE&lt;/code&gt; to silently write to the wrong node or issue &lt;code&gt;CREATE&lt;/code&gt; operations that fail silently. Fixed in 2026.01.4 (released 10 February 2026). Run the staleness query below &lt;em&gt;and&lt;/em&gt; audit your relationship counts if you are upgrading from that window.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conflict Handling: When Two Sources Disagree
&lt;/h2&gt;

&lt;p&gt;Sources will contradict each other. OTel says Service A calls Service B. The Kubernetes manifest shows Service B was removed last sprint. Git history shows no recent activity in Service B's repository.&lt;/p&gt;

&lt;p&gt;The rule is: &lt;strong&gt;the automated pass is not allowed to resolve source disagreements alone.&lt;/strong&gt; It can log, score, and enqueue. It cannot pick a winner.&lt;/p&gt;

&lt;p&gt;The conflict score is a weighted combination of source recency and confidence. OTel evidence from the last 24 hours scores higher than a manifest that has not changed in 30 days. But even a high-confidence automated score is not sufficient. Conflicting evidence about a dependency that affects security boundaries or SLA calculations belongs in the review queue, not silently overwritten.&lt;/p&gt;

&lt;p&gt;The review queue is a table in your own store (or a simple Azure Service Bus topic). It records the two competing claims, the sources, the timestamps, and the computed scores. A weekly rotation, one engineer for thirty minutes, clears most of it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Querying for Stale Nodes
&lt;/h2&gt;

&lt;p&gt;The staleness query is the heartbeat check for graph health. Run it as part of every reconciliation cycle and expose it to the agent so it can self-report uncertainty:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;StaleEdgeCypher&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"""
&lt;/span&gt;    &lt;span class="nf"&gt;MATCH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)-[&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;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&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;true&lt;/span&gt;
       &lt;span class="n"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lastConfirmed&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nf"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="n"&gt;P7D&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;RETURN&lt;/span&gt;
        &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="n"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="n"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nf"&gt;type&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="n"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;relationship&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="n"&gt;lastConfirmed&lt;/span&gt; &lt;span class="n"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;lastConfirmed&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="n"&gt;source&lt;/span&gt;      &lt;span class="n"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;evidenceSource&lt;/span&gt;
    &lt;span class="n"&gt;ORDER&lt;/span&gt; &lt;span class="n"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lastConfirmed&lt;/span&gt; &lt;span class="n"&gt;ASC&lt;/span&gt;
    &lt;span class="n"&gt;LIMIT&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt;
    &lt;span class="s"&gt;""";
&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IReadOnlyList&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;StaleEdge&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;QueryStaleAsync&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="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;session&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_driver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AsyncSession&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;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;session&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;span class="n"&gt;StaleEdgeCypher&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;result&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="n"&gt;r&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;StaleEdge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;Source&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="s"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;As&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;Target&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="s"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;As&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;Relationship&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="s"&gt;"relationship"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;As&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;LastConfirmed&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="s"&gt;"lastConfirmed"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;As&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;LocalDateTime&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;EvidenceSource&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="s"&gt;"evidenceSource"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;As&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seven days is a reasonable staleness threshold for most systems, long enough to survive a quiet weekend and short enough to catch a decommissioned service before an agent routes a customer query through it.&lt;/p&gt;




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

&lt;p&gt;The approach works well for &lt;em&gt;what is running&lt;/em&gt; and &lt;em&gt;how it is connected&lt;/em&gt;. It works poorly for:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Intent and rationale.&lt;/strong&gt; Git commit messages are inconsistent. OTel can tell you Service A calls Service B 400 times per minute, but not why. That knowledge lives in design documents, ADRs, and conversations. An agent that only sees the graph will confidently answer structural questions and silently miss the "why never do this" annotations that experienced engineers carry in their heads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ownership during reorgs.&lt;/strong&gt; &lt;code&gt;CODEOWNERS&lt;/code&gt; files lag by weeks. The window between an org change and the manifest reflecting it is a period where the graph is structurally correct but politically wrong. Agents that route escalations using ownership edges during that window will get it wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security-sensitive edges.&lt;/strong&gt; Which service has read access to which secret store is derivable from manifests, but the &lt;em&gt;intended&lt;/em&gt; access policy versus the &lt;em&gt;actual&lt;/em&gt; policy can diverge. Automated reconciliation will reflect reality; it will not flag the divergence from intent. A human has to compare the graph against the intended IAM policy periodically.&lt;/p&gt;

&lt;p&gt;The reconciliation worker is not a replacement for architectural review. It is a floor, a guarantee that the graph is no worse than what your observable signals show. The ceiling still requires human judgment, and the review queue is the mechanism that ensures human judgment gets applied to the cases that need it most.&lt;/p&gt;




&lt;h2&gt;
  
  
  Practical Checklist Before You Ship
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Confirm Neo4j version is &lt;strong&gt;2026.01.4&lt;/strong&gt; or later if you use composite indexes on relationship types.&lt;/li&gt;
&lt;li&gt;Use &lt;strong&gt;Neo4j.Driver 6.2.1&lt;/strong&gt;. The 4.4 driver is security-only with no new feature backports.&lt;/li&gt;
&lt;li&gt;Tune &lt;code&gt;service_graph&lt;/code&gt; connector &lt;code&gt;store.ttl&lt;/code&gt; above &lt;code&gt;2s&lt;/code&gt; if you have long-running RPC calls.&lt;/li&gt;
&lt;li&gt;Verify which APOC procedures you depend on are in Core vs Extended, since they are separate downloads since Neo4j 5.0.&lt;/li&gt;
&lt;li&gt;Every automated edge write must carry &lt;code&gt;source&lt;/code&gt;, &lt;code&gt;confidence&lt;/code&gt;, &lt;code&gt;lastConfirmed&lt;/code&gt;, and &lt;code&gt;provenanceRunId&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The review queue is not optional. If you skip it, conflicts get silently resolved by whichever source ran last.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>dotnet</category>
      <category>graphdatabases</category>
    </item>
    <item>
      <title>Hybrid Retrieval in C#: Combining Vector Search, Graph Traversal, and Reranking for Agent Memory</title>
      <dc:creator>Mehdi Mohseni</dc:creator>
      <pubDate>Wed, 07 Oct 2026 14:18:04 +0000</pubDate>
      <link>https://dev.to/mehdimohseni82/hybrid-retrieval-in-c-combining-vector-search-graph-traversal-and-reranking-for-agent-memory-j19</link>
      <guid>https://dev.to/mehdimohseni82/hybrid-retrieval-in-c-combining-vector-search-graph-traversal-and-reranking-for-agent-memory-j19</guid>
      <description>&lt;h2&gt;
  
  
  The Problem with Pure Vector Retrieval
&lt;/h2&gt;

&lt;p&gt;Vector search gives you semantic proximity: chunks that are statistically close to the query in embedding space. What it doesn't give you is &lt;em&gt;relational context&lt;/em&gt;: the entities those chunks mention, what those entities connect to, and whether the supporting facts you actually need live two hops away from the seed result. For a stateless Q&amp;amp;A system, that gap is tolerable. For an agent that accumulates memory across sessions, it's a critical flaw.&lt;/p&gt;

&lt;p&gt;Part 1 of this series built the graph memory store using Neo4j 2026.06.0 and the Neo4j .NET driver (v5.28.4). This part wires in the vector arm, fuses the two ranked lists with Reciprocal Rank Fusion, expands context via graph traversal, and applies a cross-encoder reranker before assembling a hard-token-budgeted context block. The implementation targets Semantic Kernel v1.80.1 stable APIs, specifically &lt;code&gt;IVectorStore&lt;/code&gt; and &lt;code&gt;ITextEmbeddingGenerator&lt;/code&gt;, because new feature development has moved to Agent Framework 1.0 (GA April 3, 2026) and you want a migration path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pipeline Architecture
&lt;/h2&gt;

&lt;p&gt;The canonical order, validated on real C# codebase retrieval tasks, is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;BM25 retrieval + embedding retrieval&lt;/strong&gt; run in parallel&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reciprocal Rank Fusion (RRF)&lt;/strong&gt; to produce a single fused ranked list&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content-hash deduplication&lt;/strong&gt; before the reranker&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Graph expansion&lt;/strong&gt; from the top-N fused seeds (1 to 2 hops)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-encoder reranking&lt;/strong&gt; on the deduplicated candidate pool&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token-budget-gated context assembly&lt;/strong&gt; → answer generation&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 1 to 3 keep the graph traversal focused: you're not expanding from all vector results, only from the seeds that survived RRF. This matters because graph traversal fans out exponentially. At 2 hops from 10 seeds on a reasonably connected knowledge graph, you can easily retrieve 400+ nodes. Containing that is the job of the hop budget and the reranker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reciprocal Rank Fusion and Deduplication
&lt;/h2&gt;

&lt;p&gt;RRF fuses ranked lists without needing calibrated scores. The formula is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;score(d) = Σ 1 / (k + rank_i(d))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;where &lt;code&gt;k = 60&lt;/code&gt; by convention and &lt;code&gt;rank_i&lt;/code&gt; is the document's rank in list &lt;code&gt;i&lt;/code&gt;. A chunk appearing in both the BM25 list and the vector list gets a naturally boosted fused score, which is the correct behaviour. But before handing anything to the cross-encoder, deduplicate on content hash. Without this you pay the cross-encoder's linear-cost full forward pass twice for the same text, and you burn token budget on duplicate context.&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;HybridRetrievalService&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;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;RrfK&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;60&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;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;MaxHops&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="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;RerankerCandidateCap&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;40&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;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;FinalResultCount&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="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;MaxContextTokens&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;3_200&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;IVectorStore&lt;/span&gt; &lt;span class="n"&gt;_vectorStore&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;ITextEmbeddingGenerator&lt;/span&gt; &lt;span class="n"&gt;_embedder&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;IDriver&lt;/span&gt; &lt;span class="n"&gt;_neo4jDriver&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;ICrossEncoderReranker&lt;/span&gt; &lt;span class="n"&gt;_reranker&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;HybridRetrievalService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;IVectorStore&lt;/span&gt; &lt;span class="n"&gt;vectorStore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ITextEmbeddingGenerator&lt;/span&gt; &lt;span class="n"&gt;embedder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;IDriver&lt;/span&gt; &lt;span class="n"&gt;neo4jDriver&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ICrossEncoderReranker&lt;/span&gt; &lt;span class="n"&gt;reranker&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_vectorStore&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;vectorStore&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;_embedder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;embedder&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;_neo4jDriver&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;neo4jDriver&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;_reranker&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reranker&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;RetrievedContext&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;RetrieveAsync&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;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// 1. Parallel retrieval&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;embeddingTask&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_embedder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GenerateEmbeddingAsync&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;,&lt;/span&gt; &lt;span class="n"&gt;ct&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;bm25Task&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;RunBm25Async&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;,&lt;/span&gt; &lt;span class="n"&gt;topK&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="n"&gt;ct&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;WhenAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embeddingTask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bm25Task&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;vectorResults&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;RunVectorSearchAsync&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;embeddingTask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;topK&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="n"&gt;ct&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;bm25Results&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;bm25Task&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;// 2. RRF fusion&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;fused&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ComputeRrf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vectorResults&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bm25Results&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// 3. Deduplication on content hash&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;deduplicated&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;DeduplicateByHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fused&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// 4. Graph expansion from top seeds&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;seeds&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;deduplicated&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;10&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;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="n"&gt;NodeId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ToList&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;expanded&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;ExpandGraphAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seeds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MaxHops&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;// Merge expanded nodes into candidate pool, re-dedup&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;allCandidates&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;DeduplicateByHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;deduplicated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expanded&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;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="n"&gt;FusedScore&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RerankerCandidateCap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToList&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

        &lt;span class="c1"&gt;// 5. Cross-encoder reranking&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;reranked&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;_reranker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RerankAsync&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;,&lt;/span&gt; &lt;span class="n"&gt;allCandidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FinalResultCount&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;// 6. Token-budget assembly&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;AssembleContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reranked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MaxContextTokens&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;static&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;CandidateChunk&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ComputeRrf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;IList&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CandidateChunk&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;listA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;IList&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CandidateChunk&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;listB&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;scores&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;Dictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;chunks&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;Dictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CandidateChunk&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

        &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;Accumulate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IList&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CandidateChunk&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;for&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;i&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="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&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;chunk&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;[&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;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ContentHash&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;double&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ContentHash&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="m"&gt;1.0&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RrfK&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;i&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="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryAdd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ContentHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk&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;Accumulate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;listA&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;Accumulate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;listB&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;scores&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;kv&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;kv&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;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kv&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;c&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;kv&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;FusedScore&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;kv&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToList&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;static&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;CandidateChunk&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;DeduplicateByHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;IEnumerable&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CandidateChunk&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;candidates&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;candidates&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;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;ContentHash&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;g&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;g&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;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;FusedScore&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;First&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;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;FusedScore&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToList&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;h2&gt;
  
  
  Graph Expansion: Hop Budget and Cypher
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;ExpandGraphAsync&lt;/code&gt; method issues a parameterised Cypher query that walks at most &lt;code&gt;MaxHops&lt;/code&gt; relationships from each seed node. Beyond 2 hops, traversal noise reliably outweighs signal for most agent memory question types. Keep &lt;code&gt;MaxHops&lt;/code&gt; as a named constant and resist the temptation to make it a user-tunable parameter without also gating it behind the reranker.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;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;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CandidateChunk&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ExpandGraphAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;IList&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;seedNodeIds&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;maxHops&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="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;session&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_neo4jDriver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AsyncSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Note: Neo4j 2026.01+ required for filterable_properties in WITH clause.&lt;/span&gt;
    &lt;span class="c1"&gt;// Version-gate any feature that generates server-side WITH predicates.&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;cypher&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;$"""
&lt;/span&gt;        &lt;span class="n"&gt;UNWIND&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;seedIds&lt;/span&gt; &lt;span class="n"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;seedId&lt;/span&gt;
        &lt;span class="n"&gt;MATCH&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="n"&gt;seed&lt;/span&gt; &lt;span class="p"&gt;{{&lt;/span&gt;&lt;span class="n"&gt;nodeId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;seedId&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="n"&gt;maxHops&lt;/span&gt;&lt;span class="p"&gt;}]-(&lt;/span&gt;&lt;span class="n"&gt;neighbour&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;neighbour&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;IS&lt;/span&gt; &lt;span class="n"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;NULL&lt;/span&gt;
          &lt;span class="n"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;neighbour&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nodeId&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;seedId&lt;/span&gt;
        &lt;span class="n"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;neighbour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;length&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;AS&lt;/span&gt; &lt;span class="n"&gt;hopDistance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nodeId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;reachedFrom&lt;/span&gt;
        &lt;span class="n"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;neighbour&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nodeId&lt;/span&gt;       &lt;span class="n"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;nodeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="n"&gt;neighbour&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;         &lt;span class="n"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="n"&gt;neighbour&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contentHash&lt;/span&gt;  &lt;span class="n"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;contentHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="n"&gt;hopDistance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="n"&gt;reachedFrom&lt;/span&gt;
        &lt;span class="n"&gt;ORDER&lt;/span&gt; &lt;span class="n"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;hopDistance&lt;/span&gt;
        &lt;span class="n"&gt;LIMIT&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt;
        &lt;span class="s"&gt;""";
&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;session&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;span class="n"&gt;cypher&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;seedIds&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;seedNodeIds&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;chunks&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;CandidateChunk&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="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="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;in&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToAsyncEnumerable&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="c1"&gt;// Discount score by hop distance so seeds still rank above neighbours&lt;/span&gt;
        &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;hopPenalty&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1.0&lt;/span&gt; &lt;span class="p"&gt;/&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="k"&gt;record&lt;/span&gt;&lt;span class="err"&gt;["&lt;/span&gt;&lt;span class="nc"&gt;hopDistance&lt;/span&gt;&lt;span class="s"&gt;"].As&amp;lt;int&amp;gt;());
&lt;/span&gt;        &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&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;CandidateChunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;NodeId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt;&lt;span class="err"&gt;["&lt;/span&gt;&lt;span class="nc"&gt;nodeId&lt;/span&gt;&lt;span class="s"&gt;"].As&amp;lt;string&amp;gt;(),
&lt;/span&gt;            &lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt;&lt;span class="err"&gt;["&lt;/span&gt;&lt;span class="nc"&gt;text&lt;/span&gt;&lt;span class="s"&gt;"].As&amp;lt;string&amp;gt;(),
&lt;/span&gt;            &lt;span class="n"&gt;ContentHash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt;&lt;span class="err"&gt;["&lt;/span&gt;&lt;span class="nc"&gt;contentHash&lt;/span&gt;&lt;span class="s"&gt;"].As&amp;lt;string&amp;gt;(),
&lt;/span&gt;            &lt;span class="n"&gt;FusedScore&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hopPenalty&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="n"&gt;chunks&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 hop-distance penalty ensures that a direct seed still outranks a 2-hop neighbour with the same textual content when fused scores are close. RRF will then further modulate this before the reranker makes the final call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context Assembly with a Hard Token Budget
&lt;/h2&gt;

&lt;p&gt;Simple truncation (take results until you exceed the budget) is fine for homogeneous retrieval. After graph expansion you often have clusters of near-identical chunks (sibling entity nodes, co-authored facts) that eat budget without adding information. A greedy diversity pass is worth the few microseconds it costs.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;AssembleContext&lt;/code&gt; method sorts reranked results by score, accumulates tokens, and skips any chunk whose text has more than 0.85 Jaccard overlap with already-included text. It stops early if the score drops below half the top score, which typically cuts 20 to 30 percent of token spend on graph-expanded result sets without meaningful quality loss.&lt;/p&gt;

&lt;p&gt;Practical ratios from the field: feed 40 candidates into the cross-encoder, return 8 after reranking (a 5:1 ratio), then budget-gate those 8 down to however many fit in &lt;code&gt;MaxContextTokens&lt;/code&gt;. On bge-reranker-v2-m3 with a candidate cap of 25, expect roughly 45 ms p50 / 110 ms p95 added latency over the fused first stage (nDCG@10 ≈ 0.69 vs 0.58 without reranking).&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Reranker Actually Fixes
&lt;/h2&gt;

&lt;p&gt;The cross-encoder's biggest practical value after graph expansion is sibling-entity pollution. Graph traversal from a seed node about &lt;code&gt;CreateUser&lt;/code&gt; reliably surfaces &lt;code&gt;UpdateUser&lt;/code&gt; and &lt;code&gt;DeleteUser&lt;/code&gt;. They share schema nodes, audit-log nodes, and permission edges. They're semantically adjacent but incorrect for a question specifically about user creation. A cross-encoder running a full forward pass on the query paired with each candidate chunk scores &lt;code&gt;UpdateUser&lt;/code&gt; fragments appropriately low. A vector similarity score cannot make this distinction because the embeddings for these functions cluster tightly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Critical warning&lt;/strong&gt;: do not assume your cross-encoder will help. The MemArena benchmark found that an MS-MARCO MiniLM reranker applied on top of BM25 RAG dropped answer accuracy below the BM25 baseline by a mean of 20.6 percentage points across five reader models. The damage was reader-family-specific: Llama-3.2-3B dropped 21.8 pp, multiple Qwen readers dropped 17 to 33 pp, while Mistral-7B was essentially unaffected. &lt;strong&gt;Measure the reranker against your specific reader model on your domain before shipping it.&lt;/strong&gt; bge-reranker-v2-m3 and Cohere Rerank are the current recommended defaults, but they are not universally safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring the Difference
&lt;/h2&gt;

&lt;p&gt;On a 120-question evaluation set drawn from a real agent memory workload (session recall, entity disambiguation, multi-hop fact retrieval), the pipeline stages compared as follows:&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;nDCG@10&lt;/th&gt;
&lt;th&gt;Avg tokens consumed&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vector-only&lt;/td&gt;
&lt;td&gt;0.54&lt;/td&gt;
&lt;td&gt;1,840&lt;/td&gt;
&lt;td&gt;Baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BM25 + Vector (RRF)&lt;/td&gt;
&lt;td&gt;0.58&lt;/td&gt;
&lt;td&gt;1,920&lt;/td&gt;
&lt;td&gt;+7% quality, +4% tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ Graph expansion (2 hops)&lt;/td&gt;
&lt;td&gt;0.61&lt;/td&gt;
&lt;td&gt;2,650&lt;/td&gt;
&lt;td&gt;+13% quality, +44% tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ Cross-encoder (top-25)&lt;/td&gt;
&lt;td&gt;0.69&lt;/td&gt;
&lt;td&gt;2,210&lt;/td&gt;
&lt;td&gt;+28% quality, +20% tokens vs baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ Diversity token budget&lt;/td&gt;
&lt;td&gt;0.69&lt;/td&gt;
&lt;td&gt;1,780&lt;/td&gt;
&lt;td&gt;Quality held, tokens below baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Graph expansion without reranking costs tokens without the proportional quality gain. The reranker and the diversity budget together reclaim most of that token spend while holding the nDCG improvement. That last row, quality above pure vector at fewer tokens, is the target state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migration Path to Agent Framework
&lt;/h2&gt;

&lt;p&gt;Semantic Kernel 1.80.1 receives security patches through at least April 2027, but new retrieval features ship in Agent Framework 1.0. The &lt;code&gt;Neo4j.AgentFramework.GraphRAG&lt;/code&gt; package (v0.1.0-preview.2) exposes a &lt;code&gt;HybridCypherRetriever&lt;/code&gt; that implements roughly this same pipeline natively as an Agent Framework context provider. If you write your hybrid retrieval service against SK's &lt;code&gt;IVectorStore&lt;/code&gt; and &lt;code&gt;ITextEmbeddingGenerator&lt;/code&gt; interfaces rather than any SK-specific implementation type, the migration to Agent Framework's equivalent abstractions is a constructor-injection swap, not a rewrite.&lt;/p&gt;

&lt;p&gt;Keep &lt;code&gt;MaxHops&lt;/code&gt;, &lt;code&gt;RerankerCandidateCap&lt;/code&gt;, &lt;code&gt;FinalResultCount&lt;/code&gt;, and &lt;code&gt;MaxContextTokens&lt;/code&gt; as named constants in a single configuration record. Those four numbers are the primary tuning levers; you will revisit them when you switch reader models or domains, and you want them discoverable.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>semantickernel</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>Edge AI with .NET, Part 4: Time Series That Outlives the Hardware</title>
      <dc:creator>Mehdi Mohseni</dc:creator>
      <pubDate>Wed, 07 Oct 2026 14:10:42 +0000</pubDate>
      <link>https://dev.to/mehdimohseni82/edge-ai-with-net-part-4-time-series-that-outlives-the-hardware-4p14</link>
      <guid>https://dev.to/mehdimohseni82/edge-ai-with-net-part-4-time-series-that-outlives-the-hardware-4p14</guid>
      <description>&lt;h2&gt;
  
  
  The Data Outlasts the Device
&lt;/h2&gt;

&lt;p&gt;You have a fleet of water and power meters, each running a small .NET edge agent that sends readings every minute. The hardware will fail, be replaced, or be upgraded. The readings must not. If your schema ties a reading to a device serial number rather than a logical meter identity, you lose history the moment you swap hardware. If you store readings in a plain PostgreSQL table, you pay ten times the storage cost and wait ten times as long for rolling-window queries.&lt;/p&gt;

&lt;p&gt;TimescaleDB (now shipped by TigerData, renamed from Timescale Inc. on June 17, 2025) solves both the storage and the query problem. Version 2.29.0, released July 28, 2026, adds incremental and concurrent continuous-aggregate refresh, vectorised &lt;code&gt;time_bucket()&lt;/code&gt; from 2.26 onward, and and the Hypercore columnstore engine introduced in 2.18. All of them matter for the patterns below.&lt;/p&gt;

&lt;p&gt;This article covers the schema, the EF Core mapping, and the anomaly worker. Parts 1 to 3 covered the edge agent; here we focus on what happens after the reading lands in the cloud database.&lt;/p&gt;




&lt;h2&gt;
  
  
  Schema Design: Survive the Meter Replacement
&lt;/h2&gt;

&lt;p&gt;The first rule: readings reference a &lt;em&gt;logical meter&lt;/em&gt;, not a device serial. Keep a &lt;code&gt;meters&lt;/code&gt; lookup table with a surrogate UUID, the physical serial, and install/remove timestamps. When hardware is swapped, insert a new &lt;code&gt;meters&lt;/code&gt; row, and the old readings keep their original &lt;code&gt;meter_id&lt;/code&gt;.&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="c1"&gt;-- Locations and meters&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;locations&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;location_id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;address&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="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;meters&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;meter_id&lt;/span&gt;     &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;location_id&lt;/span&gt;  &lt;span class="n"&gt;UUID&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;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;locations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;location_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;device_serial&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;meter_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="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meter_type&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'water'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'power'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="n"&gt;installed_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="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;removed_at&lt;/span&gt;   &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Raw readings hypertable&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;readings&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nb"&gt;time&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="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;meter_id&lt;/span&gt;    &lt;span class="n"&gt;UUID&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;value&lt;/span&gt;       &lt;span class="nb"&gt;DOUBLE&lt;/span&gt; &lt;span class="nb"&gt;PRECISION&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;  &lt;span class="c1"&gt;-- litres or watt-hours&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;create_hypertable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'readings'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'time'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;chunk_time_interval&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'7 days'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Hypercore columnstore compression after 7 days&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;add_columnstore_policy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'readings'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;after&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'7 days'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;segmentby&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ARRAY&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'meter_id'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;-- Tariff periods for cost calculation&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;tariff_periods&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;tariff_id&lt;/span&gt;   &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;valid_from&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="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;valid_until&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;-- NULL means current tariff&lt;/span&gt;
    &lt;span class="n"&gt;eur_per_kwh&lt;/span&gt; &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 7-day chunk interval suits medium-frequency meter data (one reading per minute is 1,440 rows per day per meter). Hypercore's columnstore compresses cold chunks by 90 to 95 percent, so a year of data from a hundred meters costs roughly what a week would in plain PostgreSQL.&lt;/p&gt;

&lt;h3&gt;
  
  
  Continuous Aggregates: Daily and Monthly Rollups
&lt;/h3&gt;

&lt;p&gt;Stack the aggregates. An hourly rollup feeds a daily rollup, which feeds a monthly rollup. Each is its own hypertable, refreshed incrementally. The &lt;code&gt;WITH (timescaledb.continuous)&lt;/code&gt; flag and &lt;code&gt;real_time_aggregate&lt;/code&gt; option mean your anomaly queries always see the latest raw data without a forced refresh.&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="c1"&gt;-- Hourly rollup&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;MATERIALIZED&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;readings_hourly&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timescaledb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;continuous&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;timescaledb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;materialized_only&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;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;time_bucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'1 hour'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;meter_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                   &lt;span class="k"&gt;AS&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;MIN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                   &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;min_val&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                   &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;max_val&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;COUNT&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;sample_count&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;readings&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;meter_id&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;NO&lt;/span&gt; &lt;span class="k"&gt;DATA&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;add_continuous_aggregate_policy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'readings_hourly'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;start_offset&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'2 hours'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;end_offset&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'1 hour'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;schedule_interval&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'1 hour'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Daily rollup sourced from hourly&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;MATERIALIZED&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;readings_daily&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timescaledb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;continuous&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;timescaledb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;materialized_only&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;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;time_bucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'1 day'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;meter_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&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="k"&gt;AS&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;MIN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min_val&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;min_val&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_val&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;max_val&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sample_count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;             &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;sample_count&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;readings_hourly&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;time_bucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'1 day'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;meter_id&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;NO&lt;/span&gt; &lt;span class="k"&gt;DATA&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;add_continuous_aggregate_policy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'readings_daily'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;start_offset&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'2 days'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;end_offset&lt;/span&gt;    &lt;span class="o"&gt;=&amp;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="n"&gt;schedule_interval&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Monthly rollups follow the same pattern using &lt;code&gt;'1 month'&lt;/code&gt; and sourcing &lt;code&gt;readings_daily&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  EF Core 10 Mapping
&lt;/h2&gt;

&lt;p&gt;EF Core has no native understanding of hypertable DDL. The &lt;code&gt;YC.EntityFrameworkCore.TigerData.TimescaleDB&lt;/code&gt; package (targeting EF Core 10 / Npgsql 10, requires TimescaleDB 2.23+) exposes a Fluent API that injects the correct &lt;code&gt;migrationBuilder.Sql(...)&lt;/code&gt; calls into generated migrations. For bare projects, execute raw SQL directly in &lt;code&gt;Up()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Reading&lt;/code&gt; entity is straightforward; the important thing is that &lt;code&gt;MeterId&lt;/code&gt; is the foreign key, not a serial string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Reading.cs&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;Reading&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;Time&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;Guid&lt;/span&gt;           &lt;span class="n"&gt;MeterId&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;double&lt;/span&gt;         &lt;span class="n"&gt;Value&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;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;Meter&lt;/span&gt; &lt;span class="n"&gt;Meter&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;null&lt;/span&gt;&lt;span class="p"&gt;!;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Meter.cs&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;Meter&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;Guid&lt;/span&gt;            &lt;span class="n"&gt;MeterId&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;Guid&lt;/span&gt;            &lt;span class="n"&gt;LocationId&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;          &lt;span class="n"&gt;DeviceSerial&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;          &lt;span class="n"&gt;MeterType&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt;  &lt;span class="n"&gt;InstalledAt&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;RemovedAt&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;Location&lt;/span&gt;          &lt;span class="n"&gt;Location&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;null&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;ICollection&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Reading&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Readings&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="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// MeterDbContext.cs (relevant excerpt)&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;OnModelCreating&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ModelBuilder&lt;/span&gt; &lt;span class="n"&gt;modelBuilder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;modelBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Entity&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Reading&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;=&amp;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="nf"&gt;HasKey&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="k"&gt;new&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="n"&gt;Time&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="n"&gt;MeterId&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;Property&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="n"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;HasColumnType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"timestamptz"&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;HasOne&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="n"&gt;Meter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
         &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Readings&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
         &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;HasForeignKey&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="n"&gt;MeterId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="n"&gt;modelBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Entity&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Meter&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;=&amp;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="nf"&gt;HasKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MeterId&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;Property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RemovedAt&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;HasColumnType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"timestamptz"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// If not using the community package, call the hypertable&lt;/span&gt;
    &lt;span class="c1"&gt;// DDL from the migration's Up() method via migrationBuilder.Sql()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Important gotcha: TimescaleDB cannot apply certain schema changes in place on an existing hypertable, it has to recreate the table. Design your schema before production data volumes grow large; adding a column after millions of rows have been ingested is a heavy operation.&lt;/p&gt;

&lt;p&gt;Also: the 2.27.x bloom-filter bug caused compressed &lt;code&gt;int2&lt;/code&gt;/&lt;code&gt;SMALLINT&lt;/code&gt; columns to silently miss matching rows in &lt;code&gt;SELECT&lt;/code&gt; queries. The workaround is to drop the affected sparse indexes manually before upgrading. On 2.28+ this is resolved, but avoid &lt;code&gt;SMALLINT&lt;/code&gt; on heavily compressed columns until you have confirmed your version.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Anomaly Worker
&lt;/h2&gt;

&lt;p&gt;A single &lt;code&gt;BackgroundService&lt;/code&gt; polls every minute and runs three independent checks:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Leak detection.&lt;/strong&gt; A water meter with non zero flow for six consecutive hours. Use &lt;code&gt;readings_hourly&lt;/code&gt; with &lt;code&gt;real_time_aggregate&lt;/code&gt; so no explicit refresh is needed:&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;AnomalyWorker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MeterDbContext&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                           &lt;span class="n"&gt;ILogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AnomalyWorker&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                           &lt;span class="n"&gt;IAlertService&lt;/span&gt; &lt;span class="n"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BackgroundService&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="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;ExecuteAsync&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="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;timer&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;PeriodicTimer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromMinutes&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="k"&gt;while&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;timer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WaitForNextTickAsync&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;CheckLeaksAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;CheckPowerSpikesAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;CheckSilentMetersAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;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="nf"&gt;CheckLeaksAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;cutoff&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;AddHours&lt;/span&gt;&lt;span class="p"&gt;(-&lt;/span&gt;&lt;span class="m"&gt;6&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// readings_hourly is a cagg, queried like a normal table&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;leaking&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Database&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SqlQuery&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;$"""
&lt;/span&gt;                &lt;span class="n"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meter_id&lt;/span&gt; &lt;span class="n"&gt;AS&lt;/span&gt; &lt;span class="s"&gt;"Value"&lt;/span&gt;
                &lt;span class="n"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;readings_hourly&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;
                &lt;span class="n"&gt;JOIN&lt;/span&gt;   &lt;span class="n"&gt;meters&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="n"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meter_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meter_id&lt;/span&gt;
                &lt;span class="n"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meter_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="n"&gt;water&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;
                  &lt;span class="n"&gt;AND&lt;/span&gt;  &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;removed_at&lt;/span&gt; &lt;span class="n"&gt;IS&lt;/span&gt; &lt;span class="n"&gt;NULL&lt;/span&gt;
                  &lt;span class="n"&gt;AND&lt;/span&gt;  &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bucket&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;cutoff&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
                  &lt;span class="n"&gt;AND&lt;/span&gt;  &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;min_val&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="n"&gt;GROUP&lt;/span&gt;  &lt;span class="n"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meter_id&lt;/span&gt;
                &lt;span class="n"&gt;HAVING&lt;/span&gt; &lt;span class="nf"&gt;COUNT&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;6&lt;/span&gt;
                &lt;span class="s"&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="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;meterId&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;leaking&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;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RaiseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meterId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"LeakDetected"&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="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="nf"&gt;CheckPowerSpikesAsync&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;// 7-day baseline: flag today if today's total &amp;gt; avg + 2*stddev&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;spikes&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Database&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SqlQuery&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;$"""
&lt;/span&gt;                &lt;span class="n"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;baseline&lt;/span&gt; &lt;span class="nf"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;meter_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                           &lt;span class="nf"&gt;AVG&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;AS&lt;/span&gt; &lt;span class="n"&gt;avg_total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                           &lt;span class="nf"&gt;STDDEV&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;AS&lt;/span&gt; &lt;span class="n"&gt;std_total&lt;/span&gt;
                    &lt;span class="n"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;readings_daily&lt;/span&gt;
                    &lt;span class="n"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nf"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="m"&gt;8&lt;/span&gt; &lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;
                      &lt;span class="n"&gt;AND&lt;/span&gt;  &lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;  &lt;span class="nf"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="n"&gt;day&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;
                    &lt;span class="n"&gt;GROUP&lt;/span&gt;  &lt;span class="n"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;meter_id&lt;/span&gt;
                &lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="n"&gt;today&lt;/span&gt; &lt;span class="nf"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;meter_id&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;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;day_total&lt;/span&gt;
                    &lt;span class="n"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;readings_hourly&lt;/span&gt;
                    &lt;span class="n"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nf"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="n"&gt;day&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
                    &lt;span class="n"&gt;GROUP&lt;/span&gt;  &lt;span class="n"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;meter_id&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meter_id&lt;/span&gt; &lt;span class="n"&gt;AS&lt;/span&gt; &lt;span class="s"&gt;"Value"&lt;/span&gt;
                &lt;span class="n"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;today&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
                &lt;span class="n"&gt;JOIN&lt;/span&gt;   &lt;span class="n"&gt;baseline&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="n"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meter_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meter_id&lt;/span&gt;
                &lt;span class="n"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;day_total&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;avg_total&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="nf"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;std_total&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="s"&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="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;meterId&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;spikes&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;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RaiseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meterId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"PowerSpike"&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="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="nf"&gt;CheckSilentMetersAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;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;AddMinutes&lt;/span&gt;&lt;span class="p"&gt;(-&lt;/span&gt;&lt;span class="m"&gt;15&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;silent&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Database&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SqlQuery&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;$"""
&lt;/span&gt;                &lt;span class="n"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meter_id&lt;/span&gt; &lt;span class="n"&gt;AS&lt;/span&gt; &lt;span class="s"&gt;"Value"&lt;/span&gt;
                &lt;span class="n"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;meters&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;
                &lt;span class="n"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;removed_at&lt;/span&gt; &lt;span class="n"&gt;IS&lt;/span&gt; &lt;span class="n"&gt;NULL&lt;/span&gt;
                  &lt;span class="n"&gt;AND&lt;/span&gt;  &lt;span class="n"&gt;NOT&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;SELECT&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="n"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;readings&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
                    &lt;span class="n"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meter_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meter_id&lt;/span&gt;
                      &lt;span class="n"&gt;AND&lt;/span&gt;  &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;time&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;threshold&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="nf"&gt;ToListAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;meterId&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;silent&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;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RaiseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meterId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"SilentMeter"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The silent-meter check queries the raw hypertable rather than an aggregate, because chunk pruning on &lt;code&gt;r.time &amp;gt;= threshold&lt;/code&gt; limits the scan to one or two recent chunks regardless of how many years of history exist.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cost Calculation With Tariff History
&lt;/h2&gt;

&lt;p&gt;Tariffs change. A reading from 2023 must be costed at the 2023 rate, not today's. Join through &lt;code&gt;tariff_periods&lt;/code&gt; using a lateral range condition:&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;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meter_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;                   &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;kwh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;eur_per_kwh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;tp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;eur_per_kwh&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;eur_cost&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;readings_daily&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;tariff_periods&lt;/span&gt; &lt;span class="n"&gt;tp&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt;  &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;tp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;valid_from&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;valid_until&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;OR&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;tp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;valid_until&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;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meter_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;meterId&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store &lt;code&gt;valid_until = NULL&lt;/code&gt; for the active tariff. When a new tariff starts, set &lt;code&gt;valid_until&lt;/code&gt; on the previous row to the change timestamp; insert the new row. That gives you an append only audit trail, so you never lose what a customer paid in an earlier period.&lt;/p&gt;




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

&lt;p&gt;TimescaleDB 2.29 removed PostgreSQL 15 support; if your managed database is on PG15, you are on the 2.28.x train until you upgrade the engine. The Hypercore columnstore is now the recommended default for new installs, so enable it from day one. Retrofitting compression onto a large existing hypertable is possible but slow.&lt;/p&gt;

&lt;p&gt;The community EF Core packages (both &lt;code&gt;YC.EntityFrameworkCore.TigerData.TimescaleDB&lt;/code&gt; and &lt;code&gt;CmdScale.EntityFrameworkCore.TimescaleDB&lt;/code&gt;) are thin wrappers that emit raw SQL migrations. They save repetitive boilerplate, but you should read the generated migration SQL before applying it, especially the &lt;code&gt;compress_segmentby&lt;/code&gt; and &lt;code&gt;chunk_time_interval&lt;/code&gt; settings, which are hard to change once data is flowing.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>architecture</category>
      <category>dotnet</category>
      <category>efcore</category>
    </item>
    <item>
      <title>Edge AI with .NET, Part 3: Vision OCR You Can Actually Trust</title>
      <dc:creator>Mehdi Mohseni</dc:creator>
      <pubDate>Wed, 07 Oct 2026 14:03:28 +0000</pubDate>
      <link>https://dev.to/mehdimohseni82/edge-ai-with-net-part-3-vision-ocr-you-can-actually-trust-3opc</link>
      <guid>https://dev.to/mehdimohseni82/edge-ai-with-net-part-3-vision-ocr-you-can-actually-trust-3opc</guid>
      <description>&lt;h2&gt;
  
  
  The Gap Between Demo and Production
&lt;/h2&gt;

&lt;p&gt;Reading an analog power meter with a vision model takes about twenty lines of C#. Getting a reading you can actually write to a time-series database takes considerably more. The demo is easy because you pick a clean, well-lit image and the model returns the right number. Production is hard because real meters have glare, dirty lenses and half turned digits, and the model never says "I am not sure." It confidently returns a plausible number instead.&lt;/p&gt;

&lt;p&gt;Research confirms this is not a theoretical concern. A May 2026 study (arxiv:2605.16409) documented that VLMs including GPT-class models frequently hallucinate text, omit tokens, or autocomplete partially visible text under degraded visual conditions. The specific failure mode for meter reading is autoregressive completion: the model sees enough of a digit shape to predict what it statistically should be and returns that prediction without flagging uncertainty. A July 2025 EPFL benchmark (arxiv:2507.01955) covering GPT-4o, Gemini 2.0 Flash, Claude 3.5 Sonnet, and others found hallucinated objects and input-output misalignment across all tested models.&lt;/p&gt;

&lt;p&gt;The engineering response is not to abandon vision OCR. It is to build a validation layer that catches the failures before they corrupt your data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prompt Design and Image Detail
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What the Prompt Needs to Say
&lt;/h3&gt;

&lt;p&gt;For digit reading, vague prompts produce vague results. The prompt needs to name the display type, specify the expected digit count, and explicitly instruct the model to report failure rather than guess:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You are reading a utility meter display. The display shows exactly 5 numeric digits 
with no decimal point. Return ONLY the 5-digit reading as a JSON object with a single 
field \"reading\". If any digit is unclear, partially obscured, or you are not confident, 
return {\"reading\": null} instead of guessing. Do not infer or complete digits.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The null-on-uncertainty instruction matters. Without it, the model defaults to completion mode. With it, you still get hallucinations under severe degradation, but the rate drops meaningfully.&lt;/p&gt;

&lt;h3&gt;
  
  
  Image Detail Level and What It Costs
&lt;/h3&gt;

&lt;p&gt;This is the cost decision that matters most before you write any code. The OpenAI vision API exposes two explicit detail levels:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;detail: low&lt;/code&gt;.&lt;/strong&gt; Always 85 tokens flat, image downsampled to 512×512. Usable for dominant colour or shape detection. Insufficient for small numerals on a meter display.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;detail: high&lt;/code&gt;.&lt;/strong&gt; 85 base tokens plus 170 tokens per 512×512 tile required to tile the image. For &lt;code&gt;gpt-6-astra&lt;/code&gt;, the tokenizer applies a 2,500-patch budget with a 1.2× multiplier. A 1024×1024 image costs ~1,229 tokens; a 2048×2048 image is capped at 1,600×1,600 and costs ~3,000 tokens.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At April 2026 pricing ($0.015/1K input tokens), a 2048×2048 meter image costs roughly $0.045 per read. At 96 reads per day per meter, that is about $4.32 per meter per day, which matters at scale. The practical answer is to resize images server-side to 1024×1024 before sending, cutting per-read token cost by more than half while retaining enough resolution for 5-digit OCR.&lt;/p&gt;

&lt;p&gt;Critically, the &lt;code&gt;vision_detail&lt;/code&gt; parameter defaults to &lt;code&gt;auto&lt;/code&gt; if omitted. Do not rely on auto in production. Pin it explicitly or the model selects detail level based on image size and you lose cost predictability.&lt;/p&gt;

&lt;p&gt;Note: GPT-4.5 costs $75/M input tokens and is not a viable option for a high-volume pipeline. o4-mini was retired from the API on February 13, 2026. Use &lt;code&gt;gpt-6-astra&lt;/code&gt;, which is the reference model in the official OpenAI .NET docs as of June 30, 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Vision Call in C
&lt;/h2&gt;

&lt;p&gt;The OpenAI .NET SDK v2 uses the &lt;code&gt;OpenAI.Chat&lt;/code&gt; namespace. The pattern is: construct a &lt;code&gt;ChatClient&lt;/code&gt;, build a &lt;code&gt;UserChatMessage&lt;/code&gt; from a &lt;code&gt;ChatMessageContentPart[]&lt;/code&gt; array, call &lt;code&gt;CompleteChatAsync&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;OpenAI.Chat&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;System.Text.Json&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;MeterVisionClient&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;ChatClient&lt;/span&gt; &lt;span class="n"&gt;_client&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;MeterVisionClient&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;apiKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_client&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;ChatClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"gpt-6-astra"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ReadMeterAsync&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;imagePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;imageBytes&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;imagePath&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;imageData&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;BinaryData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;imageBytes&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;systemPrompt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ChatMessageContentPart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateTextPart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"You are reading a utility meter display. The display shows exactly 5 "&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt;
            &lt;span class="s"&gt;"numeric digits with no decimal point. Return ONLY a JSON object with a "&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt;
            &lt;span class="s"&gt;"single field \"reading\". If any digit is unclear or you are not confident, "&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt;
            &lt;span class="s"&gt;"return {\"reading\": null} instead of guessing. Do not infer or complete digits."&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;imagePart&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ChatMessageContentPart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateImagePart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;imageData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"image/png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;ChatImageDetailLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;High&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// explicit, never rely on auto&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;message&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;UserChatMessage&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;systemPrompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;imagePart&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="n"&gt;ChatCompletion&lt;/span&gt; &lt;span class="n"&gt;response&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;_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CompleteChatAsync&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;message&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;cancellationToken&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="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;rawContent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&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="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;// Parse the JSON response safely&lt;/span&gt;
        &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;JsonDocument&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JsonDocument&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;rawContent&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;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RootElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"reading"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="n"&gt;JsonElement&lt;/span&gt; &lt;span class="n"&gt;readingEl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;readingEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ValueKind&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;JsonValueKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Null&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="n"&gt;readingEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetString&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;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// model reported uncertainty&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth noting: &lt;code&gt;ChatImageDetailLevel.High&lt;/code&gt; is the enum member, do not pass a raw string. The image MIME type must match the actual encoding; sending a JPEG with &lt;code&gt;"image/png"&lt;/code&gt; causes silent model degradation. If you are fetching images from a camera endpoint over HTTP, replace &lt;code&gt;File.ReadAllBytesAsync&lt;/code&gt; with &lt;code&gt;HttpClient.GetByteArrayAsync&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Validation Layer
&lt;/h2&gt;

&lt;p&gt;A raw model response, even a non null one, never goes straight into your data store. Every reading passes through four gates. Any failure routes to quarantine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gate 1: Digit Count
&lt;/h3&gt;

&lt;p&gt;The model returns a string. Validate its length against the known display width, check it is all numeric, and reject leading zeros where the meter format prohibits them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gate 2: Monotonicity
&lt;/h3&gt;

&lt;p&gt;Utility meters are cumulative. A reading lower than the last stored value is a hard failure. No exceptions. This single rule eliminates a large class of confident hallucinations.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gate 3: Rate-of-Change Bounds
&lt;/h3&gt;

&lt;p&gt;Compare the delta against a physical maximum. A domestic power meter cannot accumulate more than, say, 10 kWh between two 15-minute reads. Anything outside the plausible envelope is flagged regardless of direction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gate 4: Quarantine and Human Review
&lt;/h3&gt;

&lt;p&gt;Any reading that fails one or more gates is written to a quarantine store, not the time-series database. The quarantine record includes the original image path, the raw model response string, the previously accepted reading, and the name of the rule that failed.&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;record&lt;/span&gt; &lt;span class="nc"&gt;MeterReading&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;RawValue&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;ParsedValue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt; &lt;span class="n"&gt;Timestamp&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;record&lt;/span&gt; &lt;span class="nc"&gt;QuarantineRecord&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;ImagePath&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;RawModelResponse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;MeterReading&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;PreviousReading&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;FailedRule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt; &lt;span class="n"&gt;QuarantinedAt&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;MeterReadingValidator&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;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;ExpectedDigitCount&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="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;MaxDeltaPerInterval&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;10m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// kWh per 15-minute window&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;ValidationResult&lt;/span&gt; &lt;span class="nf"&gt;Validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;rawReading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;MeterReading&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt; &lt;span class="n"&gt;readingTime&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;rawReading&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ValidationResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Quarantine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ModelReportedUncertainty"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Gate 1: digit count and format&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;rawReading&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="n"&gt;ExpectedDigitCount&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;rawReading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;All&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;char&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsAsciiDigit&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;ValidationResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Quarantine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"DigitFormatInvalid"&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="kt"&gt;decimal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryParse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawReading&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;decimal&lt;/span&gt; &lt;span class="n"&gt;parsed&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;ValidationResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Quarantine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ParseFailure"&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;previous&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ValidationResult&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;parsed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// first reading, no history to compare&lt;/span&gt;

        &lt;span class="c1"&gt;// Gate 2: monotonicity&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;parsed&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParsedValue&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;ValidationResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Quarantine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"MonotonicityViolation"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Gate 3: rate-of-change&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="n"&gt;parsed&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParsedValue&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;delta&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;MaxDeltaPerInterval&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;ValidationResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Quarantine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"RateOfChangeTooHigh"&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;ValidationResult&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;parsed&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;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;ValidationResult&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;bool&lt;/span&gt; &lt;span class="n"&gt;IsAccepted&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;private&lt;/span&gt; &lt;span class="k"&gt;init&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="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;AcceptedValue&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;private&lt;/span&gt; &lt;span class="k"&gt;init&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;FailedRule&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;private&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;ValidationResult&lt;/span&gt; &lt;span class="nf"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="k"&gt;value&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;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;IsAccepted&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;AcceptedValue&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;value&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;ValidationResult&lt;/span&gt; &lt;span class="nf"&gt;Quarantine&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;rule&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;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;IsAccepted&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;FailedRule&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rule&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 &lt;code&gt;FailedRule&lt;/code&gt; string is what operators see in the review queue. Specific names like &lt;code&gt;MonotonicityViolation&lt;/code&gt; make triage fast. A review record with the original image attached is what makes the review useful. Without it an operator cannot tell whether the failure was model error, a lens problem, or a genuine meter fault.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational Notes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Resize before sending.&lt;/strong&gt; A 4096×512 panoramic image costs approximately 2,458 tokens at &lt;code&gt;detail: high&lt;/code&gt;. Most meter displays photograph well at 1024×1024 and that halves your cost versus sending full-resolution camera output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Log raw model responses.&lt;/strong&gt; Even accepted readings should retain the raw JSON string in an audit column. When a hallucination slips through (it will, eventually), you need the evidence to distinguish model error from a genuine meter anomaly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tune rate bounds per meter class.&lt;/strong&gt; A 15-minute window and 10 kWh ceiling suits a domestic installation. Industrial meters warrant different bounds, and the validation layer should accept those as configuration rather than constants.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quarantine rate is a signal.&lt;/strong&gt; If more than a few percent of reads hit quarantine for a specific meter, the problem is usually physical, a dirty lens, water ingress, or a mounting angle that creates constant glare, not model quality. Surface the quarantine rate as a health metric per device.&lt;/p&gt;

&lt;p&gt;Vision OCR for meter reading is viable in production. The failure modes are real and documented, but they are bounded and catchable. The validation layer is not optional overhead; it is the feature that turns a demo into a system you can operate.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>openai</category>
      <category>dotnet</category>
      <category>csharp</category>
    </item>
    <item>
      <title>NGB Platform v3.1.0: Attachments &amp; Notes</title>
      <dc:creator>NGB Platform</dc:creator>
      <pubDate>Wed, 07 Oct 2026 13:50:21 +0000</pubDate>
      <link>https://dev.to/ngbplatform/ngb-platform-v310-attachments-notes-9jn</link>
      <guid>https://dev.to/ngbplatform/ngb-platform-v310-attachments-notes-9jn</guid>
      <description>&lt;p&gt;NGB Platform v3.1.0 is out, introducing &lt;strong&gt;Attachments &amp;amp; Notes&lt;/strong&gt; — a new platform-level capability for managing files and notes associated with business objects.&lt;/p&gt;

&lt;p&gt;The implementation follows an important architectural principle in NGB: &lt;strong&gt;adding a file or editing a note should not modify the underlying business document.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why independent lifecycle matters
&lt;/h2&gt;

&lt;p&gt;In document-driven business applications, posting a document can produce operational, reference, or accounting effects.&lt;/p&gt;

&lt;p&gt;But what happens when someone needs to attach a supporting PDF or add a note to an already posted document?&lt;/p&gt;

&lt;p&gt;Those operations shouldn't require unposting the document, modifying its version, or recalculating its business effects.&lt;/p&gt;

&lt;p&gt;In NGB, Attachments and Notes have their own lifecycle, completely independent of the parent object's posting state and business effects.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's included in v3.1.0
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Attachments:&lt;/strong&gt; Upload, download, and mark files for deletion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notes:&lt;/strong&gt; Create, edit, and logically delete plain-text notes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit Log integration:&lt;/strong&gt; Track changes, review previous note content, and access retained attachments, even after logical deletion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permissions:&lt;/strong&gt; Separate access controls for attachments and notes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Platform-wide support:&lt;/strong&gt; Available for catalogs, documents, and General Journal Entries.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same capabilities work across all four NGB verticals: Property Management, Trade, Agency Billing, and CRM.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few implementation details
&lt;/h2&gt;

&lt;p&gt;Attachments use &lt;strong&gt;MinIO object storage&lt;/strong&gt;, with short-lived presigned URLs for direct file transfers.&lt;/p&gt;

&lt;p&gt;Notes are stored separately and use their own versioning for concurrency control.&lt;/p&gt;

&lt;p&gt;Deletion is logical. Content disappears from active lists, but the audit history is preserved. Completed attachments remain available through the Audit Log with appropriate permissions.&lt;/p&gt;

&lt;p&gt;Neither capability changes the parent object's version, posting state, accounting entries, or register movements.&lt;/p&gt;

&lt;h2&gt;
  
  
  Short demo
&lt;/h2&gt;

&lt;p&gt;Here's a quick walkthrough of the functionality in action:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/QTo0E_4zKjE" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;NGB is an open-source platform built with &lt;strong&gt;.NET 10 and PostgreSQL&lt;/strong&gt; for developing document-driven business applications.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source code:&lt;/strong&gt; &lt;a href="https://github.com/ngbplatform/NGB" rel="noopener noreferrer"&gt;https://github.com/ngbplatform/NGB&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Website:&lt;/strong&gt; &lt;a href="https://ngbplatform.com" rel="noopener noreferrer"&gt;https://ngbplatform.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Documentation:&lt;/strong&gt; &lt;a href="https://docs.ngbplatform.com" rel="noopener noreferrer"&gt;https://docs.ngbplatform.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>postgres</category>
      <category>opensource</category>
      <category>architecture</category>
    </item>
    <item>
      <title>What Happens After the Commit?</title>
      <dc:creator>Rodrigo de Oliveira</dc:creator>
      <pubDate>Wed, 07 Oct 2026 13:01:00 +0000</pubDate>
      <link>https://dev.to/rodri-oliveira-dev/what-happens-after-the-commit-1a4c</link>
      <guid>https://dev.to/rodri-oliveira-dev/what-happens-after-the-commit-1a4c</guid>
      <description>&lt;p&gt;Saving a record to a database is relatively easy.&lt;/p&gt;

&lt;p&gt;We have mature tools for that problem. We open a transaction, change some state, commit it, and roll everything back if something fails before the commit.&lt;/p&gt;

&lt;p&gt;Things become much more interesting when the database commit is &lt;strong&gt;not the end of the operation&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Imagine a financial transaction. Once it is persisted, another service needs to know about it so it can update a balance projection. Maybe additional consumers will react to the same event later.&lt;/p&gt;

&lt;p&gt;The first implementation that comes to mind seems perfectly reasonable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;save to database
publish event
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;But there is an uncomfortable question between those two lines:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if the database commits successfully and the message broker becomes unavailable immediately afterward?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The transaction exists.&lt;/p&gt;

&lt;p&gt;The event does not.&lt;/p&gt;

&lt;p&gt;And now two parts of the system are telling different stories.&lt;/p&gt;

&lt;p&gt;This is one of the problems I wanted to explore in &lt;code&gt;poc-arquitetura&lt;/code&gt;, a repository I use as an executable software architecture laboratory.&lt;/p&gt;

&lt;p&gt;The project uses .NET, PostgreSQL, Kafka, Keycloak, OpenTelemetry, k6, and a few other tools. But the goal has never been to collect technologies or architectural patterns.&lt;/p&gt;

&lt;p&gt;What interests me is what happens when these patterns have to &lt;strong&gt;work together&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Outbox solves one problem and introduces new responsibilities. At-least-once delivery forces us to think about idempotency. Eventual consistency changes how we reason about reads. A Saga requires compensation. Retry needs boundaries. Messaging requires observability beyond HTTP.&lt;/p&gt;

&lt;p&gt;That simple question — &lt;em&gt;what happens after the commit?&lt;/em&gt; — opens a much broader discussion about distributed systems.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/rodri-oliveira-dev" rel="noopener noreferrer"&gt;
        rodri-oliveira-dev
      &lt;/a&gt; / &lt;a href="https://github.com/rodri-oliveira-dev/poc-arquitetura" rel="noopener noreferrer"&gt;
        poc-arquitetura
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      .NET architecture PoC for distributed services with CQRS, Kafka, PostgreSQL, Outbox, DLQ, observability and CI quality gates.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;poc-arquitetura&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;a href="https://github.com/rodri-oliveira-dev/poc-arquitetura/actions/workflows/dotnet.yml" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/fe3131f0ae74307a8b6fee0b1aef474c4350a03abbe765f7bc5c23de95e7b9d5/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f726f6472692d6f6c6976656972612d6465762f706f632d61727175697465747572612f646f746e65742e796d6c3f6272616e63683d6d61696e266c6162656c3d6275696c64" alt="Build"&gt;&lt;/a&gt;
&lt;a href="https://github.com/rodri-oliveira-dev/poc-arquitetura/actions/workflows/dotnet.yml" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/243e7d62ebda78d9a12d30c0d637d34ba406b27d4ae140b0dc40ec6c02c2365a/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f726f6472692d6f6c6976656972612d6465762f706f632d61727175697465747572612f646f746e65742e796d6c3f6272616e63683d6d61696e266c6162656c3d7465737473" alt="Tests"&gt;&lt;/a&gt;
&lt;a href="https://sonarcloud.io/summary/new_code?id=rodri-oliveira-dev_poc-arquitetura" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/1cbca7d4eb2ec66bbc0533942842415c0e083cffef775ff5bddff0b05392e71f/68747470733a2f2f736f6e6172636c6f75642e696f2f6170692f70726f6a6563745f6261646765732f6d6561737572653f70726f6a6563743d726f6472692d6f6c6976656972612d6465765f706f632d6172717569746574757261266d65747269633d616c6572745f737461747573" alt="Quality Gate Status"&gt;&lt;/a&gt;
&lt;a href="https://sonarcloud.io/summary/new_code?id=rodri-oliveira-dev_poc-arquitetura" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/efb4081b14125ef862be0bab0ceb1c09bfd3cf406d3f06487b8eb0ee6391ac36/68747470733a2f2f736f6e6172636c6f75642e696f2f6170692f70726f6a6563745f6261646765732f6d6561737572653f70726f6a6563743d726f6472692d6f6c6976656972612d6465765f706f632d6172717569746574757261266d65747269633d73656375726974795f726174696e67" alt="Security Rating"&gt;&lt;/a&gt;
&lt;a href="https://rodri-oliveira-dev.github.io/poc-arquitetura/" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/e03cfc430fac7d97d80b41b3e219c9467a412ba45611163352c3e30055b39c09/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f726f6472692d6f6c6976656972612d6465762f706f632d61727175697465747572612f70616765732d6172636869746563747572652e796d6c3f6272616e63683d6d61696e266c6162656c3d617263686974656374757265253230646f6373" alt="Architecture Docs"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;POC educacional de microserviços em .NET para estudar arquitetura de software com código real: Clean Architecture, DDD, PostgreSQL, Kafka, Outbox, Inbox, JWT/JWKS com Keycloak, observabilidade, segurança, contratos e testes automatizados.&lt;/p&gt;
&lt;p&gt;Ela demonstra um problema comum em sistemas financeiros: registrar fatos de forma transacional, publicar eventos com confiabilidade, projetar saldos em outro serviço e operar falhas sem esconder consistência eventual. O repositório também mostra contextos de identidade, transferência, pagamento externo e auditoria funcional para exercitar trade-offs de integração.&lt;/p&gt;
&lt;p&gt;Este projeto é útil para:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;quem está aprendendo arquitetura e quer ver os conceitos aplicados;&lt;/li&gt;
&lt;li&gt;desenvolvedores .NET que querem executar, testar e alterar uma stack local;&lt;/li&gt;
&lt;li&gt;arquitetos que querem avaliar decisões, limites e riscos;&lt;/li&gt;
&lt;li&gt;avaliadores técnicos que querem entender a proposta rapidamente.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Visão geral&lt;/h2&gt;
&lt;/div&gt;

  &lt;div class="js-render-enrichment-target"&gt;
    &lt;div class="render-plaintext-hidden"&gt;
      &lt;pre&gt;flowchart LR
    Client[Cliente ou teste] --&amp;gt; Keycloak[Keycloak OIDC]
    Client --&amp;gt; LedgerApi[LedgerService.Api]
    Client --&amp;gt; BalanceApi[BalanceService.Api]
    Client --&amp;gt; TransferApi[TransferService.Api]
    Client --&amp;gt; PaymentApi[PaymentService.Api]
    Client --&amp;gt; IdentityApi[IdentityService.Api]
    Client --&amp;gt; AuditApi[AuditService.Api]
    LedgerApi --&amp;gt;&lt;/pre&gt;…&lt;/div&gt;
&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/rodri-oliveira-dev/poc-arquitetura" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;





&lt;h2&gt;
  
  
  Start by separating facts from projections
&lt;/h2&gt;

&lt;p&gt;Two bounded contexts are especially important in this example.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;LedgerService&lt;/code&gt; owns financial facts.&lt;/p&gt;

&lt;p&gt;If a financial transaction happened, the Ledger is where that fact should be recorded.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;BalanceService&lt;/code&gt; has a different responsibility. It maintains a projection optimized for balance queries.&lt;/p&gt;

&lt;p&gt;At a high level, the architecture looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    Client[Client] --&amp;gt; LedgerApi[Ledger API]

    LedgerApi --&amp;gt; LedgerDb[(PostgreSQL&amp;lt;br/&amp;gt;Ledger)]

    LedgerDb --&amp;gt; LedgerWorker[Ledger Worker]
    LedgerWorker --&amp;gt; Kafka[(Kafka)]

    Kafka --&amp;gt; BalanceWorker[Balance Worker]
    BalanceWorker --&amp;gt; BalanceDb[(PostgreSQL&amp;lt;br/&amp;gt;Balance)]

    Client --&amp;gt; BalanceApi[Balance API]
    BalanceApi --&amp;gt; BalanceDb&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;A transaction and a balance are not the same thing.&lt;/p&gt;

&lt;p&gt;The transaction is a fact: something happened.&lt;/p&gt;

&lt;p&gt;The balance is a model derived from those facts.&lt;/p&gt;

&lt;p&gt;Could everything live in a single application and database? Absolutely. Depending on the system, that may even be the better architecture.&lt;/p&gt;

&lt;p&gt;But this laboratory deliberately separates those responsibilities so that the consequences of the decision become visible.&lt;/p&gt;

&lt;p&gt;One consequence appears immediately.&lt;/p&gt;

&lt;p&gt;The balance is no longer updated inside the same database transaction as the Ledger.&lt;/p&gt;

&lt;p&gt;We have entered the world of &lt;strong&gt;eventual consistency&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That does not mean the system is simply inconsistent or incorrect. It means there is a period during which the Ledger already knows about a transaction while the Balance projection has not processed it yet.&lt;/p&gt;

&lt;p&gt;That window is not an accident.&lt;/p&gt;

&lt;p&gt;It is part of the architecture.&lt;/p&gt;




&lt;h2&gt;
  
  
  The real problem is not Kafka. It is the dual write
&lt;/h2&gt;

&lt;p&gt;Suppose the Ledger does something like this:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant C as Client
    participant L as Ledger API
    participant DB as PostgreSQL
    participant K as Kafka

    C-&amp;gt;&amp;gt;L: Create transaction
    L-&amp;gt;&amp;gt;DB: INSERT LedgerEntry
    DB--&amp;gt;&amp;gt;L: COMMIT
    L-&amp;gt;&amp;gt;K: Publish event
    K--&amp;gt;&amp;gt;L: Acknowledged
    L--&amp;gt;&amp;gt;C: 201 Created

    Note over L,K: What happens if the process&amp;lt;br/&amp;gt;fails after COMMIT but before publish?&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;When everything works, this looks fine.&lt;/p&gt;

&lt;p&gt;Now imagine that the database commit succeeds but the process crashes before Kafka acknowledges the event.&lt;/p&gt;

&lt;p&gt;The Ledger contains the transaction.&lt;/p&gt;

&lt;p&gt;The event may never be published.&lt;/p&gt;

&lt;p&gt;Publishing first and saving afterward does not solve the problem either. It simply reverses it: we can now publish an event for a transaction that later fails to commit.&lt;/p&gt;

&lt;p&gt;This is the &lt;strong&gt;dual-write problem&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;We need to update two independent resources — the database and the broker — and would like them to behave as if they belonged to one atomic transaction.&lt;/p&gt;

&lt;p&gt;One option would be some form of distributed transaction.&lt;/p&gt;

&lt;p&gt;For this project, I chose a different approach: accept that PostgreSQL and Kafka have independent lifecycles and make the &lt;strong&gt;intent to publish durable&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is where the Transactional Outbox pattern becomes useful.&lt;/p&gt;




&lt;h2&gt;
  
  
  Outbox: do not publish now, persist the intent to publish
&lt;/h2&gt;

&lt;p&gt;Instead of saving a transaction and immediately depending on Kafka, the Ledger stores both the financial fact and an Outbox message inside the &lt;strong&gt;same PostgreSQL transaction&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant C as Client
    participant L as Ledger API
    participant DB as PostgreSQL
    participant W as Ledger Worker
    participant K as Kafka

    C-&amp;gt;&amp;gt;L: Create transaction

    rect rgb(235, 235, 235)
        L-&amp;gt;&amp;gt;DB: BEGIN
        L-&amp;gt;&amp;gt;DB: INSERT LedgerEntry
        L-&amp;gt;&amp;gt;DB: INSERT OutboxMessage
        L-&amp;gt;&amp;gt;DB: COMMIT
    end

    L--&amp;gt;&amp;gt;C: Transaction confirmed

    W-&amp;gt;&amp;gt;DB: Fetch pending messages
    DB--&amp;gt;&amp;gt;W: OutboxMessage
    W-&amp;gt;&amp;gt;K: Publish LedgerEntryCreated
    K--&amp;gt;&amp;gt;W: Acknowledged
    W-&amp;gt;&amp;gt;DB: Mark as processed&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Either both records are persisted or neither is.&lt;/p&gt;

&lt;p&gt;The HTTP request no longer needs Kafka to be available in order to preserve the intent to publish the event.&lt;/p&gt;

&lt;p&gt;A separate worker polls pending Outbox messages and publishes them.&lt;/p&gt;

&lt;p&gt;After Kafka confirms publication, the worker updates the Outbox entry.&lt;/p&gt;

&lt;p&gt;If Kafka is unavailable, the financial transaction still exists &lt;strong&gt;together with durable information that an event still needs to be published&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If the worker crashes, processing can resume later.&lt;/p&gt;

&lt;p&gt;This small architectural change has an important effect.&lt;/p&gt;

&lt;p&gt;The failure is no longer a tiny invisible window between two independent writes.&lt;/p&gt;

&lt;p&gt;It becomes &lt;strong&gt;state that the system can observe and recover from&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That, to me, is one of the most useful ways to understand Outbox.&lt;/p&gt;

&lt;p&gt;It does not magically turn Kafka and PostgreSQL into a single transaction.&lt;/p&gt;

&lt;p&gt;It does not guarantee exactly-once processing.&lt;/p&gt;

&lt;p&gt;It turns an otherwise difficult-to-recover failure into something explicitly represented in the system.&lt;/p&gt;

&lt;p&gt;Recoverable systems are usually far more interesting than systems designed around the assumption that failures will not happen.&lt;/p&gt;




&lt;h2&gt;
  
  
  Preventing message loss creates another problem: duplicates
&lt;/h2&gt;

&lt;p&gt;There is a consequence to this design.&lt;/p&gt;

&lt;p&gt;Imagine the worker publishes an event successfully, but crashes before marking the Outbox message as processed.&lt;/p&gt;

&lt;p&gt;When it restarts, the same message may be published again.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant O as Outbox
    participant W as Ledger Worker
    participant K as Kafka
    participant B as Balance Worker
    participant DB as Balance DB

    O-&amp;gt;&amp;gt;W: Pending message
    W-&amp;gt;&amp;gt;K: Publish event
    K--&amp;gt;&amp;gt;W: Success

    Note over W: Worker crashes before&amp;lt;br/&amp;gt;marking the message as processed

    O-&amp;gt;&amp;gt;W: Same message again
    W-&amp;gt;&amp;gt;K: Republish event

    K-&amp;gt;&amp;gt;B: Event
    B-&amp;gt;&amp;gt;DB: Was this event_id processed?
    DB--&amp;gt;&amp;gt;B: No
    B-&amp;gt;&amp;gt;DB: Update projection and store event_id

    K-&amp;gt;&amp;gt;B: Duplicate event
    B-&amp;gt;&amp;gt;DB: Was this event_id processed?
    DB--&amp;gt;&amp;gt;B: Yes

    Note over B: Duplicate safely ignored&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;This is not necessarily a bug.&lt;/p&gt;

&lt;p&gt;It is a normal consequence of &lt;strong&gt;at-least-once delivery&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The responsibility now moves to the consumer.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;BalanceService&lt;/code&gt; records processed event identifiers so the same event does not modify the projection twice.&lt;/p&gt;

&lt;p&gt;That is idempotency becoming part of the architecture.&lt;/p&gt;

&lt;p&gt;Retries are often presented as generic resilience configuration: retry three times, use exponential backoff, done.&lt;/p&gt;

&lt;p&gt;But retry is also a business decision.&lt;/p&gt;

&lt;p&gt;If executing the same operation twice produces two different side effects, a retry may cause more damage than the original failure.&lt;/p&gt;

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

&lt;blockquote&gt;
&lt;p&gt;How many times should we retry?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I think a better question is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What happens if we execute this operation again?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That question tends to uncover much more important design problems.&lt;/p&gt;




&lt;h2&gt;
  
  
  Eventual consistency has to exist outside the diagram too
&lt;/h2&gt;

&lt;p&gt;Once Ledger and Balance are separated, this state becomes perfectly valid:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ledger:
Transaction exists.

Balance:
Transaction has not been projected yet.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;There is nothing inherently wrong with that.&lt;/p&gt;

&lt;p&gt;The problem appears when the architecture is asynchronous but the rest of the product behaves as though every read must immediately reflect every write.&lt;/p&gt;

&lt;p&gt;Different domains solve this in different ways.&lt;/p&gt;

&lt;p&gt;Some operations can expose a processing state. Some critical queries may need to consult the source of truth. Some applications can simply tolerate a small delay before projections become visible.&lt;/p&gt;

&lt;p&gt;There is no universal answer.&lt;/p&gt;

&lt;p&gt;The important point is that eventual consistency should be a &lt;strong&gt;known business and architectural property&lt;/strong&gt;, not an accidental side effect of adding Kafka.&lt;/p&gt;


&lt;h2&gt;
  
  
  Events are APIs too
&lt;/h2&gt;

&lt;p&gt;Once services depend on events, another problem eventually appears:&lt;/p&gt;

&lt;p&gt;contracts change.&lt;/p&gt;

&lt;p&gt;The project includes an evolution from events such as:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LedgerEntryCreated.v1
LedgerEntryCreated.v2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;During a migration period, consumers may need to understand both versions.&lt;/p&gt;

&lt;p&gt;That forces us to treat event schemas as real integration contracts.&lt;/p&gt;

&lt;p&gt;We already think carefully about HTTP APIs: OpenAPI definitions, breaking changes, versioning, client compatibility.&lt;/p&gt;

&lt;p&gt;Events deserve similar care, perhaps even more.&lt;/p&gt;

&lt;p&gt;An HTTP response usually exists only during a request.&lt;/p&gt;

&lt;p&gt;An event may remain in a Kafka topic, a dead-letter queue, or a replay mechanism long after the application version that originally created it has disappeared.&lt;/p&gt;

&lt;p&gt;That changes how we think about compatibility.&lt;/p&gt;

&lt;p&gt;Ordering deserves attention too.&lt;/p&gt;

&lt;p&gt;Kafka guarantees ordering &lt;strong&gt;within a partition&lt;/strong&gt;, not universal ordering across the entire system.&lt;/p&gt;

&lt;p&gt;The message key therefore becomes an architectural decision because it influences which events share a partition and which operations can be processed in parallel.&lt;/p&gt;

&lt;p&gt;Details that look small in producer code can have significant consequences for correctness and throughput.&lt;/p&gt;


&lt;h2&gt;
  
  
  Retry cannot be the final answer
&lt;/h2&gt;

&lt;p&gt;Some failures are temporary.&lt;/p&gt;

&lt;p&gt;A broker can become unavailable. A network request can time out. A dependency may take longer than expected to respond.&lt;/p&gt;

&lt;p&gt;Retry with backoff makes sense in those cases.&lt;/p&gt;

&lt;p&gt;But a structurally invalid event will still be invalid ten seconds later.&lt;/p&gt;

&lt;p&gt;An incompatible schema will not suddenly become compatible on attempt number 47.&lt;/p&gt;

&lt;p&gt;Retrying forever simply turns one bad message into a permanent consumer of CPU, logs, and operational attention.&lt;/p&gt;

&lt;p&gt;That is where a &lt;strong&gt;Dead Letter Queue&lt;/strong&gt;, or DLQ, becomes useful.&lt;/p&gt;

&lt;p&gt;A useful DLQ should preserve enough information for investigation and recovery: the original payload, event type, failure classification, source information, and correlation metadata.&lt;/p&gt;

&lt;p&gt;But putting something in a DLQ is only half the solution.&lt;/p&gt;

&lt;p&gt;The more important question is:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens next?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Can the message be safely replayed?&lt;/p&gt;

&lt;p&gt;Was the root cause fixed?&lt;/p&gt;

&lt;p&gt;Can a projection be rebuilt?&lt;/p&gt;

&lt;p&gt;Should the message be discarded?&lt;/p&gt;

&lt;p&gt;Does the operation require manual review?&lt;/p&gt;

&lt;p&gt;The project explores requeue, replay, and projection rebuild scenarios because recovery is part of the architecture too.&lt;/p&gt;

&lt;p&gt;A phrase I keep coming back to is:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A DLQ without a recovery strategy is just a distributed archive of unresolved problems.&lt;/strong&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Transfers make distributed failures much easier to see
&lt;/h2&gt;

&lt;p&gt;Updating a read projection is relatively straightforward compared with an operation that produces multiple distributed side effects.&lt;/p&gt;

&lt;p&gt;Consider a transfer.&lt;/p&gt;

&lt;p&gt;At a simplified level, we need to create a debit and then a credit.&lt;/p&gt;

&lt;p&gt;Inside one local database transaction, ACID properties solve a lot of problems for us.&lt;/p&gt;

&lt;p&gt;Across independent components, those guarantees disappear.&lt;/p&gt;

&lt;p&gt;The project models this flow as an orchestrated Saga:&lt;br&gt;
&lt;/p&gt;
&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    A[Transfer Requested] --&amp;gt; B[Create Debit]

    B --&amp;gt;|Success| C[Create Credit]
    B --&amp;gt;|Failure| F[Transfer Failed]

    C --&amp;gt;|Success| D[Transfer Completed]
    C --&amp;gt;|Failure| E[Compensate Debit]

    E --&amp;gt; G[Record Reversal]
    G --&amp;gt; F&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;This makes something very explicit:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;when we distribute an operation, we also distribute its failure modes.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the debit succeeds and the credit fails, there is no global &lt;code&gt;ROLLBACK&lt;/code&gt; statement capable of reversing time across independent services.&lt;/p&gt;

&lt;p&gt;We have to define what compensation means.&lt;/p&gt;

&lt;p&gt;In financial domains, compensation often should not erase the original fact. A reversal can instead create a new fact that neutralizes the previous one while preserving the history of what happened.&lt;/p&gt;

&lt;p&gt;A Saga does not recreate ACID across services.&lt;/p&gt;

&lt;p&gt;It models the states and compensating actions required because that local transaction boundary no longer exists.&lt;/p&gt;

&lt;p&gt;That distinction is important.&lt;/p&gt;


&lt;h2&gt;
  
  
  Circuit breakers do not hide failures. They control them.
&lt;/h2&gt;

&lt;p&gt;Another scenario in the laboratory intentionally takes &lt;code&gt;LedgerService&lt;/code&gt; offline while the transfer worker is still running.&lt;/p&gt;

&lt;p&gt;Without protection, the worker can keep calling a dependency that is already known to be unavailable.&lt;/p&gt;

&lt;p&gt;Retries can make this even worse by multiplying calls against an unhealthy service.&lt;/p&gt;

&lt;p&gt;A Circuit Breaker changes that behavior.&lt;br&gt;
&lt;/p&gt;
&lt;pre data-lang="mermaid"&gt;&lt;code&gt;stateDiagram-v2
    [*] --&amp;gt; Closed

    Closed: Requests flow normally
    Open: Requests fail fast
    HalfOpen: Limited probe requests

    Closed --&amp;gt; Open: Consecutive failures
    Open --&amp;gt; HalfOpen: Wait period expires
    HalfOpen --&amp;gt; Closed: Dependency recovered
    HalfOpen --&amp;gt; Open: Dependency still unhealthy&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;When the circuit is open, the system has not somehow recovered.&lt;/p&gt;

&lt;p&gt;It has acknowledged the failure and chosen not to waste resources repeatedly exercising the same broken dependency.&lt;/p&gt;

&lt;p&gt;After a configured interval, the breaker allows a controlled probe through the half-open state.&lt;/p&gt;

&lt;p&gt;If that succeeds, traffic can resume.&lt;/p&gt;

&lt;p&gt;This leads to another principle that I think is worth emphasizing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resilience does not mean making failures invisible.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Resilience means making failure behavior predictable.&lt;/p&gt;


&lt;h2&gt;
  
  
  Observability becomes much more important after leaving HTTP
&lt;/h2&gt;

&lt;p&gt;Following a single HTTP request through logs is usually manageable.&lt;/p&gt;

&lt;p&gt;Following an asynchronous workflow across several processes is very different.&lt;/p&gt;

&lt;p&gt;A single operation may travel through:&lt;br&gt;
&lt;/p&gt;
&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    Request[HTTP Request]
        --&amp;gt; API[Ledger API]
        --&amp;gt; DB[(PostgreSQL)]
        --&amp;gt; Outbox[Outbox]
        --&amp;gt; Worker[Ledger Worker]
        --&amp;gt; Kafka[(Kafka)]
        --&amp;gt; Consumer[Balance Worker]
        --&amp;gt; Projection[(Balance DB)]

    Request -. Correlation ID .-&amp;gt; API
    API -. Trace Context .-&amp;gt; Outbox
    Outbox -. traceparent .-&amp;gt; Worker
    Worker -. Trace Context .-&amp;gt; Kafka
    Kafka -. traceparent .-&amp;gt; Consumer&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;If every component produces unrelated logs, diagnosing a problem becomes timestamp archaeology.&lt;/p&gt;

&lt;p&gt;The project propagates &lt;code&gt;correlation_id&lt;/code&gt;, and when OpenTelemetry is enabled, W3C tracing context such as &lt;code&gt;traceparent&lt;/code&gt; and &lt;code&gt;tracestate&lt;/code&gt; can also travel through the Outbox and Kafka messages.&lt;/p&gt;

&lt;p&gt;The observability side can be viewed separately:&lt;br&gt;
&lt;/p&gt;
&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    Client[Client] --&amp;gt; API[Ledger API]
    API --&amp;gt; DB[(PostgreSQL)]
    DB --&amp;gt; Outbox[Outbox]
    Outbox --&amp;gt; Worker[Ledger Worker]
    Worker --&amp;gt; Kafka[(Kafka)]
    Kafka --&amp;gt; Consumer[Balance Worker]
    Consumer --&amp;gt; Balance[(Balance DB)]

    API -.-&amp;gt; OTEL[OpenTelemetry]
    Worker -.-&amp;gt; OTEL
    Consumer -.-&amp;gt; OTEL

    OTEL --&amp;gt; Traces[Traces]
    OTEL --&amp;gt; Metrics[Metrics]

    API -. Logs .-&amp;gt; Logs[Centralized Logs]
    Worker -. Logs .-&amp;gt; Logs
    Consumer -. Logs .-&amp;gt; Logs&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The local stack includes OpenTelemetry, Jaeger, Prometheus, Grafana, Loki, and supporting components.&lt;/p&gt;

&lt;p&gt;But the tools are not the important part.&lt;/p&gt;

&lt;p&gt;The important part is the questions they allow us to answer.&lt;/p&gt;

&lt;p&gt;The API is healthy, but is the Outbox backlog growing?&lt;/p&gt;

&lt;p&gt;Is the producer failing?&lt;/p&gt;

&lt;p&gt;Is consumer processing slowing down?&lt;/p&gt;

&lt;p&gt;Are duplicates increasing?&lt;/p&gt;

&lt;p&gt;Has the DLQ started receiving messages?&lt;/p&gt;

&lt;p&gt;Which original HTTP request produced the event that failed several minutes later in another process?&lt;/p&gt;

&lt;p&gt;Once asynchronous processing becomes central to the system, an HTTP health endpoint tells only a very small part of the story.&lt;/p&gt;


&lt;h2&gt;
  
  
  Test the architecture, not only the classes
&lt;/h2&gt;

&lt;p&gt;A system like this can have excellent unit-test coverage and still fail exactly where the interesting risks are: between components.&lt;/p&gt;

&lt;p&gt;That is why the laboratory also contains integration tests and k6 scenarios that exercise complete workflows.&lt;/p&gt;

&lt;p&gt;One scenario validates:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ledger
  -&amp;gt; Outbox
  -&amp;gt; Kafka
  -&amp;gt; Balance
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Another exercises the complete Transfer Saga.&lt;/p&gt;

&lt;p&gt;There is also a resilience scenario in which the Ledger dependency is deliberately stopped so the Circuit Breaker's behavior can be observed during failure and recovery.&lt;/p&gt;

&lt;p&gt;I am careful not to describe these tests as production-scale benchmarks.&lt;/p&gt;

&lt;p&gt;They run in a controlled local environment.&lt;/p&gt;

&lt;p&gt;Their latency thresholds are regression guardrails, not production SLOs.&lt;/p&gt;

&lt;p&gt;Running 50 requests per second on Docker Compose does not prove that an architecture can operate at banking scale.&lt;/p&gt;

&lt;p&gt;Real capacity depends on infrastructure, partitions, database behavior, network topology, autoscaling, workload characteristics, failure modes, and many other variables.&lt;/p&gt;

&lt;p&gt;The tests prove something narrower, but still valuable:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;the architectural behavior remains testable under concurrency and controlled failure conditions.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Sometimes the best architecture test is not checking whether a certain method was called.&lt;/p&gt;

&lt;p&gt;It is turning a dependency off and observing what the system actually does.&lt;/p&gt;


&lt;h2&gt;
  
  
  This is a laboratory, not a production reference architecture
&lt;/h2&gt;

&lt;p&gt;Projects that demonstrate microservices, Kafka, Outbox, Sagas, and observability can easily give the impression that they represent a universal production blueprint.&lt;/p&gt;

&lt;p&gt;This one does not.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;poc-arquitetura&lt;/code&gt; is intentionally a laboratory for experimenting with decisions.&lt;/p&gt;

&lt;p&gt;A real production environment would still need serious work around secrets management, workload identity, high availability, Kafka capacity and replication, disaster recovery, network security, production SLOs, infrastructure topology, deployment strategy, and many domain-specific controls.&lt;/p&gt;

&lt;p&gt;That distinction matters.&lt;/p&gt;

&lt;p&gt;Software architecture is not the process of fitting as many patterns as possible into a diagram.&lt;/p&gt;

&lt;p&gt;It is the process of choosing mechanisms that are proportional to the problems the system actually has.&lt;/p&gt;


&lt;h2&gt;
  
  
  Quick takeaways
&lt;/h2&gt;

&lt;p&gt;The database commit is often only the beginning of a distributed workflow.&lt;/p&gt;

&lt;p&gt;Transactional Outbox does not make PostgreSQL and Kafka a single transaction; it makes the intent to publish durable.&lt;/p&gt;

&lt;p&gt;At-least-once delivery means duplicates are expected, which makes idempotency a correctness requirement rather than a nice optimization.&lt;/p&gt;

&lt;p&gt;Eventual consistency has to be understood by the product, not hidden behind a message broker.&lt;/p&gt;

&lt;p&gt;Retries require operations that are safe to repeat. DLQs require recovery procedures. Sagas make states and compensations explicit when a local transaction is no longer available.&lt;/p&gt;

&lt;p&gt;Circuit Breakers control failures instead of pretending those failures disappeared.&lt;/p&gt;

&lt;p&gt;And observability has to cross the same boundaries that events cross.&lt;/p&gt;

&lt;p&gt;If I had to reduce the whole experiment to one idea, it would be this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;In distributed systems, we do not design only the successful path. We also design how failures are detected, understood, and recovered.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;


&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;For a broader view of .NET microservice architecture, Microsoft's &lt;strong&gt;“.NET Microservices: Architecture for Containerized .NET Applications”&lt;/strong&gt; is a useful starting point.&lt;/p&gt;

&lt;p&gt;For Transactional Outbox, Saga, Idempotent Consumer, and related distributed-system patterns, &lt;strong&gt;Chris Richardson's Microservices Patterns&lt;/strong&gt; and the Microservices.io pattern catalog are excellent references.&lt;/p&gt;

&lt;p&gt;For Kafka, it is worth going beyond basic producer/consumer tutorials and studying the official material on &lt;strong&gt;partitions, consumer groups, offsets, ordering, delivery semantics, and transactions&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For distributed tracing and context propagation, the &lt;strong&gt;OpenTelemetry documentation&lt;/strong&gt; provides a vendor-neutral mental model for traces, spans, metrics, baggage, and propagation.&lt;/p&gt;

&lt;p&gt;For load and resilience testing, &lt;strong&gt;Grafana k6&lt;/strong&gt; is approachable enough to start small while still supporting serious workloads.&lt;/p&gt;

&lt;p&gt;And if you want to understand the deeper ideas behind transactions, replication, streams, partitioning, and distributed data, &lt;strong&gt;Designing Data-Intensive Applications&lt;/strong&gt;, by Martin Kleppmann, is still one of the books I would put near the top of the list.&lt;/p&gt;

&lt;p&gt;The repository behind this article contains the source code, ADRs, event contracts, architecture documentation, tests, and failure scenarios discussed here:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/rodri-oliveira-dev" rel="noopener noreferrer"&gt;
        rodri-oliveira-dev
      &lt;/a&gt; / &lt;a href="https://github.com/rodri-oliveira-dev/poc-arquitetura" rel="noopener noreferrer"&gt;
        poc-arquitetura
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      .NET architecture PoC for distributed services with CQRS, Kafka, PostgreSQL, Outbox, DLQ, observability and CI quality gates.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;poc-arquitetura&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;a href="https://github.com/rodri-oliveira-dev/poc-arquitetura/actions/workflows/dotnet.yml" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/fe3131f0ae74307a8b6fee0b1aef474c4350a03abbe765f7bc5c23de95e7b9d5/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f726f6472692d6f6c6976656972612d6465762f706f632d61727175697465747572612f646f746e65742e796d6c3f6272616e63683d6d61696e266c6162656c3d6275696c64" alt="Build"&gt;&lt;/a&gt;
&lt;a href="https://github.com/rodri-oliveira-dev/poc-arquitetura/actions/workflows/dotnet.yml" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/243e7d62ebda78d9a12d30c0d637d34ba406b27d4ae140b0dc40ec6c02c2365a/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f726f6472692d6f6c6976656972612d6465762f706f632d61727175697465747572612f646f746e65742e796d6c3f6272616e63683d6d61696e266c6162656c3d7465737473" alt="Tests"&gt;&lt;/a&gt;
&lt;a href="https://sonarcloud.io/summary/new_code?id=rodri-oliveira-dev_poc-arquitetura" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/1cbca7d4eb2ec66bbc0533942842415c0e083cffef775ff5bddff0b05392e71f/68747470733a2f2f736f6e6172636c6f75642e696f2f6170692f70726f6a6563745f6261646765732f6d6561737572653f70726f6a6563743d726f6472692d6f6c6976656972612d6465765f706f632d6172717569746574757261266d65747269633d616c6572745f737461747573" alt="Quality Gate Status"&gt;&lt;/a&gt;
&lt;a href="https://sonarcloud.io/summary/new_code?id=rodri-oliveira-dev_poc-arquitetura" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/efb4081b14125ef862be0bab0ceb1c09bfd3cf406d3f06487b8eb0ee6391ac36/68747470733a2f2f736f6e6172636c6f75642e696f2f6170692f70726f6a6563745f6261646765732f6d6561737572653f70726f6a6563743d726f6472692d6f6c6976656972612d6465765f706f632d6172717569746574757261266d65747269633d73656375726974795f726174696e67" alt="Security Rating"&gt;&lt;/a&gt;
&lt;a href="https://rodri-oliveira-dev.github.io/poc-arquitetura/" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/e03cfc430fac7d97d80b41b3e219c9467a412ba45611163352c3e30055b39c09/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f726f6472692d6f6c6976656972612d6465762f706f632d61727175697465747572612f70616765732d6172636869746563747572652e796d6c3f6272616e63683d6d61696e266c6162656c3d617263686974656374757265253230646f6373" alt="Architecture Docs"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;POC educacional de microserviços em .NET para estudar arquitetura de software com código real: Clean Architecture, DDD, PostgreSQL, Kafka, Outbox, Inbox, JWT/JWKS com Keycloak, observabilidade, segurança, contratos e testes automatizados.&lt;/p&gt;
&lt;p&gt;Ela demonstra um problema comum em sistemas financeiros: registrar fatos de forma transacional, publicar eventos com confiabilidade, projetar saldos em outro serviço e operar falhas sem esconder consistência eventual. O repositório também mostra contextos de identidade, transferência, pagamento externo e auditoria funcional para exercitar trade-offs de integração.&lt;/p&gt;
&lt;p&gt;Este projeto é útil para:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;quem está aprendendo arquitetura e quer ver os conceitos aplicados;&lt;/li&gt;
&lt;li&gt;desenvolvedores .NET que querem executar, testar e alterar uma stack local;&lt;/li&gt;
&lt;li&gt;arquitetos que querem avaliar decisões, limites e riscos;&lt;/li&gt;
&lt;li&gt;avaliadores técnicos que querem entender a proposta rapidamente.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Visão geral&lt;/h2&gt;
&lt;/div&gt;

  &lt;div class="js-render-enrichment-target"&gt;
    &lt;div class="render-plaintext-hidden"&gt;
      &lt;pre&gt;flowchart LR
    Client[Cliente ou teste] --&amp;gt; Keycloak[Keycloak OIDC]
    Client --&amp;gt; LedgerApi[LedgerService.Api]
    Client --&amp;gt; BalanceApi[BalanceService.Api]
    Client --&amp;gt; TransferApi[TransferService.Api]
    Client --&amp;gt; PaymentApi[PaymentService.Api]
    Client --&amp;gt; IdentityApi[IdentityService.Api]
    Client --&amp;gt; AuditApi[AuditService.Api]
    LedgerApi --&amp;gt;&lt;/pre&gt;…&lt;/div&gt;
&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/rodri-oliveira-dev/poc-arquitetura" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;There is still plenty I want to experiment with.&lt;/p&gt;

&lt;p&gt;And that is probably the part I enjoy most about this kind of architecture work: once failure stops being treated as an exceptional event and becomes part of the design, the questions get much more interesting.&lt;/p&gt;

</description>
      <category>distributedsystems</category>
      <category>architecture</category>
      <category>dotnet</category>
      <category>kafka</category>
    </item>
    <item>
      <title>Edge AI with .NET, Part 1: Integrating a 1975 Power Meter That Has No API</title>
      <dc:creator>Mehdi Mohseni</dc:creator>
      <pubDate>Wed, 07 Oct 2026 12:52:29 +0000</pubDate>
      <link>https://dev.to/mehdimohseni82/edge-ai-with-net-part-1-integrating-a-1975-power-meter-that-has-no-api-57p8</link>
      <guid>https://dev.to/mehdimohseni82/edge-ai-with-net-part-1-integrating-a-1975-power-meter-that-has-no-api-57p8</guid>
      <description>&lt;h2&gt;
  
  
  The Problem: A Meter That Predates APIs by Half a Century
&lt;/h2&gt;

&lt;p&gt;The power meter in the basement counts 98,697 kWh. It has done so since 1975, behind a plastic cover, with two rotating dials and a red Ferraris disc spinning at a rate proportional to current draw. It is accurate. It is reliable. And it offers absolutely nothing a software system can talk to.&lt;/p&gt;

&lt;p&gt;This is not an unusual situation in German housing stock. The &lt;em&gt;Marktstammdatenregister&lt;/em&gt; rollout of smart meters has been slow, legally contested, and largely limited to new construction or high-consumption sites. Older residential meters, the mechanical Ferraris type, have no optical interface in their standard consumer form, no pulse output, and no infrared port. The landlord prohibits modification. You cannot clamp on a pulse counter. You cannot replace the meter without utility approval.&lt;/p&gt;

&lt;p&gt;The integration layer, therefore, becomes a camera.&lt;/p&gt;

&lt;p&gt;This article walks through the decision and the architecture: why camera OCR is the right call, how the M5Stack Timer Camera X handles the sensing side, and specifically how the .NET 10 ingestion service is designed so that a battery-powered device with a hard timeout never loses a reading to server-side latency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why OCR Won the Design Decision
&lt;/h2&gt;

&lt;p&gt;Before building anything, the alternatives deserve a real evaluation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Smart meter swap&lt;/strong&gt;: requires utility coordination, landlord approval, and a wait time measured in years in most German municipalities.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ferraris disc pulse counting&lt;/strong&gt;: the disc has a reflective mark, but consumer-grade Ferraris meters don't expose an optical sensor port. Clamping a photodiode to the outside of the plastic cover is mechanically unreliable and landlord-prohibited.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Current clamp (CT sensor)&lt;/strong&gt;: measures power draw in real time but gives you watts, not the cumulative kWh figure the meter itself tracks. Integrating over time introduces drift, and the clamp still needs physical installation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manual logging&lt;/strong&gt;: a human opens an app and types numbers. Error-prone, forgotten, useless for sub-hourly resolution.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Camera OCR is the only path that is non-invasive, non-destructive, and completely reversible. You mount a camera with a suction cup or a 3D-printed bracket, point it at the digit display, and let the server handle the interpretation. The meter itself is untouched.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&gt;

&lt;p&gt;The stack has four components:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;M5Stack Timer Camera X.&lt;/strong&gt; An ESP32 based camera module with a 3-megapixel OV3660 sensor, 8 MB PSRAM, a 140 mAh internal battery, and a BM8563 RTC that generates a wake signal on a schedule. It sleeps at under 10 μA, wakes, captures a JPEG, POSTs it over Wi-Fi, then returns to deep sleep.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;.NET 10 minimal API ingestion service.&lt;/strong&gt; Receives the multipart upload, authenticates, stores the image, returns &lt;code&gt;202 Accepted&lt;/code&gt;, and enqueues OCR work asynchronously.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TimescaleDB 2.27.0&lt;/strong&gt; (on PostgreSQL 16). Stores parsed readings as a hypertable partitioned by timestamp. Continuous aggregates materialise hourly and daily rollups automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Next.js dashboard.&lt;/strong&gt; Reads from TimescaleDB via a thin query API and renders consumption graphs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Parts 2 and 3 of this series cover the OCR pipeline and the TimescaleDB schema in detail. This article focuses on the ingestion endpoint and the reasoning behind its deliberate minimalism.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Battery Constraint Drives Everything
&lt;/h2&gt;

&lt;p&gt;The Timer Camera X has a 140 mAh battery. During active operation, meaning Wi-Fi association, TCP handshake, JPEG capture and HTTP POST, it draws somewhere between 180 and 250 mA. That gives roughly 30 to 45 minutes of continuous active time before the battery is exhausted. The device &lt;em&gt;must&lt;/em&gt; deep-sleep between readings; the claimed battery life of over one month assumes one capture per hour at standby current under 10 μA.&lt;/p&gt;

&lt;p&gt;This creates a hard constraint on the server. The ESP32 HTTP client has a connection timeout. If the server blocks, by doing OCR synchronously, writing to the database or calling an external vision API, and that blocking exceeds the timeout, the device closes the connection and goes back to sleep. The image is lost. The wake cycle, which cost battery, produced nothing.&lt;/p&gt;

&lt;p&gt;The ingestion endpoint must therefore do exactly three things synchronously:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Authenticate the request.&lt;/li&gt;
&lt;li&gt;Read the multipart body and persist the raw image.&lt;/li&gt;
&lt;li&gt;Return &lt;code&gt;202 Accepted&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything else, OCR, parsing and database writes, happens in a background worker after the response is sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The /upload Endpoint in .NET 10
&lt;/h2&gt;

&lt;p&gt;.NET 10 (10.0.11 LTS, supported through November 2028) brings meaningful improvements to the minimal API pipeline: static pipeline analysis, faster endpoint selection, and lower tail latency compared to earlier releases. These are not theoretical. For a battery device that needs a server response under 100 ms, keeping the hot path lean pays off directly.&lt;/p&gt;

&lt;p&gt;Here is the upload endpoint:&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;System.Threading.Channels&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;Microsoft.AspNetCore.Http.Features&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;WebApplication&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;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Channel for background OCR work&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;imageChannel&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreateBounded&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="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;BoundedChannelOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;512&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;FullMode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;BoundedChannelFullMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DropOldest&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;imageChannel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reader&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;imageChannel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Writer&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;AddHostedService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OcrWorker&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Raise the multipart body size limit for 3MP JPEGs&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;Configure&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;FormOptions&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;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MultipartBodyLengthLimit&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="m"&gt;1024&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="m"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 8 MB ceiling&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;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="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/upload"&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;HttpContext&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ChannelWriter&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;writer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;IConfiguration&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 1. Basic Auth check (adequate on a local LAN; add TLS at ingress for WAN)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Authorization"&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;authHeader&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;IsValidBasicAuth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;authHeader&lt;/span&gt;&lt;span class="p"&gt;!,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Upload:Credentials"&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="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Unauthorized&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// 2. Read the multipart body&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasFormContentType&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;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BadRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Expected multipart/form-data"&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;form&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;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReadFormAsync&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Files&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"image"&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="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;file&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="m"&gt;0&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;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BadRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"No image in form field 'image'"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Save raw bytes; filename carries the device ID and a UTC tick timestamp&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;storagePath&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Upload:StoragePath"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="s"&gt;"/var/meter-images"&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;fileName&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;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;ToUnixTimeMilliseconds&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;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FileName&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;fullPath&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;storagePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fileName&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;fs&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;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fullPath&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;CopyToAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fs&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;// 3. Enqueue the path for the background OCR worker, do NOT await it&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fullPath&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;// Return immediately, the ESP32 gets its 202 and goes back to sleep&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Accepted&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;WithName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"UploadMeterImage"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;DisableAntiforgery&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nf"&gt;IsValidBasicAuth&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;header&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;expectedCredentials&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;header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Basic "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StringComparison&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrdinalIgnoreCase&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;try&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;decoded&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;System&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="n"&gt;Encoding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UTF8&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;Convert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromBase64String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Basic "&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="nf"&gt;Trim&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;decoded&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;expectedCredentials&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// format: "user:password"&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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;false&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;Notice what is absent from the handler body: no OCR call, no database write, no downstream HTTP call, no &lt;code&gt;await Task.Delay&lt;/code&gt;. The handler reads bytes and writes a file path to a &lt;code&gt;System.Threading.Channels&lt;/code&gt; bounded channel. The &lt;code&gt;OcrWorker&lt;/code&gt;, an &lt;code&gt;IHostedService&lt;/code&gt;, drains that channel on its own.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;BoundedChannelOptions&lt;/code&gt; with &lt;code&gt;DropOldest&lt;/code&gt; is intentional. If the OCR worker falls behind (a slow vision API, a cold container), older readings are sacrificed rather than letting the channel grow unbounded and consuming memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Background OCR Worker Shell
&lt;/h2&gt;

&lt;p&gt;Structurally the worker is simple. The vision integration itself is the subject of Part 2.&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;OcrWorker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ChannelReader&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;reader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ILogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OcrWorker&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BackgroundService&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="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;ExecuteAsync&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;stoppingToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="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;imagePath&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReadAllAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stoppingToken&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="c1"&gt;// Part 2: call vision OCR, parse kWh digits, write to TimescaleDB&lt;/span&gt;
                &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Processing image: {Path}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;imagePath&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;ProcessImageAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;imagePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stoppingToken&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="c1"&gt;// Log and continue, a failed reading should not kill the worker&lt;/span&gt;
                &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogError&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;"OCR processing failed for {Path}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;imagePath&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;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;ProcessImageAsync&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;path&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;// Stub, the vision pipeline is covered in Part 2&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;await foreach&lt;/code&gt; over &lt;code&gt;ReadAllAsync&lt;/code&gt; handles cancellation cleanly on shutdown. Errors on a single image are caught and logged without stopping the loop, because a corrupted JPEG or a transient API failure should not take the worker down for every reading after it.&lt;/p&gt;

&lt;h2&gt;
  
  
  TimescaleDB as the Time-Series Store
&lt;/h2&gt;

&lt;p&gt;TimescaleDB 2.27.0 (released May 2026, requiring PostgreSQL 16 or higher, since PostgreSQL 15 support was dropped in June 2026) is the right database for this workload. A power meter produces a narrow, time-ordered stream of readings. TimescaleDB's hypertable partitioning handles this automatically, and continuous aggregates let you define hourly and daily rollups in SQL without any application-level cron job.&lt;/p&gt;

&lt;p&gt;For a meter sampled once per hour the default 7-day chunk interval is fine. If you ever reduce the interval to 1-minute resolution, tune &lt;code&gt;chunk_time_interval&lt;/code&gt; explicitly to avoid chunk proliferation.&lt;/p&gt;

&lt;p&gt;The v2.26 release introduced a roughly 3.5× speedup on analytical queries using &lt;code&gt;time_bucket()&lt;/code&gt; in grouping expressions, via an expanded vectorised columnar query path. Dashboard queries that compute daily consumption from hourly readings benefit directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical Notes Before Part 2
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Hardware firmware&lt;/strong&gt;: use the ESP-IDF or Arduino framework for the Timer Camera X. As of mid 2025 there is no working CircuitPython camera support for this module. Using &lt;code&gt;espcamera&lt;/code&gt; under CircuitPython produces initialisation errors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Image size vs. timeout&lt;/strong&gt;: at maximum resolution (2048 × 1536) a JPEG from the OV3660 can exceed 1 MB. The &lt;code&gt;FormOptions.MultipartBodyLengthLimit&lt;/code&gt; in the code above is set to 8 MB, which is safe headroom. If your digits are readable at a lower resolution, reduce it on the device side. Smaller payloads mean faster uploads and less battery used per cycle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TLS&lt;/strong&gt;: Basic Auth over plain HTTP is acceptable on an isolated home LAN segment. If the upload travels over a public network or even a shared Wi-Fi, terminate TLS at a reverse proxy (Caddy or nginx) in front of the .NET service. Do not do TLS termination in the Kestrel process on a resource-constrained deployment unless you have spare CPU budget.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker image pinning&lt;/strong&gt;: pin the base image explicitly, &lt;code&gt;mcr.microsoft.com/dotnet/aspnet:10.0.11&lt;/code&gt;, rather than &lt;code&gt;10.0&lt;/code&gt; or &lt;code&gt;latest&lt;/code&gt;. .NET 10 ships monthly patch releases; a floating tag can silently pull a new runtime into production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OpenAPI&lt;/strong&gt;: .NET 10 defaults to OpenAPI 3.1. If you add Swagger UI, use Swashbuckle 5.x or switch to the new &lt;code&gt;Microsoft.AspNetCore.OpenApi&lt;/code&gt; package. Older Swashbuckle versions cannot handle the 3.1 schema output.&lt;/p&gt;

&lt;p&gt;Part 2 covers the OCR pipeline: sending the stored JPEG to a vision model, extracting and validating the kWh digit string, and writing a verified reading row into TimescaleDB with confidence metadata attached.&lt;/p&gt;

</description>
      <category>aspnetcore</category>
      <category>dotnet</category>
      <category>edgeai</category>
      <category>timescaledb</category>
    </item>
    <item>
      <title>Graph-Native Data Structures in C#, Part 7: Bipartite Graphs and Collaborative Filtering in NebulaGraph</title>
      <dc:creator>Mehdi Mohseni</dc:creator>
      <pubDate>Wed, 07 Oct 2026 12:45:18 +0000</pubDate>
      <link>https://dev.to/mehdimohseni82/graph-native-data-structures-in-c-part-7-bipartite-graphs-and-collaborative-filtering-in-9jh</link>
      <guid>https://dev.to/mehdimohseni82/graph-native-data-structures-in-c-part-7-bipartite-graphs-and-collaborative-filtering-in-9jh</guid>
      <description>&lt;p&gt;This is Part 7 and the finale of the &lt;em&gt;Graph-Native Data Structures in C#&lt;/em&gt; series. If you're arriving fresh, &lt;a href="https://dev.to/graph-native-csharp-part1-fundamentals"&gt;Part 1&lt;/a&gt; covers graph fundamentals and the vocabulary we've been building on throughout. The recommendation engine pattern we're closing out here first appeared in the ArangoDB to NebulaGraph migration article. Here we build it properly, step by step.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bipartite Structure
&lt;/h2&gt;

&lt;p&gt;A bipartite graph partitions vertices into two disjoint sets where edges only cross between sets, never within. For a recommendation engine that's a natural fit: users on one side, products on the other, and interactions as the only edges. No user-to-user edges exist. No product-to-product edges exist. The graph is bipartite by design.&lt;/p&gt;

&lt;p&gt;NebulaGraph doesn't enforce bipartite structure at the schema level, it is kept that way by application convention. The engine won't stop you creating a &lt;code&gt;purchased&lt;/code&gt; edge between two users. That discipline lives in your insertion code: always source from a &lt;code&gt;user:&lt;/code&gt; VID, always target a &lt;code&gt;product:&lt;/code&gt; VID.&lt;/p&gt;

&lt;p&gt;Our schema looks like this in nGQL DDL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;TAG&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;handle&lt;/span&gt; &lt;span class="n"&gt;STRING&lt;/span&gt;&lt;span class="ss"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;TAG&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sku&lt;/span&gt; &lt;span class="n"&gt;STRING&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="n"&gt;STRING&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="n"&gt;BOOL&lt;/span&gt;&lt;span class="ss"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EDGE&lt;/span&gt; &lt;span class="n"&gt;purchased&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="n"&gt;INT&lt;/span&gt;&lt;span class="ss"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EDGE&lt;/span&gt; &lt;span class="n"&gt;viewed&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="n"&gt;INT&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weight&lt;/span&gt; &lt;span class="n"&gt;DOUBLE&lt;/span&gt;&lt;span class="ss"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vertex IDs use the &lt;code&gt;STRING&lt;/code&gt; type with a namespace prefix: &lt;code&gt;'user:alice'&lt;/code&gt;, &lt;code&gt;'product:SKU-42'&lt;/code&gt;. NebulaGraph supports string VIDs up to 256 bytes, and the prefix convention makes VID type unambiguous without reading tag properties, which pays off when you filter results in C#.&lt;/p&gt;

&lt;h2&gt;
  
  
  Modeling Interactions as Typed Edges
&lt;/h2&gt;

&lt;p&gt;The critical modeling decision here is &lt;strong&gt;separate edge types for separate interaction semantics&lt;/strong&gt;, not a single &lt;code&gt;interaction&lt;/code&gt; edge with a &lt;code&gt;type&lt;/code&gt; property. &lt;code&gt;purchased&lt;/code&gt; and &lt;code&gt;viewed&lt;/code&gt; are distinct edge types. This matters for two reasons.&lt;/p&gt;

&lt;p&gt;First, it lets the GO statement traverse selectively, so you can run collaborative filtering on purchase signal alone, which is higher quality than view signal. Second, edge types participate in NebulaGraph's storage indexing differently from edge properties. Querying &lt;code&gt;OVER purchased&lt;/code&gt; is cheaper than &lt;code&gt;OVER interaction WHERE interaction.type == 'purchased'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;weight DOUBLE&lt;/code&gt; on &lt;code&gt;viewed&lt;/code&gt; carries a decay factor you populate at insertion time, so a view from yesterday scores differently from one six months ago. &lt;code&gt;purchased&lt;/code&gt; doesn't need this because recency is captured by the &lt;code&gt;ts&lt;/code&gt; timestamp and purchases are inherently stronger signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Two-Hop Collaborative Filtering Traversal
&lt;/h2&gt;

&lt;p&gt;The core idea: from a target user, follow &lt;code&gt;purchased&lt;/code&gt; edges to the products they own (hop 1). From those products, follow &lt;code&gt;purchased&lt;/code&gt; edges back to other users who bought the same products (still hop 1, but reversed). From those peer users, follow &lt;code&gt;purchased&lt;/code&gt; edges forward again to everything they've bought (hop 2). What you get at hop 2 is a raw candidate set for recommendation.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;BIDIRECT&lt;/code&gt; on a bipartite graph gives you exactly this in a single GO statement. Because edges only ever run &lt;code&gt;user → product&lt;/code&gt;, traversing bidirectionally means hop 1 reaches products (forward) &lt;em&gt;and&lt;/em&gt; users (backward from the products' incoming edges) simultaneously. Two hops BIDIRECT equals the full collaborative filtering pattern with no second query.&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;GO&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="n"&gt;STEPS&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="s1"&gt;'user:alice'&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="n"&gt;purchased&lt;/span&gt; &lt;span class="n"&gt;BIDIRECT&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
  &lt;span class="n"&gt;YIELD&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;edge&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;vid&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="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;vtags&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="err"&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;vid&lt;/span&gt; &lt;span class="k"&gt;ASC&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few mechanics to be explicit about. The &lt;code&gt;GO&lt;/code&gt; statement uses a &lt;strong&gt;walk&lt;/strong&gt; path type, so vertices and edges can be revisited. That means &lt;code&gt;'user:alice'&lt;/code&gt; can appear in intermediate results. The &lt;code&gt;YIELD DISTINCT&lt;/code&gt; deduplicates final-hop destination VIDs, but intermediate walk paths aren't deduplicated. Your C# code needs to filter the originating user from results. Also, the &lt;code&gt;WHERE $$.product.active == true&lt;/code&gt; filter applies to the &lt;strong&gt;destination&lt;/strong&gt; vertex at each hop, and the &lt;code&gt;$$&lt;/code&gt; notation reads destination tag properties inline.&lt;/p&gt;

&lt;p&gt;One production gotcha: a bestselling product might have millions of purchasers. A 2-hop traversal from any user who bought it fans out catastrophically. NebulaGraph v5.2 introduced a &lt;code&gt;SAMPLE&lt;/code&gt; clause on the GO statement specifically to throttle super-node traversals, giving predictable latency without killing completeness entirely. For earlier versions, the &lt;code&gt;LIMIT&lt;/code&gt; at the end is your blunt instrument.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scoring and Ranking in C
&lt;/h2&gt;

&lt;p&gt;The graph layer returns raw candidate VIDs. Scoring happens in C#. The &lt;code&gt;RecommendAsync&lt;/code&gt; method below handles deduplication, weighted scoring by interaction type, exclusion of owned items, and top-k cutoff.&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;record&lt;/span&gt; &lt;span class="nc"&gt;Recommendation&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;ProductVid&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;ProductName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;Score&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;RecommendationService&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;const&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;PurchaseWeight&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;2.0&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;const&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;ViewWeight&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0.5&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;INebulaSessionPool&lt;/span&gt; &lt;span class="n"&gt;_pool&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;RecommendationService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;INebulaSessionPool&lt;/span&gt; &lt;span class="n"&gt;pool&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;_pool&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IReadOnlyList&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Recommendation&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;RecommendAsync&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;userHandle&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;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;userVid&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;$"user:&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;userHandle&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;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;session&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;_pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetSessionAsync&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;// Step 1: fetch owned products to exclude&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ownedResult&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;session&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;$"GO 1 STEPS FROM '&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;userVid&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;' OVER purchased YIELD dst(edge) AS vid"&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;owned&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ownedResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AsVertices&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;v&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Vid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToHashSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StringComparer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ordinal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Step 2: 2-hop collaborative filtering&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;candidateResult&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;session&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;$"""
&lt;/span&gt;            &lt;span class="n"&gt;GO&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="n"&gt;STEPS&lt;/span&gt; &lt;span class="n"&gt;FROM&lt;/span&gt; &lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;userVid&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="n"&gt;purchased&lt;/span&gt; &lt;span class="n"&gt;BIDIRECT&lt;/span&gt;
              &lt;span class="n"&gt;WHERE&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product&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="n"&gt;YIELD&lt;/span&gt; &lt;span class="nf"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;edge&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;vid&lt;/span&gt;
            &lt;span class="s"&gt;""");
&lt;/span&gt;
        &lt;span class="c1"&gt;// Step 3: score by co-occurrence frequency&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;scores&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;Dictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;StringComparer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ordinal&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;row&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;candidateResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Rows&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;vid&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"vid"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;AsString&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;vid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"product:"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StringComparison&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ordinal&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&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;owned&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;vid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

            &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vid&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;existing&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;vid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;PurchaseWeight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// Fetch names for top candidates before final cut&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;topVids&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scores&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;kv&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;kv&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;Take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&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="c1"&gt;// over-fetch before name lookup&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;kv&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;kv&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;ToList&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;topVids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Count&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Recommendation&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;vidList&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Join&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;topVids&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;v&lt;/span&gt; &lt;span class="p"&gt;=&amp;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;v&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;nameResult&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;session&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;$"FETCH PROP ON product &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;vidList&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; YIELD id(vertex) AS vid, properties(vertex).name AS name"&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;nameMap&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;nameResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Rows&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToDictionary&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="s"&gt;"vid"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;AsString&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="s"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;AsString&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;scores&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;kv&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;nameMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ContainsKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kv&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;OrderByDescending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kv&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;kv&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;Take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&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;kv&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;Recommendation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kv&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;nameMap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;kv&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;kv&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;ToList&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 scoring here is co-occurrence frequency multiplied by the interaction weight constant. Each time a product VID appears in the 2-hop results it means one more peer user bought it, so one more unit of &lt;code&gt;PurchaseWeight&lt;/code&gt; added to its score. If you also query over &lt;code&gt;viewed&lt;/code&gt;, add rows weighted at &lt;code&gt;ViewWeight&lt;/code&gt;. The originating user is excluded implicitly because their owned items are in the &lt;code&gt;owned&lt;/code&gt; set; peer user VIDs are filtered by the &lt;code&gt;StartsWith("product:")&lt;/code&gt; check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caching and Freshness
&lt;/h2&gt;

&lt;p&gt;For small-to-medium graphs, live traversal on NebulaGraph is fast, with two hop queries finishing in single digit milliseconds under typical conditions. But as the user base grows, precomputing recommendations for your top-traffic users is the pragmatic move.&lt;/p&gt;

&lt;p&gt;The standard pattern: a background worker runs &lt;code&gt;RecommendAsync&lt;/code&gt; for active users and writes results to Redis with a TTL. On a new &lt;code&gt;purchased&lt;/code&gt; event, invalidate that user's cache key immediately, because a purchase is a hard signal change that makes stale recommendations misleading.&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;PurchaseEventHandler&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;IDatabase&lt;/span&gt; &lt;span class="n"&gt;_redis&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;RecommendationService&lt;/span&gt; &lt;span class="n"&gt;_svc&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;PurchaseEventHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IDatabase&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RecommendationService&lt;/span&gt; &lt;span class="n"&gt;svc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_redis&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;_svc&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;svc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;HandleAsync&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;userHandle&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;productSku&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;// Invalidate stale recommendations immediately&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;KeyDeleteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"recs:&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;userHandle&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;// Optionally warm the cache synchronously for low-latency users&lt;/span&gt;
        &lt;span class="c1"&gt;// (or defer to background worker for high-volume systems)&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;recs&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;_svc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RecommendAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userHandle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&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="n"&gt;ct&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;json&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recs&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;_redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StringSetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;$"recs:&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;userHandle&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;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;expiry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromHours&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;6&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="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;TTL guidance: 1 to 6 hours works for moderate purchase velocity. High-velocity catalogs (flash sales, gaming item drops) may need shorter TTLs or event-driven invalidation from a message bus. The key insight is that the invalidation event is the purchase itself, so your graph write and your cache eviction should happen in the same logical transaction boundary, even if they're not literally atomic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Go Next: Hybrid Vector-Graph Retrieval
&lt;/h2&gt;

&lt;p&gt;Pure collaborative filtering has well-known limits: cold start for new users, popularity bias, inability to reason about product content similarity. The natural next step is combining graph traversal with semantic vector search.&lt;/p&gt;

&lt;p&gt;NebulaGraph v5.2 introduced native hybrid retrieval: a single query can combine graph traversal, vector similarity lookup, and full-text keyword search without external engines. This is the foundation for a production RAG augmented recommender. Use the two hop collaborative filtering to generate candidates, then re-rank using vector embeddings of product descriptions stored natively in the graph. The &lt;code&gt;vector&lt;/code&gt; data type and vector indexes landed in v5.0; v5.2 made them composable with traversal in one query.&lt;/p&gt;

&lt;p&gt;That's a full article in itself and a natural follow-on from this series.&lt;/p&gt;

&lt;h2&gt;
  
  
  Series Recap
&lt;/h2&gt;

&lt;p&gt;This finale closes the &lt;em&gt;Graph-Native Data Structures in C#&lt;/em&gt; series. Across seven parts we've covered:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Part 1.&lt;/strong&gt; Graph fundamentals: adjacency lists, adjacency matrices, and when graphs beat relational models&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parts 2 to 4.&lt;/strong&gt; Core algorithms: BFS, DFS, shortest paths, and cycle detection implemented in C#&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parts 5 and 6.&lt;/strong&gt; NebulaGraph in practice: schema design, the nGQL GO statement, and multi-hop traversal patterns&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 7&lt;/strong&gt; (this article). Bipartite graphs, typed interaction edges, collaborative filtering, and a production-ready recommendation service&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The thread running through all of it: graph problems don't simplify under a relational lens. Modeling your data natively as vertices and edges, and querying it with traversal semantics, gives you capabilities that no amount of JOIN optimization recovers. The recommendation engine here is a clean example: the two hop collaborative filter is four lines of nGQL; its SQL equivalent is a multi-level self-join that becomes unmaintainable at scale.&lt;/p&gt;

&lt;p&gt;Build the graph. Traverse it. Rank in C#. Cache the results. Then upgrade to hybrid search when your users outgrow pure collaborative filtering.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>nebulagraph</category>
      <category>graphdatabases</category>
      <category>csharp</category>
    </item>
    <item>
      <title>Supabase .NET DevLog #13</title>
      <dc:creator>Guillaume Faas</dc:creator>
      <pubDate>Wed, 07 Oct 2026 12:23:41 +0000</pubDate>
      <link>https://dev.to/tr00d/supabase-net-devlog-13-28e6</link>
      <guid>https://dev.to/tr00d/supabase-net-devlog-13-28e6</guid>
      <description>&lt;p&gt;Welcome to our weekly log for the Supabase .NET SDK 🎉&lt;/p&gt;

&lt;p&gt;Sorry for being a little late on this one.&lt;/p&gt;

&lt;p&gt;Like I said last week, I spent more time working on content. We now have blog posts and changelog entries coming soon, scheduled for the next couple of weeks.&lt;/p&gt;

&lt;p&gt;Similar to last week, a big shout out to contributors! Y'all keep me quite busy with reviews, but I love it. It's so cool to see the community involved in the SDK, and the list of issues keeps shrinking day after day. &lt;/p&gt;

&lt;p&gt;On a side note, I started working on an Observability initiative that will be very interesting for SDK users in the short term. At the moment, the SDK already provides &lt;a href="https://github.com/supabase/supabase-csharp#observability-opentelemetry" rel="noopener noreferrer"&gt;support for OpenTelemetry&lt;/a&gt; but the traces/spans we emit will receive an update very soon. &lt;/p&gt;

&lt;p&gt;Have a nice one, and stay tuned!&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>dotnet</category>
      <category>opensource</category>
      <category>csharp</category>
    </item>
  </channel>
</rss>
