<?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: Kazem</title>
    <description>The latest articles on DEV Community by Kazem (@kazemmdev).</description>
    <link>https://dev.to/kazemmdev</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%2F364498%2F589ae8fd-fd0c-4c55-bec2-f339b2dca232.jpg</url>
      <title>DEV Community: Kazem</title>
      <link>https://dev.to/kazemmdev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kazemmdev"/>
    <language>en</language>
    <item>
      <title>ABP and Elsa Both Want to Own Your API: Pick One</title>
      <dc:creator>Kazem</dc:creator>
      <pubDate>Wed, 26 Aug 2026 03:38:51 +0000</pubDate>
      <link>https://dev.to/kazemmdev/abp-and-elsa-both-want-to-own-your-api-pick-one-cc7</link>
      <guid>https://dev.to/kazemmdev/abp-and-elsa-both-want-to-own-your-api-pick-one-cc7</guid>
      <description>&lt;p&gt;When I scaffolded my project, an enterprise workflow platform on ABP Framework, Elsa 3.x, EF Core, and PostgreSQL, I ran into a problem that never shows up in either framework's docs, because each framework assumes it's the only one in the room.&lt;/p&gt;

&lt;p&gt;ABP wants you to expose functionality through application services. That's the whole point of the framework: you write a C# class, decorate it with a permission attribute, and ABP auto-generates a REST controller for it. No manual routing, no manual DTO wiring if you let AutoMapper profiles do the work.&lt;/p&gt;

&lt;p&gt;Elsa also wants to expose functionality over HTTP. It ships an &lt;code&gt;HttpEndpoint&lt;/code&gt; activity that lets a workflow itself define a route, accept a request, and respond, no ASP.NET controller involved at all. You drop the activity into a workflow definition and Elsa wires up the endpoint for you.&lt;/p&gt;

&lt;p&gt;Put those two together on the same project and you get two frameworks each offering to be your public API. That's not a hypothetical: it's what I had in front of me the day I needed to expose "start an approval workflow" and "complete a human task" to the rest of the system. Elsa would happily let me do it inside the workflow definition. ABP would happily let me do it as an application service. Pick wrong and you end up with two different ways to call what is conceptually the same operation: discoverable through two different mechanisms, secured by two different permission systems, versioned (or not) by two different conventions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is worse than it sounds
&lt;/h2&gt;

&lt;p&gt;The obvious reaction is "just pick one and move on." The problem is that Elsa's &lt;code&gt;HttpEndpoint&lt;/code&gt; is convenient in a way that's easy to fall for. You're already inside the workflow definition, you already have the business logic for what happens when a request comes in, and Elsa gives you a working route with zero extra files. For a demo, or a single standalone workflow, that's a fine trade.&lt;/p&gt;

&lt;p&gt;But ProcessHub isn't a single workflow. It's a platform where workflows are one implementation detail behind an approval-task inbox, an audit trail, role-based access, and caching. The moment you let Elsa own the public boundary for even one endpoint, you've committed to two rules for how the outside world talks to your system:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"For approval submission, call this ABP application service, which is authorized by an ABP permission, logged by the ABP audit system, and documented by the ABP Swagger integration."&lt;/li&gt;
&lt;li&gt;"For task completion, call this Elsa &lt;code&gt;HttpEndpoint&lt;/code&gt;, which is authorized however that workflow definition wired it up, logged wherever Elsa's execution log happens to write it, and documented nowhere in particular."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither rule is wrong in isolation. Together, they mean every new integration has to ask "wait, is this one of the Elsa-exposed operations or an ABP one?" before it can even find the right client to call. That's the kind of inconsistency that looks small in a design doc and turns into a support ticket six months later when someone can't find the endpoint for a feature they know exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I landed on
&lt;/h2&gt;

&lt;p&gt;The public boundary is the application service, not the workflow engine's own endpoints. Elsa runs entirely behind that boundary. No &lt;code&gt;HttpEndpoint&lt;/code&gt; activities, no direct client-to-Elsa communication of any kind.&lt;/p&gt;

&lt;p&gt;Concretely, that means the approval flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WorkflowTaskAppService&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ApplicationService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IWorkflowTaskAppService&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;IApprovalTaskRepository&lt;/span&gt; &lt;span class="n"&gt;_tasks&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;IRunTaskRequestHandler&lt;/span&gt; &lt;span class="n"&gt;_runTaskHandler&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;ProcessHubPermissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Complete&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;CompleteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CompleteTaskInput&lt;/span&gt; &lt;span class="n"&gt;input&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;task&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;_tasks&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="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// ApprovalTask is our own aggregate, not Elsa's execution state.&lt;/span&gt;
        &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CurrentUser&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;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Decision&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;Comment&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;_tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UpdateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Resume the suspended Elsa workflow through our handler,&lt;/span&gt;
        &lt;span class="c1"&gt;// not through any endpoint Elsa exposes itself.&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_runTaskHandler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ResumeAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WorkflowInstanceId&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;Decision&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 workflow itself contains a &lt;code&gt;RunTask&lt;/code&gt; activity that suspends execution and waits. Nothing about that activity is reachable from outside the process. The only door in is &lt;code&gt;WorkflowTaskAppService&lt;/code&gt;, authorized by an ABP permission (&lt;code&gt;ProcessHubPermissions.Tasks.Complete&lt;/code&gt;). It shows up in the same Swagger document as every other endpoint in the system, and it gets logged by the same audit tables that log everything else: the append-only domain tables I built independent of Elsa's own execution log, specifically so audit history doesn't depend on which engine happens to be running underneath.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ApprovalTask&lt;/code&gt; is worth calling out too. It's a domain aggregate I own, not Elsa's workflow instance state. The application service reads and writes it directly. Elsa's job is to drive the state machine that decides when a task becomes eligible and what happens after it's resolved. It doesn't get to be the system of record for what the task inbox shows a user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the "just use both" instinct is wrong here
&lt;/h2&gt;

&lt;p&gt;I get why the instinct is to use &lt;code&gt;HttpEndpoint&lt;/code&gt; for anything workflow-shaped and application services for everything else. It feels like using each tool for what it's good at. But that's a category error. &lt;code&gt;HttpEndpoint&lt;/code&gt; is good at "this workflow needs to react to an inbound webhook from a third party who doesn't know or care about your internal architecture." It is not good at "this is a first-class operation my own frontend calls," because it opts that operation out of every convention ABP gives you for free: permission checks, DTO validation, Swagger docs, the audit trail, the unit-of-work interceptor that wraps &lt;code&gt;SaveChanges&lt;/code&gt; around the request.&lt;/p&gt;

&lt;p&gt;Once I framed it that way, the rule got easy to defend in code review. If the caller is internal, meaning my own UI or my own downstream services, it goes through an application service, full stop. If the caller is a genuinely external system triggering a workflow with no other integration in the codebase (say, a webhook from a payment provider), &lt;code&gt;HttpEndpoint&lt;/code&gt; is fine, because there's no ABP convention being bypassed. There was never an ABP endpoint for that caller anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Native AOT wrinkle, and why it doesn't change the answer
&lt;/h2&gt;

&lt;p&gt;There's a second reason application services stay the boundary, and it's less about design taste and more about what ABP actually costs you. ABP builds application services on dynamic proxies and interceptors: the unit-of-work wrapper, the auto-validation, the auditing all run through interception at runtime. That's convenient, but it means Native AOT is off the table for this codebase. If you're chasing trimmed, ahead-of-time-compiled binaries, ABP's application service layer isn't going to get you there.&lt;/p&gt;

&lt;p&gt;It also means unit-testing an application service in true isolation is awkward. Mock the repository, call the method directly, and you've bypassed the interceptor pipeline that does half the actual work (the unit-of-work commit, the auditing, the authorization check). I stopped fighting that and write integration tests through the HTTP boundary instead, hitting the real controller ABP generates. Slower than a pure unit test, but it's testing the thing that's actually going to run in production, interceptors included.&lt;/p&gt;

&lt;p&gt;None of that pushed me toward letting Elsa own more of the surface. If anything it reinforced the opposite: if application services are going to cost me AOT and force integration-style testing, I want that cost paid once, at one consistent boundary, not paid partially while a second boundary quietly exists elsewhere with different rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general version of this
&lt;/h2&gt;

&lt;p&gt;If you're bringing ABP into a project that already has an opinionated engine underneath it, whether that's a workflow engine, a job scheduler, or a message bus with its own HTTP hooks, check whether that engine wants to expose its own API surface. A lot of them do, because it's a nice feature to advertise in isolation. Almost none of them coordinate with whatever else in your stack is also trying to be "the" API.&lt;/p&gt;

&lt;p&gt;The fix isn't a framework setting. It's a decision you make explicitly and write down: one thing owns the public boundary, and everything else, no matter how convenient its shortcut looks, runs behind it. On ProcessHub that's the ABP application service layer: modules instead of &lt;code&gt;Program.cs&lt;/code&gt; registrations, permission attributes instead of manual &lt;code&gt;IAuthorizationService&lt;/code&gt; checks, with Elsa treated as an internal state machine and nothing more. It's a small rule. It's also the one I've had to re-explain the most, because the alternative always looks like less code in the moment you're writing it.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>The .NET Production Checklist Is Really a List of Places the Framework Lies to You</title>
      <dc:creator>Kazem</dc:creator>
      <pubDate>Wed, 26 Aug 2026 03:37:41 +0000</pubDate>
      <link>https://dev.to/kazemmdev/the-net-production-checklist-is-really-a-list-of-places-the-framework-lies-to-you-2lkc</link>
      <guid>https://dev.to/kazemmdev/the-net-production-checklist-is-really-a-list-of-places-the-framework-lies-to-you-2lkc</guid>
      <description>&lt;p&gt;Most of this checklist isn't about .NET at all. It's a list of every place the framework's defaults will quietly hurt you in production, because the happy path was built to make &lt;code&gt;dotnet run&lt;/code&gt; work on your laptop, not to survive three replicas behind a load balancer with a database that has a connection limit.&lt;/p&gt;

&lt;p&gt;None of these are exotic problems. They're the kind of thing you only learn by getting paged for them once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrations: the happy path runs them on every replica
&lt;/h2&gt;

&lt;p&gt;The default story is simple: your app starts up, EF Core applies pending migrations, you're live. It works great with one instance.&lt;/p&gt;

&lt;p&gt;It falls apart the moment you scale past one. Three replicas start at roughly the same time, all three try to apply the same migration, and now you're debugging a race condition in your schema instead of your code. Sometimes it's harmless: the migration is idempotent, one replica wins, the others fail quietly and move on. Sometimes it isn't, and you get a half-applied schema or a deadlock on the migrations history table.&lt;/p&gt;

&lt;p&gt;The fix is boring: migrations run as a single deploy step, not as part of application startup. One job, one shot, before any replica of the new version starts serving traffic. It's less convenient than "it just happens," but "it just happens" is exactly the kind of behavior that's fine until it isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Health checks: "alive" and "ready" are not the same question
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/health&lt;/code&gt; as a single endpoint answers one question badly. What you actually need are two different questions with two different consequences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is the process alive? (Should Kubernetes restart the pod?)&lt;/li&gt;
&lt;li&gt;Is the process ready to take traffic? (Should the load balancer route to it?)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A service can be alive and not ready — it's up, but its database connection is down, or it's still warming a cache. If your only health check conflates the two, either you route traffic to a pod that can't serve it, or you restart a pod that's actually fine and just waiting on a dependency to come back.&lt;/p&gt;

&lt;p&gt;Split them: &lt;code&gt;/health/live&lt;/code&gt; checks that the process itself is up. &lt;code&gt;/health/ready&lt;/code&gt; checks that its dependencies are reachable. Only &lt;code&gt;/health/ready&lt;/code&gt; gates traffic. This is a small amount of extra wiring for a failure mode that otherwise shows up as "why did we get 500s during a routine dependency blip."&lt;/p&gt;

&lt;h2&gt;
  
  
  Shutdown: three timeouts, each one has to be bigger than the last
&lt;/h2&gt;

&lt;p&gt;Graceful shutdown looks like it should be free — the framework gives you &lt;code&gt;IHostApplicationLifetime&lt;/code&gt;, you hook &lt;code&gt;ApplicationStopping&lt;/code&gt;, done. But shutdown in a container orchestrator is actually a chain of three separate timeouts, and if you only configure the first one, the other two will cut you off anyway:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;IHostApplicationLifetime&lt;/code&gt;: your code's chance to stop accepting new work and finish what's in flight.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ShutdownTimeout&lt;/code&gt;: how long the host waits for that to happen before it forces termination. This has to be set above your longest in-flight request, or the host kills requests that were about to finish cleanly.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;terminationGracePeriodSeconds&lt;/code&gt;: how long Kubernetes waits before sending SIGKILL. This has to be set above &lt;code&gt;ShutdownTimeout&lt;/code&gt;, or Kubernetes kills the process before your own shutdown timeout even gets a chance to fire.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Get the ordering wrong and you'll see intermittent failed requests during every deploy, and they'll look like application bugs. They're not. They're a shutdown grace period that's smaller than the request it was supposed to protect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connection pools: the number that has to be true across every replica
&lt;/h2&gt;

&lt;p&gt;This one is a simple formula that's easy to forget applies at all once you're running more than one instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Max Pool Size × replicas &amp;lt; DB max_connections
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A pool size of 100 is fine for one replica against a database that allows 200 connections. Scale to three replicas and you're asking for 300 connections against a limit of 200. That shows up as intermittent connection exhaustion under load, usually during a traffic spike, which is exactly when you have the least patience for debugging it.&lt;/p&gt;

&lt;p&gt;Pair this with actual resiliency: &lt;code&gt;EnableRetryOnFailure()&lt;/code&gt; for transient database errors, and a standard resilience handler on your outbound HTTP clients. Networks fail. The question is whether your service treats that as routine or as an unhandled exception.&lt;/p&gt;

&lt;h2&gt;
  
  
  The runtime image: no SDK, no root
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runtime&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; out .&lt;/span&gt;
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; $APP_UID&lt;/span&gt;
&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["./Sample.Api"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things happen here that are easy to skip if you just want a working Dockerfile. This is a chiseled/distroless image: no shell, no package manager, no SDK, nothing beyond what the app needs to run. If someone gets code execution inside the container, there isn't much there to pivot with. On top of that, &lt;code&gt;USER $APP_UID&lt;/code&gt; runs the process as a non-root user instead of the container default.&lt;/p&gt;

&lt;p&gt;Neither of these makes the app faster or fixes a bug you'll notice in staging. They're the difference between a compromised container being a dead end and being a foothold. The publish step that gets you here is also worth keeping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dotnet publish src/Sample.Api -c Release -o out /p:PublishReadyToRun=true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PublishReadyToRun=true&lt;/code&gt; precompiles a chunk of the IL to native code ahead of time, which matters for the next item.&lt;/p&gt;

&lt;h2&gt;
  
  
  Warm-up: the first requests are the slowest ones
&lt;/h2&gt;

&lt;p&gt;.NET JITs your code as it runs: tier-0 first, optimizing later tiers as methods get hot. That's a reasonable tradeoff for a long-running process. It's a bad one for the first few requests a brand-new pod receives, because those requests get compiled cold, right when they're also the ones deciding whether your rollout looks healthy.&lt;/p&gt;

&lt;p&gt;The fix is to send a warm-up request after deploy, before the pod joins the load balancer. It costs a few seconds. What it buys you is not showing your actual users the slowest version of every code path, right as a new deploy goes live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everything else on the list is the same pattern
&lt;/h2&gt;

&lt;p&gt;Structured logging to stdout, so your log aggregator doesn't have to parse whatever format someone chose two years ago. OpenTelemetry traces and metrics with a correlation ID that survives a hop across services, so a slow request can actually be traced instead of guessed at. Response compression and output caching where they pay off, not everywhere, just where the numbers say so. Resource limits and &lt;code&gt;DOTNET_GCHeapHardLimitPercent&lt;/code&gt; set explicitly, because an unbounded GC heap in a container with a memory limit is just a slower way of getting OOMKilled. Backups verified by an actual restore, because a backup nobody has restored is a hope, not a backup.&lt;/p&gt;

&lt;p&gt;None of these are interesting individually. What they have in common is that they're all invisible until the day they aren't — and by then it's an incident, not a checklist item.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CI gate that keeps this from being a one-time exercise
&lt;/h2&gt;

&lt;p&gt;The checklist part is only half of it. None of it stays true unless CI enforces it on every change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dotnet format --verify-no-changes → dotnet build -warnaserror → dotnet test → vulnerability scan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Format check first, so style drift doesn't show up as noise in review. Build with warnings as errors, so a warning today doesn't become a bug next quarter because everyone got used to ignoring the build output. Tests. Then a vulnerability scan against your dependencies, because &lt;code&gt;dotnet list package --vulnerable&lt;/code&gt; catching something in CI is a much better day than catching it after it's in production.&lt;/p&gt;

&lt;p&gt;The tooling underneath is less important than the discipline of running it on every PR instead of remembering to run it manually before a release: CSharpier or &lt;code&gt;dotnet format&lt;/code&gt; for formatting, Roslyn analyzers and SonarAnalyzer for static analysis, NetArchTest or ArchUnitNET if you want architecture rules enforced by a test rather than a code review comment, and Husky.NET for pre-commit hooks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this checklist reads the way it does
&lt;/h2&gt;

&lt;p&gt;Every item here comes from the same root cause: the defaults optimize for one instance on your machine, not for several instances that all have to agree on the same schema, the same connection budget, and the same shutdown sequence at once. Add an orchestrator with its own lifecycle rules on top, and that gap becomes the whole checklist.&lt;/p&gt;

&lt;p&gt;None of this is specific to .NET's quirks. It's what "production" means once more than one thing has to agree on the state of the world at the same time.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>dotnet</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The .NET Production Checklist Is Really a List of Places the Framework Lies to You</title>
      <dc:creator>Kazem</dc:creator>
      <pubDate>Tue, 25 Aug 2026 12:13:17 +0000</pubDate>
      <link>https://dev.to/kazemmdev/the-net-production-checklist-is-really-a-list-of-places-the-framework-lies-to-you-f00</link>
      <guid>https://dev.to/kazemmdev/the-net-production-checklist-is-really-a-list-of-places-the-framework-lies-to-you-f00</guid>
      <description>&lt;p&gt;Most of this checklist isn't about .NET at all. It's a list of every place the framework's defaults will quietly hurt you in production, because the happy path was built to make &lt;code&gt;dotnet run&lt;/code&gt; work on your laptop, not to survive three replicas behind a load balancer with a database that has a connection limit.&lt;/p&gt;

&lt;p&gt;None of these are exotic problems. They're the kind of thing you only learn by getting paged for them once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrations: the happy path runs them on every replica
&lt;/h2&gt;

&lt;p&gt;The default story is simple: your app starts up, EF Core applies pending migrations, you're live. It works great with one instance.&lt;/p&gt;

&lt;p&gt;It falls apart the moment you scale past one. Three replicas start at roughly the same time, all three try to apply the same migration, and now you're debugging a race condition in your schema instead of your code. Sometimes it's harmless: the migration is idempotent, one replica wins, the others fail quietly and move on. Sometimes it isn't, and you get a half-applied schema or a deadlock on the migrations history table.&lt;/p&gt;

&lt;p&gt;The fix is boring: migrations run as a single deploy step, not as part of application startup. One job, one shot, before any replica of the new version starts serving traffic. It's less convenient than "it just happens," but "it just happens" is exactly the kind of behavior that's fine until it isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Health checks: "alive" and "ready" are not the same question
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/health&lt;/code&gt; as a single endpoint answers one question badly. What you actually need are two different questions with two different consequences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is the process alive? (Should Kubernetes restart the pod?)&lt;/li&gt;
&lt;li&gt;Is the process ready to take traffic? (Should the load balancer route to it?)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A service can be alive and not ready — it's up, but its database connection is down, or it's still warming a cache. If your only health check conflates the two, either you route traffic to a pod that can't serve it, or you restart a pod that's actually fine and just waiting on a dependency to come back.&lt;/p&gt;

&lt;p&gt;Split them: &lt;code&gt;/health/live&lt;/code&gt; checks that the process itself is up. &lt;code&gt;/health/ready&lt;/code&gt; checks that its dependencies are reachable. Only &lt;code&gt;/health/ready&lt;/code&gt; gates traffic. This is a small amount of extra wiring for a failure mode that otherwise shows up as "why did we get 500s during a routine dependency blip."&lt;/p&gt;

&lt;h2&gt;
  
  
  Shutdown: three timeouts, each one has to be bigger than the last
&lt;/h2&gt;

&lt;p&gt;Graceful shutdown looks like it should be free — the framework gives you &lt;code&gt;IHostApplicationLifetime&lt;/code&gt;, you hook &lt;code&gt;ApplicationStopping&lt;/code&gt;, done. But shutdown in a container orchestrator is actually a chain of three separate timeouts, and if you only configure the first one, the other two will cut you off anyway:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;IHostApplicationLifetime&lt;/code&gt;: your code's chance to stop accepting new work and finish what's in flight.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ShutdownTimeout&lt;/code&gt;: how long the host waits for that to happen before it forces termination. This has to be set above your longest in-flight request, or the host kills requests that were about to finish cleanly.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;terminationGracePeriodSeconds&lt;/code&gt;: how long Kubernetes waits before sending SIGKILL. This has to be set above &lt;code&gt;ShutdownTimeout&lt;/code&gt;, or Kubernetes kills the process before your own shutdown timeout even gets a chance to fire.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Get the ordering wrong and you'll see intermittent failed requests during every deploy, and they'll look like application bugs. They're not. They're a shutdown grace period that's smaller than the request it was supposed to protect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connection pools: the number that has to be true across every replica
&lt;/h2&gt;

&lt;p&gt;This one is a simple formula that's easy to forget applies at all once you're running more than one instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Max Pool Size × replicas &amp;lt; DB max_connections
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A pool size of 100 is fine for one replica against a database that allows 200 connections. Scale to three replicas and you're asking for 300 connections against a limit of 200. That shows up as intermittent connection exhaustion under load, usually during a traffic spike, which is exactly when you have the least patience for debugging it.&lt;/p&gt;

&lt;p&gt;Pair this with actual resiliency: &lt;code&gt;EnableRetryOnFailure()&lt;/code&gt; for transient database errors, and a standard resilience handler on your outbound HTTP clients. Networks fail. The question is whether your service treats that as routine or as an unhandled exception.&lt;/p&gt;

&lt;h2&gt;
  
  
  The runtime image: no SDK, no root
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runtime&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; out .&lt;/span&gt;
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; $APP_UID&lt;/span&gt;
&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["./Sample.Api"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things happen here that are easy to skip if you just want a working Dockerfile. This is a chiseled/distroless image: no shell, no package manager, no SDK, nothing beyond what the app needs to run. If someone gets code execution inside the container, there isn't much there to pivot with. On top of that, &lt;code&gt;USER $APP_UID&lt;/code&gt; runs the process as a non-root user instead of the container default.&lt;/p&gt;

&lt;p&gt;Neither of these makes the app faster or fixes a bug you'll notice in staging. They're the difference between a compromised container being a dead end and being a foothold. The publish step that gets you here is also worth keeping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dotnet publish src/Sample.Api -c Release -o out /p:PublishReadyToRun=true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PublishReadyToRun=true&lt;/code&gt; precompiles a chunk of the IL to native code ahead of time, which matters for the next item.&lt;/p&gt;

&lt;h2&gt;
  
  
  Warm-up: the first requests are the slowest ones
&lt;/h2&gt;

&lt;p&gt;.NET JITs your code as it runs: tier-0 first, optimizing later tiers as methods get hot. That's a reasonable tradeoff for a long-running process. It's a bad one for the first few requests a brand-new pod receives, because those requests get compiled cold, right when they're also the ones deciding whether your rollout looks healthy.&lt;/p&gt;

&lt;p&gt;The fix is to send a warm-up request after deploy, before the pod joins the load balancer. It costs a few seconds. What it buys you is not showing your actual users the slowest version of every code path, right as a new deploy goes live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everything else on the list is the same pattern
&lt;/h2&gt;

&lt;p&gt;Structured logging to stdout, so your log aggregator doesn't have to parse whatever format someone chose two years ago. OpenTelemetry traces and metrics with a correlation ID that survives a hop across services, so a slow request can actually be traced instead of guessed at. Response compression and output caching where they pay off, not everywhere, just where the numbers say so. Resource limits and &lt;code&gt;DOTNET_GCHeapHardLimitPercent&lt;/code&gt; set explicitly, because an unbounded GC heap in a container with a memory limit is just a slower way of getting OOMKilled. Backups verified by an actual restore, because a backup nobody has restored is a hope, not a backup.&lt;/p&gt;

&lt;p&gt;None of these are interesting individually. What they have in common is that they're all invisible until the day they aren't — and by then it's an incident, not a checklist item.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CI gate that keeps this from being a one-time exercise
&lt;/h2&gt;

&lt;p&gt;The checklist part is only half of it. None of it stays true unless CI enforces it on every change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dotnet format --verify-no-changes → dotnet build -warnaserror → dotnet test → vulnerability scan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Format check first, so style drift doesn't show up as noise in review. Build with warnings as errors, so a warning today doesn't become a bug next quarter because everyone got used to ignoring the build output. Tests. Then a vulnerability scan against your dependencies, because &lt;code&gt;dotnet list package --vulnerable&lt;/code&gt; catching something in CI is a much better day than catching it after it's in production.&lt;/p&gt;

&lt;p&gt;The tooling underneath is less important than the discipline of running it on every PR instead of remembering to run it manually before a release: CSharpier or &lt;code&gt;dotnet format&lt;/code&gt; for formatting, Roslyn analyzers and SonarAnalyzer for static analysis, NetArchTest or ArchUnitNET if you want architecture rules enforced by a test rather than a code review comment, and Husky.NET for pre-commit hooks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this checklist reads the way it does
&lt;/h2&gt;

&lt;p&gt;Every item here comes from the same root cause: the defaults optimize for one instance on your machine, not for several instances that all have to agree on the same schema, the same connection budget, and the same shutdown sequence at once. Add an orchestrator with its own lifecycle rules on top, and that gap becomes the whole checklist.&lt;/p&gt;

&lt;p&gt;None of this is specific to .NET's quirks. It's what "production" means once more than one thing has to agree on the state of the world at the same time.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>dotnet</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Never Use UseInMemoryDatabase in ASP.NET Tests</title>
      <dc:creator>Kazem</dc:creator>
      <pubDate>Tue, 25 Aug 2026 12:04:25 +0000</pubDate>
      <link>https://dev.to/kazemmdev/never-use-useinmemorydatabase-in-aspnet-tests-1356</link>
      <guid>https://dev.to/kazemmdev/never-use-useinmemorydatabase-in-aspnet-tests-1356</guid>
      <description>&lt;p&gt;A test suite can pass completely against &lt;code&gt;UseInMemoryDatabase&lt;/code&gt; and still break the moment the same code runs against real Postgres. Green checkmarks, CI happy, PR merged — and then a query that relied on a foreign key constraint does the wrong thing in production, because nothing in the test run had a foreign key to enforce.&lt;/p&gt;

&lt;p&gt;That's the one rule in ASP.NET testing I don't compromise on: never test against &lt;code&gt;UseInMemoryDatabase&lt;/code&gt;. It's not a relational database. No constraints, no transactions, and its LINQ translation doesn't match what EF Core actually generates against Postgres or SQL Server. You get green tests that lie to you about what happens in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the in-memory provider fails you
&lt;/h2&gt;

&lt;p&gt;EF Core's in-memory provider was built for quick prototyping, not for verifying behavior. It stores your entities in a plain in-memory collection and evaluates LINQ against that collection using regular .NET semantics. That sounds convenient until you remember that a real database doesn't work that way.&lt;/p&gt;

&lt;p&gt;A unique index violation, a cascade delete, a check constraint, a &lt;code&gt;NOT NULL&lt;/code&gt; column — none of that exists in the in-memory provider. Your test can happily insert two rows that would collide on a unique constraint in Postgres, and it'll pass. A query that translates to a specific SQL expression against Npgsql might translate completely differently against the in-memory LINQ provider, or not translate at all. A bug in your query gets masked because the fake provider is more forgiving than the real one.&lt;/p&gt;

&lt;p&gt;Transactions are the other big gap. If your code relies on transactional rollback, or on isolation between concurrent operations, the in-memory provider gives you none of that. So you end up testing a database that behaves nothing like the one your users hit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to use instead
&lt;/h2&gt;

&lt;p&gt;Testcontainers. Spin up a real Postgres instance in a container for the test run, and point EF Core at it like you would in production.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Real Postgres per test run — not an in-memory provider&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ApiFactory&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;WebApplicationFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Program&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt; &lt;span class="n"&gt;IAsyncLifetime&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;PostgreSqlContainer&lt;/span&gt; &lt;span class="n"&gt;_db&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;PostgreSqlBuilder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"postgres:17-alpine"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;ConfigureWebHost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IWebHostBuilder&lt;/span&gt; &lt;span class="n"&gt;builder&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;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConfigureTestServices&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;=&amp;gt;&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;RemoveAll&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DbContextOptions&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;&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;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;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseNpgsql&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="nf"&gt;GetConnectionString&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
            &lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TimeProvider&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;FakeTimeProvider&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;InitializeAsync&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;StartAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="cm"&gt;/* apply migrations */&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;new&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;DisposeAsync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;DisposeAsync&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;ApiFactory&lt;/code&gt; boots the real ASP.NET pipeline through &lt;code&gt;WebApplicationFactory&amp;lt;Program&amp;gt;&lt;/code&gt;, but swaps the database connection to point at a containerized Postgres instead of whatever's configured for the app normally. Same constraints, same transaction behavior, same SQL your production database will actually run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pyramid, ASP.NET-flavored
&lt;/h2&gt;

&lt;p&gt;Once you accept that integration tests need a real database, the shape of your test suite follows from that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Integration tests (~60%)&lt;/strong&gt; — this is where most of the value is. &lt;code&gt;WebApplicationFactory&amp;lt;Program&amp;gt;&lt;/code&gt; boots the real pipeline in memory, but against a real database in a container. You're exercising the actual HTTP pipeline, the actual EF Core mappings, the actual constraints. Highest value per line, and it's basically the same idea as Laravel feature tests, if you're coming from that world.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unit tests (~35%)&lt;/strong&gt; — domain rules, calculators, validators, mappers. No I/O, so they're fast. This is where you test the logic that doesn't need a database or an HTTP request to verify.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;E2E (~5%)&lt;/strong&gt; — Playwright, reserved for the handful of flows that must never break. Login, checkout, whatever your equivalent is. E2E tests are slow and brittle by nature, so you don't want your whole suite living here.&lt;/p&gt;

&lt;p&gt;Here's what a couple of integration tests look like against &lt;code&gt;ApiFactory&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PostApiTests&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ApiFactory&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IClassFixture&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ApiFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;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;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;Creates_a_post_for_an_authenticated_user&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="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateAuthenticatedClient&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;TestUsers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Author&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;PostAsJsonAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/posts"&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;title&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Hello"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"World"&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="nf"&gt;ShouldBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HttpStatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Created&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;dto&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;Content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadFromJsonAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PostDto&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
        &lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;!.&lt;/span&gt;&lt;span class="n"&gt;Title&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ShouldBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Hello"&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;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;Forbids_editing_someone_elses_post&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;post&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;factory&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="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;PostBuilder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;WithAuthor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TestUsers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Other&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="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateAuthenticatedClient&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;TestUsers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Author&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;PutAsJsonAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"/api/posts/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;post&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;"&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;title&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Hacked"&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="nf"&gt;ShouldBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HttpStatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Forbidden&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what these tests actually assert on: HTTP status codes and response bodies. Not internal method calls, not whether some service was invoked with the right arguments. You go through the HTTP boundary the same way a real client would. If you start asserting on internal call sequences instead, your tests become coupled to implementation details, and refactoring turns into a chore even when behavior hasn't changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The supporting rules
&lt;/h2&gt;

&lt;p&gt;A few things make this pyramid work in practice, beyond just using Testcontainers.&lt;/p&gt;

&lt;p&gt;Test data should come from builders, not raw object initializers scattered through every test file. &lt;code&gt;new PostBuilder().WithAuthor(TestUsers.Other)&lt;/code&gt; in the second test above is the ASP.NET equivalent of a Laravel factory: a readable way to construct exactly the entity state a test needs.&lt;/p&gt;

&lt;p&gt;Isolation matters just as much. Wrap each test in a transaction and roll it back, or reset the database between runs with something like Respawn. What you don't want is shared mutable seed data that one test can quietly corrupt for the next one, because that's how you end up with tests that pass individually and fail when run together, one of the more annoying categories of flakiness to debug.&lt;/p&gt;

&lt;p&gt;Time-dependent code needs a &lt;code&gt;FakeTimeProvider&lt;/code&gt; instead of assertions built around &lt;code&gt;DateTime.UtcNow&lt;/code&gt;. If your code branches on expiry windows or scheduling, you want that time to be a value you control in the test, not whatever the clock happens to say when CI runs. &lt;code&gt;ApiFactory&lt;/code&gt; above registers &lt;code&gt;FakeTimeProvider&lt;/code&gt; as a singleton for exactly this reason.&lt;/p&gt;

&lt;p&gt;And outbound HTTP calls should be faked with a stub &lt;code&gt;HttpMessageHandler&lt;/code&gt; or WireMock.NET. A test suite that hits the real internet is a flaky suite, full stop. Any external call, whether a third-party API or a webhook, should be intercepted at the HTTP client level so your tests aren't at the mercy of some other service's uptime.&lt;/p&gt;

&lt;h2&gt;
  
  
  The toolchain
&lt;/h2&gt;

&lt;p&gt;xUnit is the default runner choice, though NUnit and MSTest work fine if that's what a codebase already uses. Shouldly or FluentAssertions handle assertions. Worth checking FluentAssertions' license terms before using it commercially, since those changed. Mocking is NSubstitute or Moq, take your pick.&lt;/p&gt;

&lt;p&gt;None of these choices matter as much as the database decision. You can swap test runners and assertion libraries without much consequence. But if your integration tests run against &lt;code&gt;UseInMemoryDatabase&lt;/code&gt;, you don't actually know what your code does against Postgres — you know what it does against a LINQ-over-a-list emulation of a database, which is a different thing that happens to share an interface.&lt;/p&gt;

&lt;p&gt;The fix isn't complicated. Testcontainers spins up and tears down fast enough that it doesn't meaningfully slow your suite down, and the confidence it buys you is the whole point of writing integration tests in the first place. If your tests aren't telling you the truth about production, they're not saving you the time debugging in production later — they're just moving the debugging to a worse moment.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>webdev</category>
      <category>programming</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>Async in .NET Has Traps Laravel Never Prepared Me For</title>
      <dc:creator>Kazem</dc:creator>
      <pubDate>Tue, 25 Aug 2026 11:48:06 +0000</pubDate>
      <link>https://dev.to/kazemmdev/async-in-net-has-traps-laravel-never-prepared-me-for-590f</link>
      <guid>https://dev.to/kazemmdev/async-in-net-has-traps-laravel-never-prepared-me-for-590f</guid>
      <description>&lt;p&gt;Most of the move from Laravel to .NET has been mechanical: different syntax, same instincts. Controllers are controllers, dependency injection is dependency injection, an ORM is an ORM. Async is where that stops working. It has no Laravel equivalent, and it's where most of the incidents I've seen actually come from.&lt;/p&gt;

&lt;p&gt;PHP doesn't have this problem because PHP doesn't really have this concept. A request comes in, runs on its own process or thread, and there's no shared thread pool to starve. .NET's async model is built around a shared thread pool, and if you get async wrong, you're not just writing slower code. You can deadlock the request, swallow an exception the process never tells you about, or run the server out of sockets under load.&lt;/p&gt;

&lt;h2&gt;
  
  
  Blocking on async code
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ deadlock / thread-pool starvation under load&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="n"&gt;_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetUserAsync&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This line looks harmless. It compiles, it works in dev, it might even work in production for a while under light load. Then traffic goes up and requests start hanging.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;.Result&lt;/code&gt; blocks the calling thread until the async call finishes. Under load, the thread pool is busy running other requests, and the continuation for &lt;code&gt;GetUserAsync&lt;/code&gt; is waiting for a free thread to resume on. If the pool is saturated, that continuation never gets scheduled, the blocked thread never unblocks, and you've got a deadlock. At minimum you get thread-pool starvation, where things slow down instead of failing outright, which is actually worse to diagnose.&lt;/p&gt;

&lt;p&gt;The fix is not clever:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ✅&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;_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetUserAsync&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;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Async all the way down. The moment you mix in one blocking call, you've undone the point of using async in the first place. It's not a style preference. A single &lt;code&gt;.Result&lt;/code&gt; or &lt;code&gt;.Wait()&lt;/code&gt; buried three layers deep in an otherwise-async call chain is enough to bring a whole request path down under load.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;async void&lt;/code&gt;
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ exception is unobservable, process may crash&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;Handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Event&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="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;async void&lt;/code&gt; exists for one legitimate case: UI event handlers, where the framework calls you and there's nowhere to &lt;code&gt;await&lt;/code&gt;. Everywhere else it's a trap. An exception thrown inside an &lt;code&gt;async void&lt;/code&gt; method doesn't propagate the way you'd expect. It doesn't get captured by the caller's try/catch. It gets rethrown on the synchronization context, and for a lot of hosting models that means it can crash the process outright.&lt;/p&gt;

&lt;p&gt;Compare that to an &lt;code&gt;async Task&lt;/code&gt; method: exceptions get captured in the returned &lt;code&gt;Task&lt;/code&gt;, and the caller can &lt;code&gt;await&lt;/code&gt; it, catch it, log it, whatever. An &lt;code&gt;async void&lt;/code&gt; method just fires the exception into the void.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ✅&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;HandleAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Event&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;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="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;Same shape, completely different failure behavior. If you're wiring up an event handler and it's not a UI callback, it should return &lt;code&gt;Task&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A new &lt;code&gt;HttpClient&lt;/code&gt; per call
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ new HttpClient per call → socket exhaustion&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;http&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;HttpClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one's sneaky. &lt;code&gt;HttpClient&lt;/code&gt; implements &lt;code&gt;IDisposable&lt;/code&gt;, so the &lt;code&gt;using&lt;/code&gt; block looks like correct, responsible code. It isn't. Disposing an &lt;code&gt;HttpClient&lt;/code&gt; doesn't immediately release the underlying socket. It goes into a &lt;code&gt;TIME_WAIT&lt;/code&gt; state. Do this on every request and under enough load you exhaust the available sockets on the box, and now everything making outbound HTTP calls starts failing, not just this one client.&lt;/p&gt;

&lt;p&gt;The usual next move people make is to fix that by making the &lt;code&gt;HttpClient&lt;/code&gt; &lt;code&gt;static&lt;/code&gt; and reusing it forever. That solves socket exhaustion but introduces a different bug: a static &lt;code&gt;HttpClient&lt;/code&gt; doesn't respect DNS changes, because the connection stays pinned to whatever IP it resolved first. If the downstream service moves (a failover, a DNS update, a container restart behind a load balancer), your static client keeps hammering the old address.&lt;/p&gt;

&lt;p&gt;The actual fix is &lt;code&gt;IHttpClientFactory&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ✅&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;AddHttpClient&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IPaymentClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PaymentClient&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;AddStandardResilienceHandler&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;     &lt;span class="c1"&gt;// retry + circuit breaker + timeout&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It manages the connection pool for you, rotates handlers on a schedule so DNS changes get picked up, and &lt;code&gt;AddStandardResilienceHandler()&lt;/code&gt; gives you retry, circuit breaker, and timeout behavior in one line. There's no manual &lt;code&gt;HttpClient&lt;/code&gt; lifecycle to get wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules that fall out of this
&lt;/h2&gt;

&lt;p&gt;Once you've been burned by these a couple of times, they collapse into a short list I now just follow by default:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Async all the way down.&lt;/strong&gt; One blocking call anywhere in the chain undoes the benefit of everything above it and can deadlock the request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Take a &lt;code&gt;CancellationToken&lt;/code&gt; in every async method and pass it on.&lt;/strong&gt; If the client disconnects, that should stop costing you a database connection. Threading the token through every layer is tedious the first few times and then it's just muscle memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;IHttpClientFactory&lt;/code&gt;, always.&lt;/strong&gt; Manual &lt;code&gt;HttpClient&lt;/code&gt; instances exhaust sockets. A static one misses DNS changes. There isn't a third option that's actually safe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing mutable in a singleton without a real concurrency primitive.&lt;/strong&gt; &lt;code&gt;ConcurrentDictionary&lt;/code&gt;, &lt;code&gt;SemaphoreSlim&lt;/code&gt;, &lt;code&gt;Channel&amp;lt;T&amp;gt;&lt;/code&gt;, &lt;code&gt;Interlocked&lt;/code&gt;: pick the one that fits, but don't put a plain &lt;code&gt;Dictionary&lt;/code&gt; or a counter on a singleton and assume it'll be fine because it worked in local testing with one user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Background work belongs in &lt;code&gt;BackgroundService&lt;/code&gt;/&lt;code&gt;IHostedService&lt;/code&gt;, or better, a durable queue.&lt;/strong&gt; Hangfire, Quartz, a real broker. &lt;code&gt;Task.Run(...)&lt;/code&gt; and forget feels fine in dev and then dies with the pod on a rolling deploy, taking whatever it was doing with it. If the work needs to actually complete, it needs to survive a restart, and &lt;code&gt;Task.Run&lt;/code&gt; doesn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For caching&lt;/strong&gt;, &lt;code&gt;HybridCache&lt;/code&gt;, in-process plus distributed with stampede protection, is the current default answer. &lt;code&gt;IDistributedCache&lt;/code&gt; with Redis covers cross-instance state when you need something simpler or already have Redis in the stack.&lt;/p&gt;

&lt;p&gt;None of these are exotic. They're all things the framework already gives you a correct way to do. The trap isn't that .NET makes async hard, it's that the wrong version of each of these compiles, runs, and looks identical to the right version until the thread pool is under real load. Laravel never put me in that position, because it never had a shared thread pool to starve.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>programming</category>
      <category>dotnet</category>
      <category>async</category>
    </item>
    <item>
      <title>Authorize Can't See Your Data</title>
      <dc:creator>Kazem</dc:creator>
      <pubDate>Tue, 25 Aug 2026 11:18:48 +0000</pubDate>
      <link>https://dev.to/kazemmdev/authorize-cant-see-your-data-47pc</link>
      <guid>https://dev.to/kazemmdev/authorize-cant-see-your-data-47pc</guid>
      <description>&lt;p&gt;The first authorization bug I ever shipped wasn't a missing &lt;code&gt;[Authorize]&lt;/code&gt; attribute. It was the opposite — everything had &lt;code&gt;[Authorize]&lt;/code&gt;, the tests were green, and it still let one user edit another user's stuff. The attribute was doing exactly what it promised. I just wanted it to promise something else.&lt;/p&gt;

&lt;p&gt;That's the gap that trips people up: &lt;code&gt;[Authorize]&lt;/code&gt; answers "is this user allowed to hit this endpoint." It has no idea "is this user allowed to touch this specific row." Those are two different questions, and ASP.NET Core gives you two different tools for them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authentication first, briefly
&lt;/h2&gt;

&lt;p&gt;Before authorization, there's the "who are you" question, and the answer depends on who's asking:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Internal API behind corporate SSO&lt;/td&gt;
&lt;td&gt;OIDC via Entra ID / Keycloak, JWT bearer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public API for third parties&lt;/td&gt;
&lt;td&gt;JWT bearer + a proper identity provider (Duende, Keycloak, Auth0)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server-rendered app&lt;/td&gt;
&lt;td&gt;Cookie authentication + ASP.NET Core Identity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service-to-service&lt;/td&gt;
&lt;td&gt;Client credentials flow, or mTLS inside the cluster&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;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="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="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;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Authority&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;"Auth:Authority"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Audience&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"sample-api"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TokenValidationParameters&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;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;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;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;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// default 5 min is too generous&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the &lt;code&gt;ClockSkew&lt;/code&gt;. The default is five minutes, which is generous enough that an expired token can still get through for a while after it should have died. Worth tightening on anything that matters.&lt;/p&gt;

&lt;p&gt;And don't hand-roll token issuance or password hashing. Use an identity provider, or ASP.NET Core Identity, which already does PBKDF2/Argon2-grade hashing, lockout, and 2FA. This isn't the place to prove you can implement crypto yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where policy-based authorization stops
&lt;/h2&gt;

&lt;p&gt;Once you know who the user is, &lt;code&gt;[Authorize]&lt;/code&gt; policies handle the "what role can hit what endpoint" layer well:&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;AddAuthorization&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"CanPublish"&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="nf"&gt;RequireClaim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"permission"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"posts.publish"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Adults"&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;Requirements&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;MinimumAgeRequirement&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="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FallbackPolicy&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;AuthorizationPolicyBuilder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;RequireAuthenticatedUser&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;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;Policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"CanPublish"&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;Publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="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 works fine for "can this role publish posts at all." It falls apart the moment the question becomes "can &lt;em&gt;this&lt;/em&gt; user publish &lt;em&gt;this&lt;/em&gt; post." &lt;code&gt;[Authorize]&lt;/code&gt; runs before the action method does anything. It never loaded the post, so it has nothing to check the user against — it can only see claims and roles, not data.&lt;/p&gt;

&lt;p&gt;This is the same shape of problem as a Laravel policy, if you've worked in that world. A policy method takes the model instance and the user and decides together. &lt;code&gt;[Authorize]&lt;/code&gt; alone can't do that, because by the time it runs, there's no instance yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resource-based authorization
&lt;/h2&gt;

&lt;p&gt;The fix is &lt;code&gt;IAuthorizationService&lt;/code&gt;, called after the resource is loaded, with the resource passed in as an argument:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PostAuthorizationHandler&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AuthorizationHandler&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OperationAuthorizationRequirement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Post&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;protected&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;HandleRequirementAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;AuthorizationHandlerContext&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;OperationAuthorizationRequirement&lt;/span&gt; &lt;span class="n"&gt;requirement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Post&lt;/span&gt; &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AuthorId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FindFirstValue&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;NameIdentifier&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Succeed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requirement&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CompletedTask&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// In the handler, once the resource is loaded:&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;authorization&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AuthorizeAsync&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;post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Operations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Update&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;Forbid&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;[Authorize]&lt;/code&gt; alone cannot express "the owner of this post," because it doesn't have the post. Anything instance-specific goes through &lt;code&gt;IAuthorizationService&lt;/code&gt;: load the resource first, then ask the authorization service whether this user can do this operation on this specific instance. It's a deliberate two-step. The decision genuinely depends on data that only exists once you've queried it, so there's no way to collapse it into a single attribute.&lt;/p&gt;

&lt;p&gt;I ran into this exact shape while building the approval workflow core for on my current project. Tasks get completed through a &lt;code&gt;WorkflowTaskAppService&lt;/code&gt;, and the access check there has to look past the role claim: is this specific task assigned to this user, or does their role let them act on it. That can't happen at the attribute level. It happens after the &lt;code&gt;ApprovalTask&lt;/code&gt; is loaded, against that instance. same reasoning as the &lt;code&gt;Post&lt;/code&gt; example, different domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anonymous should be the exception, not the default
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;FallbackPolicy&lt;/code&gt; line in the snippet above is easy to skip past, but it's doing real work:&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;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FallbackPolicy&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;AuthorizationPolicyBuilder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;RequireAuthenticatedUser&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without it, any endpoint without an explicit &lt;code&gt;[Authorize]&lt;/code&gt; is open by default. With it, the default flips: everything requires authentication unless you explicitly mark it &lt;code&gt;[AllowAnonymous]&lt;/code&gt;. Forgetting &lt;code&gt;[Authorize]&lt;/code&gt; on one new controller is the classic way to ship an open endpoint. A fallback policy turns that mistake from silent to loud — a forgotten attribute now means "user gets a 401," not "endpoint quietly accepts anyone."&lt;/p&gt;

&lt;p&gt;It's a small config change, but it changes what kind of mistake is possible. Instead of relying on everyone remembering to lock every new controller, you're relying on someone remembering to unlock the few that should be public. That second failure mode is much easier to catch in review.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist worth keeping around
&lt;/h2&gt;

&lt;p&gt;A few of these are directly related to the resource-based point above, others are just things that are cheap to get right and expensive to get wrong later:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HTTPS + HSTS; &lt;code&gt;UseHttpsRedirection&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Fallback authorization policy; anonymous is opt-in&lt;/li&gt;
&lt;li&gt;Rate limiting (&lt;code&gt;AddRateLimiter&lt;/code&gt;) on auth and expensive endpoints&lt;/li&gt;
&lt;li&gt;Secrets from a vault, never from &lt;code&gt;appsettings.json&lt;/code&gt; or the repo&lt;/li&gt;
&lt;li&gt;Parameterized queries only — &lt;code&gt;FromSqlInterpolated&lt;/code&gt;, never string concatenation into &lt;code&gt;FromSqlRaw&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Don't log tokens, PII, or full request bodies; scrub structured log properties&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;app.UseCors()&lt;/code&gt; with an explicit origin list, never &lt;code&gt;AllowAnyOrigin&lt;/code&gt; + credentials&lt;/li&gt;
&lt;li&gt;Dependency scanning: &lt;code&gt;dotnet list package --vulnerable --include-transitive&lt;/code&gt; in CI&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are exotic. Most authorization bugs I've seen didn't come from someone doing something clever wrong — they came from reaching for &lt;code&gt;[Authorize]&lt;/code&gt; when the actual question needed a resource loaded first.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>programming</category>
      <category>webdev</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>Getting Request Handling Right in .NET APIs</title>
      <dc:creator>Kazem</dc:creator>
      <pubDate>Mon, 24 Aug 2026 11:29:30 +0000</pubDate>
      <link>https://dev.to/kazemmdev/getting-request-handling-right-in-net-apis-4j08</link>
      <guid>https://dev.to/kazemmdev/getting-request-handling-right-in-net-apis-4j08</guid>
      <description>&lt;p&gt;Every request handler answers three questions: where does this data come from, is it well-formed, and is it legal? Most bugs I see in API code come from answering these in the wrong place: validating something in the controller that belongs in the domain, or trying to bind data that should've been rejected by a route constraint before your code even ran. .NET already gives you the tools to keep these separate. The trick is using them for what they're actually for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does the data come from
&lt;/h2&gt;

&lt;p&gt;Take a single action signature:&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;HttpPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{id:guid}/comments"&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;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;FromRoute&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;FromQuery&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;notify&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;FromBody&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="n"&gt;CommentRequest&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;FromHeader&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;"X-Idempotency-Key"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;FromServices&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;ICommentService&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five different binding sources, five different jobs. &lt;code&gt;id&lt;/code&gt; comes from the route because it identifies the resource. &lt;code&gt;notify&lt;/code&gt; sits in the query string since it's an optional modifier, and &lt;code&gt;body&lt;/code&gt;, the actual payload, belongs in the request body. The idempotency key is transport-level metadata rather than part of the resource, so it comes from a header. The service isn't part of the request at all; it comes from DI.&lt;/p&gt;

&lt;p&gt;None of this is exotic. It's just naming things by where they belong instead of dumping everything into one bag and sorting it out in code. When you see a handler that manually reads &lt;code&gt;Request.Query["notify"]&lt;/code&gt; inside the method body, that's usually a sign someone skipped past the binding attributes rather than a sign they weren't available.&lt;/p&gt;

&lt;p&gt;Route constraints do part of this job before your action even runs. &lt;code&gt;{id:guid}&lt;/code&gt; rejects anything that isn't a valid GUID at the routing layer. The request never reaches your handler, so you don't write an &lt;code&gt;if (!Guid.TryParse(...))&lt;/code&gt; check that's really just working around a mistake. &lt;code&gt;{page:int:min(1)}&lt;/code&gt; does the same thing for pagination: no page zero, no negative pages, no need for a manual bounds check in every paginated endpoint. Use them. They're free correctness you don't have to write yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is the input well-formed
&lt;/h2&gt;

&lt;p&gt;Once the request is bound, the next question is whether the values make sense on their own: not whether the operation is allowed, just whether the shape is right.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;CreatePostRequest&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Required&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;StringLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MinimumLength&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Title&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Required&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;PublishedAt&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;[ApiController]&lt;/code&gt; on the controller, a failing model state returns 400 plus a &lt;code&gt;ValidationProblemDetails&lt;/code&gt; body automatically. You don't write that branch yourself. And as of .NET 10, minimal APIs get the same thing without a hand-rolled filter: &lt;code&gt;AddValidation()&lt;/code&gt; wires up DataAnnotations validation directly, so the gap between controllers and minimal APIs on this point mostly closes.&lt;/p&gt;

&lt;p&gt;For anything past "is this field required and how long," DataAnnotations starts to strain. Conditional rules, cross-field checks, async checks like uniqueness: that's FluentValidation territory. I keep it in the Application layer, not on the entity. The reason is the question it's answering: request validation asks "is this input well-formed?" The domain asks "is this operation legal?" Those are different questions with different answers depending on context, and collapsing them into one validation step is where things get messy. A title being too short is always wrong. A post being published while the author is suspended is only wrong because of state the entity itself owns. Mixing those means either the entity ends up depending on request shapes it shouldn't know about, or the request validator starts reaching into business rules it has no business enforcing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shaping the response
&lt;/h2&gt;

&lt;p&gt;On the way out, prefer &lt;code&gt;TypedResults&lt;/code&gt; over &lt;code&gt;Results&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;TypedResults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                                  &lt;span class="c1"&gt;// 200&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;TypedResults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Created&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"/api/posts/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;dto&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;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// 201 + Location&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;TypedResults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NoContent&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                              &lt;span class="c1"&gt;// 204&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;TypedResults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NotFound&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                               &lt;span class="c1"&gt;// 404&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;TypedResults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ValidationProblem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                &lt;span class="c1"&gt;// 400&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;TypedResults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Problem&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="m"&gt;409&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Conflict"&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;TypedResults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"text/csv"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"export.csv"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference isn't cosmetic. The concrete type flows into OpenAPI generation and is assertable in tests: you can check that an endpoint returns &lt;code&gt;Created&amp;lt;PostDto&amp;gt;&lt;/code&gt; instead of asserting on a generic &lt;code&gt;IActionResult&lt;/code&gt; and hoping the status code lines up. &lt;code&gt;Results&lt;/code&gt; still works, but you lose that.&lt;/p&gt;

&lt;p&gt;For large collections, don't buffer the whole thing into a list before returning it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;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;"/export"&lt;/span&gt;&lt;span class="p"&gt;,&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;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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Posts&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;AsAsyncEnumerable&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;   &lt;span class="c1"&gt;// IAsyncEnumerable → streamed JSON&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;IAsyncEnumerable&lt;/code&gt; streams the JSON as rows come off the database instead of materializing everything in memory first. For an export endpoint especially, that's the difference between a response that starts immediately and one that hangs while the server loads ten thousand rows before writing a single byte.&lt;/p&gt;

&lt;h2&gt;
  
  
  Contract hygiene
&lt;/h2&gt;

&lt;p&gt;A few rules I hold to regardless of the endpoint:&lt;/p&gt;

&lt;p&gt;DTOs both ways, always. Never accept or return entities directly. Over-posting (a client sending fields it shouldn't be able to set, which then get bound straight onto your entity) is a real vulnerability, not a theoretical one. And returning entities means every change to your domain model is also a silent breaking change to your API clients, whether you meant it to be or not.&lt;/p&gt;

&lt;p&gt;Version from day one, either &lt;code&gt;/api/v1/...&lt;/code&gt; or a header. Retro-fitting versioning onto an API that's already live and already has consumers is painful in a way that's hard to appreciate until you're the one doing it.&lt;/p&gt;

&lt;p&gt;For docs, &lt;code&gt;AddOpenApi()&lt;/code&gt; / &lt;code&gt;MapOpenApi()&lt;/code&gt; generates the OpenAPI document now. Swashbuckle's templates left as of .NET 9. Pair it with Scalar or Swagger UI for the browsable page.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ProblemDetails&lt;/code&gt; everywhere, including on 500s. One error shape across the whole API means clients write one error-handling path instead of special-casing whatever shape each endpoint happened to return.&lt;/p&gt;

&lt;p&gt;And status codes should mean what they say: 401 is "who are you," 403 is "I know who you are and no," 409 is a concurrency conflict, 422 is semantically invalid input: the request was well-formed but the operation itself can't happen. Getting these right isn't pedantry. It's the difference between a client that can programmatically decide whether to retry, prompt for re-authentication, or just fail, versus a client that has to parse your error message to figure out what happened.&lt;/p&gt;

&lt;p&gt;What trips people up isn't any single piece of this. It's blending the layers: validating business rules in a request DTO, or handling routing concerns inside a service method. Keep each question where it belongs, and the framework does most of the work for you.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>backend</category>
      <category>dotnet</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Controllers vs Minimal APIs: Stop Picking a Winner</title>
      <dc:creator>Kazem</dc:creator>
      <pubDate>Mon, 24 Aug 2026 11:04:42 +0000</pubDate>
      <link>https://dev.to/kazemmdev/controllers-vs-minimal-apis-stop-picking-a-winner-452g</link>
      <guid>https://dev.to/kazemmdev/controllers-vs-minimal-apis-stop-picking-a-winner-452g</guid>
      <description>&lt;p&gt;Every .NET 10 project I start now has the same five-minute argument with myself: Controllers or Minimal APIs?&lt;/p&gt;

&lt;p&gt;Both are first-class citizens in .NET 10, with the same routing, DI, filters, and model binding under the hood. So the question isn't which one is "better." It's which shape fits the API you're actually building. And that decision matters a lot less than what you do once you've made it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual tradeoff
&lt;/h2&gt;

&lt;p&gt;Here's how I think about it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Controllers (MVC)&lt;/th&gt;
&lt;th&gt;Minimal APIs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Best for&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Large APIs, convention-heavy teams, model binding edge cases, existing MVC codebases, framework integration (ABP, OData)&lt;/td&gt;
&lt;td&gt;Small/medium services, vertical slices, high-throughput endpoints, AOT scenarios&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Discovery&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Attribute routing, conventions&lt;/td&gt;
&lt;td&gt;Explicit &lt;code&gt;Map*&lt;/code&gt; calls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Overhead&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Slightly more per request&lt;/td&gt;
&lt;td&gt;Lowest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Risk&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;"Fat controller" gravity&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Program.cs&lt;/code&gt; becomes a 900-line wall unless you split into endpoint modules&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Notice the risk column. Both approaches share the same failure mode: everything ends up in one place because nobody stopped it early. Controllers accrete logic because it's easy to add "just one more method" to an existing class. Minimal APIs accrete &lt;code&gt;Map*&lt;/code&gt; calls in &lt;code&gt;Program.cs&lt;/code&gt; for the same reason: the file's already open, the pattern's already there, why not.&lt;/p&gt;

&lt;p&gt;What matters is keeping either one from collapsing into a dumping ground, not which style you start with.&lt;/p&gt;

&lt;h2&gt;
  
  
  What that looks like for Minimal APIs
&lt;/h2&gt;

&lt;p&gt;If you're going with Minimal APIs, don't stack your endpoints in &lt;code&gt;Program.cs&lt;/code&gt;. Pull them into modules and register them with an extension method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Minimal API, organized as a module — not dumped in Program.cs&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PostEndpoints&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;IEndpointRouteBuilder&lt;/span&gt; &lt;span class="nf"&gt;MapPosts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt; &lt;span class="n"&gt;IEndpointRouteBuilder&lt;/span&gt; &lt;span class="n"&gt;app&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="k"&gt;group&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;MapGroup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/posts"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithTags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Posts"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RequireAuthorization&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddEndpointFilter&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ValidationFilter&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

        &lt;span class="k"&gt;group&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="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IPostService&lt;/span&gt; &lt;span class="n"&gt;svc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;AsParameters&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;PostQuery&lt;/span&gt; &lt;span class="n"&gt;q&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;TypedResults&lt;/span&gt;&lt;span class="p"&gt;.&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;await&lt;/span&gt; &lt;span class="n"&gt;svc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SearchAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&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;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/"&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;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Created&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PostDto&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt; &lt;span class="n"&gt;ValidationProblem&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;CreatePostRequest&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IPostService&lt;/span&gt; &lt;span class="n"&gt;svc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;dto&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;svc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&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;TypedResults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Created&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"/api/posts/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;dto&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;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Program.cs&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;MapPosts&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;MapGroup&lt;/code&gt; gives you a single place to attach tags, auth, and filters for a whole slice of routes, instead of repeating &lt;code&gt;.RequireAuthorization()&lt;/code&gt; on every individual &lt;code&gt;Map*&lt;/code&gt; call. &lt;code&gt;Program.cs&lt;/code&gt; stays one line per feature. That's the whole trick, and it only works if you do it from the start, before you have twenty endpoints to retrofit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What that looks like for Controllers
&lt;/h2&gt;

&lt;p&gt;The controller side has its own version of the same discipline, and it's basically the same spirit I'd apply in Laravel: keep the controller thin and let it do exactly one job, translating an HTTP request into a call on something else.&lt;/p&gt;

&lt;p&gt;A few things I hold to on every controller:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Thin.&lt;/strong&gt; Bind → delegate → shape a response. No EF queries in a controller action, no business rules. If you're writing a &lt;code&gt;Where&lt;/code&gt; clause in a controller, that logic belongs in a service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Always &lt;code&gt;[ApiController]&lt;/code&gt;.&lt;/strong&gt; It gives you automatic 400s on model-binding failures and infers binding sources for you. There's no good reason to opt out of this on a real API controller.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never return a raw entity.&lt;/strong&gt; Return &lt;code&gt;IActionResult&lt;/code&gt; or &lt;code&gt;TypedResults&lt;/code&gt;, and shape the response explicitly. Your database schema is not your API contract. The moment you return an EF entity directly, every column you add to that table becomes a payload change for every client, whether you meant it to or not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Always accept and forward &lt;code&gt;CancellationToken&lt;/code&gt;.&lt;/strong&gt; And never &lt;code&gt;async void&lt;/code&gt;, never &lt;code&gt;.Result&lt;/code&gt;, never &lt;code&gt;.Wait()&lt;/code&gt;. Under load, blocking on an async call like that is a thread-pool starvation deadlock waiting to happen, the kind of bug that shows up in production instead of dev, under exactly the traffic you were trying to handle.&lt;/p&gt;

&lt;h2&gt;
  
  
  So which one do I pick
&lt;/h2&gt;

&lt;p&gt;It depends on the shape of what I'm building. That's not a cop-out; it's the actual answer. A handful of high-throughput, independently-scaled endpoints with no shared model-binding complexity? Minimal APIs, one module per feature. A large API integrating with something like ABP or OData, where framework conventions are doing real work for me? Controllers.&lt;/p&gt;

&lt;p&gt;What I've stopped doing is treating the choice as the interesting part. What matters is whether, six months in, the routing layer is still organized the way it was on day one: whether &lt;code&gt;Program.cs&lt;/code&gt; is still readable, whether controllers are still thin. That's a discipline problem, not an API-style problem.&lt;/p&gt;

&lt;p&gt;Pick the shape that fits, then actually enforce the guardrails that keep it from turning into the thing you were trying to avoid in the other approach.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>tutorial</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>Middleware Order Is Behavior, Not Style</title>
      <dc:creator>Kazem</dc:creator>
      <pubDate>Mon, 24 Aug 2026 11:04:01 +0000</pubDate>
      <link>https://dev.to/kazemmdev/middleware-order-is-behavior-not-style-13ni</link>
      <guid>https://dev.to/kazemmdev/middleware-order-is-behavior-not-style-13ni</guid>
      <description>&lt;p&gt;Middleware ordering looks like a formatting preference, like where you put your using statements. It isn't. Put &lt;code&gt;UseAuthorization()&lt;/code&gt; before &lt;code&gt;UseAuthentication()&lt;/code&gt; in a pipeline and every request sails through as anonymous. No error, no warning, nothing in the logs to flag it. The app just quietly stops checking who anyone is.&lt;/p&gt;

&lt;p&gt;That's the thing about ASP.NET Core middleware: it's a pipeline, and pipelines are order-sensitive by nature. Each component gets the &lt;code&gt;HttpContext&lt;/code&gt;, can act before and after the next one runs, and can short-circuit the whole thing. Swap two lines and you haven't changed how the code looks: you've changed what "authenticated" or "handled" means for every request that comes through. And the framework won't tell you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short-circuit and what runs after it
&lt;/h2&gt;

&lt;p&gt;Here's a tenant-resolution middleware that shows most of the moving parts at 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;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TenantMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RequestDelegate&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;InvokeAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HttpContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ITenantStore&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// scoped svc injected per-call&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;tenantId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"X-Tenant"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;FirstOrDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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="n"&gt;StatusCodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status400BadRequest&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteAsJsonAsync&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;ProblemDetails&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;Title&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Missing X-Tenant header."&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="m"&gt;400&lt;/span&gt;
            &lt;span class="p"&gt;});&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                                     &lt;span class="c1"&gt;// short-circuit: next is never called&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Items&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Tenant"&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;store&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;tenantId&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;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                            &lt;span class="c1"&gt;// everything after this runs on the way out&lt;/span&gt;

        &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"X-Tenant"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ⚠ too late if the response already started&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="n"&gt;UseMiddleware&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TenantMiddleware&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the header is missing, we return a 400 and never call &lt;code&gt;next(context)&lt;/code&gt;. That's the short-circuit: nothing further down the pipeline runs, including routing, auth, or the endpoint itself. It's a deliberate gate, and it's the same mechanism that makes ordering matter everywhere else in the pipeline: whatever runs before &lt;code&gt;next()&lt;/code&gt; sees the request on the way in, and whatever runs after sees it on the way out, only if something further down didn't already short-circuit it.&lt;/p&gt;

&lt;p&gt;That last line is worth pausing on: setting a response header after &lt;code&gt;await next(context)&lt;/code&gt; assumes the response hasn't started yet. If anything downstream has already begun streaming the response, which is common the moment you're dealing with larger payloads or certain content types, that header write does nothing. No exception, no warning. It just silently fails to take effect.&lt;/p&gt;

&lt;p&gt;There's a second trap in that same snippet that has nothing to do with ordering: &lt;code&gt;ITenantStore store&lt;/code&gt; is injected as a parameter on &lt;code&gt;InvokeAsync&lt;/code&gt;, not through the constructor. That's not a style choice either. Convention-based middleware like this is instantiated once, at startup, and reused as a singleton for the lifetime of the app. If you constructor-inject a scoped service, like most &lt;code&gt;DbContext&lt;/code&gt;-backed stores, you're capturing it once and reusing that same instance across every request, which is exactly the kind of bug that only shows up under concurrent load, well after you've stopped looking at this file. Injecting scoped services as &lt;code&gt;InvokeAsync&lt;/code&gt; parameters sidesteps that, because the DI container resolves them fresh per request. The other option is to implement &lt;code&gt;IMiddleware&lt;/code&gt; and register it explicitly, which gives you per-request instantiation instead of the singleton convention.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline as a dependency graph
&lt;/h2&gt;

&lt;p&gt;Once you've internalized that middleware runs in a strict, request-in/response-out sequence, the rest of a typical pipeline reads less like a checklist and more like a dependency graph:&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;UseExceptionHandler&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;      &lt;span class="c1"&gt;// outermost — must wrap everything to catch it&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="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;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseStaticFiles&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;UseRouting&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;               &lt;span class="c1"&gt;// decides WHICH endpoint&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;UseRateLimiter&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;UseCors&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;UseAuthentication&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;        &lt;span class="c1"&gt;// WHO are you        — must be after UseRouting&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;UseAuthorization&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;         &lt;span class="c1"&gt;// are you ALLOWED    — must be after authentication&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;UseOutputCache&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;MapControllers&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;           &lt;span class="c1"&gt;// executes the endpoint&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;UseExceptionHandler()&lt;/code&gt; goes first because it needs to wrap everything else. Register it last and it's inside the pipeline instead of around it, so it won't catch exceptions thrown by the middleware registered before it. That's easy to miss because a handler like this can look correct in isolation and pass a test built around a deliberately-thrown exception in a controller action, while still doing nothing the one time an earlier middleware fails.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;UseAuthentication()&lt;/code&gt; has to come after &lt;code&gt;UseRouting()&lt;/code&gt;, and &lt;code&gt;UseAuthorization()&lt;/code&gt; has to come after &lt;code&gt;UseAuthentication()&lt;/code&gt;. Flip that last pair, authorization before authentication, and you get the anonymous-request problem: every request is evaluated as anonymous because the authorization middleware runs before anything has had a chance to establish who the caller is. The request doesn't error. It just gets treated as if no one is logged in, which in a lot of setups means either "reject everyone" or, worse, "allow everyone" depending on how your policies are written.&lt;/p&gt;

&lt;p&gt;Neither mistake throws a compile-time error or a runtime warning. The pipeline builds fine. Requests come through. The behavior is just wrong, and the only way to catch it is to actually exercise the auth path or read the pipeline order carefully enough to reason through what each stage assumes about the ones before it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handle exceptions once, centrally
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;UseExceptionHandler()&lt;/code&gt; line pairs with an &lt;code&gt;IExceptionHandler&lt;/code&gt; implementation, which is where cross-cutting exception handling actually lives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DomainExceptionHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IProblemDetailsService&lt;/span&gt; &lt;span class="n"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IExceptionHandler&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;ValueTask&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;TryHandleAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HttpContext&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="n"&gt;DomainException&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// let the next handler try&lt;/span&gt;

        &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;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="n"&gt;StatusCodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status422UnprocessableEntity&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;problems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryWriteAsync&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;ProblemDetailsContext&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;HttpContext&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;ProblemDetails&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Title&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Domain rule violated"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Detail&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&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;AddExceptionHandler&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DomainExceptionHandler&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddProblemDetails&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you find yourself wrapping every controller action in &lt;code&gt;try/catch&lt;/code&gt;, that's usually a sign the exception handling belongs here instead. Registering multiple &lt;code&gt;IExceptionHandler&lt;/code&gt; implementations lets you handle different exception types separately (&lt;code&gt;TryHandleAsync&lt;/code&gt; returning &lt;code&gt;false&lt;/code&gt; just passes the exception to the next registered handler), while every response still comes back as a consistent RFC 9457 &lt;code&gt;ProblemDetails&lt;/code&gt; payload instead of whatever ad hoc shape each action happened to return.&lt;/p&gt;

&lt;h2&gt;
  
  
  Middleware or endpoint filter?
&lt;/h2&gt;

&lt;p&gt;Not everything belongs in the middleware pipeline just because it can go there. The distinction that's actually useful:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Use middleware for&lt;/th&gt;
&lt;th&gt;Use an endpoint filter / action filter for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Anything cross-cutting on every request (logging, auth, correlation IDs, compression)&lt;/td&gt;
&lt;td&gt;Concerns tied to a specific endpoint or group (validation, idempotency keys, resource authorization)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Work that must run before routing&lt;/td&gt;
&lt;td&gt;Work that needs the bound arguments / model&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Middleware runs before the framework has matched a route or bound any parameters, so it's the right place for things that apply uniformly: logging every request, stamping a correlation ID, checking who the caller is. The moment you need the actual bound arguments of a specific endpoint (validating a request body, checking an idempotency key against a specific resource), you're past what middleware can see cleanly, and an endpoint filter is the better fit. Trying to do argument-level validation in middleware usually means re-parsing or re-binding something the framework is about to do anyway.&lt;/p&gt;

&lt;p&gt;None of this is complicated once you see the pipeline for what it is: a strict, ordered sequence where each stage's correctness depends on assumptions about what already ran. The bugs aren't exotic. They're one-line reorderings that compile clean, run clean, and quietly change what your API does.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>backend</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>EF Core bugs that look like correct code</title>
      <dc:creator>Kazem</dc:creator>
      <pubDate>Mon, 24 Aug 2026 09:40:31 +0000</pubDate>
      <link>https://dev.to/kazemmdev/ef-core-bugs-that-look-like-correct-code-51j6</link>
      <guid>https://dev.to/kazemmdev/ef-core-bugs-that-look-like-correct-code-51j6</guid>
      <description>&lt;p&gt;Most EF Core bugs I've seen in production aren't from bad code. They're from code that looks right. It compiles, it passes review, it works fine locally against a database with twelve rows in it. Then it hits a table with five thousand rows, or a second replica, or a request that gets cancelled halfway through, and it falls over in a way nobody wrote a test for.&lt;/p&gt;

&lt;p&gt;None of the mistakes below are exotic. They're the default behavior of EF Core when you don't opt out of it, or the default behavior of a deployment when nobody thought about what "five pods start at the same time" actually means. Here's the setup I use and the list of ways it goes wrong if you skip a step.&lt;/p&gt;

&lt;h2&gt;
  
  
  The entity
&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;namespace&lt;/span&gt; &lt;span class="nn"&gt;Sample.Domain.Posts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Post&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;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;private&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="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateVersion7&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// sequential → index-friendly&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Title&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Slug&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;PublishedAt&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;AuthorId&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;uint&lt;/span&gt; &lt;span class="n"&gt;RowVersion&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;// optimistic concurrency token&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;Publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeProvider&lt;/span&gt; &lt;span class="n"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PublishedAt&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DomainException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Post is already published."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;PublishedAt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetUtcNow&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;Two things here that are easy to skip and annoying to retrofit later. Timestamps are stored as UTC (&lt;code&gt;DateTimeOffset&lt;/code&gt;), rendered in the user's timezone only at the edge — I do the same thing on my project, storing everything UTC and rendering in Asia/Turkey, because "what timezone is this in" is a much worse question to answer after the data already exists in three different formats.&lt;/p&gt;

&lt;p&gt;Second: the clock comes in as &lt;code&gt;TimeProvider&lt;/code&gt;, not a call to &lt;code&gt;DateTime.UtcNow&lt;/code&gt; buried inside the method. It's a small thing, but it's the difference between a test that can assert "publishing sets the timestamp to exactly this value" and a test that has to accept "sometime around now."&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuration, not attributes
&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;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AppDbContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DbContextOptions&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;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;DbContext&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="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;DbSet&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Posts&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;OnModelCreating&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ModelBuilder&lt;/span&gt; &lt;span class="n"&gt;b&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;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ApplyConfigurationsFromAssembly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AppDbContext&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;Assembly&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PostConfiguration&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;Post&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;Post&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;ToTable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"posts"&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;HasKey&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;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;Title&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;HasMaxLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;IsRequired&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;Slug&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="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="k"&gt;new&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;PublishedAt&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;Id&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;RowVersion&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;IsRowVersion&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;Mapping lives in &lt;code&gt;IEntityTypeConfiguration&amp;lt;T&amp;gt;&lt;/code&gt; classes instead of &lt;code&gt;[MaxLength]&lt;/code&gt; and &lt;code&gt;[Required]&lt;/code&gt; attributes scattered across the entity. The entity is domain code — it shouldn't need to know it's being persisted to a &lt;code&gt;posts&lt;/code&gt; table with a unique index on &lt;code&gt;Slug&lt;/code&gt;. Keep persistence concerns out of the domain and the entity stays readable as just "what a Post is," not "what a Post is plus how Postgres stores it."&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrations as a deploy step, not a startup hook
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet tool &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--global&lt;/span&gt; dotnet-ef
dotnet ef migrations add CreatePosts &lt;span class="nt"&gt;-p&lt;/span&gt; src/Sample.Infrastructure &lt;span class="nt"&gt;-s&lt;/span&gt; src/Sample.Api
dotnet ef database update &lt;span class="nt"&gt;-s&lt;/span&gt; src/Sample.Api

&lt;span class="c"&gt;# Production: generate idempotent SQL, review it, apply it in the deploy pipeline&lt;/span&gt;
dotnet ef migrations script &lt;span class="nt"&gt;--idempotent&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; migrate.sql &lt;span class="nt"&gt;-s&lt;/span&gt; src/Sample.Api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the one I'd flag hardest: don't call &lt;code&gt;db.Database.Migrate()&lt;/code&gt; at app startup in a multi-replica deployment. It looks convenient: the app migrates itself, one less step to remember. But five pods racing to alter the same schema on boot is how you get a half-migrated database at 3am, and you find out about it from an alert, not from a code review comment. Migrations run as a separate deploy step, an init container or a pipeline stage, with a single runner. On a K3s deployment where the whole point is running multiple replicas, this isn't an edge case, it's the normal startup path.&lt;/p&gt;

&lt;p&gt;The other rule, same as I'd apply to any migration system including Laravel's: migrations are append-only once merged. You don't go back and edit a migration that's already shipped, you write a new one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reads that don't drag the change tracker along
&lt;/h2&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/posts"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PostsController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IPostService&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;)&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;span class="n"&gt;HttpGet&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ProducesResponseType&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PagedResult&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PostDto&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;StatusCodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status200OK&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;Index&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;FromQuery&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;PostQuery&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;=&amp;gt;&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;await&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SearchAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;HttpGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{id:guid}"&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;"GetPost"&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;Show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Guid&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;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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;posts&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;id&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;is&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;dto&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;NotFound&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;Store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CreatePostRequest&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;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;dto&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;posts&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;request&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;return&lt;/span&gt; &lt;span class="nf"&gt;CreatedAtRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"GetPost"&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;dto&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;dto&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// 201 + Location&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;HttpDelete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{id:guid}"&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;Destroy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Guid&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;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;DeleteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&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;return&lt;/span&gt; &lt;span class="nf"&gt;NoContent&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                                          &lt;span class="c1"&gt;// 204&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The read side — project to a DTO in SQL, don't materialize entities you won't mutate&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;PagedResult&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PostDto&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;SearchAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PostQuery&lt;/span&gt; &lt;span class="n"&gt;q&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;query&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;Posts&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;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;PublishedAt&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PublishedAt&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetUtcNow&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrWhiteSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Search&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;query&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;EF&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Functions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ILike&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;Title&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;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Search&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;%"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;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;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CountAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OrderByDescending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;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;PublishedAt&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ThenBy&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Skip&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Page&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PageSize&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PageSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&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="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;PostDto&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;Id&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;Title&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;Slug&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;PublishedAt&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;// projection&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToListAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;PagedResult&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PostDto&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PageSize&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the routine version. The interesting part is the list of ways a piece of code that looks just like this one goes wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The traps that cost real money
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Forgetting &lt;code&gt;AsNoTracking()&lt;/code&gt; on reads.&lt;/strong&gt; The change tracker snapshots every entity it loads so it can detect what changed on &lt;code&gt;SaveChanges()&lt;/code&gt;. On a list endpoint returning 5,000 rows you're never going to mutate, that snapshotting is pure overhead. It's easy to miss because nothing throws — the endpoint just gets slower and nobody notices until the row count grows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;N+1 via &lt;code&gt;Include&lt;/code&gt; in a loop.&lt;/strong&gt; Lazy loading is off by default, which is the right call. But a &lt;code&gt;foreach&lt;/code&gt; with a &lt;code&gt;.Load()&lt;/code&gt; inside it, or an &lt;code&gt;Include&lt;/code&gt; chain built inside a loop, recreates the exact N+1 problem lazy loading was supposed to prevent. The fix isn't a rule you remember, it's counting queries in tests, logging query counts and asserting on them, because this one hides well in code review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cartesian explosion.&lt;/strong&gt; Two collection &lt;code&gt;Include&lt;/code&gt;s in a single query multiply rows: if a post has several comments and several tags, you don't get one row per relationship back, you get every combination multiplied together. &lt;code&gt;AsSplitQuery()&lt;/code&gt; fixes it by running separate queries instead of one join, but you have to know to reach for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Client-side evaluation.&lt;/strong&gt; If a &lt;code&gt;Where&lt;/code&gt; clause can't translate to SQL, EF Core 3+ throws instead of silently pulling the whole table into memory and filtering there. That's on purpose. The fix is to read the exception and rewrite the predicate, not to slap a &lt;code&gt;.ToList()&lt;/code&gt; before the filter to make the error go away. That "fix" is how you get the exact behavior the throw was designed to prevent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No cancellation token.&lt;/strong&gt; Every async DB call should take the request's &lt;code&gt;CancellationToken&lt;/code&gt;. Skip it and an aborted request (the user closed the tab, the client timed out) keeps the query running and burning DB time for work nobody's waiting on anymore.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;SaveChanges&lt;/code&gt; per row in a loop.&lt;/strong&gt; Updating rows by loading each one, mutating it, and calling &lt;code&gt;SaveChangesAsync()&lt;/code&gt; inside the loop turns one update into a round trip per row. Batch the changes into one &lt;code&gt;SaveChanges&lt;/code&gt; call, or better, use &lt;code&gt;ExecuteUpdateAsync&lt;/code&gt; / &lt;code&gt;ExecuteDeleteAsync&lt;/code&gt; for set-based operations that don't need entities loaded into memory at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Long-lived &lt;code&gt;DbContext&lt;/code&gt;.&lt;/strong&gt; It's registered &lt;code&gt;Scoped&lt;/code&gt; for a reason. It isn't thread-safe, and its change tracker grows unbounded the longer it's kept alive. Holding onto one across requests, or sharing it across concurrent operations, is asking for either an &lt;code&gt;InvalidOperationException&lt;/code&gt; mid-request or a slow memory leak.&lt;/p&gt;

&lt;p&gt;Individually these read like a checklist. In practice they're the shape production incidents actually take: an endpoint that behaves fine solo and falls over under concurrent load, a migration that half-applies because two pods started within the same second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Commit first, dispatch after
&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;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="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;post&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;Post&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Title&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Slug&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;Slugify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Title&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;AuthorId&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Posts&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;post&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveChangesAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;outbox&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;PostCreated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post&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;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// same transaction&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CommitAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule here is simple to state and easy to violate without noticing: never publish to a message bus or call a third-party API inside a transaction. If the transaction rolls back after the message already went out, you've told the rest of the system about a &lt;code&gt;Post&lt;/code&gt; that doesn't exist. Write to an outbox table in the same transaction as the domain change, commit, then dispatch from the outbox afterward. The message only goes out if the write actually stuck.&lt;/p&gt;

&lt;p&gt;This pattern matters more the more moving parts a system has downstream: background jobs, other services reacting to events, things that can't be un-told once they've happened. It's the same reasoning behind offloading side effects to a job runner instead of doing them inline: the database transaction is where correctness gets decided, and everything after commit is just telling the rest of the world what already happened.&lt;/p&gt;

&lt;p&gt;None of this is advanced EF Core. It's the boring stuff: entity design, mapping, migrations, queries that don't drag more into memory than they need to. The bugs aren't hard to fix once you see them. The hard part is that the code that has them doesn't look wrong.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>webdev</category>
      <category>backend</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>Register, Then Run: The Discipline Hiding in Program.cs</title>
      <dc:creator>Kazem</dc:creator>
      <pubDate>Mon, 24 Aug 2026 07:41:20 +0000</pubDate>
      <link>https://dev.to/kazemmdev/register-then-run-the-discipline-hiding-in-programcs-19p9</link>
      <guid>https://dev.to/kazemmdev/register-then-run-the-discipline-hiding-in-programcs-19p9</guid>
      <description>&lt;p&gt;The first time I tried to register a service after &lt;code&gt;builder.Build()&lt;/code&gt; in ASP.NET Core, I got an error that made no sense to me. The container was already sealed, it said. I'd spent years in Laravel, where you can bind things into the container more or less whenever you feel like it, and my instinct was to go looking for the service registration wherever the code needed it. ASP.NET Core doesn't work that way, and once I understood why, a handful of other things that had confused me stopped being confusing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two phases, strictly ordered
&lt;/h2&gt;

&lt;p&gt;Here's what a minimal &lt;code&gt;Program.cs&lt;/code&gt; looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WebApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// ---------- 1. REGISTRATION: build the DI container + config ----------&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;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseNpgsql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetConnectionString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Default"&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;AddScoped&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IPostService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PostService&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddProblemDetails&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddOpenApi&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAuthentication&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;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;AddAuthorization&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddHealthChecks&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;AddDbContextCheck&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// container is now SEALED — no more registrations&lt;/span&gt;

&lt;span class="c1"&gt;// ---------- 2. PIPELINE: order is behaviour ----------&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="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;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseAuthentication&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;UseAuthorization&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// ---------- 3. ENDPOINTS ----------&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;MapControllers&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;MapHealthChecks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/health/ready"&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;MapOpenApi&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;Everything on &lt;code&gt;builder.Services&lt;/code&gt; happens before &lt;code&gt;builder.Build()&lt;/code&gt;. Everything on &lt;code&gt;app&lt;/code&gt; happens after. You register everything, build the container once, and only then configure the pipeline. There's no &lt;code&gt;Startup.cs&lt;/code&gt; anymore, either. If you're reading a tutorial with &lt;code&gt;Startup.ConfigureServices&lt;/code&gt;, it's pre-.NET 6.&lt;/p&gt;

&lt;p&gt;This isn't a syntax quirk. It's the same register-then-run discipline you'll run into again as soon as you touch DI lifetimes or configuration, and skipping past it is exactly how people carry over habits from more dynamic frameworks and get burned.&lt;/p&gt;

&lt;h2&gt;
  
  
  The captive dependency trap
&lt;/h2&gt;

&lt;p&gt;DI lifetimes in ASP.NET Core come in three flavors:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Lifetime&lt;/th&gt;
&lt;th&gt;One instance per&lt;/th&gt;
&lt;th&gt;Use for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Singleton&lt;/td&gt;
&lt;td&gt;Application&lt;/td&gt;
&lt;td&gt;Stateless services, caches, config, &lt;code&gt;IHttpClientFactory&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scoped&lt;/td&gt;
&lt;td&gt;HTTP request&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;DbContext&lt;/code&gt;, repositories, unit of work, anything request-specific&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transient&lt;/td&gt;
&lt;td&gt;Every resolution&lt;/td&gt;
&lt;td&gt;Cheap, stateless helpers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The DI scope is created per request, and everything registered as &lt;code&gt;Scoped&lt;/code&gt; (your &lt;code&gt;DbContext&lt;/code&gt;, your unit of work) lives and dies with it. The trap is injecting a &lt;code&gt;Scoped&lt;/code&gt; service into a &lt;code&gt;Singleton&lt;/code&gt;. The singleton is built once, so it captures whatever instance of the scoped service happened to be around at that moment (usually the first request's) and holds onto it forever. Your &lt;code&gt;DbContext&lt;/code&gt; becomes a shared, thread-unsafe, ever-growing object that nobody intended to create.&lt;/p&gt;

&lt;p&gt;The default container throws on this in development, via &lt;code&gt;ValidateScopes&lt;/code&gt;. Keep that on, and turn on &lt;code&gt;ValidateOnBuild&lt;/code&gt; too so CI catches it before it ships:&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;Host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseDefaultServiceProvider&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&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="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ValidateScopes&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;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ValidateOnBuild&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;If you genuinely need scoped work inside a singleton or a &lt;code&gt;BackgroundService&lt;/code&gt;, you don't fight the lifetime. You create a scope explicitly:&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;_serviceScopeFactory&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Configuration
&lt;/h2&gt;

&lt;p&gt;Configuration in ASP.NET Core is layered: &lt;code&gt;appsettings.json&lt;/code&gt;, then &lt;code&gt;appsettings.{Environment}.json&lt;/code&gt;, then user secrets in dev, then environment variables, then command line, each one overriding the last.&lt;/p&gt;

&lt;p&gt;Binding options follows a simple rule: validate at startup, not at first use.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Bind + validate at startup, not at first use&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;AddOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SmtpOptions&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;Bind&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;GetSection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Smtp"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ValidateDataAnnotations&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ValidateOnStart&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Consume&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Mailer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SmtpOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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;There are three ways to consume it, and picking the wrong one is another version of the captive dependency problem: &lt;code&gt;IOptions&amp;lt;T&amp;gt;&lt;/code&gt; is a singleton snapshot, taken once and frozen. &lt;code&gt;IOptionsSnapshot&amp;lt;T&amp;gt;&lt;/code&gt; re-reads per request. &lt;code&gt;IOptionsMonitor&amp;lt;T&amp;gt;&lt;/code&gt; gives you change notifications and is the only one of the three that's safe to use inside a singleton. Never put secrets in &lt;code&gt;appsettings.json&lt;/code&gt;. Use user secrets locally, and a real secret store in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for project structure
&lt;/h2&gt;

&lt;p&gt;A layered solution (Domain, Application, Infrastructure, Api) enforces a dependency rule: arrows point inward, Domain knows nothing about EF Core or HTTP. If your Domain project needs a &lt;code&gt;using Microsoft.EntityFrameworkCore;&lt;/code&gt;, the layering is broken.&lt;/p&gt;

&lt;p&gt;Two things I've learned from actually building this, not from a tutorial: four projects is the ceiling for a small service, not the floor. A single well-organized project with folders is fine below roughly ten endpoints, and it's much easier to move around later. And for feature-heavy apps, vertical slices (a folder per feature containing its endpoint, handler, DTOs, and validator) tend to scale better in practice than horizontal layers. Layers keep dependencies honest; slices keep changes local. Pick one on purpose, and write down why, because you'll want to remember the reasoning the next time someone asks why the project isn't laid out the "standard" way.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>The .csproj File Nobody Reads</title>
      <dc:creator>Kazem</dc:creator>
      <pubDate>Mon, 24 Aug 2026 07:19:27 +0000</pubDate>
      <link>https://dev.to/kazemmdev/the-csproj-file-nobody-reads-21g8</link>
      <guid>https://dev.to/kazemmdev/the-csproj-file-nobody-reads-21g8</guid>
      <description>&lt;p&gt;Most people copy a &lt;code&gt;.csproj&lt;/code&gt; from the last project and never look at it again. It's the file you generate once with &lt;code&gt;dotnet new webapi&lt;/code&gt; and then ignore for the rest of the project's life. I think that's a mistake: a handful of settings in there decide how much your build actually catches before broken code reaches production.&lt;/p&gt;

&lt;p&gt;Before getting to those settings, it's worth clearing up something a lot of people are fuzzy on: the difference between the SDK and the Runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  SDK vs Runtime
&lt;/h2&gt;

&lt;p&gt;The .NET download page gives you two options, and a lot of beginners aren't sure which one they need.&lt;/p&gt;

&lt;p&gt;The SDK is the full developer toolkit. It includes the Runtime, the compiler, the &lt;code&gt;dotnet&lt;/code&gt; CLI, build tools, and project templates. If you're writing code, this is what you install.&lt;/p&gt;

&lt;p&gt;The Runtime is smaller and only runs applications. You can't create a project with it, build one, or restore NuGet packages. It's meant for production servers where the app has already been built elsewhere. These days a lot of production setups just use a Docker image with the Runtime baked in, so even that distinction shows up less than it used to.&lt;/p&gt;

&lt;p&gt;Easy way to remember it: developers install the SDK, production servers usually need only the Runtime.&lt;/p&gt;

&lt;p&gt;One thing that makes this less painful than it sounds: the SDK has roll-forward support. Install the .NET 10 SDK and it can still build projects targeting &lt;code&gt;net8.0&lt;/code&gt; or &lt;code&gt;net9.0&lt;/code&gt;. You don't need five SDK versions installed just because you're bouncing between older repos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters right now
&lt;/h2&gt;

&lt;p&gt;Microsoft ships two kinds of releases: LTS (Long-Term Support) and STS (Standard-Term Support). LTS versions get support for several years and are what most companies run in production. .NET 8 and .NET 10 fall into this category. STS versions get new features sooner but with a shorter support window, which is fine if your team upgrades often and less fine if it doesn't.&lt;/p&gt;

&lt;p&gt;For most enterprise applications, the answer is simple: use the latest LTS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;.NET 8 and .NET 9 both reach end of support on November 10, 2026.&lt;/strong&gt; If you're running either in production, that's the actual deadline to have an upgrade plan. And if you're going to touch the project anyway to bump the target framework, that's the moment to also look at what else is sitting in the &lt;code&gt;.csproj&lt;/code&gt; that you never configured on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The settings that actually matter
&lt;/h2&gt;

&lt;p&gt;A typical &lt;code&gt;.csproj&lt;/code&gt; looks something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;Project&lt;/span&gt; &lt;span class="na"&gt;Sdk=&lt;/span&gt;&lt;span class="s"&gt;"Microsoft.NET.Sdk.Web"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;PropertyGroup&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;TargetFramework&amp;gt;&lt;/span&gt;net10.0&lt;span class="nt"&gt;&amp;lt;/TargetFramework&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;Nullable&amp;gt;&lt;/span&gt;enable&lt;span class="nt"&gt;&amp;lt;/Nullable&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;ImplicitUsings&amp;gt;&lt;/span&gt;enable&lt;span class="nt"&gt;&amp;lt;/ImplicitUsings&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;TreatWarningsAsErrors&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/TreatWarningsAsErrors&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;EnforceCodeStyleInBuild&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/EnforceCodeStyleInBuild&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;AnalysisLevel&amp;gt;&lt;/span&gt;latest-recommended&lt;span class="nt"&gt;&amp;lt;/AnalysisLevel&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/PropertyGroup&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;/Project&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ImplicitUsings&lt;/code&gt; is the least consequential of the group. It just means you stop writing &lt;code&gt;using System;&lt;/code&gt;, &lt;code&gt;using System.Linq;&lt;/code&gt;, and so on at the top of every file, since the common namespaces get included automatically. Nice, but cosmetic.&lt;/p&gt;

&lt;p&gt;The rest change what your build is willing to tolerate.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TreatWarningsAsErrors&lt;/code&gt; turns a warning from something you can ignore into something that stops the build cold. Normally a warning is just noise in the output and the project compiles anyway. With this on, a warning fails the build the same way a compile error would. Warnings usually point at real problems, and this setting forces you to deal with them the moment they appear instead of letting them pile up as debt someone else has to clean up later.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;EnforceCodeStyleInBuild&lt;/code&gt; checks your style rules (the ones defined in &lt;code&gt;.editorconfig&lt;/code&gt;) at build time, not just when someone happens to run a linter. That means the whole team follows the same conventions automatically, because the build won't let inconsistent formatting through.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AnalysisLevel&lt;/code&gt; set to &lt;code&gt;latest-recommended&lt;/code&gt; turns on the newest Roslyn analyzers your SDK version supports. These catch things beyond simple syntax problems: possible bugs, performance issues, API misuse, and other patterns that are technically valid C# but bad practice. Catching that at build time, before the code ships, is a lot cheaper than catching it in production.&lt;/p&gt;

&lt;p&gt;None of these are exotic. They're one line each in a &lt;code&gt;PropertyGroup&lt;/code&gt;. But the difference between a build that just compiles your code and a build that actively checks it is exactly these three lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Repository-wide, not per-project
&lt;/h2&gt;

&lt;p&gt;If you're running more than one project in a solution, repeating these settings in every &lt;code&gt;.csproj&lt;/code&gt; gets old fast, and it's easy for them to drift out of sync. &lt;code&gt;Directory.Build.props&lt;/code&gt; at the repo root lets you define shared settings once and have every project pick them up.&lt;/p&gt;

&lt;p&gt;The same idea applies to package versions. Instead of each project picking its own version of a NuGet package, &lt;code&gt;Directory.Packages.props&lt;/code&gt; centralizes that in one place: fewer version conflicts, easier upgrades, and everyone in the solution referencing the same version of the same package.&lt;/p&gt;

&lt;p&gt;Both of these are about whether the build system does useful work on your behalf before your code goes anywhere near production. Worth checking what's actually in that file next time you open a project, especially one still targeting .NET 8 or 9 with that November 2026 deadline on the calendar.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>tutorial</category>
      <category>dotnet</category>
    </item>
  </channel>
</rss>
