<?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: GANESH KUMAR</title>
    <description>The latest articles on DEV Community by GANESH KUMAR (@rgk).</description>
    <link>https://dev.to/rgk</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3860603%2F179c3a8b-a63c-4b47-9405-115a77b790ae.jpg</url>
      <title>DEV Community: GANESH KUMAR</title>
      <link>https://dev.to/rgk</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rgk"/>
    <language>en</language>
    <item>
      <title>Send emails in ASP.NET Core with SendGrid and a background queue</title>
      <dc:creator>GANESH KUMAR</dc:creator>
      <pubDate>Wed, 14 Oct 2026 17:43:03 +0000</pubDate>
      <link>https://dev.to/rgk/send-emails-in-aspnet-core-with-sendgrid-and-a-background-queue-2klf</link>
      <guid>https://dev.to/rgk/send-emails-in-aspnet-core-with-sendgrid-and-a-background-queue-2klf</guid>
      <description>&lt;p&gt;Customers expect an email the moment they order, and another each time the order status changes. The first version most of us write sends the email right inside the checkout request. It works until the email provider is slow, or down, and suddenly checkout is slow, or fails, for a reason that has nothing to do with the order.&lt;/p&gt;

&lt;p&gt;Here's how to send order emails with SendGrid in ASP.NET Core, with simplified code. Order and status-update emails are part of &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;eCommerceMVCCore, my ASP.NET Core e-commerce platform&lt;/a&gt;, so this is a problem I've spent time on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule one: an email must never fail an order
&lt;/h2&gt;

&lt;p&gt;By the time you send the confirmation, the payment is captured and the order is saved. If the email throws, the customer must still see "Thank you for your order", not an error page. So the checkout only &lt;em&gt;queues&lt;/em&gt; the email; something else sends it.&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%2Fywb2knrmr2zx5hcz93st.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%2Fywb2knrmr2zx5hcz93st.png" alt="Checkout saves the order and queues the email; a background service sends it through SendGrid" width="799" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A small in-memory queue
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;System.Threading.Channels&lt;/code&gt; makes a simple, fast queue:&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;EmailMessage&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;To&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;Subject&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;Html&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;EmailQueue&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;Channel&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;EmailMessage&lt;/span&gt;&lt;span class="p"&gt;&amp;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;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="n"&gt;EmailMessage&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;1000&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;Wait&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;ValueTask&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;EmailMessage&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;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;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;message&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;IAsyncEnumerable&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;EmailMessage&lt;/span&gt;&lt;span class="p"&gt;&amp;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;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;channel&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;ReadAllAsync&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;Register it as a singleton, and checkout just does:&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="nf"&gt;EmailMessage&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="s"&gt;$"Order #&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;Id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; confirmed"&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;templates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RenderAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"OrderConfirmation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A background service that sends
&lt;/h2&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;EmailSenderService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EmailQueue&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IServiceScopeFactory&lt;/span&gt; &lt;span class="n"&gt;scopes&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;EmailSenderService&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;log&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;message&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;queue&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="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scopes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateScope&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;sender&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServiceProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IEmailSender&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;sender&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;message&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="n"&gt;log&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;"Couldn't send email to {To}: {Subject}"&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;To&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;Subject&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;try/catch&lt;/code&gt; is inside the loop on purpose. One failed email is logged, and the service keeps sending the rest.&lt;/p&gt;

&lt;p&gt;The scope matters too. A &lt;code&gt;BackgroundService&lt;/code&gt; is a singleton, so anything scoped, like a &lt;code&gt;DbContext&lt;/code&gt; or a settings service, has to come from a fresh scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sending through SendGrid
&lt;/h2&gt;

&lt;p&gt;The official &lt;code&gt;SendGrid&lt;/code&gt; NuGet package keeps it short:&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;SendGridEmailSender&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ISettingsService&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IEmailSender&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;SendAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EmailMessage&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;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;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;SendGridClient&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;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SendGridApiKey"&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;from&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;EmailAddress&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;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"FromEmail"&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;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"StoreName"&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;msg&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MailHelper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateSingleEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;EmailAddress&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;To&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;Subject&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="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Html&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;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;SendEmailAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsSuccessStatusCode&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;$"SendGrid returned &lt;/span&gt;&lt;span class="p"&gt;{(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&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="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReadAsStringAsync&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="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SendEmailAsync&lt;/code&gt; doesn't throw on a rejected email; it returns the status. Check &lt;code&gt;IsSuccessStatusCode&lt;/code&gt;, or failures will pass silently. The most common one is a 403 because the sender address isn't verified.&lt;/p&gt;

&lt;p&gt;I read the API key from database-backed settings, so the store owner can paste it into the admin dashboard instead of editing config files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Status-update emails
&lt;/h2&gt;

&lt;p&gt;When an admin moves an order from "Placed" to "Shipped", the same queue sends the update. Keep the send out of the status change itself: save the new status first, then enqueue the email.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;See it end to end:&lt;/strong&gt; with the &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;ASP.NET Core 10 e-commerce source code&lt;/a&gt;, customers get an email when they order and each time an admin updates the status.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The trade-off of an in-memory queue
&lt;/h2&gt;

&lt;p&gt;If the app restarts with emails still in the channel, they're lost. For a small store, the risk is low and the simplicity is worth it. If every email must be delivered, use the &lt;strong&gt;outbox pattern&lt;/strong&gt;: write the email to a database table in the same transaction as the order, and have the background service send unsent rows and mark them sent. It survives restarts, and you get a record of every email for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deliverability is half the job
&lt;/h2&gt;

&lt;p&gt;The code is the easy part. To keep order emails out of spam, verify your domain in SendGrid (SPF and DKIM), send from an address on that domain, and include a plain, readable layout. Test with a Gmail and an Outlook address before launch.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  How do I send email from ASP.NET Core?
&lt;/h3&gt;

&lt;p&gt;Use a provider's library, such as SendGrid's, or an SMTP client, wrap it behind an &lt;code&gt;IEmailSender&lt;/code&gt; interface, and send outside the request when you can. &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;eCommerceMVCCore&lt;/a&gt; uses SendGrid, with the API key set from the admin dashboard.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why do my SendGrid emails go to spam?
&lt;/h3&gt;

&lt;p&gt;Usually because the sending domain isn't authenticated. Set up SPF and DKIM through SendGrid's domain authentication, send from an address on that domain, and avoid image-only emails.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use an in-memory queue, Hangfire or an outbox?
&lt;/h3&gt;

&lt;p&gt;An in-memory &lt;code&gt;Channel&lt;/code&gt; is simplest but loses queued emails on restart. Hangfire adds persistence, retries and a dashboard. An outbox table guarantees the email is recorded with the order in one transaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the source code
&lt;/h2&gt;

&lt;p&gt;Order confirmation and status-update emails through SendGrid are part of &lt;strong&gt;eCommerceMVCCore&lt;/strong&gt;. &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;Get the ASP.NET Core 10 e-commerce source code&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Do you use an in-memory queue, an outbox table, or a job library like Hangfire for emails? Tell me in the comments.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>aspnetcore</category>
      <category>sendgrid</category>
      <category>csharp</category>
    </item>
    <item>
      <title>ASP.NET Core production checklist: going live with an e-commerce site</title>
      <dc:creator>GANESH KUMAR</dc:creator>
      <pubDate>Wed, 14 Oct 2026 17:43:02 +0000</pubDate>
      <link>https://dev.to/rgk/aspnet-core-production-checklist-going-live-with-an-e-commerce-site-56a3</link>
      <guid>https://dev.to/rgk/aspnet-core-production-checklist-going-live-with-an-e-commerce-site-56a3</guid>
      <description>&lt;p&gt;A project that runs perfectly on &lt;code&gt;localhost&lt;/code&gt; is set up for development: demo data, a known admin password, placeholder keys, sandbox payments. All of that is helpful locally and dangerous in production.&lt;/p&gt;

&lt;p&gt;This is the checklist I ship with &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;eCommerceMVCCore, my ASP.NET Core 10 store&lt;/a&gt;. Most of it applies to any ASP.NET Core app going live.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Change or remove the demo admin
&lt;/h2&gt;

&lt;p&gt;Seeded demo accounts have published passwords. They're in the README, the docs, sometimes the live demo page. Before launch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create your real admin account with a strong password.&lt;/li&gt;
&lt;li&gt;Delete the demo admin, or at least change its password and email.&lt;/li&gt;
&lt;li&gt;Turn off demo seeding in production, so a fresh database doesn't recreate it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Replace the JWT signing key
&lt;/h2&gt;

&lt;p&gt;If your API issues JWTs, the signing key in development config is a placeholder anyone can read. With it, anyone can mint a valid token for any user, including an admin.&lt;/p&gt;

&lt;p&gt;Generate a long random key and keep it out of source control:&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;# a 64-byte random key, base64-encoded&lt;/span&gt;
openssl rand &lt;span class="nt"&gt;-base64&lt;/span&gt; 64
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pass it in through an environment variable (&lt;code&gt;Jwt__Key&lt;/code&gt;), your host's secret settings, or a key vault. Never commit it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Point at the production database
&lt;/h2&gt;

&lt;p&gt;Set the provider and connection string through environment variables rather than editing &lt;code&gt;appsettings.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DatabaseProvider=SqlServer
ConnectionStrings__SqlServer=Server=...;Database=...;User Id=...;Password=...;Encrypt=True
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use a database user with only the permissions the app needs. And decide how migrations run in production: at startup is fine for a single instance, but a separate deployment step is safer once you scale out.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Set up email
&lt;/h2&gt;

&lt;p&gt;Order confirmations and status updates need a real email provider. For SendGrid, that means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Add your API key in the admin settings (or in configuration).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify your sending domain&lt;/strong&gt; with SPF and DKIM records. Without it, order emails land in spam.&lt;/li&gt;
&lt;li&gt;Set the admin notification address.&lt;/li&gt;
&lt;li&gt;Place one test order and check the email arrives, including on a mobile mail app.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Switch payments from Sandbox to Live
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Create &lt;strong&gt;live&lt;/strong&gt; PayPal app credentials. Sandbox ones won't work in production, and live ones should never be used for testing.&lt;/li&gt;
&lt;li&gt;Flip the Sandbox / Live switch.&lt;/li&gt;
&lt;li&gt;Register the webhook URL for the live app and confirm verification passes.&lt;/li&gt;
&lt;li&gt;Make one small real purchase and refund it from the dashboard. It's the only way to know the whole loop works.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Want payments ready to switch on?&lt;/strong&gt; The &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;ASP.NET Core e-commerce source code&lt;/a&gt; has a PayPal Sandbox / Live switch and one-click refunds in the dashboard.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  6. HTTPS and security headers
&lt;/h2&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsDevelopment&lt;/span&gt;&lt;span class="p"&gt;())&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;UseExceptionHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/Home/Error"&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;UseHsts&lt;/span&gt;&lt;span class="p"&gt;();&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;UseHttpsRedirection&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make sure &lt;code&gt;ASPNETCORE_ENVIRONMENT&lt;/code&gt; is &lt;code&gt;Production&lt;/code&gt;. In &lt;code&gt;Development&lt;/code&gt;, the detailed error page shows stack traces and configuration details to anyone who triggers an error.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Persist Data Protection keys
&lt;/h2&gt;

&lt;p&gt;ASP.NET Core encrypts login cookies and antiforgery tokens with Data Protection keys. In containers or on hosts that reset the file system, the keys vanish on restart and everyone is logged out, or worse, forms start failing. Store them somewhere durable:&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;AddDataProtection&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PersistKeysToFileSystem&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;DirectoryInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/keys"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;// a mounted volume&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetApplicationName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"my-store"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  8. Logging and backups
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Send logs somewhere you can search: Application Insights, Seq, or your host's log stream. You'll want them the first time a customer says "my order disappeared".&lt;/li&gt;
&lt;li&gt;Turn on automated database backups, and &lt;strong&gt;test a restore once&lt;/strong&gt;. A backup you've never restored is a hope, not a backup.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  9. Replace the demo content
&lt;/h2&gt;

&lt;p&gt;Demo products, sample prices, placeholder contact details, the default store name and logo, and the terms and privacy pages. It's easy to miss one, so click through every footer link before launch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Print it, tick it
&lt;/h2&gt;

&lt;p&gt;None of these steps takes long. Missing one can cost far more than the time it would have taken.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  How do I deploy an ASP.NET Core app to production?
&lt;/h3&gt;

&lt;p&gt;Publish a Release build, set &lt;code&gt;ASPNETCORE_ENVIRONMENT&lt;/code&gt; to &lt;code&gt;Production&lt;/code&gt;, pass secrets through environment variables, enable HTTPS, apply migrations and set up logging. Azure App Service, a Linux server behind Nginx, and Docker all work. &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;eCommerceMVCCore&lt;/a&gt; comes with documentation that walks through these steps.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why are users logged out after every deployment?
&lt;/h3&gt;

&lt;p&gt;The Data Protection keys aren't persisted, so each new instance creates new keys and old cookies can't be read. Store the keys on a mounted volume, in blob storage or in a database.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is it safe to run with ASPNETCORE_ENVIRONMENT=Development?
&lt;/h3&gt;

&lt;p&gt;No. The developer exception page shows stack traces and configuration details to anyone who triggers an error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the source code
&lt;/h2&gt;

&lt;p&gt;A go-live checklist with step-by-step instructions is part of the documentation for &lt;strong&gt;eCommerceMVCCore&lt;/strong&gt;. &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;Get the ASP.NET Core 10 e-commerce source code and docs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What's on your go-live list that isn't on mine? Add it in the comments.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>aspnetcore</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>ASP.NET Core MVC project structure: a practical layered architecture</title>
      <dc:creator>GANESH KUMAR</dc:creator>
      <pubDate>Wed, 14 Oct 2026 17:42:26 +0000</pubDate>
      <link>https://dev.to/rgk/aspnet-core-mvc-project-structure-a-practical-layered-architecture-43o8</link>
      <guid>https://dev.to/rgk/aspnet-core-mvc-project-structure-a-practical-layered-architecture-43o8</guid>
      <description>&lt;p&gt;Architecture debates online tend to swing between two extremes: everything in one project, or Clean Architecture with a dozen projects and an interface for every class. For a real e-commerce app I wanted something in between that a new developer could understand in ten minutes.&lt;/p&gt;

&lt;p&gt;My solution, &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;eCommerceMVCCore&lt;/a&gt;, has five projects. Here's what each does, which way the references point, and the trade-offs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five projects
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;eCommerce.Web        MVC controllers, views, admin dashboard, REST API
eCommerce.Services   business logic: pricing, orders, payments, email
eCommerce.Data       EF Core DbContext, migrations, configurations
eCommerce.Entities   domain classes: Product, Order, Category, PromoCode
eCommerce.Shared     helpers, constants, localization keys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;References only point "down":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Web  →  Services  →  Data  →  Entities
  ↘        ↓          ↓
           Shared  ←──┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Entities&lt;/code&gt; and &lt;code&gt;Shared&lt;/code&gt; depend on nothing in the solution. &lt;code&gt;Web&lt;/code&gt; never touches the &lt;code&gt;DbContext&lt;/code&gt; directly; it goes through &lt;code&gt;Services&lt;/code&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%2Fzmkbawmm04ck0jzfqc5k.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%2Fzmkbawmm04ck0jzfqc5k.png" alt="Five projects: Web references Services, Services references Data, Data references Entities, and all can use Shared"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why controllers don't use the DbContext
&lt;/h2&gt;

&lt;p&gt;This is the rule that pays off most. When the website's checkout and the mobile API's checkout both call &lt;code&gt;IOrderService.PlaceOrderAsync&lt;/code&gt;, the rules for prices, stock and promo codes exist exactly once:&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;CheckoutController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IOrderService&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Controller&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;HttpPost&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;IActionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;PlaceOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CheckoutForm&lt;/span&gt; &lt;span class="n"&gt;form&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;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;orders&lt;/span&gt;&lt;span class="p"&gt;.&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;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetUserId&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="nf"&gt;ToRequest&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Succeeded&lt;/span&gt;
            &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;RedirectToAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Confirmation"&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;id&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;OrderId&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
            &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;View&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Index"&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="nf"&gt;WithError&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;Error&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The controller translates HTTP into a service call and back. Nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I didn't add a repository layer
&lt;/h2&gt;

&lt;p&gt;EF Core's &lt;code&gt;DbContext&lt;/code&gt; is already a unit of work, and each &lt;code&gt;DbSet&lt;/code&gt; is already a repository. Wrapping them in &lt;code&gt;IProductRepository&lt;/code&gt; with &lt;code&gt;GetById&lt;/code&gt;, &lt;code&gt;Add&lt;/code&gt; and &lt;code&gt;Update&lt;/code&gt; mostly adds code, and it tends to hide the features that make EF Core good: projections, &lt;code&gt;Include&lt;/code&gt;, &lt;code&gt;ExecuteUpdateAsync&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Services use the &lt;code&gt;DbContext&lt;/code&gt; directly. When a query is reused in several places, it becomes an extension method on &lt;code&gt;IQueryable&amp;lt;Product&amp;gt;&lt;/code&gt;, for example &lt;code&gt;.Published()&lt;/code&gt; or &lt;code&gt;.InCategory(id)&lt;/code&gt;, not a repository class.&lt;/p&gt;

&lt;h2&gt;
  
  
  Entities stay plain
&lt;/h2&gt;

&lt;p&gt;Entities are plain classes with no EF attributes. Mapping lives in &lt;code&gt;Data&lt;/code&gt;, in &lt;code&gt;IEntityTypeConfiguration&amp;lt;T&amp;gt;&lt;/code&gt; classes:&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;ProductConfiguration&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IEntityTypeConfiguration&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Product&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;void&lt;/span&gt; &lt;span class="nf"&gt;Configure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EntityTypeBuilder&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Product&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="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="nf"&gt;Property&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;Price&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;HasPrecision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;HasIndex&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;Sku&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;IsUnique&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That keeps &lt;code&gt;Entities&lt;/code&gt; free of database concerns, which matters when one model has to work on SQL Server, PostgreSQL, MySQL and SQLite.&lt;/p&gt;

&lt;h2&gt;
  
  
  View models live in Web
&lt;/h2&gt;

&lt;p&gt;Views never get entities directly. A &lt;code&gt;ProductDetailsViewModel&lt;/code&gt; contains exactly what the page shows. That avoids accidentally rendering fields you didn't mean to, and stops model binding from writing to properties like &lt;code&gt;Price&lt;/code&gt; or &lt;code&gt;IsAdmin&lt;/code&gt; that a form should never set (the over-posting problem).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Browse the structure:&lt;/strong&gt; the &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;eCommerceMVCCore source code&lt;/a&gt; is 100% C# and Razor, organised exactly like this.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What I deliberately left out
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MediatR and CQRS.&lt;/strong&gt; Useful in big systems, but for this app it would double the number of classes without changing behaviour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A separate Application and Domain layer.&lt;/strong&gt; &lt;code&gt;Services&lt;/code&gt; plays both roles. If it grows too big, it can be split by feature later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interfaces for everything.&lt;/strong&gt; Services that need to be swapped or mocked get one, like &lt;code&gt;IPaymentGateway&lt;/code&gt; or &lt;code&gt;IEmailSender&lt;/code&gt;. Plain helpers don't.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Would I structure it differently next time?
&lt;/h2&gt;

&lt;p&gt;Maybe by feature inside each project (&lt;code&gt;Orders/&lt;/code&gt;, &lt;code&gt;Catalog/&lt;/code&gt;, &lt;code&gt;Payments/&lt;/code&gt;) rather than by type (&lt;code&gt;Controllers/&lt;/code&gt;, &lt;code&gt;Services/&lt;/code&gt;). The project boundaries, though, have held up well through a full .NET 10 upgrade.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Do I need the repository pattern with EF Core?
&lt;/h3&gt;

&lt;p&gt;Usually not. &lt;code&gt;DbContext&lt;/code&gt; is already a unit of work and each &lt;code&gt;DbSet&lt;/code&gt; is a repository. Add your own abstraction only where you really need to swap the data source or hide complex queries.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the difference between Clean Architecture and a layered architecture?
&lt;/h3&gt;

&lt;p&gt;Clean Architecture inverts the dependencies, so the core has no reference to infrastructure. A layered architecture lets references point down to the data layer. It's simpler, and fine for most data-driven web apps.&lt;/p&gt;

&lt;h3&gt;
  
  
  How should I organise an ASP.NET Core solution?
&lt;/h3&gt;

&lt;p&gt;Split by responsibility into a few projects, and organise by feature inside them. &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;eCommerceMVCCore&lt;/a&gt; uses five projects: Web, Services, Data, Entities and Shared.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the source code
&lt;/h2&gt;

&lt;p&gt;That clean five-project solution, 100% source code, is &lt;strong&gt;eCommerceMVCCore&lt;/strong&gt;. &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;Explore the ASP.NET Core 10 e-commerce platform&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;How do you structure your ASP.NET Core apps? Repository layer or not? Let me know in the comments.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>aspnetcore</category>
      <category>architecture</category>
      <category>csharp</category>
    </item>
    <item>
      <title>ASP.NET Core settings in the database: change config without a redeploy</title>
      <dc:creator>GANESH KUMAR</dc:creator>
      <pubDate>Wed, 14 Oct 2026 17:42:24 +0000</pubDate>
      <link>https://dev.to/rgk/aspnet-core-settings-in-the-database-change-config-without-a-redeploy-4mjn</link>
      <guid>https://dev.to/rgk/aspnet-core-settings-in-the-database-change-config-without-a-redeploy-4mjn</guid>
      <description>&lt;p&gt;&lt;code&gt;appsettings.json&lt;/code&gt; is great for things developers change: connection strings, logging levels. It's a poor fit for things a store owner changes: the contact email, whether PayPal is enabled, the shipping fee, the SendGrid key. Asking the owner to edit JSON and redeploy for each of those isn't realistic.&lt;/p&gt;

&lt;p&gt;So &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;eCommerceMVCCore, my ASP.NET Core store&lt;/a&gt;, keeps those settings in a database table, editable from &lt;strong&gt;Dashboard → Configurations&lt;/strong&gt;. Here's the design, with simplified code.&lt;/p&gt;

&lt;h2&gt;
  
  
  A simple key/value table
&lt;/h2&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;Configuration&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;Id&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;Key&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="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;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Description&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// shown in the admin UI&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;IsSecret&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each row carries a description, so the admin page can explain what "EnablePayPal" or "PayPalClientSecret" does right next to the field. A unique index on &lt;code&gt;Key&lt;/code&gt; prevents duplicates.&lt;/p&gt;

&lt;h2&gt;
  
  
  A cached settings service
&lt;/h2&gt;

&lt;p&gt;Reading the database on every request would be wasteful, so a small service loads all settings once and caches them:&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;SettingsService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AppDbContext&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;IMemoryCache&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ISettingsService&lt;/span&gt;
&lt;span class="p"&gt;{&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;CacheKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"site-settings"&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;IReadOnlyDictionary&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;string&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetAllAsync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetOrCreateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CacheKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;entry&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;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AbsoluteExpirationRelativeToNow&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;30&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configurations&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;ToDictionaryAsync&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;Key&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;Value&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;GetAsync&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;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;GetAllAsync&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;GetValueOrDefault&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;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;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetBoolAsync&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;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="kt"&gt;bool&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;GetAsync&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;out&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;b&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;b&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;void&lt;/span&gt; &lt;span class="nf"&gt;Invalidate&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;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CacheKey&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;When the admin saves a setting, call &lt;code&gt;Invalidate()&lt;/code&gt;. The next request reloads fresh values, and the change is live immediately.&lt;/p&gt;

&lt;p&gt;The 30-minute expiry is a safety net. If you run more than one instance, a memory cache is per instance, so other instances pick up the change when their cache expires. For instant changes across a farm, use a distributed cache or a change-notification message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Never send secrets back to the browser
&lt;/h2&gt;

&lt;p&gt;Some settings are secrets: the PayPal client secret, the SendGrid API key. The admin page should let you &lt;em&gt;set&lt;/em&gt; them, but never &lt;em&gt;show&lt;/em&gt; them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When rendering the form, show a masked placeholder such as &lt;code&gt;••••••••&lt;/code&gt; for secret rows, never the real value.&lt;/li&gt;
&lt;li&gt;When saving, treat an empty secret field as "keep the current value".
&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;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;input&lt;/span&gt; &lt;span class="k"&gt;in&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;Settings&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;row&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;Configurations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SingleAsync&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;Key&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;input&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;if&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;IsSecret&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&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;IsNullOrWhiteSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&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;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                       &lt;span class="c1"&gt;// left empty: keep the existing secret&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;Value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;input&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="nf"&gt;Trim&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;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Invalidate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That way a secret never travels to the browser, never shows in the page source, and isn't wiped by accidentally saving the form.&lt;/p&gt;

&lt;p&gt;Encrypting secret values at rest is a good next step. ASP.NET Core's Data Protection API (&lt;code&gt;IDataProtector.Protect&lt;/code&gt; and &lt;code&gt;Unprotect&lt;/code&gt;) does it in a few lines, as long as the key ring is persisted somewhere that survives restarts.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;See the settings page:&lt;/strong&gt; the &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;eCommerceMVCCore admin dashboard&lt;/a&gt; lists each setting with its description, and secrets stay masked.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Who can change what
&lt;/h2&gt;

&lt;p&gt;Settings change how the store takes money, so the page is restricted to the Administrator role, not Moderators:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Roles&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Administrator"&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;ConfigurationsController&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Controller&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;
  
  
  The same idea for translations
&lt;/h2&gt;

&lt;p&gt;UI translations work the same way: a table of key, culture and text, loaded into a cache and edited from the dashboard. Admins can fix a typo in the Arabic checkout page, or import a whole language pack, without touching code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What stays in appsettings.json
&lt;/h2&gt;

&lt;p&gt;Anything needed &lt;em&gt;before&lt;/em&gt; the database is reachable stays in configuration: the connection string, the database provider, the JWT signing key and logging. Database settings are for the business; file settings are for the infrastructure.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Should settings live in appsettings.json or the database?
&lt;/h3&gt;

&lt;p&gt;Infrastructure settings that are needed before the database is reachable, such as connection strings, signing keys and logging, belong in configuration. Business settings that the owner changes belong in the database.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I change ASP.NET Core settings without restarting?
&lt;/h3&gt;

&lt;p&gt;For files, &lt;code&gt;reloadOnChange&lt;/code&gt; plus &lt;code&gt;IOptionsMonitor&lt;/code&gt; picks up edits. For database settings, cache them and invalidate the cache when they're saved, as shown above.&lt;/p&gt;

&lt;h3&gt;
  
  
  How should I store API keys in ASP.NET Core?
&lt;/h3&gt;

&lt;p&gt;User secrets in development; environment variables or a key vault in production. If keys live in the database, encrypt them with the Data Protection API and never send them back to the browser. In &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;eCommerceMVCCore&lt;/a&gt;, the PayPal and SendGrid secrets are masked in the dashboard after saving.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the source code
&lt;/h2&gt;

&lt;p&gt;Database-backed settings and translations, editable from the admin dashboard with secrets masked, are part of &lt;strong&gt;eCommerceMVCCore&lt;/strong&gt;. &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;Get the ASP.NET Core 10 e-commerce source code&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Where do you draw the line between file configuration and database settings? I'd like to hear your rule.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>aspnetcore</category>
      <category>csharp</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Prevent overselling in EF Core: concurrency-safe stock updates</title>
      <dc:creator>GANESH KUMAR</dc:creator>
      <pubDate>Wed, 14 Oct 2026 17:41:52 +0000</pubDate>
      <link>https://dev.to/rgk/prevent-overselling-in-ef-core-concurrency-safe-stock-updates-47ah</link>
      <guid>https://dev.to/rgk/prevent-overselling-in-ef-core-concurrency-safe-stock-updates-47ah</guid>
      <description>&lt;p&gt;Stock tracking looks like the easiest part of an online store. Every product has a quantity, and each order subtracts from it. Then a sale goes live, two people buy the last item in the same second, and you've sold something you don't have.&lt;/p&gt;

&lt;p&gt;Here's why the obvious code fails, and the small change that fixes it on SQL Server, PostgreSQL, MySQL and SQLite alike. Stock tracking is a core part of &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;eCommerceMVCCore, my ASP.NET Core e-commerce platform&lt;/a&gt;, so it's a problem I've had to think through. The code is simplified.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious code
&lt;/h2&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;product&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;Products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FindAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;productId&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;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StockQuantity&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Not enough stock."&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;StockQuantity&lt;/span&gt; &lt;span class="p"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;quantity&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it for two requests at once with one item in stock:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Request A reads &lt;code&gt;StockQuantity = 1&lt;/code&gt;. The check passes.&lt;/li&gt;
&lt;li&gt;Request B reads &lt;code&gt;StockQuantity = 1&lt;/code&gt;. The check passes.&lt;/li&gt;
&lt;li&gt;A saves 0.&lt;/li&gt;
&lt;li&gt;B saves 0.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two orders, one item, and the stock says 0 as if nothing went wrong. It's the classic lost update, and nothing in that code will warn you.&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%2F9zfbohltuws2llh42td2.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%2F9zfbohltuws2llh42td2.png" alt="A read-then-write stock check sells one item twice; a single conditional UPDATE lets only one order through" width="800" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1: a conditional update
&lt;/h2&gt;

&lt;p&gt;Let the database check and subtract in one statement:&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;updated&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;Products&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;productId&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&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;StockQuantity&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExecuteUpdateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetProperty&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;StockQuantity&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;StockQuantity&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;quantity&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;updated&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="nf"&gt;Fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Sorry, that item just sold out."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That becomes a single &lt;code&gt;UPDATE ... SET StockQuantity = StockQuantity - @qty WHERE Id = @id AND StockQuantity &amp;gt;= @qty&lt;/code&gt;. The database locks the row while it runs, so only one of the two requests can match the &lt;code&gt;WHERE&lt;/code&gt; clause. The other updates zero rows and gets a friendly message instead of an order.&lt;/p&gt;

&lt;p&gt;This is my default. It's short, fast, needs no schema change, and behaves the same on every provider.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: optimistic concurrency
&lt;/h2&gt;

&lt;p&gt;If you'd rather keep the load-modify-save style, add a concurrency token. EF Core then includes the original value in the &lt;code&gt;UPDATE&lt;/code&gt; and throws if the row changed in the meantime:&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;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;Product&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Property&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;StockQuantity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsConcurrencyToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;try&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;StockQuantity&lt;/span&gt; &lt;span class="p"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;quantity&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="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;DbUpdateConcurrencyException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// someone else changed the stock: reload and check again, or tell the shopper&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On SQL Server you could use a &lt;code&gt;rowversion&lt;/code&gt; column instead, but that's SQL Server-specific. Marking the stock column itself as the token works on all four databases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multiple items in one order
&lt;/h2&gt;

&lt;p&gt;A cart with three products needs all three decrements to succeed, or none. Wrap them, and saving the order, in one transaction:&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;tx&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;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="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;line&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Lines&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;ok&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;Products&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProductId&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&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;StockQuantity&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quantity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExecuteUpdateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetProperty&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;StockQuantity&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;StockQuantity&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quantity&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;ok&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RollbackAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Fail&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;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProductName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; doesn't have enough stock left."&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="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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CommitAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If anything fails, the rollback puts the earlier lines' stock back.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Building a store?&lt;/strong&gt; &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;eCommerceMVCCore&lt;/a&gt; gives you stock tracking, a low-stock filter and a server-checked checkout in ASP.NET Core 10.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Where payments fit in
&lt;/h2&gt;

&lt;p&gt;With online payments there's a second race: the stock can run out between "Pay" and the payment being captured. My order is: re-check stock, capture the payment, then save the order and decrement stock in one transaction. If that final step fails, refund automatically. I covered that flow in my article on taking PayPal payments safely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Show low stock before it's zero
&lt;/h2&gt;

&lt;p&gt;The other half of stock is the admin side. A "low stock" filter on the product list, for example &lt;code&gt;StockQuantity &amp;lt;= 5&lt;/code&gt;, tells the store owner what to reorder before customers see "sold out".&lt;/p&gt;

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

&lt;h3&gt;
  
  
  What is a lost update?
&lt;/h3&gt;

&lt;p&gt;Two transactions read the same value, both change it, and the second save overwrites the first. Neither fails, so the bug stays silent until the numbers don't add up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use ExecuteUpdateAsync or a concurrency token?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ExecuteUpdateAsync&lt;/code&gt; (EF Core 7 and later) checks and changes stock in one statement with no retry logic. A concurrency token keeps the load-modify-save style but needs you to catch &lt;code&gt;DbUpdateConcurrencyException&lt;/code&gt; and retry. Both work on every major provider.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I reserve stock when an item is added to the cart?
&lt;/h3&gt;

&lt;p&gt;Usually not for a small store: reserving at checkout is simpler. Cart reservations need expiry times and clean-up, or abandoned carts lock up stock. &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;eCommerceMVCCore&lt;/a&gt; shows stock on every product page and lets admins filter for low stock.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the source code
&lt;/h2&gt;

&lt;p&gt;Stock tracking with a low-stock filter in the admin dashboard is part of &lt;strong&gt;eCommerceMVCCore&lt;/strong&gt;. &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;Get the ASP.NET Core 10 e-commerce source code&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Have you been bitten by a lost update in production? Tell me how you found it.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>efcore</category>
      <category>database</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>Docker Compose profiles for ASP.NET Core: one file, four databases</title>
      <dc:creator>GANESH KUMAR</dc:creator>
      <pubDate>Wed, 14 Oct 2026 17:41:50 +0000</pubDate>
      <link>https://dev.to/rgk/docker-compose-profiles-for-aspnet-core-one-file-four-databases-199l</link>
      <guid>https://dev.to/rgk/docker-compose-profiles-for-aspnet-core-one-file-four-databases-199l</guid>
      <description>&lt;p&gt;My ASP.NET Core project, &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;eCommerceMVCCore&lt;/a&gt;, supports four databases. I wanted anyone, buyer or contributor, to try any of them with one command and no local database install.&lt;/p&gt;

&lt;p&gt;The answer was Docker Compose &lt;strong&gt;profiles&lt;/strong&gt;. One &lt;code&gt;docker-compose.yml&lt;/code&gt;, one profile per database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;--profile&lt;/span&gt; sqlite up &lt;span class="nt"&gt;--build&lt;/span&gt;
docker compose &lt;span class="nt"&gt;--profile&lt;/span&gt; postgres up &lt;span class="nt"&gt;--build&lt;/span&gt;
docker compose &lt;span class="nt"&gt;--profile&lt;/span&gt; mysql up &lt;span class="nt"&gt;--build&lt;/span&gt;
docker compose &lt;span class="nt"&gt;--profile&lt;/span&gt; sqlserver up &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's how it's put together. The YAML below is simplified.&lt;/p&gt;

&lt;h2&gt;
  
  
  Profiles in one sentence
&lt;/h2&gt;

&lt;p&gt;A service with &lt;code&gt;profiles: [postgres]&lt;/code&gt; only starts when you pass &lt;code&gt;--profile postgres&lt;/code&gt;. Services without a profile always start. That lets one file describe four different setups without them all starting at once.&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%2Fhcsba2kgzs02v22uyrfo.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%2Fhcsba2kgzs02v22uyrfo.png" alt="Four Compose profiles, each starting its database with a healthcheck and an app service that waits for it" width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The database services
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:17&lt;/span&gt;
    &lt;span class="na"&gt;profiles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_PASSWORD:-Dev_Passw0rd!}&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;postgres&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;shop"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;

  &lt;span class="na"&gt;mysql&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mysql:8.4&lt;/span&gt;
    &lt;span class="na"&gt;profiles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;mysql&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_ROOT_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_PASSWORD:-Dev_Passw0rd!}&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_DATABASE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mysqladmin"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ping"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-h"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;localhost"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;

  &lt;span class="na"&gt;sqlserver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mcr.microsoft.com/mssql/server:2022-latest&lt;/span&gt;
    &lt;span class="na"&gt;profiles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;sqlserver&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;ACCEPT_EULA&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Y"&lt;/span&gt;
      &lt;span class="na"&gt;MSSQL_SA_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_PASSWORD:-Dev_Passw0rd!}&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/opt/mssql-tools18/bin/sqlcmd&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-C&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-S&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;localhost&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;sa&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-P&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;$$MSSQL_SA_PASSWORD&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-Q&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'SELECT&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1'"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details are worth calling out. SQL Server's newer images ship &lt;code&gt;sqlcmd&lt;/code&gt; under &lt;code&gt;mssql-tools18&lt;/code&gt;, and it needs &lt;code&gt;-C&lt;/code&gt; to trust the self-signed certificate. And &lt;code&gt;$$&lt;/code&gt; escapes the dollar sign, so the variable is read inside the container, not by Compose.&lt;/p&gt;

&lt;h2&gt;
  
  
  One app service per profile
&lt;/h2&gt;

&lt;p&gt;The app needs to wait for a &lt;em&gt;different&lt;/em&gt; database in each profile, so I define one app service per profile. A YAML anchor keeps the shared parts in one place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;x-app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nl"&gt;&amp;amp;app&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080:8080"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;app-postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;*app&lt;/span&gt;
    &lt;span class="na"&gt;profiles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DatabaseProvider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PostgreSQL&lt;/span&gt;
      &lt;span class="na"&gt;ConnectionStrings__PostgreSQL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Host=postgres;Database=shop;Username=postgres;Password=${DB_PASSWORD:-Dev_Passw0rd!}&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;

  &lt;span class="na"&gt;app-sqlite&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;*app&lt;/span&gt;
    &lt;span class="na"&gt;profiles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;sqlite&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DatabaseProvider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Sqlite&lt;/span&gt;
      &lt;span class="na"&gt;ConnectionStrings__Sqlite&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Data Source=/data/shop.db&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sqlite-data:/data"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;sqlite-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ConnectionStrings__PostgreSQL&lt;/code&gt; uses the double underscore that ASP.NET Core maps to &lt;code&gt;ConnectionStrings:PostgreSQL&lt;/code&gt;. No config file changes are needed per environment.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Try the profiles:&lt;/strong&gt; &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;download the ASP.NET Core store with a Docker Compose profile for every database&lt;/a&gt; and run &lt;code&gt;docker compose --profile sqlite up --build&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why the healthcheck matters
&lt;/h2&gt;

&lt;p&gt;Without &lt;code&gt;condition: service_healthy&lt;/code&gt;, &lt;code&gt;depends_on&lt;/code&gt; only waits for the database &lt;em&gt;container&lt;/em&gt; to start, not for the database to accept connections. SQL Server in particular takes several seconds to get ready. The app would start, try to run migrations, fail and exit. With healthchecks, the app starts only when the database answers.&lt;/p&gt;

&lt;p&gt;I still keep EF Core's retry logic on for the providers that support it, such as &lt;code&gt;EnableRetryOnFailure()&lt;/code&gt; for SQL Server. That covers short network blips in production too, not just the startup race.&lt;/p&gt;

&lt;h2&gt;
  
  
  SQLite needs a volume
&lt;/h2&gt;

&lt;p&gt;SQLite is just a file. If it lives inside the container, it disappears on every rebuild. A named volume keeps the demo data between runs. It's the profile I recommend for a first try: no database container, fastest start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrations and seeding on startup
&lt;/h2&gt;

&lt;p&gt;On first start, the app applies the right migrations for the chosen provider and seeds demo data. So &lt;code&gt;docker compose --profile sqlite up --build&lt;/code&gt; ends with a working store at &lt;code&gt;http://localhost:8080&lt;/code&gt;, products and all. How the seeding works is a separate article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Development defaults, not production settings
&lt;/h2&gt;

&lt;p&gt;The default passwords above are for local use only. For anything public, pass real secrets through an &lt;code&gt;.env&lt;/code&gt; file that isn't committed, or through your host's secret store, and don't expose database ports.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  What are Docker Compose profiles?
&lt;/h3&gt;

&lt;p&gt;Optional groups of services. A service with a &lt;code&gt;profiles&lt;/code&gt; list only starts when one of its profiles is active, through &lt;code&gt;--profile&lt;/code&gt; or the &lt;code&gt;COMPOSE_PROFILES&lt;/code&gt; variable. Services without profiles always start.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I make an ASP.NET Core container wait for its database?
&lt;/h3&gt;

&lt;p&gt;Give the database service a &lt;code&gt;healthcheck&lt;/code&gt;, and use &lt;code&gt;depends_on&lt;/code&gt; with &lt;code&gt;condition: service_healthy&lt;/code&gt; on the app. Keep EF Core's retry-on-failure enabled as well, for short outages after startup.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I pass a connection string to ASP.NET Core in Docker?
&lt;/h3&gt;

&lt;p&gt;Use an environment variable with a double underscore, such as &lt;code&gt;ConnectionStrings__PostgreSQL&lt;/code&gt;. ASP.NET Core maps it to &lt;code&gt;ConnectionStrings:PostgreSQL&lt;/code&gt;, which is how &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;eCommerceMVCCore&lt;/a&gt; configures each database profile.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the source code
&lt;/h2&gt;

&lt;p&gt;This Compose setup ships with &lt;strong&gt;eCommerceMVCCore&lt;/strong&gt;, with a profile for every supported database. &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;Get the ASP.NET Core 10 e-commerce source code with Docker support&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Have you used Compose profiles, or do you keep a separate compose file per setup? Let me know in the comments.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>docker</category>
      <category>devops</category>
      <category>database</category>
    </item>
    <item>
      <title>ASP.NET Core JWT and cookie authentication in one project</title>
      <dc:creator>GANESH KUMAR</dc:creator>
      <pubDate>Wed, 14 Oct 2026 17:41:16 +0000</pubDate>
      <link>https://dev.to/rgk/aspnet-core-jwt-and-cookie-authentication-in-one-project-3d67</link>
      <guid>https://dev.to/rgk/aspnet-core-jwt-and-cookie-authentication-in-one-project-3d67</guid>
      <description>&lt;p&gt;My store has two kinds of clients. Shoppers use the website, where a normal Identity login cookie is the right choice. Mobile apps use a REST API, where a cookie is awkward and a bearer token is the norm.&lt;/p&gt;

&lt;p&gt;I didn't want two backends, two user tables or two copies of the business logic. Both live in one ASP.NET Core project and share the same services. This is how &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;eCommerceMVCCore, my ASP.NET Core e-commerce platform&lt;/a&gt;, does it, with simplified code.&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%2Fxyn9ow7b5qh38oexqkk7.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%2Fxyn9ow7b5qh38oexqkk7.png" alt="Website requests use the Identity cookie and redirect to login; API requests use JWT bearer and get a 401" width="799" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Keep Identity cookies as the default
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;AddIdentity&lt;/code&gt; (or &lt;code&gt;AddDefaultIdentity&lt;/code&gt;) already registers cookie authentication and makes it the default scheme. Leave that alone. The website keeps working exactly as before.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Add JWT bearer as a second scheme
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;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;AddAuthentication&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddJwtBearer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JwtBearerDefaults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AuthenticationScheme&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="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="s"&gt;"Jwt:Key"&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;TokenValidationParameters&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;TokenValidationParameters&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;ValidateIssuer&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;ValidIssuer&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="s"&gt;"Jwt:Issuer"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;ValidateAudience&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;ValidAudience&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="s"&gt;"Jwt:Audience"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;ValidateIssuerSigningKey&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;IssuerSigningKey&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;SymmetricSecurityKey&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;GetBytes&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;ValidateLifetime&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;ClockSkew&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="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;Calling &lt;code&gt;AddAuthentication()&lt;/code&gt; with no arguments adds the scheme without changing the default.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Tell the API controllers to use JWT
&lt;/h2&gt;

&lt;p&gt;Put the scheme on a base controller, so every API controller gets it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ApiController&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"api/[controller]"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AuthenticationSchemes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JwtBearerDefaults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AuthenticationScheme&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;abstract&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ApiControllerBase&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ControllerBase&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 also fixes the most common bug in mixed apps. With the cookie scheme, an unauthenticated request gets a &lt;strong&gt;302 redirect to the login page&lt;/strong&gt;. A mobile app expects a &lt;strong&gt;401&lt;/strong&gt;, and gets an HTML login page instead. With the JWT scheme on the API controllers, the challenge returns a clean 401.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Issue tokens with the same Identity users
&lt;/h2&gt;

&lt;p&gt;The token endpoint checks the password with Identity's &lt;code&gt;SignInManager&lt;/code&gt;, then builds a token with the user's roles:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;AllowAnonymous&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;HttpPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/Token"&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;IActionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LoginRequest&lt;/span&gt; &lt;span class="n"&gt;request&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;user&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;userManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FindByNameAsync&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;UserName&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;user&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="nf"&gt;Unauthorized&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;signInManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CheckPasswordSignInAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lockoutOnFailure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Succeeded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Unauthorized&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;claims&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;Claim&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JwtRegisteredClaimNames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ClaimTypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserName&lt;/span&gt;&lt;span class="p"&gt;!)&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;role&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;userManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetRolesAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;claims&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;Claim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ClaimTypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;role&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;creds&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;SigningCredentials&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;SymmetricSecurityKey&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;GetBytes&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;"Jwt:Key"&lt;/span&gt;&lt;span class="p"&gt;]!)),&lt;/span&gt;
        &lt;span class="n"&gt;SecurityAlgorithms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HmacSha256&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;JwtSecurityToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;issuer&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;"Jwt:Issuer"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;audience&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;"Jwt:Audience"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;expires&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddHours&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;signingCredentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;JwtSecurityTokenHandler&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;WriteToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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 &lt;code&gt;lockoutOnFailure: true&lt;/code&gt;. A token endpoint is a password-guessing target, so it should respect the same lockout rules as the login page.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;See the API:&lt;/strong&gt; the &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;ASP.NET Core REST API in eCommerceMVCCore&lt;/a&gt; covers catalog, cart, orders, PayPal and account endpoints, with a Postman collection.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  5. Share the services, not the controllers
&lt;/h2&gt;

&lt;p&gt;The MVC &lt;code&gt;CartController&lt;/code&gt; and the API &lt;code&gt;CartController&lt;/code&gt; are thin. Both call the same &lt;code&gt;ICartService&lt;/code&gt;, &lt;code&gt;IOrderService&lt;/code&gt; and pricing code. The rules for prices, stock and promo codes live in one place, so the website and the app can't disagree about what an order costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things to get right before production
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The signing key.&lt;/strong&gt; Use a long random secret, kept out of &lt;code&gt;appsettings.json&lt;/code&gt; in source control. Environment variables or a secret store are both fine. A placeholder key in production means anyone can mint admin tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token lifetime.&lt;/strong&gt; Short-lived tokens limit the damage of a leaked one. If you need long sessions in the app, add refresh tokens rather than making access tokens last for weeks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CORS.&lt;/strong&gt; A native mobile app doesn't need CORS, but a browser-based client on another domain does. Allow only the origins you actually use.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Testing it
&lt;/h2&gt;

&lt;p&gt;I ship a Postman collection with the project. The first request calls &lt;code&gt;POST /api/Token&lt;/code&gt; and saves the token to a variable, and every other request sends it as &lt;code&gt;Authorization: Bearer ...&lt;/code&gt;. It's the quickest way to check that the API returns 401s, not login pages.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Can ASP.NET Core use cookie and JWT authentication at the same time?
&lt;/h3&gt;

&lt;p&gt;Yes. Register both schemes, keep the cookie as the default, and name the JWT scheme on your API controllers. For more complex rules, a policy scheme with &lt;code&gt;ForwardDefaultSelector&lt;/code&gt; can choose the scheme per request.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my ASP.NET Core API return a login page instead of 401?
&lt;/h3&gt;

&lt;p&gt;Because the cookie scheme's challenge is a redirect to the login page. Authenticate API controllers with the JWT bearer scheme, and its challenge returns a plain 401.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where should I store the JWT signing key?
&lt;/h3&gt;

&lt;p&gt;In environment variables, a secret store or a key vault, never in source control. Use a long random key: at least 256 bits for HS256. The go-live checklist in &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;eCommerceMVCCore&lt;/a&gt; includes replacing the placeholder key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the source code
&lt;/h2&gt;

&lt;p&gt;This setup, with catalog, cart, order, PayPal and account endpoints, is part of &lt;strong&gt;eCommerceMVCCore&lt;/strong&gt;. &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;Get the ASP.NET Core 10 source code with the JWT REST API&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Do you keep your API in the same project as the website, or split it out? Tell me in the comments.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>aspnetcore</category>
      <category>jwt</category>
      <category>security</category>
    </item>
    <item>
      <title>ASP.NET Core localization: language URLs (/en, /ar) and RTL layouts</title>
      <dc:creator>GANESH KUMAR</dc:creator>
      <pubDate>Wed, 14 Oct 2026 17:41:15 +0000</pubDate>
      <link>https://dev.to/rgk/aspnet-core-localization-language-urls-en-ar-and-rtl-layouts-4e1c</link>
      <guid>https://dev.to/rgk/aspnet-core-localization-language-urls-en-ar-and-rtl-layouts-4e1c</guid>
      <description>&lt;p&gt;My ASP.NET Core e-commerce project, &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;eCommerceMVCCore&lt;/a&gt;, sells in English, Arabic, French, Spanish and Turkish. Early on, the language lived in a cookie. It worked for shoppers, but search engines saw a single URL with five different languages behind it, and a shared link opened in whatever language the recipient had last used.&lt;/p&gt;

&lt;p&gt;Putting the language in the URL fixed both problems: &lt;code&gt;/en/products&lt;/code&gt;, &lt;code&gt;/ar/products&lt;/code&gt;, &lt;code&gt;/fr/products&lt;/code&gt;. Here's the setup, with simplified code.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Add the culture to the route
&lt;/h2&gt;

&lt;p&gt;Make the culture the first route segment, with a constraint so random words don't match:&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapControllerRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"localized"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"{culture:regex(^(en|ar|fr|es|tr)$)}/{controller=Home}/{action=Index}/{id?}"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// "/" on its own redirects to the default language&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;MapGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&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;Redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/en"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Read the culture from the route
&lt;/h2&gt;

&lt;p&gt;ASP.NET Core ships a &lt;code&gt;RouteDataRequestCultureProvider&lt;/code&gt;. Put it first, so the URL wins over cookies and the browser's &lt;code&gt;Accept-Language&lt;/code&gt; header:&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;cultures&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"ar"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"fr"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"es"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"tr"&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;Configure&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;RequestLocalizationOptions&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="nf"&gt;SetDefaultCulture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
           &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddSupportedCultures&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cultures&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
           &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddSupportedUICultures&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cultures&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;RequestCultureProviders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&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;RouteDataRequestCultureProvider&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;RouteDataStringKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"culture"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;UIRouteDataStringKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"culture"&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// after UseRouting, so route values are available&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;UseRequestLocalization&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One catch: the culture provider needs route values, so &lt;code&gt;UseRequestLocalization&lt;/code&gt; has to run after &lt;code&gt;UseRouting&lt;/code&gt;. If the culture is always &lt;code&gt;en&lt;/code&gt; no matter the URL, check the middleware order first.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Keep the culture in every generated link
&lt;/h2&gt;

&lt;p&gt;Once the culture is a route value, the tag helpers carry it along automatically for links within the same request. &lt;code&gt;asp-controller="Products" asp-action="Details"&lt;/code&gt; on an &lt;code&gt;/ar/...&lt;/code&gt; page produces an &lt;code&gt;/ar/...&lt;/code&gt; link. For a language switcher, set it explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;asp-route-culture=&lt;/span&gt;&lt;span class="s"&gt;"fr"&lt;/span&gt; &lt;span class="na"&gt;asp-controller=&lt;/span&gt;&lt;span class="s"&gt;"Home"&lt;/span&gt; &lt;span class="na"&gt;asp-action=&lt;/span&gt;&lt;span class="s"&gt;"Index"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Français&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Flip the layout for right-to-left languages
&lt;/h2&gt;

&lt;p&gt;.NET already knows which cultures are right-to-left. Use it in the layout instead of hard-coding "ar":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@using System.Globalization
@{
    var ui = CultureInfo.CurrentUICulture;
    var dir = ui.TextInfo.IsRightToLeft ? "rtl" : "ltr";
}
&amp;lt;html lang="@ui.TwoLetterISOLanguageName" dir="@dir"&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then load an RTL build of your CSS framework when &lt;code&gt;dir&lt;/code&gt; is &lt;code&gt;rtl&lt;/code&gt;. Bootstrap has an RTL stylesheet, and the bigger job is your own CSS. Anything written as &lt;code&gt;margin-left&lt;/code&gt; or &lt;code&gt;float: right&lt;/code&gt; needs a second look. CSS logical properties such as &lt;code&gt;margin-inline-start&lt;/code&gt; flip on their own and save a lot of overrides.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;See it live:&lt;/strong&gt; the &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;multilingual ASP.NET Core store&lt;/a&gt; switches between &lt;code&gt;/en&lt;/code&gt; and &lt;code&gt;/ar&lt;/code&gt; with a full right-to-left layout.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  5. Tell search engines about the other languages
&lt;/h2&gt;

&lt;p&gt;Add &lt;code&gt;hreflang&lt;/code&gt; links to each page so Google shows the right language in each country:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@foreach (var c in new[] { "en", "ar", "fr", "es", "tr" })
{
    &amp;lt;link rel="alternate" hreflang="@c"
          href="@Url.Action(null, null, new { culture = c }, Context.Request.Scheme)" /&amp;gt;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Things that tripped me up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Numbers and prices.&lt;/strong&gt; &lt;code&gt;ToString("C")&lt;/code&gt; uses the current culture, so a price can suddenly show with a comma as the decimal separator. Decide whether currency formatting follows the language or the store's currency, and format explicitly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model binding.&lt;/strong&gt; Decimal inputs are parsed with the current culture too. A French user typing &lt;code&gt;12,50&lt;/code&gt; and an English user typing &lt;code&gt;12.50&lt;/code&gt; both need to work, so test your forms in each language.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Icons with direction.&lt;/strong&gt; Arrows and "next" chevrons should mirror in RTL. A simple &lt;code&gt;[dir=rtl] .icon-arrow { transform: scaleX(-1); }&lt;/code&gt; handles most of them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Translations from the database
&lt;/h2&gt;

&lt;p&gt;I keep the UI strings in the database, not in &lt;code&gt;.resx&lt;/code&gt; files, so translations can be edited and imported from the admin dashboard without a redeploy. That's a separate article, but the routing above works the same either way.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  How do I add multiple languages to an ASP.NET Core MVC app?
&lt;/h3&gt;

&lt;p&gt;Register localization, configure &lt;code&gt;RequestLocalizationOptions&lt;/code&gt; with your supported cultures, choose how the culture is picked (here, from the route), and serve translated strings from &lt;code&gt;.resx&lt;/code&gt; files or a database.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a URL prefix, a subdomain or a cookie better for SEO?
&lt;/h3&gt;

&lt;p&gt;A URL prefix or a subdomain. Search engines need a separate URL for each language; a cookie hides the other languages from crawlers. A prefix such as &lt;code&gt;/fr&lt;/code&gt; is the simplest to run on one domain.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Bootstrap support right-to-left layouts?
&lt;/h3&gt;

&lt;p&gt;Yes. Bootstrap 5 ships &lt;code&gt;bootstrap.rtl.min.css&lt;/code&gt;, and Bootstrap 4 has separate RTL builds. &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;eCommerceMVCCore&lt;/a&gt; uses Bootstrap with an RTL stylesheet for Arabic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the source code
&lt;/h2&gt;

&lt;p&gt;All of this, including five ready language packs and full RTL support, is part of &lt;strong&gt;eCommerceMVCCore&lt;/strong&gt;. &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;Get the multilingual ASP.NET Core 10 e-commerce source code&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Do you put the language in the URL, a subdomain or a cookie? I'd like to hear what worked for you.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>aspnetcore</category>
      <category>localization</category>
      <category>csharp</category>
    </item>
    <item>
      <title>PayPal integration in ASP.NET Core: a safe checkout flow with Orders v2</title>
      <dc:creator>GANESH KUMAR</dc:creator>
      <pubDate>Wed, 14 Oct 2026 17:40:42 +0000</pubDate>
      <link>https://dev.to/rgk/paypal-integration-in-aspnet-core-a-safe-checkout-flow-with-orders-v2-45kc</link>
      <guid>https://dev.to/rgk/paypal-integration-in-aspnet-core-a-safe-checkout-flow-with-orders-v2-45kc</guid>
      <description>&lt;p&gt;Adding a PayPal button takes an afternoon. Making sure you never lose money, never ship unpaid orders and never charge for an order you didn't save takes more care.&lt;/p&gt;

&lt;p&gt;This is the PayPal checkout flow I use in &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;eCommerceMVCCore, my ASP.NET Core 10 e-commerce platform&lt;/a&gt;, with simplified C# code for each step. It works for PayPal accounts and for debit and credit cards through PayPal.&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%2Fzzwkpitg4emdcdpcrqjw.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%2Fzzwkpitg4emdcdpcrqjw.png" alt="The safe PayPal flow: recalculate, create, capture, save, and refund automatically if saving fails" width="799" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Never trust the browser's total
&lt;/h2&gt;

&lt;p&gt;The browser can send any price it likes. Before creating a PayPal order, rebuild the cart on the server from product IDs and quantities, and read prices, discounts and promo codes from your database.&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;lines&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;cartService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetLinesAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// product IDs + quantities&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;pricingService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CalculateTotalAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// prices from the database&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;paypalOrder&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;payPal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateOrderAsync&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;currency&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Ok&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;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;paypalOrder&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Capture first
&lt;/h2&gt;

&lt;p&gt;After the shopper approves the payment, capture it on the server with PayPal's Orders API (&lt;code&gt;POST /v2/checkout/orders/{id}/capture&lt;/code&gt;). Only a successful capture means you were paid.&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;capture&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;payPal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CaptureOrderAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderId&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;capture&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;"COMPLETED"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&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;"Payment was not completed."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Save the order only after a successful capture
&lt;/h2&gt;

&lt;p&gt;If you save the order before capturing, a failed payment leaves an unpaid order in your dashboard. So save it after the capture:&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;try&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;orderService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreatePaidOrderAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;capture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CaptureId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;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;"Order save failed after capture {CaptureId}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;capture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CaptureId&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;payPal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RefundCaptureAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;capture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CaptureId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// POST /v2/payments/captures/{id}/refund&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Problem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"We couldn't save your order, so your payment has been refunded."&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;
  
  
  4. Refund automatically if saving fails
&lt;/h2&gt;

&lt;p&gt;That &lt;code&gt;catch&lt;/code&gt; block is the important part. If the database is down or stock ran out at the last second, the shopper gets their money back automatically instead of waiting on a support email.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;See the full flow:&lt;/strong&gt; the &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;ASP.NET Core PayPal checkout in eCommerceMVCCore&lt;/a&gt; includes one-click refunds from the admin dashboard and a Sandbox / Live switch.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  5. Keep statuses in sync with verified webhooks
&lt;/h2&gt;

&lt;p&gt;Refunds and disputes can also happen in the PayPal dashboard, outside your app. Subscribe to PayPal webhooks, and verify every webhook's signature with PayPal (&lt;code&gt;POST /v1/notifications/verify-webhook-signature&lt;/code&gt;) before you change an order's status. Without verification, anyone can post fake "payment completed" events to your endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Test in Sandbox
&lt;/h2&gt;

&lt;p&gt;Keep a Sandbox / Live switch in your settings, with separate credentials for each. Test the whole flow, including the refund path, in Sandbox. A quick way to test the refund is to make the order save throw on purpose.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;Recalculate the total on the server.&lt;/li&gt;
&lt;li&gt;Capture the payment.&lt;/li&gt;
&lt;li&gt;Save the order after the capture.&lt;/li&gt;
&lt;li&gt;Refund automatically if the save fails.&lt;/li&gt;
&lt;li&gt;Verify webhooks before trusting them.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;h3&gt;
  
  
  Should I use the PayPal JavaScript SDK or a server-side integration?
&lt;/h3&gt;

&lt;p&gt;Both. The JavaScript SDK renders the buttons and handles the buyer's approval. Creating and capturing the order happens on your server through the Orders v2 API, so the server, not the browser, decides the amount.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can customers pay by card without a PayPal account?
&lt;/h3&gt;

&lt;p&gt;Yes. PayPal's debit and credit card option lets guests pay by card without signing up. On the server, you create and capture the order exactly as you do for a PayPal account payment.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I test PayPal payments in ASP.NET Core?
&lt;/h3&gt;

&lt;p&gt;Create a Sandbox app in the PayPal developer dashboard, use its credentials and Sandbox buyer accounts, and test the refund path as well as the happy path. &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;eCommerceMVCCore&lt;/a&gt; has a Sandbox / Live switch in its settings for exactly this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the source code
&lt;/h2&gt;

&lt;p&gt;This flow, plus one-click refunds from the admin dashboard, is built into &lt;strong&gt;eCommerceMVCCore&lt;/strong&gt;. &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;Get the ASP.NET Core 10 e-commerce source code with PayPal checkout&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;How do you handle the "paid but not saved" case in your apps? Let me know in the comments.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>aspnetcore</category>
      <category>paypal</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>EF Core with SQL Server, PostgreSQL, MySQL and SQLite in one codebase</title>
      <dc:creator>GANESH KUMAR</dc:creator>
      <pubDate>Wed, 14 Oct 2026 17:40:40 +0000</pubDate>
      <link>https://dev.to/rgk/ef-core-with-sql-server-postgresql-mysql-and-sqlite-in-one-codebase-4d1g</link>
      <guid>https://dev.to/rgk/ef-core-with-sql-server-postgresql-mysql-and-sqlite-in-one-codebase-4d1g</guid>
      <description>&lt;p&gt;When I moved &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;eCommerceMVCCore, my ASP.NET Core e-commerce project&lt;/a&gt;, to .NET 10, I wanted buyers to use whichever database they already run. Agencies often have a SQL Server licence, startups like PostgreSQL, cheap hosting usually means MySQL, and SQLite is perfect for a quick local try.&lt;/p&gt;

&lt;p&gt;Here's how I made the provider a single config value, and the pitfalls I hit along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick the provider from configuration
&lt;/h2&gt;

&lt;p&gt;The app reads one setting, &lt;code&gt;DatabaseProvider&lt;/code&gt;, and registers the right EF Core provider. A simplified version:&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;provider&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="s"&gt;"DatabaseProvider"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="s"&gt;"SqlServer"&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;connectionString&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="n"&gt;provider&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;AddDbContext&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;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="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"SqlServer"&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="nf"&gt;UseSqlServer&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;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"PostgreSQL"&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="nf"&gt;UseNpgsql&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;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"MySQL"&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="nf"&gt;UseMySQL&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;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// MySql.EntityFrameworkCore&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"Sqlite"&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="nf"&gt;UseSqlite&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;break&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="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;$"Unknown DatabaseProvider '&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;'."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fail loudly on an unknown value. A typo that silently falls back to another database is much harder to debug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep migrations per provider
&lt;/h2&gt;

&lt;p&gt;Each provider generates different SQL, so one set of migrations can't serve all four. Keep a separate migrations set for each provider (for example in separate folders or assemblies), and apply the right one at startup:&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;scope&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="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateScope&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;db&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServiceProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;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;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;Migrate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// then seed demo data if the database is empty&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running migrations and seeding on first start means a buyer can clone, run and see a working store without a manual setup step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls I hit
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Decimal precision.&lt;/strong&gt; Prices need an explicit precision, or each provider picks its own default:&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;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;Product&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Property&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;Price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;HasPrecision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;18&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;SQLite and decimals.&lt;/strong&gt; SQLite has no native decimal type. EF Core can't translate some decimal operations, such as ordering or summing in SQL, so test your sorting and totals on SQLite specifically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PostgreSQL and DateTime.&lt;/strong&gt; Npgsql maps &lt;code&gt;DateTime&lt;/code&gt; to &lt;code&gt;timestamp with time zone&lt;/code&gt; and expects UTC values. Store &lt;code&gt;DateTime.UtcNow&lt;/code&gt; everywhere and convert for display.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Case sensitivity.&lt;/strong&gt; PostgreSQL string comparisons are case-sensitive by default, while SQL Server's usual collation is not. A search box that works on SQL Server can miss results on PostgreSQL, so normalise the case in your search queries.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Try it yourself:&lt;/strong&gt; the &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;complete ASP.NET Core store&lt;/a&gt; ships with all four EF Core providers configured and a migration set for each.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Docker makes it easy to test all four
&lt;/h2&gt;

&lt;p&gt;I added one Docker Compose profile per database, so testing a provider is one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;--profile&lt;/span&gt; postgres up &lt;span class="nt"&gt;--build&lt;/span&gt;   &lt;span class="c"&gt;# or sqlserver / mysql / sqlite&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each database container has a healthcheck, and the app waits for it before migrating. That removed the "app started before the database was ready" errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth it?
&lt;/h2&gt;

&lt;p&gt;Supporting four databases costs some extra testing on every change. In return, the project fits into whatever stack a client already has, which matters a lot when you sell source code.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Can one EF Core DbContext support multiple database providers?
&lt;/h3&gt;

&lt;p&gt;Yes. The same &lt;code&gt;DbContext&lt;/code&gt; and model work with every provider; you choose the provider at startup. What can't be shared is the migrations, because each provider generates different SQL, so keep one migrations set per provider.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which database is best for an ASP.NET Core e-commerce site?
&lt;/h3&gt;

&lt;p&gt;SQL Server fits teams already on Azure or Windows. PostgreSQL is a strong free choice for production. MySQL is what most budget hosting offers. SQLite is ideal for local development and demos, but not for a busy production store.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I switch EF Core from SQL Server to PostgreSQL?
&lt;/h3&gt;

&lt;p&gt;Install &lt;code&gt;Npgsql.EntityFrameworkCore.PostgreSQL&lt;/code&gt;, call &lt;code&gt;UseNpgsql&lt;/code&gt; instead of &lt;code&gt;UseSqlServer&lt;/code&gt;, generate PostgreSQL migrations, and store every &lt;code&gt;DateTime&lt;/code&gt; in UTC. &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;eCommerceMVCCore&lt;/a&gt; does this with a single &lt;code&gt;DatabaseProvider&lt;/code&gt; setting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the source code
&lt;/h2&gt;

&lt;p&gt;The full project is &lt;strong&gt;eCommerceMVCCore&lt;/strong&gt;, a production-ready ASP.NET Core 10 e-commerce platform with a storefront, admin dashboard, REST API and PayPal payments. &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;See the live demo, documentation and source code here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Questions about any of the pitfalls above? Ask in the comments.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>efcore</category>
      <category>database</category>
    </item>
    <item>
      <title>ASP.NET Core promo codes: validating discounts safely with EF Core</title>
      <dc:creator>GANESH KUMAR</dc:creator>
      <pubDate>Wed, 14 Oct 2026 17:40:02 +0000</pubDate>
      <link>https://dev.to/rgk/aspnet-core-promo-codes-validating-discounts-safely-with-ef-core-4313</link>
      <guid>https://dev.to/rgk/aspnet-core-promo-codes-validating-discounts-safely-with-ef-core-4313</guid>
      <description>&lt;p&gt;A promo code looks like a small feature: a text box and a percentage. Then a code meant for 100 people gets used 3,000 times, an expired code still works because of a time-zone bug, or a 10% discount turns a $0.05 item into a negative total.&lt;/p&gt;

&lt;p&gt;Here's how I model and apply promo codes in &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;eCommerceMVCCore, my ASP.NET Core e-commerce store&lt;/a&gt;, with simplified code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model
&lt;/h2&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;PromoCode&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;Id&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;Code&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// stored upper-case&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;DiscountType&lt;/span&gt; &lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;           &lt;span class="c1"&gt;// Percent or FixedAmount&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;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="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;MinimumOrder&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;StartsAtUtc&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;ExpiresAtUtc&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;MaxUses&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;                &lt;span class="c1"&gt;// null = unlimited&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;TimesUsed&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;bool&lt;/span&gt; &lt;span class="n"&gt;IsActive&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;true&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;Give &lt;code&gt;Code&lt;/code&gt; a unique index, and store it upper-case so &lt;code&gt;summer10&lt;/code&gt; and &lt;code&gt;SUMMER10&lt;/code&gt; are the same code on every database. (PostgreSQL compares strings case-sensitively by default, so this matters.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Validate everything on the server
&lt;/h2&gt;

&lt;p&gt;The browser only ever sends the code. The server looks it up and recalculates the cart from the database, every time the total is shown and again at checkout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PromoResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ValidateAsync&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;input&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;subtotal&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;code&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;input&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="nf"&gt;ToUpperInvariant&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;promo&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;PromoCodes&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="nf"&gt;SingleOrDefaultAsync&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;Code&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;code&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;promo&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="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;promo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsActive&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;PromoResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Invalid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"This code isn't valid."&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;promo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StartsAtUtc&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;PromoResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Invalid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"This code isn't active yet."&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;promo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ExpiresAtUtc&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                          &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;PromoResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Invalid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"This code has expired."&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;promo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxUses&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;max&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;promo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TimesUsed&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;max&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;PromoResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Invalid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"This code has been fully used."&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;subtotal&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;promo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MinimumOrder&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;PromoResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Invalid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"Spend at least &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;promo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MinimumOrder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;0.00&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; to use this code."&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;PromoResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Valid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;promo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;CalculateDiscount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;promo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subtotal&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;Store dates in UTC and compare with &lt;code&gt;DateTime.UtcNow&lt;/code&gt;. "Expires at midnight" in the admin's time zone should be converted to UTC once, when the code is saved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round once, and never go below zero
&lt;/h2&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;decimal&lt;/span&gt; &lt;span class="nf"&gt;CalculateDiscount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PromoCode&lt;/span&gt; &lt;span class="n"&gt;promo&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;subtotal&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;discount&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;promo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;DiscountType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Percent&lt;/span&gt;
        &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;subtotal&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;promo&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="m"&gt;100m&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;promo&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;discount&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;discount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MidpointRounding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AwayFromZero&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;Math&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="n"&gt;discount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subtotal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// a discount can't make the total negative&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;decimal&lt;/code&gt;, never &lt;code&gt;double&lt;/code&gt;, for money. And round at one well-defined point. Rounding every line and then the total again gives totals that are off by a cent, and payment providers reject orders whose lines don't add up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Count usage atomically
&lt;/h2&gt;

&lt;p&gt;The classic bug: two shoppers check out at the same moment with the last use of a code. Both read &lt;code&gt;TimesUsed = 99&lt;/code&gt;, both pass the check, both save 100.&lt;/p&gt;

&lt;p&gt;Fix it with a conditional update, so the database does the check and the increment in one statement:&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;updated&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;PromoCodes&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;promoId&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxUses&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="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TimesUsed&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&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;MaxUses&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExecuteUpdateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetProperty&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;TimesUsed&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;TimesUsed&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;updated&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;CheckoutResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Failed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"This code has just been used up."&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;ExecuteUpdateAsync&lt;/code&gt; works the same on SQL Server, PostgreSQL, MySQL and SQLite. If the update touches zero rows, someone got there first.&lt;/p&gt;

&lt;p&gt;Do this as part of placing the order, in the same transaction as saving it, so a failed order doesn't burn a use.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;See it in action:&lt;/strong&gt; in the &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;ASP.NET Core e-commerce platform&lt;/a&gt;, promo codes are managed from the admin dashboard and applied at checkout.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Save what was applied
&lt;/h2&gt;

&lt;p&gt;Store the code and the discount amount on the order itself, not just a link to the promo. If the admin later edits the code from 10% to 20%, old orders and invoices must still show what the customer actually paid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small touches that reduce support emails
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Explain &lt;em&gt;why&lt;/em&gt; a code failed: expired, not active yet, minimum not reached.&lt;/li&gt;
&lt;li&gt;Re-validate when the cart changes. Removing an item can drop the subtotal below the minimum.&lt;/li&gt;
&lt;li&gt;Let admins deactivate a code instantly instead of deleting it, so order history stays intact.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  How do I stop a coupon code being used too many times?
&lt;/h3&gt;

&lt;p&gt;Increment the usage count with a conditional update, &lt;code&gt;WHERE TimesUsed &amp;lt; MaxUses&lt;/code&gt;, in the same transaction that saves the order. If it updates zero rows, the code is used up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should discounts be calculated in JavaScript or on the server?
&lt;/h3&gt;

&lt;p&gt;On the server. JavaScript can show a preview, but the total that's charged must be rebuilt from the database at checkout. In &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;eCommerceMVCCore&lt;/a&gt;, prices are re-checked on the server before any payment.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use decimal or double for prices in C#?
&lt;/h3&gt;

&lt;p&gt;Always &lt;code&gt;decimal&lt;/code&gt;. &lt;code&gt;double&lt;/code&gt; stores binary fractions, so values like 0.1 can't be represented exactly, and totals drift by a cent. Set an explicit precision on money columns too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the source code
&lt;/h2&gt;

&lt;p&gt;Promo codes and discounts, managed from the admin dashboard and checked on the server, are part of &lt;strong&gt;eCommerceMVCCore&lt;/strong&gt;. &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;Get the ASP.NET Core 10 e-commerce source code&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What's the strangest promo-code abuse you've seen? Share it in the comments.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>efcore</category>
      <category>ecommerce</category>
      <category>csharp</category>
    </item>
    <item>
      <title>EF Core seed data: demo data that works on the first run</title>
      <dc:creator>GANESH KUMAR</dc:creator>
      <pubDate>Wed, 14 Oct 2026 17:40:01 +0000</pubDate>
      <link>https://dev.to/rgk/ef-core-seed-data-demo-data-that-works-on-the-first-run-2dhb</link>
      <guid>https://dev.to/rgk/ef-core-seed-data-demo-data-that-works-on-the-first-run-2dhb</guid>
      <description>&lt;p&gt;When someone downloads a project, the first five minutes decide whether they keep going. An empty store with a blank product page is a bad first impression. A store with categories, pictures and 171 products looks like the real thing.&lt;/p&gt;

&lt;p&gt;So &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;eCommerceMVCCore, my ASP.NET Core e-commerce project&lt;/a&gt;, creates and seeds its database on first run. Here's how to do it without duplicate rows, race conditions or a demo password left in production. The code is simplified.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrations first, then seed
&lt;/h2&gt;

&lt;p&gt;Run migrations at startup, then seed only if the data isn't there yet:&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="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;scope&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="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateScope&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;services&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServiceProvider&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;var&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;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AppDbContext&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;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;MigrateAsync&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;DemoData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SeedAsync&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;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;UserManager&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AppUser&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;RoleManager&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IdentityRole&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;());&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;MigrateAsync&lt;/code&gt; creates the database if it doesn't exist, so there's no separate "create database" step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make every seed step idempotent
&lt;/h2&gt;

&lt;p&gt;Each step checks before it inserts. Running the app twice must never create two copies of anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;SeedAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AppDbContext&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;UserManager&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AppUser&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RoleManager&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IdentityRole&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;)&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;role&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;"Administrator"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Moderator"&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RoleExistsAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;role&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;roles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateAsync&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;IdentityRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;role&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FindByNameAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"admin"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="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;admin&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;AppUser&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;UserName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"admin"&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="s"&gt;"admin@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EmailConfirmed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Change_Me_123!"&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;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddToRoleAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Administrator"&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="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;Categories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AnyAsync&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="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;Categories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DemoCatalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Categories&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="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="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;Products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AnyAsync&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="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;Products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DemoCatalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Products&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;Categories&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;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="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;Roles and users go through Identity's managers, not straight into the tables. That way passwords are hashed and normalised names are set correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  HasData or runtime seeding?
&lt;/h2&gt;

&lt;p&gt;EF Core's &lt;code&gt;HasData&lt;/code&gt; puts seed rows into migrations. It's great for small, fixed lookup data, such as order statuses. For demo data it's a poor fit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every change to the demo catalog produces a new migration, with every row in it.&lt;/li&gt;
&lt;li&gt;It needs fixed primary keys for every row.&lt;/li&gt;
&lt;li&gt;It can't hash passwords or call services.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For 171 products with pictures and translations, runtime seeding is much easier to maintain.&lt;/p&gt;

&lt;p&gt;EF Core 9 also added &lt;code&gt;UseSeeding&lt;/code&gt; and &lt;code&gt;UseAsyncSeeding&lt;/code&gt; on the &lt;code&gt;DbContext&lt;/code&gt; options, which run as part of &lt;code&gt;EnsureCreated&lt;/code&gt; and &lt;code&gt;Migrate&lt;/code&gt;. It's a tidy place for the same idempotent code, if you'd rather keep it next to the context configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the demo data out of C
&lt;/h2&gt;

&lt;p&gt;I keep the catalog in a JSON file that ships with the app and load it in &lt;code&gt;DemoCatalog&lt;/code&gt;. A non-developer can edit product names and prices, and the C# stays short. Pictures go in &lt;code&gt;wwwroot&lt;/code&gt; with matching file names.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;See the result:&lt;/strong&gt; the &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;ASP.NET Core store demo&lt;/a&gt; starts with categories, pictures and 171 demo products, straight from the first run.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Watch out for multiple instances
&lt;/h2&gt;

&lt;p&gt;If two instances of the app start at the same time (two containers, or a scaled-out web app), both can run the "is it empty?" check before either inserts. For production, run migrations as a separate deployment step rather than at app startup. For a demo or a single instance, startup seeding is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The demo admin is a demo account
&lt;/h2&gt;

&lt;p&gt;A seeded admin with a known password is perfect locally and dangerous in production. My go-live checklist starts with "change or remove the demo admin". Better still, read the initial admin password from configuration, or skip creating the admin unless an environment variable asks for it.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Should I use HasData or custom seeding in EF Core?
&lt;/h3&gt;

&lt;p&gt;Use &lt;code&gt;HasData&lt;/code&gt; for small, fixed lookup data that belongs in migrations. Use runtime seeding, or &lt;code&gt;UseSeeding&lt;/code&gt; / &lt;code&gt;UseAsyncSeeding&lt;/code&gt; in EF Core 9 and later, for demo data, large catalogs and Identity users.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I seed Identity users and roles in ASP.NET Core?
&lt;/h3&gt;

&lt;p&gt;Create a scope at startup, resolve &lt;code&gt;RoleManager&lt;/code&gt; and &lt;code&gt;UserManager&lt;/code&gt;, and create each role and user only if it doesn't exist yet. Going through the managers hashes passwords and sets normalised names correctly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I run EF Core migrations at application startup?
&lt;/h3&gt;

&lt;p&gt;For a single instance or a demo, yes. With several instances in production, apply them as a deployment step, for example with a migration bundle or a SQL script. &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;eCommerceMVCCore&lt;/a&gt; migrates on startup, so the first run needs no setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the source code
&lt;/h2&gt;

&lt;p&gt;Database creation and seeding on first run, for SQL Server, PostgreSQL, MySQL and SQLite, are built into &lt;strong&gt;eCommerceMVCCore&lt;/strong&gt;. &lt;a href="https://rgkjobber.gumroad.com/l/zkcwon" rel="noopener noreferrer"&gt;Get the ASP.NET Core 10 e-commerce source code&lt;/a&gt; and have a working store running in minutes.&lt;/p&gt;

&lt;p&gt;Do you seed in code, from JSON or with SQL scripts? I'd like to know what works for you.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>efcore</category>
      <category>csharp</category>
      <category>database</category>
    </item>
  </channel>
</rss>
