<?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: kododo</title>
    <description>The latest articles on DEV Community by kododo (@kododo).</description>
    <link>https://dev.to/kododo</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3918541%2Fdd2c7bde-9957-4871-8954-ab62696b4948.png</url>
      <title>DEV Community: kododo</title>
      <link>https://dev.to/kododo</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kododo"/>
    <language>en</language>
    <item>
      <title>How RunWay solves the outbox pattern in .NET without the boilerplate</title>
      <dc:creator>kododo</dc:creator>
      <pubDate>Sat, 23 May 2026 10:49:00 +0000</pubDate>
      <link>https://dev.to/kododo/how-runway-solves-the-outbox-pattern-in-net-without-the-boilerplate-54bm</link>
      <guid>https://dev.to/kododo/how-runway-solves-the-outbox-pattern-in-net-without-the-boilerplate-54bm</guid>
      <description>&lt;p&gt;Here's a scenario you've probably written before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;PlaceOrderAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;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="n"&gt;db&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;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="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="nf"&gt;SaveChangesAsync&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;emailQueue&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="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;SendConfirmationEmail&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;To&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Email&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;The order is saved. The email is queued. Looks fine.&lt;/p&gt;

&lt;p&gt;Now imagine the process crashes between those two lines. Or the message broker is temporarily unavailable. Or there's a network blip.&lt;/p&gt;

&lt;p&gt;The order exists in your database. The email never gets queued. Your customer placed an order and never received a confirmation. You'll find out from a support ticket.&lt;/p&gt;

&lt;p&gt;This is the problem the outbox pattern solves — and it's more common than it looks.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why it keeps happening
&lt;/h2&gt;

&lt;p&gt;The root cause is that you're writing to two different systems in what feels like one operation. Your database and your job queue are independent — they don't share a transaction. Even a tiny gap between the two writes is enough for things to go out of sync.&lt;/p&gt;

&lt;p&gt;The naive fix is to swap the order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;emailQueue&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="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;SendConfirmationEmail&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;To&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Email&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;db&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;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="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="nf"&gt;SaveChangesAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you have the opposite problem: the email job is queued before the order is saved. If &lt;code&gt;SaveChangesAsync&lt;/code&gt; fails, the job runs against data that doesn't exist yet.&lt;/p&gt;

&lt;p&gt;You can add retry logic, deduplication keys, idempotency checks — all of which move complexity to the job handler. It's still fighting the symptom.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the outbox pattern actually is
&lt;/h2&gt;

&lt;p&gt;The outbox pattern is simple in concept: &lt;strong&gt;write the job to the same database as your business data, in the same transaction.&lt;/strong&gt; A separate process (the "runner") reads from that outbox table and dispatches jobs.&lt;/p&gt;

&lt;p&gt;Because everything goes into one transaction, you either commit both the order and the job, or you commit neither. The gap disappears.&lt;/p&gt;

&lt;p&gt;The tricky part is the implementation. You need an outbox table, a runner that polls it reliably, logic to handle in-progress jobs after a crash, deduplication to avoid double-processing — and all of this needs to share a database connection with your application code.&lt;/p&gt;

&lt;p&gt;Most teams end up building a partial version of this and calling it done.&lt;/p&gt;




&lt;h2&gt;
  
  
  How RunWay handles it
&lt;/h2&gt;

&lt;p&gt;RunWay is a background job queue for .NET that stores jobs in your database. Because jobs live in the same database as your application, participating in an existing transaction is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;transaction&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="nf"&gt;BeginTransactionAsync&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;db&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;Add&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;Order&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;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveChangesAsync&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;scheduler&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Job&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;SendConfirmationEmail&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;To&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Email&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AsTransactional&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="c1"&gt;// reuse the ambient transaction — don't open a new one&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ScheduleAsync&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;transaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CommitAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AsTransactional(false)&lt;/code&gt; tells RunWay to enlist in the open transaction rather than managing its own. The job row is written to the &lt;code&gt;runway.jobs&lt;/code&gt; table inside the same &lt;code&gt;BeginTransactionAsync&lt;/code&gt; scope.&lt;/p&gt;

&lt;p&gt;If the commit fails — for any reason — the job is rolled back along with the order. If the commit succeeds, the runner will pick up the job. There's no window where one side commits and the other doesn't.&lt;/p&gt;

&lt;p&gt;For this to work, RunWay needs to share the same database connection as your &lt;code&gt;DbContext&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;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;AddRunWay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UsePostgreSQL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AppDbContext&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;().&lt;/span&gt;&lt;span class="n"&gt;Database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetDbConnection&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
     &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRunner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;opts&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddHandlersFromAssembly&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;Program&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;Assembly&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single line — passing the connection from the existing &lt;code&gt;DbContext&lt;/code&gt; — is what makes the transaction sharing work.&lt;/p&gt;




&lt;h2&gt;
  
  
  What happens after the commit
&lt;/h2&gt;

&lt;p&gt;Once the transaction commits, RunWay's runner (a background &lt;code&gt;IHostedService&lt;/code&gt;) polls the jobs table and picks up the work. It handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retries with backoff&lt;/strong&gt; — configurable per job, not globally&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timeouts&lt;/strong&gt; — mark a job as failed if it runs too long&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heartbeats&lt;/strong&gt; — detect stuck runners and recover stalled jobs
&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;await&lt;/span&gt; &lt;span class="n"&gt;scheduler&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Job&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;SendConfirmationEmail&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;To&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Email&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AsTransactional&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithRetryDelaysInSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;600&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithTimeout&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;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ScheduleAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The job handler itself stays clean — no awareness of retries or transactions:&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;SendConfirmationEmailHandler&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IJobHandler&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SendConfirmationEmail&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;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="n"&gt;SendConfirmationEmail&lt;/span&gt; &lt;span class="n"&gt;data&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="n"&gt;emailService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SendAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;To&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Order confirmed"&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;h2&gt;
  
  
  When &lt;code&gt;AsTransactional(true)&lt;/code&gt; makes sense
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;AsTransactional(false)&lt;/code&gt; reuses an open transaction. &lt;code&gt;AsTransactional(true)&lt;/code&gt; (the default when you call &lt;code&gt;.AsTransactional()&lt;/code&gt; without arguments) opens a new transaction scoped to the job insertion itself.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;true&lt;/code&gt; when you want the job write to be atomic but you don't have — or don't want to share — an ambient transaction. It won't protect you from the two-write problem, but it does guarantee the job row is written consistently on its own.&lt;/p&gt;

&lt;p&gt;Most outbox use cases want &lt;code&gt;false&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The pattern without the boilerplate
&lt;/h2&gt;

&lt;p&gt;The outbox pattern is well understood, but most implementations require you to own the outbox table schema, the polling logic, the retry semantics, and the connection sharing setup. That's a lot of infrastructure for what should be a solved problem.&lt;/p&gt;

&lt;p&gt;RunWay gives you that infrastructure as a library — one transaction flag instead of a custom outbox table.&lt;/p&gt;

&lt;p&gt;If you keep writing the same two-lines-of-risk pattern, it's worth a look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/kododo-dev/RunWay" rel="noopener noreferrer"&gt;github.com/kododo-dev/RunWay&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://kododo.dev/runway/demo" rel="noopener noreferrer"&gt;kododo.dev/runway/demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NuGet:&lt;/strong&gt; &lt;code&gt;Kododo.RunWay&lt;/code&gt;, &lt;code&gt;Kododo.RunWay.Runner&lt;/code&gt;, &lt;code&gt;Kododo.RunWay.PostgreSQL&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've solved this differently in your team — a custom outbox table, MassTransit's outbox, something else — I'd be curious to hear how it's holding up.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>aspnetcore</category>
      <category>architecture</category>
    </item>
    <item>
      <title>I built ConfigWay — a runtime config editor for ASP.NET Core (my first open source project)</title>
      <dc:creator>kododo</dc:creator>
      <pubDate>Thu, 07 May 2026 19:47:40 +0000</pubDate>
      <link>https://dev.to/kododo/i-built-configway-a-runtime-config-editor-for-aspnet-core-my-first-open-source-project-2jok</link>
      <guid>https://dev.to/kododo/i-built-configway-a-runtime-config-editor-for-aspnet-core-my-first-open-source-project-2jok</guid>
      <description>&lt;p&gt;You know that moment when you tweak a feature flag or an email template prefix in &lt;code&gt;appsettings.json&lt;/code&gt;, rebuild, redeploy, wait… and then tweak it again? I got tired of that loop. So I built something to fix it — and decided to open source it.&lt;/p&gt;

&lt;p&gt;Meet &lt;strong&gt;ConfigWay&lt;/strong&gt; — a runtime configuration editor for ASP.NET Core. It lets you view and modify your &lt;code&gt;IOptions&amp;lt;T&amp;gt;&lt;/code&gt; values through a built-in web UI, without ever restarting the application.&lt;/p&gt;

&lt;p&gt;This is my first open source project, and I'm genuinely excited to share it.&lt;/p&gt;




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

&lt;p&gt;ASP.NET Core's configuration system is great, but once the app is running, changing a value means editing a file and restarting. For settings that need frequent tuning — timeouts, feature flags, email templates, rate limits — that cycle adds up fast.&lt;/p&gt;

&lt;p&gt;I wanted something that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;works with the existing &lt;code&gt;IOptions&amp;lt;T&amp;gt;&lt;/code&gt; pattern I already use&lt;/li&gt;
&lt;li&gt;requires minimal setup&lt;/li&gt;
&lt;li&gt;stores overrides in a real database so they survive restarts&lt;/li&gt;
&lt;li&gt;gives me a clean UI to make changes from a browser&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What ConfigWay does
&lt;/h2&gt;

&lt;p&gt;ConfigWay adds a small web UI to your application. Any &lt;code&gt;IOptions&amp;lt;T&amp;gt;&lt;/code&gt; class you register becomes an editable form — with type-aware controls (toggle for &lt;code&gt;bool&lt;/code&gt;, dropdown for &lt;code&gt;enum&lt;/code&gt;, array editor for collections, etc.).&lt;/p&gt;

&lt;p&gt;Changes are applied immediately via hot-reload. No restart needed.&lt;/p&gt;

&lt;p&gt;Here's a quick demo: &lt;strong&gt;&lt;a href="https://kododo.dev/configway/demo" rel="noopener noreferrer"&gt;kododo.dev/configway/demo&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Getting started in 3 steps
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Install the packages
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package Kododo.ConfigWay
dotnet add package Kododo.ConfigWay.UI
dotnet add package Kododo.ConfigWay.PostgreSQL  &lt;span class="c"&gt;# optional — for persistence&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Register everything
&lt;/h3&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="nf"&gt;AddConfigWay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;EmailOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
    &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;FeatureFlagOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
    &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddUiEditor&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UsePostgreSql&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;Configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetConnectionString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"DefaultConnection"&lt;/span&gt;&lt;span class="p"&gt;)!);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Mount the UI
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseConfigWay&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// mounts the editor at /config&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Open &lt;code&gt;/config&lt;/code&gt; in your browser and you'll see all your registered options, editable in real time.&lt;/p&gt;




&lt;h2&gt;
  
  
  A few things I'm particularly happy with
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;It plays nicely with validation.&lt;/strong&gt; If you already have &lt;code&gt;ValidateDataAnnotations()&lt;/code&gt; or a custom &lt;code&gt;IValidateOptions&amp;lt;T&amp;gt;&lt;/code&gt; wired up, ConfigWay will show validation errors in the UI and block saving until they're resolved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sensitive fields stay hidden.&lt;/strong&gt; Mark any &lt;code&gt;string&lt;/code&gt; with &lt;code&gt;[DataType(DataType.Password)]&lt;/code&gt; and ConfigWay treats it as a secret — rendered as &lt;code&gt;●●●●●&lt;/code&gt;, never returned from the API, requiring an explicit reset to remove.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reset to default.&lt;/strong&gt; Every field has a ↩ button that appears when the stored value differs from the underlying config layer (appsettings.json, environment variable). One click removes the override and the original value takes effect — no restart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Customizable labels.&lt;/strong&gt; Use &lt;code&gt;[Display(Name = "...", Description = "...")]&lt;/code&gt; to control how fields and sections appear in the UI. The &lt;code&gt;Description&lt;/code&gt; shows up as a tooltip icon next to the label.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture in a nutshell
&lt;/h2&gt;

&lt;p&gt;The library is split into focused packages:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Kododo.ConfigWay.Core&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Abstractions (&lt;code&gt;IStore&lt;/code&gt;, &lt;code&gt;Setting&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Kododo.ConfigWay&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;DI registration, in-memory store, hot-reload logic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Kododo.ConfigWay.UI&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Embedded React SPA served from the host app&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Kododo.ConfigWay.PostgreSQL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PostgreSQL persistence&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The UI is embedded as a resource inside the DLL — no CDN, no separate static file deployment. The PostgreSQL store creates a single &lt;code&gt;configway.settings&lt;/code&gt; table on first startup and handles everything from there.&lt;/p&gt;

&lt;p&gt;You can also plug in your own backend by implementing the &lt;code&gt;IStore&lt;/code&gt; interface from &lt;code&gt;Kododo.ConfigWay.Core&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddConfigWay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Store&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;MyRedisStore&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AppOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  This is just the beginning
&lt;/h2&gt;

&lt;p&gt;ConfigWay is my first open source project, and shipping it has been equal parts exciting and nerve-wracking. There's a lot I want to add — more storage providers, role-based field access, change history — but I wanted to get something real and useful out first.&lt;/p&gt;

&lt;p&gt;I'm also working on more projects in the &lt;strong&gt;Kododo&lt;/strong&gt; family. If this kind of thing is useful to you, I'd love it if you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;⭐ &lt;strong&gt;&lt;a href="https://github.com/kododo-dev/ConfigWay" rel="noopener noreferrer"&gt;Starred the repo&lt;/a&gt;&lt;/strong&gt; — it genuinely helps with visibility&lt;/li&gt;
&lt;li&gt;👀 &lt;strong&gt;&lt;a href="https://github.com/kododo-dev" rel="noopener noreferrer"&gt;Followed me on GitHub&lt;/a&gt;&lt;/strong&gt; — more libraries are coming&lt;/li&gt;
&lt;li&gt;💬 &lt;strong&gt;Opened an issue or a discussion&lt;/strong&gt; — feedback from real users shapes the roadmap&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thanks for reading. I hope ConfigWay saves you a few restarts. 🚀&lt;/p&gt;

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