<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://juliocasal.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://juliocasal.com/" rel="alternate" type="text/html" /><updated>2026-06-09T07:07:10-07:00</updated><id>https://juliocasal.com/feed.xml</id><title type="html">Julio Casal</title><subtitle>Learn, Grow &amp; Upgrade Your .NET Skills</subtitle><entry><title type="html">The .NET 10 Bootcamp: Price, How to Buy, and a Bonus Course</title><link href="https://juliocasal.com/blog/The-Dotnet-10-Bootcamp-Price-How-To-Buy-And-Bonus-Course.html" rel="alternate" type="text/html" title="The .NET 10 Bootcamp: Price, How to Buy, and a Bonus Course" /><published>2026-06-06T00:00:00-07:00</published><updated>2026-06-06T00:00:00-07:00</updated><id>https://juliocasal.com/blog/The-Dotnet-10-Bootcamp-Price-How-To-Buy-And-Bonus-Course</id><content type="html" xml:base="https://juliocasal.com/blog/The-Dotnet-10-Bootcamp-Price-How-To-Buy-And-Bonus-Course.html"><![CDATA[<p><em>Read time: 2 minutes</em></p>

<p>The new .NET 10 edition of the <a href="https://juliocasal.com/courses/dotnetbootcamp">.NET Developer Bootcamp</a> goes live this Tuesday, June 9, at 6 AM PDT.</p>

<p>Last Saturday I <a href="https://juliocasal.com/blog/Inside-The-New-Dotnet-10-Bootcamp">walked through everything inside it</a>: all included courses, what changed in each, and what ships with every one.</p>

<p>Today, the price and how to buy.</p>

<p>But first, one thing I didn’t mention last week.</p>

<p><br /></p>

<h2 id="theres-a-new-bonus-course-in-the-bootcamp">There’s a new bonus course in the bootcamp</h2>

<p>I built a new course for this edition: <strong>Microservices for .NET Developers</strong>.</p>

<p>It takes the same Game Store app you build through the bootcamp, a monolith, and breaks it into independent microservices. Catalog, Basket, Ordering, Payments, and Notifications, each with its own database, behind a YARP API gateway.</p>

<p><img src="/assets/images/2026-06-06/microservices-architecture.png" alt="Game Store Microservices architecture: Blazor and React frontends behind a YARP API gateway, with Catalog, Basket, Ordering, and Payments APIs plus a Notifications worker, each with its own PostgreSQL database, communicating over Kafka and an Azure Service Bus emulator, secured with Keycloak and JWT, with Stripe for payments." /></p>

<p>If you’ve followed the newsletter for a while, this is the Game Store microservices app I’ve written about before, from <a href="https://juliocasal.com/blog/building-a-distributed-system">building the distributed system</a> to <a href="https://juliocasal.com/blog/organizing-microservices-code">organizing the code</a> and <a href="https://juliocasal.com/blog/events-events-events">decoupling it with events</a>. It never fit into the original bootcamp, but this edition finally includes it.</p>

<p>It covers the things a real distributed system needs, and few courses explain in detail:</p>

<ul>
  <li>Organizing microservices codebases across multiple repos</li>
  <li>Synchronous service-to-service calls with typed clients, service discovery, and resilience</li>
  <li>Decoupling services with events, using Event-Carried State Transfer over Kafka</li>
  <li>A custom ordering saga over Azure Service Bus, including the refund path when a step fails</li>
  <li>An API gateway with YARP</li>
  <li>Aspire integration tuned for microservices development</li>
  <li>Blazor and React integration</li>
  <li>Deploying the whole thing to Azure</li>
</ul>

<p>It’s not a step-by-step build. I take the finished, working .NET 10 system and walk you through how it fits together and why each piece is there.</p>

<p>The source code is ready now, so the moment you enroll you can download it, run the full system locally, and read the comprehensive getting started guide.</p>

<p><img src="/assets/images/2026-06-06/microservices-vscode.png" alt="The Game Store Microservices solution open in VS Code: the Explorer shows each service as its own repo (basket, catalog, gateway, notifications, ordering, payments, platform), and the Catalog AppHost.cs defines the service in C# with AddProject, WithReference, and WaitFor calls wiring in PostgreSQL, blob storage, Service Bus, Kafka, and Keycloak." /></p>

<p>The video lessons come a bit later. I start recording this week, and every lesson will be live by July 3.</p>

<p>This course is included free with the new edition. Everyone who gets the .NET 10 bootcamp gets it.</p>

<p><br /></p>

<h2 id="the-price-and-how-to-buy">The price, and how to buy</h2>

<p>The full .NET 10 bootcamp is $497. For launch week, it’s <strong>$348</strong>, 30% off.</p>

<p>You pay once and keep lifetime access. The purchase link goes live Tuesday, June 9, at 6 AM PDT.</p>

<p>The $348 price stays until Sunday, June 14, at 11:59 PM PDT. After that it goes back to $497.</p>

<p><br /></p>

<h2 id="wrapping-up">Wrapping up</h2>

<p>What started as a routine .NET 8 to .NET 10 bump turned into the biggest bootcamp update to date: the whole thing rebuilt on .NET 10 and Aspire 13, around 85 lessons re-recorded, and a brand new microservices course on top.</p>

<p>This is the version of the bootcamp I always wanted to create, and I’m very excited to share it with you.</p>

<p>If you’ve been waiting to jump in, Tuesday is the day.</p>

<p>See you then!</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Read time: 2 minutes]]></summary></entry><entry><title type="html">Inside the New .NET 10 Bootcamp</title><link href="https://juliocasal.com/blog/Inside-The-New-Dotnet-10-Bootcamp.html" rel="alternate" type="text/html" title="Inside the New .NET 10 Bootcamp" /><published>2026-05-30T00:00:00-07:00</published><updated>2026-05-30T00:00:00-07:00</updated><id>https://juliocasal.com/blog/Inside-The-New-Dotnet-10-Bootcamp</id><content type="html" xml:base="https://juliocasal.com/blog/Inside-The-New-Dotnet-10-Bootcamp.html"><![CDATA[<p><em>Read time: 8 minutes</em></p>

<p>It’s almost here. Tuesday, June 9, 6 AM PT, the new .NET 10 edition of the .NET Developer Bootcamp goes live (formerly the .NET Backend Developer Bootcamp).</p>

<p>Two Saturdays ago I <a href="https://juliocasal.com/blog/The-LTS-Upgrade-Dotnet-8-To-Dotnet-10">walked through the LTS upgrade itself</a>, what changed between .NET 8 and .NET 10 worth caring about.</p>

<p>Last Saturday I <a href="https://juliocasal.com/blog/Why-Your-F5-Doesnt-Just-Work">covered Aspire</a>, what it is, how the AppHost model works, why F5 finally just works.</p>

<p>Today, I’ll walk you through what you’ll build by the end, every course in the bootcamp, what’s new if you’ve taken the previous edition, and what ships with each course.</p>

<p>Let’s start.</p>

<p><br /></p>

<h2 id="what-youll-build">What you’ll build</h2>

<p>By the end of the bootcamp, you’ll have shipped a complete e-commerce .NET backend (the Game Store) to Azure end to end, across 3 pillars:</p>

<p><br /></p>

<h3 id="1-the-essential-net-web-development-stack">1. The essential .NET web development stack</h3>

<p>Building a .NET REST API with Vertical Slice Architecture, Entity Framework Core, async, logging, middleware, error handling, and OpenAPI, plus JWT, Keycloak, OAuth 2.0, and OpenID Connect for authentication and layered authorization.</p>

<p><img src="/assets/images/2026-05-30/architecture01.png" alt="Game Store API architecture: a .NET API secured with JWT, authenticated via Keycloak using OpenID Connect, storing data in SQLite, with a front-end consuming the API." /></p>

<p><br /></p>

<h3 id="2-the-cloud-native-azure-toolkit">2. The cloud-native Azure toolkit</h3>

<p>Shipping the .NET app to the cloud with Azure App Service, Azure Database for PostgreSQL, Azure Storage behind Front Door, Managed Identities, Microsoft Entra ID, Key Vault, Docker, Azure Container Registry, Azure Container Apps, Aspire, and Bicep.</p>

<p><img src="/assets/images/2026-05-30/architecture02.png" alt="Game Store on Azure: Front Door fronting the Blazor and React frontends and the back-end API Web App, with Microsoft Entra for identity, Azure Blob Storage for files, Key Vault for secrets, Managed Identity for passwordless access, and Azure Database for PostgreSQL." /></p>

<p><br /></p>

<h3 id="3-scalable-production-ready-systems">3. Scalable, production-ready systems</h3>

<p>Wiring up Stripe Checkout end to end with webhook processing, database transactions, idempotency patterns, Azure Service Bus queues, the Outbox pattern, background Worker services, integration tests, Application Insights, and an Azure DevOps CI/CD pipeline.</p>

<p><img src="/assets/images/2026-05-30/architecture03.png" alt="Game Store payment flow: Stripe Checkout with a custom hosting integration, the API creating orders and checkout sessions with idempotency keys, transactional writes to Orders/Baskets/Outbox tables, an Outbox Processor publishing OrderPaid events to a queue, and a Fulfillment Worker consuming them." /></p>

<p><br /></p>

<p>All of that is spread across:</p>

<ul>
  <li><strong>10 courses</strong> in the bootcamp (7 main + 3 bonus mini-courses)</li>
  <li><strong>About 85 new video lessons</strong> recorded for this edition</li>
  <li><strong>241 lesson source code snapshots</strong> updated to .NET 10 and Aspire 13</li>
  <li><strong>2 brand new standalone courses</strong> in the bootcamp, where there used to be a half-course intro</li>
</ul>

<p><br /></p>

<h2 id="the-courses">The courses</h2>

<p>Each course builds on the previous one and incrementally improves a single e-commerce app (the Game Store) that students build from scratch, step by step. Here’s what each course covers.</p>

<p><img src="/assets/images/2026-05-30/courses-grid-net10.png" alt="All 10 bootcamp courses in the Thinkific catalog, each badged .NET 10." /></p>

<p><br /></p>

<h3 id="course-1-aspnet-core-essentials">Course 1: ASP.NET Core Essentials</h3>

<p>The starting point. Students build their first REST API from scratch with full CRUD endpoints, learn DTOs for mapping, organize their code with Vertical Slice Architecture, and wire up Dependency Injection with the right lifetimes.</p>

<p>Data goes into a database via Entity Framework Core, and by the end of the course they’ve connected a frontend (Blazor and React) to see their API drive a real UI.</p>

<p><img src="/assets/images/2026-05-30/rest-api.jpg" alt="Building solid REST APIs with ASP.NET Core." /></p>

<p><strong>New in this edition:</strong> <em>Chapter 3 now teaches the new built-in ASP.NET Core 10 validation (no more MinimalApis.Extensions package; just AddValidation in Program.cs), and the last chapter adds full React frontend integration with video lessons (previously this course only covered Blazor as a frontend).</em></p>

<p><br /></p>

<h3 id="course-2-aspnet-core-advanced">Course 2: ASP.NET Core Advanced</h3>

<p>The production-readiness course. Students go deep on async/await, structured logging, custom middleware, and global exception handling. They add pagination and search to their endpoints, document the API with OpenAPI and Postman, handle file uploads, and integrate the frontend against the new features.</p>

<p><img src="/assets/images/2026-05-30/error-handling.png" alt="Global error handling in ASP.NET Core, turning unhandled exceptions into clean, consistent API responses." /></p>

<p><strong>New in this edition:</strong> <em>Chapter 7 OpenAPI lessons re-recorded against the built-in OpenAPI support that now ships with ASP.NET Core 10 (Swashbuckle is gone), and Chapter 9 now includes full React frontend integration with video lessons (previously this course only covered Blazor as a frontend).</em></p>

<p><br /></p>

<h3 id="course-3-aspnet-core-security">Course 3: ASP.NET Core Security</h3>

<p>The auth and authorization course. Students implement JWT-based authentication, build out a shopping basket API to put the lessons into practice, and then add role-based, claims-based, policy-based, and resource-based authorization on top.</p>

<p>Then they bring in Keycloak (running locally in Docker) as the identity provider: managing users and roles, learning OAuth 2.0 and OpenID Connect, integrating Keycloak with the backend, and wiring the Blazor and React frontends through the full login flow.</p>

<p><img src="/assets/images/2026-05-30/oauth-code-flow.jpg" alt="The OAuth 2.0 Authorization Code flow students implement: resource owner, client, authorization server, and resource server exchanging an authorization code for an access token." /></p>

<p><strong>New in this edition:</strong> <em>The course now includes just enough Docker to get Keycloak running locally (see course 5 for the full standalone Docker course). The Keycloak realm also ships as a 1-click import file, so students don’t have to recreate users, roles, and clients by hand. Plus brand-new React frontend video lessons in the last chapter.</em></p>

<p><br /></p>

<h3 id="course-4-azure-for-net-developers">Course 4: Azure for .NET Developers</h3>

<p>The cloud deployment course. Students deploy the full stack to Azure App Service, swap local SQLite for Azure Database for PostgreSQL, and bring in Microsoft Entra as the production identity provider.</p>

<p>Azure Storage handles file uploads, with Azure Front Door in front as the CDN. Managed Identities (system-assigned and user-assigned) give the app passwordless access to Azure services, so there are no connection-string secrets to manage in the first place. Anything that still needs a secret lives in Azure Key Vault.</p>

<p><img src="/assets/images/2026-05-30/azure-portal.png" alt="The Game Store resource group in the Azure Portal: App Service, App Service plan, Azure Front Door, Azure Database for PostgreSQL, Storage account, and a Managed Identity, all provisioned and deployed across the course." /></p>

<p><strong>New in this edition:</strong> <em>Every Azure SDK updated to its latest version and the entire .NET backend runs on .NET 10.</em></p>

<p><br /></p>

<h3 id="course-5-docker-for-net-developers">Course 5: Docker for .NET Developers</h3>

<p>Starts with Docker fundamentals (images vs containers, tags, port mapping, volumes, Docker Compose), then builds container images for .NET apps two ways: with Dockerfiles, and with the .NET SDK directly (and how to keep image sizes small).</p>

<p>From there students publish their images to Azure Container Registry, deploy the app to Azure Container Apps (environments, ingress, revisions, scaling), and finish by adding liveness and readiness health checks wired into the Container Apps probes.</p>

<p><img src="/assets/images/2026-05-30/docker-dashboard.png" alt="Docker Desktop showing the Game Store containers running locally." /></p>

<p><strong>New in this edition.</strong> <em>In the previous edition, Docker was a single intro chapter inside the Containers &amp; Aspire course. Now it’s a full standalone course, opening with a Docker fundamentals chapter, then 2 hands-on chapters that containerize .NET 10 apps both with Dockerfiles and with the native .NET 10 SDK.</em></p>

<p><br /></p>

<h3 id="course-6-aspire-for-net-developers">Course 6: Aspire for .NET Developers</h3>

<p>Students start with the Aspire AppHost, add their .NET app, then define the rest of the architecture in C#: PostgreSQL, Azure Storage, and Keycloak all wired up as Aspire resources. Production-ready defaults come next via the ServiceDefaults project: resilience, health checks, and diagnostics across services.</p>

<p>Then comes Infrastructure as Code. The same C# AppHost generates the actual Azure resources and deploys them through the Azure Developer CLI. Bicep covers what Aspire doesn’t reach yet (Azure Front Door, for example), and the same flow ships both the Blazor and React frontends.</p>

<p><img src="/assets/images/2026-05-30/infrastructure-as-code.png" alt="The Aspire AppHost in C#: AddAzurePostgresFlexibleServer, AddAzureStorage, ConfigureInfrastructure, and AddProject calls that double as the cloud deployment definition." /></p>

<p><strong>New in this edition.</strong> <em>In the previous edition, Aspire was a small section inside the old Containers &amp; Aspire course, built on Aspire 9. Now it’s a full standalone course built from scratch on Aspire 13: 4 chapters of brand-new content that take you from a local AppHost all the way to real Azure resources without leaving C#.</em></p>

<p><br /></p>

<h3 id="course-7-stripe-payments-for-net-developers">Course 7: Stripe Payments for .NET Developers</h3>

<p>The course that turns the Game Store into a real product. Students set up Stripe, create checkout sessions from the API, collect payments from both the Blazor and React frontends, and handle webhooks for everything that happens after the customer pays. A custom Aspire hosting integration smooths the local Stripe dev loop along the way.</p>

<p>Then it goes deep on the distributed-systems patterns behind real payments: database transactions, idempotent endpoints, Azure Service Bus, the Outbox pattern, and .NET worker services that process messages with proper dead-letter handling. The course closes with a full Azure deployment, secrets in Key Vault, and live Stripe events hitting the cloud backend.</p>

<p><img src="/assets/images/2026-05-30/checkout-screenshot.jpg" alt="The real Stripe Checkout UI students wire into the Game Store." /></p>

<p><strong>New in this edition:</strong> <em>No major content additions. The code now runs on .NET 10 and Aspire 13, Stripe.NET is bumped to the current API version (Checkout now uses the new “elements” mode), and the course was renamed from “Payments, Queues &amp; Workers” to lead with what it most uniquely teaches.</em></p>

<p><br /></p>

<h3 id="bonus-mini-courses">Bonus mini-courses</h3>

<p>Three short standalone courses sit alongside the main 7. They cover the production concerns most .NET courses skip:</p>

<ul>
  <li><strong>Integration Testing for .NET Developers</strong>: xUnit integration tests for .NET REST APIs and worker services, using WebApplicationFactory and Test Containers.</li>
  <li><strong>Azure DevOps CI/CD for .NET Developers</strong>: end-to-end CI/CD pipelines generated with AZD, including parallel test execution.</li>
  <li><strong>Troubleshooting .NET Apps in Azure</strong>: Application Insights, distributed tracing, and log queries in production.</li>
</ul>

<p><strong>New in this edition:</strong> <em>Source code updated to .NET 10 across all 3 mini-courses.</em></p>

<p><br /></p>

<h2 id="what-ships-with-every-course">What ships with every course</h2>

<p>Every course in the bootcamp includes:</p>

<ul>
  <li><strong>Per-lesson source code zips.</strong> 241 snapshots total. Every lesson has a starting snapshot and a finished snapshot, so students can jump in anywhere.</li>
  <li><strong>Prebuilt Blazor and React frontends.</strong> Students focus on the backend; the frontends are wired up and ready so the app runs end to end from day 1.</li>
  <li><strong>Postman collections and game images</strong> for the courses that need them.</li>
  <li><strong>Illustrated handouts</strong> with every slide-deck diagram for offline reference.</li>
  <li><strong>Full English captions</strong> on every video lesson.</li>
  <li><strong>A course completion certificate</strong> to share on LinkedIn.</li>
</ul>

<p><br /></p>

<h2 id="what-this-bootcamp-is-not-about">What this bootcamp is not about</h2>

<p>To set expectations clearly, here’s what’s NOT covered anywhere in the bootcamp:</p>

<ul>
  <li>C# fundamentals</li>
  <li>Clean / hexagonal / onion architecture</li>
  <li>Modular monolith</li>
  <li>CQRS</li>
  <li>DDD</li>
  <li>MediatR</li>
  <li>AutoMapper</li>
  <li>gRPC or GraphQL (the API is REST throughout)</li>
  <li>Building Blazor or React apps from scratch (the frontends ship prebuilt; the video lessons cover wiring them up to your backend)</li>
</ul>

<p>The only one you actually need for the job is C# fundamentals, and there is already excellent free C# training out there.</p>

<p><br /></p>

<h2 id="wrapping-up">Wrapping up</h2>

<p>What started as a routine .NET 8 to .NET 10 bump turned into 2 months of work across the whole bootcamp. Around 85 new video lessons recorded, every project re-checked, every package re-aligned.</p>

<p>Claude Code helped me move faster on the repetitive parts. Without it, this would have taken much longer.</p>

<p>But the bootcamp is now back to feeling current end to end, and that’s worth the time.</p>

<p>And that’s it for today.</p>

<p>See you next Saturday.</p>

<p>P.S. The new edition launches Tuesday, June 9 at 6 AM PT. Pricing and how to buy in next week’s newsletter.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Read time: 8 minutes]]></summary></entry><entry><title type="html">Why Your F5 Doesn’t Just Work</title><link href="https://juliocasal.com/blog/Why-Your-F5-Doesnt-Just-Work.html" rel="alternate" type="text/html" title="Why Your F5 Doesn’t Just Work" /><published>2026-05-23T00:00:00-07:00</published><updated>2026-05-23T00:00:00-07:00</updated><id>https://juliocasal.com/blog/Why-Your-F5-Doesnt-Just-Work</id><content type="html" xml:base="https://juliocasal.com/blog/Why-Your-F5-Doesnt-Just-Work.html"><![CDATA[<p><em>Read time: 8 minutes</em></p>

<p>You clone a .NET repo. You hit F5. It crashes because something local isn’t ready: a database that hasn’t been seeded, a connection string missing from user-secrets, a service that has to be started by hand.</p>

<p>You fix it. F5. Crash. Fix. F5. Crash. Half a morning later, it finally runs.</p>

<p>Every new teammate, every laptop swap, every “I just need to check one thing” hits the same wall.</p>

<p>Aspire is what fixes that. And if you’ve never heard of it, or you’ve heard the name and bounced off, this post is for you.</p>

<p>Today, I’ll walk you through what Aspire actually is, the AppHost model, the production-ready defaults, the deployment story, and a few honest caveats.</p>

<p>Let’s start.</p>

<p><br /></p>

<h2 id="what-is-aspire">What is Aspire?</h2>

<p>Aspire is a set of tools, templates, and packages for building observable, production-ready apps. It’s open-source, built by Microsoft, and works with whatever stack your services are written in.</p>

<p>In practical terms, it’s an AppHost project (the orchestrator) that defines your application model: every service, database, cache, queue, and frontend, plus how they depend on each other.</p>

<p>F5 boots all of it locally in one shot. The same model deploys to the cloud.</p>

<p>Your existing code sits next to it. You don’t rewrite anything.</p>

<p>The slide I use to explain it covers 4 capability areas:</p>

<p><img src="/assets/images/2026-05-23/what-is-aspire.png" alt="What is Aspire: simplified dev experience, consistent environments, building blocks that just fit, real-time diagnostics." /></p>

<p><strong>Simplified dev experience.</strong> Clone, F5, ready. The AppHost boots every service, container, and frontend in the right order. The crash-and-fix loop from the opening goes away.</p>

<p><strong>Consistent environments.</strong> Dev, QA, and prod all come from the same C# model checked into git. What the team runs locally matches what ships. No “ask the senior dev which docker-compose to use” moments.</p>

<p><strong>Building blocks that just fit.</strong> Drop a Postgres, Redis, key vault, or storage account into your app model in 1 line. Aspire owns the lifecycle, the wiring, and the connection strings. The bricks fit because Microsoft and the community ship them already shaped.</p>

<p><strong>Real-time diagnostics.</strong> A built-in dashboard shows distributed traces, logs, metrics, and resource health across every service. First F5. No Jaeger, Prometheus, or Grafana to wire up for local dev.</p>

<p>Three things make all of that possible:</p>

<ul>
  <li><strong>Your application architecture.</strong> Defined once using your preferred language (C# for .NET apps).</li>
  <li><strong>Production-ready defaults.</strong> Observability, resilience, and health checks wired into every service.</li>
  <li><strong>Repeatable cloud deployments.</strong> The same model that boots locally ships to the cloud.</li>
</ul>

<p>Let’s take them one at a time.</p>

<p><br /></p>

<h2 id="defining-your-application-architecture">Defining your application architecture</h2>

<p>The first thing you do when you adopt Aspire is write an AppHost project. For .NET apps, it’s a small C# project that uses Aspire’s AppHost SDK, and it’s where you declare every resource your system depends on: databases, caches, queues, identity providers, your own services, even raw containers.</p>

<p>That declaration is your application model.</p>

<p>Here’s part of a real one, taken from the <a href="https://juliocasal.com/courses/dotnetbootcamp">bootcamp’s</a> Game Store application:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">builder</span> <span class="p">=</span> <span class="n">DistributedApplication</span><span class="p">.</span><span class="nf">CreateBuilder</span><span class="p">(</span><span class="n">args</span><span class="p">);</span>

<span class="kt">var</span> <span class="n">postgres</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">AddPostgres</span><span class="p">(</span><span class="s">"postgres"</span><span class="p">)</span>
                      <span class="p">.</span><span class="nf">WithDataVolume</span><span class="p">()</span>
                      <span class="p">.</span><span class="nf">AddDatabase</span><span class="p">(</span><span class="s">"GameStoreDB"</span><span class="p">,</span> <span class="s">"gamestore"</span><span class="p">);</span>

<span class="kt">var</span> <span class="n">storage</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">AddAzureStorage</span><span class="p">(</span><span class="s">"Storage"</span><span class="p">).</span><span class="nf">RunAsEmulator</span><span class="p">();</span>
<span class="kt">var</span> <span class="n">blobs</span> <span class="p">=</span> <span class="n">storage</span><span class="p">.</span><span class="nf">AddBlobs</span><span class="p">(</span><span class="s">"Blobs"</span><span class="p">);</span>

<span class="kt">var</span> <span class="n">keycloak</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">AddKeycloak</span><span class="p">(</span><span class="s">"keycloak"</span><span class="p">,</span> <span class="n">port</span><span class="p">:</span> <span class="m">8080</span><span class="p">)</span>
                      <span class="p">.</span><span class="nf">WithDataVolume</span><span class="p">()</span>
                      <span class="p">.</span><span class="nf">WithRealmImport</span><span class="p">(</span><span class="s">"../../localinfra"</span><span class="p">);</span>

<span class="n">builder</span><span class="p">.</span><span class="n">AddProject</span><span class="p">&lt;</span><span class="n">Projects</span><span class="p">.</span><span class="n">GameStore_Api</span><span class="p">&gt;(</span><span class="s">"api"</span><span class="p">)</span>
       <span class="p">.</span><span class="nf">WithReference</span><span class="p">(</span><span class="n">postgres</span><span class="p">)</span>
       <span class="p">.</span><span class="nf">WithReference</span><span class="p">(</span><span class="n">blobs</span><span class="p">)</span>
       <span class="p">.</span><span class="nf">WaitFor</span><span class="p">(</span><span class="n">postgres</span><span class="p">)</span>
       <span class="p">.</span><span class="nf">WaitFor</span><span class="p">(</span><span class="n">blobs</span><span class="p">)</span>
       <span class="p">.</span><span class="nf">WaitFor</span><span class="p">(</span><span class="n">keycloak</span><span class="p">);</span>

<span class="n">builder</span><span class="p">.</span><span class="nf">Build</span><span class="p">().</span><span class="nf">Run</span><span class="p">();</span>
</code></pre></div></div>

<p>Each <code class="language-plaintext highlighter-rouge">Add*</code> call is a hosting integration. Microsoft and the community ship dozens of them: PostgreSQL, Redis, RabbitMQ, MongoDB, Kafka, Azure Storage, Azure Key Vault, Azure Service Bus, Keycloak, SQL Server, and more. Each one declares a resource and gives back a typed handle.</p>

<p>Three modifiers to know:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">.WithReference</code></strong> wires the connection info into the dependent project. The API gets the Postgres connection string and the blob endpoint as environment variables, with no <code class="language-plaintext highlighter-rouge">appsettings.Development.json</code> plumbing on your side.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">.WaitFor</code></strong> blocks startup until the dependency is healthy.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">.WithDataVolume()</code></strong> keeps Postgres data across <code class="language-plaintext highlighter-rouge">aspire run</code> restarts so you don’t reseed the database every morning.</li>
</ul>

<p>When you hit F5, the AppHost reads this model and starts everything in dependency order:</p>

<p><img src="/assets/images/2026-05-23/resource-graph.png" alt="Application model: Game Store API depends on Storage and PostgreSQL; hosting integrations feed connection strings into the model." /></p>

<p>Postgres, Storage, Keycloak, your APIs, your workers. All declared once, all started together, all wired by name. No docker-compose.yml. No environment-specific shell scripts. No “first run these 7 commands” README.</p>

<p>The big win: the application model is C# code checked into git. Every teammate runs the exact same stack on F5, because the stack definition lives in code that everyone shares.</p>

<p><br /></p>

<h2 id="production-ready-defaults">Production-ready defaults</h2>

<p>A real service that talks to Postgres and Blob Storage needs DI registrations for each client, resilience policies for transient failures (retries, timeouts, circuit breakers), and <code class="language-plaintext highlighter-rouge">/health</code> and <code class="language-plaintext highlighter-rouge">/alive</code> endpoints for liveness and readiness probes.</p>

<p>That’s around 40 lines of boilerplate per project. Each team copies and tweaks it, and the results stop matching each other in a few weeks.</p>

<p>Aspire ships those defaults in 2 layers.</p>

<p><strong>Layer 1: ServiceDefaults.</strong> Every Aspire solution has a shared ServiceDefaults project, scaffolded for you by the template. It exposes 2 extension methods:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">builder</span><span class="p">.</span><span class="nf">AddServiceDefaults</span><span class="p">();</span>   <span class="c1">// call this in every service's Program.cs</span>
<span class="n">app</span><span class="p">.</span><span class="nf">MapDefaultEndpoints</span><span class="p">();</span>      <span class="c1">// and this after building the app</span>
</code></pre></div></div>

<p>That pair registers <code class="language-plaintext highlighter-rouge">/health</code> and <code class="language-plaintext highlighter-rouge">/alive</code>, sets up service discovery, and configures HTTP client resilience (retries, timeouts, circuit breakers). One method call per project, and every service in your solution gets the same reliability baseline.</p>

<p><strong>Layer 2: Client integrations.</strong> For every hosting integration that exists, there’s a matching client integration. The hosting side declares the resource in the AppHost. The client side consumes it from the application code. Two lines in the API’s <code class="language-plaintext highlighter-rouge">Program.cs</code>:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">builder</span><span class="p">.</span><span class="n">AddNpgsqlDbContext</span><span class="p">&lt;</span><span class="n">GameStoreContext</span><span class="p">&gt;(</span><span class="s">"GameStoreDB"</span><span class="p">);</span>
<span class="n">builder</span><span class="p">.</span><span class="nf">AddAzureBlobServiceClient</span><span class="p">(</span><span class="s">"Blobs"</span><span class="p">);</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">AddNpgsqlDbContext</code> registers <code class="language-plaintext highlighter-rouge">GameStoreContext</code> in DI, reads the connection string the AppHost projected via <code class="language-plaintext highlighter-rouge">.WithReference</code>, applies Npgsql resilience defaults, and wires up a database health check.</p>

<p><code class="language-plaintext highlighter-rouge">AddAzureBlobServiceClient</code> does the same for <code class="language-plaintext highlighter-rouge">BlobServiceClient</code> against the Azurite emulator locally and Azure Blob Storage in production. Same code in both environments.</p>

<p>All those health checks show up in the Aspire dashboard the moment you hit F5:</p>

<p><img src="/assets/images/2026-05-23/dashboard-resources.png" alt="Aspire dashboard resources view: every service shows a health state badge, with healthy and unhealthy resources visible at a glance." /></p>

<p>Every resource has a state and a health state. When something goes unhealthy, you see it immediately, with the failing reason 1 click away. No probes to wire up, no <code class="language-plaintext highlighter-rouge">/health</code> controller to write, no separate uptime dashboard to set up.</p>

<p><br /></p>

<h2 id="repeatable-azure-deployment">Repeatable Azure deployment</h2>

<p>The same AppHost that boots locally also describes the production topology. Once it does, deploying is 2 commands: log in to Azure, and ship.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>az login
<span class="nv">$ </span>aspire deploy
</code></pre></div></div>

<p>The CLI prompts for subscription, region, and resource group on first run, then walks through the deployment pipeline:</p>

<p><img src="/assets/images/2026-05-23/aspire-deploy.png" alt="aspire deploy CLI output: pipeline steps running through validate, process parameters, deploy-prereq, validate-azure-login, container runtime check, and building container images for gamestore-api and gamestore-worker." /></p>

<p><code class="language-plaintext highlighter-rouge">aspire deploy</code> reads your application model, generates the Bicep, provisions the resource group and every Azure resource you declared, builds and pushes your container images to ACR, and stands up an Azure Container App for each project.</p>

<p>A few minutes later, the resource group is fully populated:</p>

<p><img src="/assets/images/2026-05-23/azure-portal.png" alt="Azure portal resource group view showing every resource Aspire provisioned: Container Apps, Container Registry, Postgres Flexible Server, Storage account, and supporting infrastructure." /></p>

<p>The big win: dev, QA, and prod environments come from the same C# model. Each one is a separate Aspire environment (<code class="language-plaintext highlighter-rouge">-e dev</code>, <code class="language-plaintext highlighter-rouge">-e qa</code>, <code class="language-plaintext highlighter-rouge">-e prod</code>), and they match each other because they all read the same Bicep generated from the same AppHost.</p>

<p>What ships to QA is what shipped to dev, and what hits prod is the exact same shape. Just C# in git and 1 CLI command per environment.</p>

<p><br /></p>

<h2 id="a-few-things-people-get-wrong-about-aspire">A few things people get wrong about Aspire</h2>

<p>A few misconceptions I hear from people picking up Aspire for the first time:</p>

<ul>
  <li><strong>“It’s only for microservices.”</strong> Aspire fits any app with more than 1 moving part. A monolith with a database and a cache counts. So does a worker with a queue, or an API with a frontend.</li>
  <li><strong>“It’s only for .NET apps.”</strong> The AppHost can be written in C# or TypeScript, and the services it orchestrates can be anything: Node, Python, Go, Java, raw containers.</li>
  <li><strong>“It’s just docker-compose for .NET.”</strong> Docker Compose runs containers. Aspire defines an application model in code, ships production-ready defaults, gives you a live dashboard, and generates the cloud deployment from the same source.</li>
  <li><strong>“It only deploys to Azure.”</strong> Azure Container Apps is the default path. Kubernetes is now first-class too. Declare a Kubernetes environment in the AppHost, run <code class="language-plaintext highlighter-rouge">aspire deploy</code>, and Aspire ships your app via Helm. Docker Compose is also supported.</li>
  <li><strong>“It runs in production.”</strong> It doesn’t. The AppHost is dev-time only. What runs in production is your services, the containers Aspire built, and the infrastructure Aspire provisioned.</li>
</ul>

<p><br /></p>

<h2 id="what-might-bite-you">What might bite you</h2>

<p>A few honest caveats so you know what you’re signing up for.</p>

<p><strong>Single-solution friction.</strong> All projects referenced by the AppHost currently need to live in the same solution. If your team works across multiple repos with separately deployed services, you’ll feel that constraint. This is the biggest “wait, really?” moment for some teams.</p>

<p><strong>The Kubernetes story is still preview.</strong> End-to-end Helm-based deploys to Kubernetes (and AKS) work, but the API surface and generated chart shape can still change. If you already own a hand-tuned Helm chart, evaluate carefully before swapping.</p>

<p><strong>Testing is the biggest gap.</strong> The Aspire team has openly called testing their largest unfinished area. You can run integration tests against the AppHost, but the experience across local, CI, and different operating systems is still inconsistent. Expect to invest some time stabilizing this for your own pipeline.</p>

<p><strong>Debugging into containers is limited.</strong> Your own .NET services debug like any other project, but stepping into a containerized resource (a sidecar, a custom container you wrapped) still requires more setup than it should. This is on the roadmap but not shipped yet.</p>

<p>None of these are blockers for most apps. They’re the kind of thing you want to know going in.</p>

<p><br /></p>

<h2 id="wrapping-up">Wrapping up</h2>

<p>If you’ve never tried Aspire, the most useful next step is to spin up the official template (<code class="language-plaintext highlighter-rouge">aspire new aspire-starter</code>) and hit F5.</p>

<p>Half an hour with it and the AppHost, the dashboard, and the production-ready defaults above will make sense.</p>

<p>The new edition of the <a href="https://juliocasal.com/courses/dotnetbootcamp">Bootcamp</a>, launching soon, includes a full standalone Aspire course built on Aspire 13, using the same Game Store sample app you saw in this post.</p>

<p>And that’s it for today.</p>

<p>See you next Saturday.</p>

<p>P.S. A few of you have asked when the new .NET 10 / Aspire 13 bootcamp edition lands. I’m almost done. I’ll share the exact date in next week’s newsletter.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Read time: 8 minutes]]></summary></entry><entry><title type="html">The LTS Upgrade: .NET 8 to .NET 10</title><link href="https://juliocasal.com/blog/The-LTS-Upgrade-Dotnet-8-To-Dotnet-10.html" rel="alternate" type="text/html" title="The LTS Upgrade: .NET 8 to .NET 10" /><published>2026-05-16T00:00:00-07:00</published><updated>2026-05-16T00:00:00-07:00</updated><id>https://juliocasal.com/blog/The-LTS-Upgrade-Dotnet-8-To-Dotnet-10</id><content type="html" xml:base="https://juliocasal.com/blog/The-LTS-Upgrade-Dotnet-8-To-Dotnet-10.html"><![CDATA[<p><em>Read time: 10 minutes</em></p>

<p>If you’re still on .NET 8 and waiting to upgrade, .NET 10 is the next stop. Most teams skip .NET 9 entirely and jump LTS to LTS, which means the move from 8 to 10 inherits 2 years of accumulated framework changes in a single bump.</p>

<p>And there’s more in there than I expected.</p>

<p>A few weeks ago I finished upgrading the entire <a href="https://juliocasal.com/courses/dotnetbootcamp">Bootcamp</a> from .NET 8 to .NET 10. Same .NET API, Blazor frontend, Worker service and integration test suite.</p>

<p>By the time I was done, I’d deleted 2 NuGet packages from every API project. I’d replaced ~110 lines of Blazor auth glue code with 2 framework calls. And I’d cut Blazor’s CSS and JS cache-busting plumbing down to a couple of lines.</p>

<p>None of that required new design work. I just upgraded.</p>

<p>Today, I’ll walk you through the changes worth caring about, the ones you get for free with the TFM bump, what stayed the same, and the upgrade checklist with the gotchas that will probably bite you.</p>

<p>Let’s start.</p>

<p><img src="/assets/images/2026-05-16/featured.png" alt="The LTS Upgrade: .NET 8 to .NET 10" /></p>

<p><br /></p>

<h2 id="1-validation-is-in-the-framework-now">1. Validation is in the framework now</h2>

<p>If you’ve been writing Minimal APIs, you probably have <code class="language-plaintext highlighter-rouge">MinimalApis.Extensions</code> installed and <code class="language-plaintext highlighter-rouge">.WithParameterValidation()</code> chained on every endpoint. That package’s last release was in 2023 and it targets .NET 7. .NET 10 makes both of them unnecessary.</p>

<p>You add one call to <code class="language-plaintext highlighter-rouge">Program.cs</code>:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">builder</span> <span class="p">=</span> <span class="n">WebApplication</span><span class="p">.</span><span class="nf">CreateBuilder</span><span class="p">(</span><span class="n">args</span><span class="p">);</span>

<span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="nf">AddValidation</span><span class="p">();</span>
</code></pre></div></div>

<p>And you delete <code class="language-plaintext highlighter-rouge">.WithParameterValidation()</code> from every endpoint:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Before</span>
<span class="n">app</span><span class="p">.</span><span class="nf">MapPost</span><span class="p">(</span><span class="s">"/games"</span><span class="p">,</span> <span class="p">(</span><span class="n">CreateGameDto</span> <span class="n">dto</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="p">{</span> <span class="cm">/* ... */</span> <span class="p">})</span>
   <span class="p">.</span><span class="nf">WithParameterValidation</span><span class="p">();</span>

<span class="c1">// After</span>
<span class="n">app</span><span class="p">.</span><span class="nf">MapPost</span><span class="p">(</span><span class="s">"/games"</span><span class="p">,</span> <span class="p">(</span><span class="n">CreateGameDto</span> <span class="n">dto</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="p">{</span> <span class="cm">/* ... */</span> <span class="p">});</span>
</code></pre></div></div>

<p>That’s it. Validation runs automatically for every endpoint once <code class="language-plaintext highlighter-rouge">AddValidation()</code> is registered, and the implementation is AOT-friendly. If you want to opt out on a specific endpoint, there’s <code class="language-plaintext highlighter-rouge">.DisableValidation()</code>.</p>

<p><br /></p>

<h2 id="2-openapi-is-in-the-framework-now">2. OpenAPI is in the framework now</h2>

<p>Same story for Swashbuckle. ASP.NET Core 9 shipped first-party OpenAPI document generation, and .NET 10 added more on top. You add the <code class="language-plaintext highlighter-rouge">Microsoft.AspNetCore.OpenApi</code> package and replace the Swagger registration:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Before</span>
<span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="nf">AddEndpointsApiExplorer</span><span class="p">();</span>
<span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="nf">AddSwaggerGen</span><span class="p">();</span>
<span class="c1">// ...</span>
<span class="k">if</span> <span class="p">(</span><span class="n">app</span><span class="p">.</span><span class="n">Environment</span><span class="p">.</span><span class="nf">IsDevelopment</span><span class="p">())</span>
<span class="p">{</span>
    <span class="n">app</span><span class="p">.</span><span class="nf">UseSwagger</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// After</span>
<span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="nf">AddOpenApi</span><span class="p">();</span>
<span class="c1">// ...</span>
<span class="k">if</span> <span class="p">(</span><span class="n">app</span><span class="p">.</span><span class="n">Environment</span><span class="p">.</span><span class="nf">IsDevelopment</span><span class="p">())</span>
<span class="p">{</span>
    <span class="n">app</span><span class="p">.</span><span class="nf">MapOpenApi</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The spec moves from <code class="language-plaintext highlighter-rouge">/swagger/v1/swagger.json</code> to <code class="language-plaintext highlighter-rouge">/openapi/v1.json</code>. If your app has a global authorization or fallback policy that requires auth, chain <code class="language-plaintext highlighter-rouge">.AllowAnonymous()</code> on <code class="language-plaintext highlighter-rouge">MapOpenApi()</code> so the spec endpoint stays reachable. Update any saved Postman, Insomnia, or Bruno collections to the new path.</p>

<p>.NET 10 added 2 things on top of the .NET 9 baseline: OpenAPI 3.1 is now the default spec version, and you can serve YAML by registering a second endpoint with a <code class="language-plaintext highlighter-rouge">.yaml</code> suffix:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">app</span><span class="p">.</span><span class="nf">MapOpenApi</span><span class="p">(</span><span class="s">"/openapi/v1.yaml"</span><span class="p">);</span>
</code></pre></div></div>

<p>If you wrote OpenAPI document, operation, or schema transformers, the underlying OpenAPI.NET library moved to 2.0 and its type model changed. Plan a small rewrite there. Most apps never touched transformers and will see no impact.</p>

<p><br /></p>

<h2 id="3-blazor-static-assets-cache-bust-themselves-now">3. Blazor static assets cache-bust themselves now</h2>

<p>Blazor moved from <code class="language-plaintext highlighter-rouge">app.UseStaticFiles()</code> to <code class="language-plaintext highlighter-rouge">app.MapStaticAssets()</code> in .NET 9. The new API is a routing endpoint, not middleware, and it does 3 things at build time that the old middleware never did:</p>

<ol>
  <li>Fingerprints asset URLs with content hashes so browsers cache them aggressively and a content change always busts the cache.</li>
  <li>Emits ETags and <code class="language-plaintext highlighter-rouge">Last-Modified</code> headers.</li>
  <li>Pre-compresses files with Brotli and gzip and serves the compressed version automatically.</li>
</ol>

<p>You change one line in <code class="language-plaintext highlighter-rouge">Program.cs</code>:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Before</span>
<span class="n">app</span><span class="p">.</span><span class="nf">UseStaticFiles</span><span class="p">();</span>

<span class="c1">// After</span>
<span class="n">app</span><span class="p">.</span><span class="nf">MapStaticAssets</span><span class="p">();</span>
</code></pre></div></div>

<p>And you reference assets through the <code class="language-plaintext highlighter-rouge">@Assets</code> map in <code class="language-plaintext highlighter-rouge">App.razor</code>:</p>

<pre><code class="language-razor">&lt;link rel="stylesheet" href="@Assets["app.css"]" /&gt;
&lt;link rel="stylesheet" href="@Assets["styles.css"]" /&gt;
</code></pre>

<p>Two lines of code and you have a production-grade caching story for CSS, JS, fonts, and images. .NET 10 also added a <code class="language-plaintext highlighter-rouge">&lt;ResourcePreloader /&gt;</code> component you can drop into your <code class="language-plaintext highlighter-rouge">App.razor</code> <code class="language-plaintext highlighter-rouge">&lt;head&gt;</code> to emit preload hints for the WebAssembly boot resources. Useful for first-paint on a fresh page load.</p>

<p><br /></p>

<h2 id="4-blazor-form-validation-with-nested-and-collection-support">4. Blazor form validation with nested and collection support</h2>

<p>Blazor’s <code class="language-plaintext highlighter-rouge">DataAnnotationsValidator</code> has worked the same way since .NET 6. It uses reflection at runtime, it can’t see into nested objects, and it can’t validate items inside collections. .NET 10 fixes both limitations with a source-generated path.</p>

<p>You opt in with the same <code class="language-plaintext highlighter-rouge">AddValidation()</code> call from section 1 plus a <code class="language-plaintext highlighter-rouge">[ValidatableType]</code> attribute on your model root:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Microsoft.AspNetCore.Components.Forms</span><span class="p">;</span>

<span class="p">[</span><span class="n">ValidatableType</span><span class="p">]</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">Order</span>
<span class="p">{</span>
    <span class="p">[</span><span class="n">Required</span><span class="p">,</span> <span class="nf">MinLength</span><span class="p">(</span><span class="m">3</span><span class="p">)]</span>
    <span class="k">public</span> <span class="kt">string</span> <span class="n">CustomerName</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span> <span class="p">=</span> <span class="s">""</span><span class="p">;</span>

    <span class="p">[</span><span class="n">Required</span><span class="p">]</span>
    <span class="k">public</span> <span class="n">ShippingAddress</span> <span class="n">Address</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span> <span class="p">=</span> <span class="k">new</span><span class="p">();</span>

    <span class="k">public</span> <span class="n">List</span><span class="p">&lt;</span><span class="n">OrderItem</span><span class="p">&gt;</span> <span class="n">Items</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span> <span class="p">=</span> <span class="p">[];</span>
<span class="p">}</span>
</code></pre></div></div>

<p>You only annotate the root type. The validator discovers <code class="language-plaintext highlighter-rouge">ShippingAddress</code> and <code class="language-plaintext highlighter-rouge">OrderItem</code> automatically and validates their properties on submit. The attribute also turns on source-generated validation for the whole type, which is AOT-friendly and compiles down to straight-line code.</p>

<p>You keep <code class="language-plaintext highlighter-rouge">&lt;DataAnnotationsValidator /&gt;</code> in your <code class="language-plaintext highlighter-rouge">EditForm</code>. In .NET 10 it uses the source-gen path internally when <code class="language-plaintext highlighter-rouge">[ValidatableType]</code> is present, so nothing else in your form needs to change.</p>

<p><br /></p>

<h2 id="5-serverwasm-auth-state-comes-from-the-framework">5. Server↔WASM auth state comes from the framework</h2>

<p>If you have a Blazor Web App with a WebAssembly client, you’ve probably been carrying the 3-file pattern the .NET 8 template generated: a <code class="language-plaintext highlighter-rouge">PersistingAuthenticationStateProvider</code> on the server, a <code class="language-plaintext highlighter-rouge">PersistentAuthenticationStateProvider</code> on the client, and a <code class="language-plaintext highlighter-rouge">UserInfo</code> DTO in the middle that serialized claims through <code class="language-plaintext highlighter-rouge">PersistentComponentState</code>.</p>

<p>.NET 9 replaced all 3 with 2 framework calls.</p>

<p>On the server:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="nf">AddRazorComponents</span><span class="p">()</span>
    <span class="p">.</span><span class="nf">AddInteractiveWebAssemblyComponents</span><span class="p">()</span>
    <span class="p">.</span><span class="nf">AddAuthenticationStateSerialization</span><span class="p">(</span><span class="n">options</span> <span class="p">=&gt;</span> <span class="n">options</span><span class="p">.</span><span class="n">SerializeAllClaims</span> <span class="p">=</span> <span class="k">true</span><span class="p">);</span>
</code></pre></div></div>

<p>On the client:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="nf">AddAuthenticationStateDeserialization</span><span class="p">();</span>
</code></pre></div></div>

<p>The framework serializes the full <code class="language-plaintext highlighter-rouge">ClaimsPrincipal</code>, encrypts the payload with Data Protection, and rebuilds it on the WASM side. Delete the 3 files, delete the registrations that pointed at them, and the auth state flow keeps working.</p>

<p>A small thing worth knowing: the default serializer only carries <code class="language-plaintext highlighter-rouge">name</code> and <code class="language-plaintext highlighter-rouge">role</code> claims. If your app reads roles or custom claims like <code class="language-plaintext highlighter-rouge">userid</code> on the client, set <code class="language-plaintext highlighter-rouge">SerializeAllClaims = true</code> like the snippet above. The default exists because the page payload gets bigger with every claim, so opt in when you need it.</p>

<p>In the prebuilt Blazor frontend that ships with the bootcamp, this collapsed about 110 lines of custom glue code into the 2 calls above. Less code to own. The payload is encrypted instead of plain JSON. And roles work on the client out of the box.</p>

<p><br /></p>

<h2 id="6-c-14-the-language-changes-youll-actually-use">6. C# 14: the language changes you’ll actually use</h2>

<p>A few C# 14 features you’ll use right away in real codebases.</p>

<p><strong>Field-backed properties.</strong> You can write a custom setter without declaring a backing field. The <code class="language-plaintext highlighter-rouge">field</code> keyword refers to the compiler-synthesized one:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="kt">string</span> <span class="n">Name</span>
<span class="p">{</span>
    <span class="k">get</span><span class="p">;</span>
    <span class="k">set</span> <span class="p">=&gt;</span> <span class="n">field</span> <span class="p">=</span> <span class="k">value</span> <span class="p">??</span> <span class="k">throw</span> <span class="k">new</span> <span class="nf">ArgumentNullException</span><span class="p">(</span><span class="k">nameof</span><span class="p">(</span><span class="k">value</span><span class="p">));</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>Null-conditional assignment.</strong> The <code class="language-plaintext highlighter-rouge">?.</code> and <code class="language-plaintext highlighter-rouge">?[]</code> operators work on the left side of an assignment now:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">customer</span><span class="p">?.</span><span class="n">Order</span> <span class="p">=</span> <span class="nf">GetCurrentOrder</span><span class="p">();</span>
</code></pre></div></div>

<p>The right side only runs when the left side isn’t null.</p>

<p><strong>Extension members.</strong> Extension properties and static extension members now work the same way extension methods do:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">static</span> <span class="k">class</span> <span class="nc">StringExtensions</span>
<span class="p">{</span>
    <span class="nf">extension</span><span class="p">(</span><span class="kt">string</span> <span class="n">s</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">public</span> <span class="kt">bool</span> <span class="n">IsBlank</span> <span class="p">=&gt;</span> <span class="kt">string</span><span class="p">.</span><span class="nf">IsNullOrWhiteSpace</span><span class="p">(</span><span class="n">s</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="c1">// Usage</span>
<span class="k">if</span> <span class="p">(</span><span class="n">input</span><span class="p">.</span><span class="n">IsBlank</span><span class="p">)</span> <span class="p">{</span> <span class="cm">/* ... */</span> <span class="p">}</span>
</code></pre></div></div>

<p>There’s also <code class="language-plaintext highlighter-rouge">nameof</code> on unbound generics (<code class="language-plaintext highlighter-rouge">nameof(List&lt;&gt;)</code>), partial constructors and events, and lambda parameter modifiers without explicit types. Worth knowing, but you’ll use the 3 above more.</p>

<p>All of these come with <code class="language-plaintext highlighter-rouge">net10.0</code>. No package install, no flag to flip.</p>

<p><br /></p>

<h2 id="what-you-get-for-free-across-the-rest-of-the-stack">What you get for free across the rest of the stack</h2>

<p>A quick list of things you get just by upgrading, no code changes required.</p>

<p><strong>Exception handling is 2 to 4 times faster than .NET 8</strong> and on by default since .NET 9 (based on the NativeAOT model). Arm64 GC pauses also dropped 8 to over 20% in .NET 10 from a write-barrier rewrite, and the JIT now stack-allocates small fixed-size arrays of both value and reference types with smarter escape analysis.</p>

<p><strong>foreach over an array stops paying the abstraction cost.</strong> Array interface methods are devirtualized in .NET 10, and PGO uses profile data more aggressively across both .NET 9 and 10.</p>

<p><strong>Complex types map to JSON columns now.</strong> On SQL Server 2025 / Azure SQL the new <code class="language-plaintext highlighter-rouge">json</code> data type is used automatically. And <code class="language-plaintext highlighter-rouge">ExecuteUpdateAsync</code> accepts a regular lambda, so conditional updates don’t need hand-built expression trees anymore.</p>

<p><strong>No more migration races at app startup.</strong> Since EF Core 9, <code class="language-plaintext highlighter-rouge">MigrateAsync()</code> and <code class="language-plaintext highlighter-rouge">dotnet ef database update</code> automatically acquire a database-wide lock before applying migrations. If you’ve ever had 2 instances of your app start up at the same time and race to migrate the database, that race is gone.</p>

<p><strong>Kestrel releases idle memory on its own.</strong> Memory pools in Kestrel, IIS, and HTTP.sys evict unused blocks during idle periods, dropping idle-time RSS automatically. And cookie auth on API endpoints returns 401 and 403 instead of redirecting to a login page.</p>

<p><strong>dotnet build output looks great now.</strong> Since .NET 9, <code class="language-plaintext highlighter-rouge">dotnet build</code>, <code class="language-plaintext highlighter-rouge">test</code>, <code class="language-plaintext highlighter-rouge">publish</code>, <code class="language-plaintext highlighter-rouge">restore</code>, and friends use the new Terminal Logger by default. Color-coded warnings and errors, clickable links, per-task duration timers, and a clean summary of failures and warnings at the end.</p>

<p><strong>Trusted HTTPS dev certs on Linux.</strong> <code class="language-plaintext highlighter-rouge">dotnet dev-certs https --trust</code> works on Linux now (Chrome, Edge, Firefox, and HttpClient). And Blazor WASM hot reload is on by default in Debug.</p>

<p>None of that needed a code change. You get it all just by upgrading.</p>

<p><br /></p>

<h2 id="what-stayed-the-same">What stayed the same</h2>

<p>The architecture didn’t move. These all work the same way as on .NET 8:</p>

<ul>
  <li>Minimal API routing, endpoint mapping, middleware ordering</li>
  <li><code class="language-plaintext highlighter-rouge">WebApplication.CreateBuilder</code>, hosting model, <code class="language-plaintext highlighter-rouge">IOptions</code>, configuration providers</li>
  <li>EF Core query API, migrations, <code class="language-plaintext highlighter-rouge">DbContext</code> configuration</li>
  <li>JWT, cookie auth, OpenID Connect setup</li>
  <li>Dependency injection, logging, OpenTelemetry wiring</li>
</ul>

<p>The .NET 8 mental model still works on .NET 10. Most of your code compiles unchanged. The upgrade is additive across every layer I’ve covered.</p>

<p><br /></p>

<h2 id="the-upgrade-checklist">The upgrade checklist</h2>

<p>A working order that minimizes the back-and-forth on a real codebase.</p>

<ol>
  <li>
    <p>Bump <code class="language-plaintext highlighter-rouge">&lt;TargetFramework&gt;</code> to <code class="language-plaintext highlighter-rouge">net10.0</code> in every <code class="language-plaintext highlighter-rouge">.csproj</code>, including test projects.</p>
  </li>
  <li>Upgrade package versions across the board:
    <ul>
      <li>EF Core to 10.x</li>
      <li>JwtBearer / OpenIdConnect / WebAssembly.Server to 10.x</li>
      <li>MVC.Testing to 10.x</li>
      <li>ServiceDiscovery and Http.Resilience to 10.4.x</li>
      <li>OpenTelemetry stack to 1.15.x</li>
    </ul>

    <p>Build before moving on.</p>
  </li>
  <li>Replace <code class="language-plaintext highlighter-rouge">MinimalApis.Extensions</code> with the built-in validation:
    <ul>
      <li>Remove the package from every API project</li>
      <li>Add <code class="language-plaintext highlighter-rouge">builder.Services.AddValidation();</code> right after <code class="language-plaintext highlighter-rouge">WebApplication.CreateBuilder(args)</code></li>
      <li>Delete every <code class="language-plaintext highlighter-rouge">.WithParameterValidation()</code> chain call</li>
    </ul>
  </li>
  <li>Replace Swashbuckle with the built-in OpenAPI:
    <ul>
      <li>Remove <code class="language-plaintext highlighter-rouge">Swashbuckle.AspNetCore</code>, add <code class="language-plaintext highlighter-rouge">Microsoft.AspNetCore.OpenApi</code></li>
      <li>Replace <code class="language-plaintext highlighter-rouge">AddEndpointsApiExplorer()</code> + <code class="language-plaintext highlighter-rouge">AddSwaggerGen()</code> with <code class="language-plaintext highlighter-rouge">AddOpenApi()</code></li>
      <li>Replace <code class="language-plaintext highlighter-rouge">app.UseSwagger()</code> with <code class="language-plaintext highlighter-rouge">app.MapOpenApi()</code></li>
      <li>If a global authorization or fallback policy is in effect, chain <code class="language-plaintext highlighter-rouge">.AllowAnonymous()</code> on <code class="language-plaintext highlighter-rouge">MapOpenApi()</code></li>
    </ul>
  </li>
  <li>For Blazor projects:
    <ul>
      <li>Change <code class="language-plaintext highlighter-rouge">app.UseStaticFiles()</code> to <code class="language-plaintext highlighter-rouge">app.MapStaticAssets()</code></li>
      <li>Update <code class="language-plaintext highlighter-rouge">App.razor</code> to reference assets through <code class="language-plaintext highlighter-rouge">@Assets["..."]</code></li>
      <li>If you have a WASM client, replace the 3 custom auth-state-provider files with <code class="language-plaintext highlighter-rouge">.AddAuthenticationStateSerialization()</code> on the server and <code class="language-plaintext highlighter-rouge">AddAuthenticationStateDeserialization()</code> on the client. Delete the now-unused <code class="language-plaintext highlighter-rouge">PersistingAuthenticationStateProvider</code>, <code class="language-plaintext highlighter-rouge">PersistentAuthenticationStateProvider</code>, and <code class="language-plaintext highlighter-rouge">UserInfo</code> files.</li>
    </ul>
  </li>
  <li>Update your CI YAMLs in 2 places:
    <ul>
      <li>.NET install step: Azure DevOps <code class="language-plaintext highlighter-rouge">UseDotNet@2</code> with <code class="language-plaintext highlighter-rouge">version: '10.x'</code>, or GitHub Actions <code class="language-plaintext highlighter-rouge">actions/setup-dotnet</code> with <code class="language-plaintext highlighter-rouge">dotnet-version: '10.x'</code></li>
      <li>Build-output paths: <code class="language-plaintext highlighter-rouge">bin/Release/net8.0/publish</code> becomes <code class="language-plaintext highlighter-rouge">bin/Release/net10.0/publish</code></li>
    </ul>
  </li>
</ol>

<p><br /></p>

<h2 id="what-might-bite-you">What might bite you</h2>

<p>A few documented behavior changes that will probably show up during a real upgrade.</p>

<p><strong>Cookie auth on API endpoints returns 401/403.</strong> Unauthenticated requests to known API endpoints no longer redirect to a login URL. The handler returns the proper HTTP status code instead. Endpoints are detected automatically via <code class="language-plaintext highlighter-rouge">IApiEndpointMetadata</code>. If you depended on the redirect behavior, override <code class="language-plaintext highlighter-rouge">OnRedirectToLogin</code> and <code class="language-plaintext highlighter-rouge">OnRedirectToAccessDenied</code> in your cookie options.</p>

<p><strong>OpenAPI 3.1 is the default.</strong> If you wrote transformers, the OpenAPI.NET 2.0 type model changed (interfaces for entities, <code class="language-plaintext highlighter-rouge">JsonNode</code> instead of <code class="language-plaintext highlighter-rouge">OpenApiAny</code>). Rewrite or pin the spec version to 3.0 with <code class="language-plaintext highlighter-rouge">options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_0</code> in <code class="language-plaintext highlighter-rouge">AddOpenApi</code>.</p>

<p><strong>EF Core 10 parameterized collections.</strong> The default translation changed from a single JSON array (<code class="language-plaintext highlighter-rouge">OPENJSON</code>) to multiple scalar parameters. Most apps see no change. If you tuned around the old plan, restore the previous behavior via <code class="language-plaintext highlighter-rouge">o.UseParameterizedCollectionMode(ParameterTranslationMode.Parameter)</code> on the provider options.</p>

<p><strong>EF tools with multi-targeted projects.</strong> <code class="language-plaintext highlighter-rouge">dotnet ef</code> now requires <code class="language-plaintext highlighter-rouge">--framework &lt;tfm&gt;</code> when the project has a <code class="language-plaintext highlighter-rouge">&lt;TargetFrameworks&gt;</code> element. Add the flag to your migration commands.</p>

<p><strong>The Program class is now public for tests.</strong> ASP.NET Core 10 ships a source generator that makes the top-level <code class="language-plaintext highlighter-rouge">Program</code> class public when a test assembly references it via <code class="language-plaintext highlighter-rouge">WebApplicationFactory&lt;Program&gt;</code>. This removes the old <code class="language-plaintext highlighter-rouge">public partial class Program {}</code> boilerplate, but if your integration test project references 2 host assemblies (say, an API and a Worker) and both get made public this way, you’ll hit <code class="language-plaintext highlighter-rouge">CS0433</code> because the <code class="language-plaintext highlighter-rouge">Program</code> type is now ambiguous across both.</p>

<p>There are a couple of ways to fix it. The lightest touch is to alias one of the project references in your test <code class="language-plaintext highlighter-rouge">.csproj</code>:</p>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;ProjectReference</span> <span class="na">Include=</span><span class="s">"..\..\src\GameStore.Worker\GameStore.Worker.csproj"</span>
                  <span class="na">Aliases=</span><span class="s">"Worker"</span> <span class="nt">/&gt;</span>
</code></pre></div></div>

<p>Then <code class="language-plaintext highlighter-rouge">extern alias</code> it in the test file that needs the Worker types:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">extern</span> <span class="k">alias</span> <span class="n">Worker</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Worker</span><span class="p">::</span><span class="n">GameStore</span><span class="p">.</span><span class="n">Worker</span><span class="p">;</span>
</code></pre></div></div>

<p>The other option, if you’d rather not touch the test project, is to convert one of the 2 <code class="language-plaintext highlighter-rouge">Program.cs</code> files to an explicit class with a namespace.</p>

<p><br /></p>

<h2 id="wrapping-up">Wrapping up</h2>

<p>This is the LTS to take. The actual work is small: bump the TFM, upgrade your packages, replace <code class="language-plaintext highlighter-rouge">MinimalApis.Extensions</code> and Swashbuckle with the built-in versions, switch Blazor to <code class="language-plaintext highlighter-rouge">MapStaticAssets()</code>, and move auth state to the framework if you have a WASM client. Everything else (runtime, GC, JIT, EF Core, hosting, tooling) comes along for free.</p>

<p>.NET 10 LTS is supported through November 2028, which gives you a solid 2.5 years before the next upgrade.</p>

<p>The new edition of the <a href="https://juliocasal.com/courses/dotnetbootcamp">Bootcamp</a>, launching soon, includes the must-have framework updates for .NET APIs and Blazor apps from Course 1 onward.</p>

<p>Next Saturday I’ll go over the Aspire 9 to Aspire 13 upgrade, which also involves its own set of wins and gotchas. If you are still on Aspire 9, that one is for you.</p>

<p>And that’s it for today.</p>

<p>See you next Saturday.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Read time: 10 minutes]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://juliocasal.com/assets/images/2026-05-16/featured.png" /><media:content medium="image" url="https://juliocasal.com/assets/images/2026-05-16/featured.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">I Rebuilt the Bootcamp on .NET 10 and Aspire 13</title><link href="https://juliocasal.com/blog/I-Rebuilt-The-Bootcamp-On-Dotnet-10-and-Aspire-13.html" rel="alternate" type="text/html" title="I Rebuilt the Bootcamp on .NET 10 and Aspire 13" /><published>2026-05-09T00:00:00-07:00</published><updated>2026-05-09T00:00:00-07:00</updated><id>https://juliocasal.com/blog/I-Rebuilt-The-Bootcamp-On-Dotnet-10-and-Aspire-13</id><content type="html" xml:base="https://juliocasal.com/blog/I-Rebuilt-The-Bootcamp-On-Dotnet-10-and-Aspire-13.html"><![CDATA[<p><em>Read time: 5 minutes</em></p>

<p>A few months back, I sat down to do what I thought was a routine job. Bump the <a href="https://juliocasal.com/courses/dotnetbootcamp">.NET Backend Developer Bootcamp</a> from .NET 8 to .NET 10. New target framework, new package versions, re-record a few lessons, ship it.</p>

<p>That plan lasted about a day.</p>

<p>The first thing I noticed was that 2 of the most common NuGet packages in the bootcamp (the ones I install in almost every project) had been absorbed into the framework itself. Then I opened the AppHost project and the .csproj file looked completely different. Then the Blazor frontend had a new authentication flow that cut out a bunch of custom code. Then Stripe had renamed their main React component.</p>

<p>And that was just one course.</p>

<p>By the time I worked through all 7 main courses (plus the 3 bonus mini-courses), the new edition was a full rebuild.</p>

<p><img src="/assets/images/2026-05-09/featured.png" alt="I Rebuilt the Bootcamp on .NET 10 and Aspire 13" /></p>

<p><br /></p>

<h2 id="whats-actually-inside-this-upgrade">What’s actually inside this upgrade</h2>

<p>Here’s a high-level overview, so you can see why this turned into months of work.</p>

<ul>
  <li><strong>.NET 8 to .NET 10.</strong> Every <code class="language-plaintext highlighter-rouge">.csproj</code> in every course bumped. Hundreds of project files, all on the same .NET 10 stack across the 7 main courses and the 3 bonus mini-courses.</li>
  <li><strong>Validation is now built into ASP.NET Core.</strong> The third-party NuGet package the bootcamp used to install on every API is gone from every project. Smaller setup, fewer dependencies.</li>
  <li><strong>OpenAPI is now built into ASP.NET Core too.</strong> Same story. Swashbuckle is gone from every project, replaced by the framework’s own first-party OpenAPI support (a .NET 9 update).</li>
  <li><strong>Blazor changed more than I expected.</strong> Static asset fingerprinting and cache-busting are now included. The server-to-WASM auth state flow (3 custom files, ~110 lines of glue code in the old bootcamp) collapses to 2 built-in API calls. Form validation went source-generated and AOT-friendly. And I caught a token refresh bug that was silently dropping custom claims.</li>
  <li><strong>The React side moved too.</strong> React 18 to 19, Vite 5 to 8, TypeScript 5 to 6, react-router-dom 6 to 7, plus the OIDC client packages. One of those silently breaks <code class="language-plaintext highlighter-rouge">npm install</code> on its latest version, which I had to track down the hard way.</li>
  <li><strong>Aspire 9.5 to Aspire 13.2.</strong> Major version jump. The AppHost project file was redesigned, tons of updated and new APIs, the Aspire CLI is now part of the standard workflow, the pattern for wiring a React frontend into the AppHost changed, and lots more.</li>
  <li><strong>Stripe, both sides.</strong> Multiple major versions of the Stripe React SDK, plus Stripe.NET jumping to 51. The main React component for embedded checkout was replaced, and the server-side checkout call changed shape.</li>
  <li><strong>Getting the integration tests onto .NET 10 took real work.</strong> C# 14 broke a <code class="language-plaintext highlighter-rouge">Program</code> type lookup, EF Core 10 + Npgsql changed when an interceptor fires, the runtime now expects a <code class="language-plaintext highlighter-rouge">TimeProvider</code> registration, and a timing race in the outbox test surfaced under the new defaults.</li>
  <li><strong>CI/CD pipelines updated end to end.</strong> Pipeline YAMLs, SDK install steps, publish paths. Every CI lesson runs on the new versions.</li>
</ul>

<p>If you’ve moved or are about to move your projects to .NET 10, this new edition includes all the changes you need to make. Every lesson snapshot matches a fresh project on the current .NET and Aspire SDK, so you can see exactly what to change and where.</p>

<p><br /></p>

<h2 id="docker-and-aspire-are-now-their-own-courses">Docker and Aspire are now their own courses</h2>

<p>The old Course 5 was called “Containers &amp; .NET Aspire.” It tried to cover 2 topics in one course, and it didn’t feel like I covered Docker fundamentals properly. Plus, the way Aspire was introduced halfway through the course followed a sequence you would not use in real projects.</p>

<p>So I split the old course in two:</p>

<ul>
  <li>
    <p><strong>Course 5: Docker for .NET Developers.</strong> Recorded natively on .NET 10 from the ground up. It starts with Docker fundamentals (what an image actually is, how containers work, how registries fit in, how networking and volumes behave, etc), then moves into containerizing real .NET applications (multi-stage builds, the right base images, configuration, secrets). 22 lessons re-recorded. A standalone Docker course tailored to .NET developers.</p>
  </li>
  <li>
    <p><strong>Course 6: Aspire for .NET Developers.</strong> All-new dedicated Aspire course. Full Aspire 13.2 coverage from scratch: the new SDK format, new APIs, updated hosting integrations, the new way to bring in React apps. 4 entire course modules redone from scratch in a brand new, more logical sequence, plus Keycloak as the default identity provider with a complete realm export so it “just works” from the first lesson (Entra ID still supported).</p>
  </li>
</ul>

<p>The Payments, Queues &amp; Workers course shifts to Course 7, followed by the 3 bonus mini-courses on Integration Testing, Azure DevOps CI/CD and Troubleshooting .NET Apps in Azure, all also updated to .NET 10 and Aspire 13.2.</p>

<p>If you’ve been on the fence about using Docker or Aspire in your .NET projects, the new structure lets you jump straight into Course 5 or Course 6 without going through the earlier courses first.</p>

<p><br /></p>

<h2 id="what-stayed-the-same">What stayed the same</h2>

<p>Everything you already know about minimal APIs, EF Core, JWT, Keycloak, Entra ID, Azure Storage, Service Bus, Aspire orchestration patterns, Blazor components, and OIDC auth flows is still right. The architecture is unchanged. The mental model is unchanged.</p>

<p>What changed is the surface. Cleaner project files. Fewer NuGet packages. Typed APIs where there used to be strings. Modern frontend tooling. None of it requires you to relearn how the bootcamp’s app actually works.</p>

<p><br /></p>

<h2 id="where-we-are-and-whats-next">Where we are and what’s next</h2>
<p>Last week I finished recording the last of the ~85 new lessons across the 7 main courses and 3 bonus mini-courses, and now I’m on the editing and polishing phase. More details on the release timeline of this new bootcamp edition coming soon.</p>

<p>In my next couple of newsletters I’ll go over why .NET 10 is a big deal, the new features that will affect real-world apps, and what’s worth knowing about Aspire 13.2 if you’re still on Aspire 9.</p>

<p>And that’s it for today.</p>

<p>See you next Saturday.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Read time: 5 minutes]]></summary></entry><entry><title type="html">Caching in ASP.NET Core: The Three Layers You Need to Know</title><link href="https://juliocasal.com/blog/Caching-In-ASP-NET-Core-Three-Layers.html" rel="alternate" type="text/html" title="Caching in ASP.NET Core: The Three Layers You Need to Know" /><published>2026-02-28T00:00:00-08:00</published><updated>2026-02-28T00:00:00-08:00</updated><id>https://juliocasal.com/blog/Caching-In-ASP-NET-Core-Three-Layers</id><content type="html" xml:base="https://juliocasal.com/blog/Caching-In-ASP-NET-Core-Three-Layers.html"><![CDATA[<p><em>Read time: 8 minutes</em></p>

<p>Caching is one of the most effective ways to improve your ASP.NET Core application’s performance. But with three different caching strategies available (in-memory, distributed, and output caching), knowing which one to use and when can be confusing.</p>

<p>In this tutorial, I’ll show you how to implement all three layers of caching in ASP.NET Core. You’ll learn when to use each approach and see practical examples of how to add Redis for distributed caching and integrate it with Aspire.</p>

<p><br /></p>

<h2 id="layer-1-in-memory-caching">Layer 1: In-Memory Caching</h2>

<p>In-memory caching stores data in the web server’s memory using <code class="language-plaintext highlighter-rouge">IMemoryCache</code>. It’s the simplest and fastest caching option.</p>

<p><strong>When to use it:</strong></p>

<ul>
  <li>Single-server deployments</li>
  <li>Data that’s expensive to compute but cheap to regenerate</li>
  <li>Session-like data that doesn’t need to survive restarts</li>
</ul>

<p>Implementation:</p>

<p>First, register the memory cache service in <code class="language-plaintext highlighter-rouge">Program.cs</code>:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="nf">AddMemoryCache</span><span class="p">();</span>
</code></pre></div></div>

<p><br /></p>

<p>Then create a service that uses <code class="language-plaintext highlighter-rouge">IMemoryCache</code> to cache products with both sliding and absolute expiration:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">class</span> <span class="nc">ProductService</span><span class="p">(</span><span class="n">IMemoryCache</span> <span class="n">cache</span><span class="p">,</span> <span class="n">AppDbContext</span> <span class="n">context</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="n">Product</span><span class="p">?&gt;</span> <span class="nf">GetProductAsync</span><span class="p">(</span><span class="kt">int</span> <span class="n">id</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">cacheKey</span> <span class="p">=</span> <span class="s">$"product-</span><span class="p">{</span><span class="n">id</span><span class="p">}</span><span class="s">"</span><span class="p">;</span>

        <span class="k">if</span> <span class="p">(!</span><span class="n">cache</span><span class="p">.</span><span class="nf">TryGetValue</span><span class="p">(</span><span class="n">cacheKey</span><span class="p">,</span> <span class="k">out</span> <span class="n">Product</span><span class="p">?</span> <span class="n">product</span><span class="p">))</span>
        <span class="p">{</span>
            <span class="n">product</span> <span class="p">=</span> <span class="k">await</span> <span class="n">context</span><span class="p">.</span><span class="n">Products</span>
                <span class="p">.</span><span class="nf">FirstOrDefaultAsync</span><span class="p">(</span><span class="n">p</span> <span class="p">=&gt;</span> <span class="n">p</span><span class="p">.</span><span class="n">Id</span> <span class="p">==</span> <span class="n">id</span><span class="p">);</span>

            <span class="k">if</span> <span class="p">(</span><span class="n">product</span> <span class="k">is</span> <span class="n">not</span> <span class="k">null</span><span class="p">)</span>
            <span class="p">{</span>
                <span class="kt">var</span> <span class="n">options</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">MemoryCacheEntryOptions</span><span class="p">()</span>
                    <span class="p">.</span><span class="nf">SetSlidingExpiration</span><span class="p">(</span><span class="n">TimeSpan</span><span class="p">.</span><span class="nf">FromMinutes</span><span class="p">(</span><span class="m">5</span><span class="p">))</span>
                    <span class="p">.</span><span class="nf">SetAbsoluteExpiration</span><span class="p">(</span><span class="n">TimeSpan</span><span class="p">.</span><span class="nf">FromMinutes</span><span class="p">(</span><span class="m">30</span><span class="p">));</span>

                <span class="n">cache</span><span class="p">.</span><span class="nf">Set</span><span class="p">(</span><span class="n">cacheKey</span><span class="p">,</span> <span class="n">product</span><span class="p">,</span> <span class="n">options</span><span class="p">);</span>
            <span class="p">}</span>
        <span class="p">}</span>

        <span class="k">return</span> <span class="n">product</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><br /></p>

<p><strong>Key concepts:</strong></p>

<ul>
  <li><strong>Sliding expiration</strong>: Resets the timer each time the item is accessed</li>
  <li><strong>Absolute expiration</strong>: Hard limit on how long the item stays cached</li>
  <li><strong>Combine both</strong>: Ensures items expire even if frequently accessed</li>
</ul>

<p><strong>Limitations:</strong></p>

<ul>
  <li>Data is lost on application restart</li>
  <li>Not shared across multiple servers</li>
  <li>Uses server memory (can cause issues under memory pressure)</li>
</ul>

<p><br /></p>

<h2 id="layer-2-distributed-caching-with-redis">Layer 2: Distributed Caching with Redis</h2>

<p>Distributed caching uses an external cache store (like Redis) that multiple application instances can share. This solves the limitations of in-memory caching.</p>

<p><strong>When to use it:</strong></p>

<ul>
  <li>Multi-server deployments (web farms, load balancers)</li>
  <li>Data that must survive application restarts</li>
  <li>Session state in distributed applications</li>
  <li>Cache that needs to be shared across services</li>
</ul>

<p>Setting up Redis with Aspire:</p>

<p>For local development with Aspire, add Redis to your AppHost:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// AppHost Program.cs</span>
<span class="kt">var</span> <span class="n">builder</span> <span class="p">=</span> <span class="n">DistributedApplication</span><span class="p">.</span><span class="nf">CreateBuilder</span><span class="p">(</span><span class="n">args</span><span class="p">);</span>

<span class="kt">var</span> <span class="n">redis</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">AddRedis</span><span class="p">(</span><span class="s">"cache"</span><span class="p">);</span>

<span class="n">builder</span><span class="p">.</span><span class="n">AddProject</span><span class="p">&lt;</span><span class="n">Projects</span><span class="p">.</span><span class="n">CachingApi</span><span class="p">&gt;(</span><span class="s">"api"</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">WithReference</span><span class="p">(</span><span class="n">redis</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">WaitFor</span><span class="p">(</span><span class="n">redis</span><span class="p">);</span>

<span class="n">builder</span><span class="p">.</span><span class="nf">Build</span><span class="p">().</span><span class="nf">Run</span><span class="p">();</span>
</code></pre></div></div>

<p><br /></p>

<p>Then register the Redis distributed cache in your API’s <code class="language-plaintext highlighter-rouge">Program.cs</code>:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">builder</span><span class="p">.</span><span class="nf">AddRedisDistributedCache</span><span class="p">(</span><span class="s">"cache"</span><span class="p">);</span>
</code></pre></div></div>

<p><br /></p>

<p>Using IDistributedCache:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">class</span> <span class="nc">ProductService</span><span class="p">(</span><span class="n">IDistributedCache</span> <span class="n">cache</span><span class="p">,</span> <span class="n">AppDbContext</span> <span class="n">context</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="n">Product</span><span class="p">?&gt;</span> <span class="nf">GetProductAsync</span><span class="p">(</span><span class="kt">int</span> <span class="n">id</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">cacheKey</span> <span class="p">=</span> <span class="s">$"product-</span><span class="p">{</span><span class="n">id</span><span class="p">}</span><span class="s">"</span><span class="p">;</span>
        <span class="kt">var</span> <span class="n">cachedBytes</span> <span class="p">=</span> <span class="k">await</span> <span class="n">cache</span><span class="p">.</span><span class="nf">GetAsync</span><span class="p">(</span><span class="n">cacheKey</span><span class="p">);</span>

        <span class="k">if</span> <span class="p">(</span><span class="n">cachedBytes</span> <span class="k">is</span> <span class="n">not</span> <span class="k">null</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="kt">var</span> <span class="n">json</span> <span class="p">=</span> <span class="n">Encoding</span><span class="p">.</span><span class="n">UTF8</span><span class="p">.</span><span class="nf">GetString</span><span class="p">(</span><span class="n">cachedBytes</span><span class="p">);</span>
            <span class="k">return</span> <span class="n">JsonSerializer</span><span class="p">.</span><span class="n">Deserialize</span><span class="p">&lt;</span><span class="n">Product</span><span class="p">&gt;(</span><span class="n">json</span><span class="p">);</span>
        <span class="p">}</span>

        <span class="kt">var</span> <span class="n">product</span> <span class="p">=</span> <span class="k">await</span> <span class="n">context</span><span class="p">.</span><span class="n">Products</span>
            <span class="p">.</span><span class="nf">FirstOrDefaultAsync</span><span class="p">(</span><span class="n">p</span> <span class="p">=&gt;</span> <span class="n">p</span><span class="p">.</span><span class="n">Id</span> <span class="p">==</span> <span class="n">id</span><span class="p">);</span>

        <span class="k">if</span> <span class="p">(</span><span class="n">product</span> <span class="k">is</span> <span class="n">not</span> <span class="k">null</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="kt">var</span> <span class="n">json</span> <span class="p">=</span> <span class="n">JsonSerializer</span><span class="p">.</span><span class="nf">Serialize</span><span class="p">(</span><span class="n">product</span><span class="p">);</span>
            <span class="kt">var</span> <span class="n">bytes</span> <span class="p">=</span> <span class="n">Encoding</span><span class="p">.</span><span class="n">UTF8</span><span class="p">.</span><span class="nf">GetBytes</span><span class="p">(</span><span class="n">json</span><span class="p">);</span>

            <span class="kt">var</span> <span class="n">cacheOptions</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">DistributedCacheEntryOptions</span><span class="p">()</span>
                <span class="p">.</span><span class="nf">SetSlidingExpiration</span><span class="p">(</span><span class="n">TimeSpan</span><span class="p">.</span><span class="nf">FromMinutes</span><span class="p">(</span><span class="m">5</span><span class="p">))</span>
                <span class="p">.</span><span class="nf">SetAbsoluteExpiration</span><span class="p">(</span><span class="n">TimeSpan</span><span class="p">.</span><span class="nf">FromMinutes</span><span class="p">(</span><span class="m">30</span><span class="p">));</span>

            <span class="k">await</span> <span class="n">cache</span><span class="p">.</span><span class="nf">SetAsync</span><span class="p">(</span><span class="n">cacheKey</span><span class="p">,</span> <span class="n">bytes</span><span class="p">,</span> <span class="n">cacheOptions</span><span class="p">);</span>
        <span class="p">}</span>

        <span class="k">return</span> <span class="n">product</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><br /></p>

<p><strong>Key differences from IMemoryCache:</strong></p>

<ul>
  <li>Works with <code class="language-plaintext highlighter-rouge">byte[]</code> instead of objects</li>
  <li>All methods have async versions</li>
  <li>Shared across all application instances</li>
  <li>Survives application restarts</li>
</ul>

<p><br /></p>

<h2 id="layer-3-output-caching">Layer 3: Output Caching</h2>

<p>Output caching (introduced in ASP.NET Core 7) caches entire HTTP responses. It’s the most efficient form of caching because it bypasses most of your application pipeline.</p>

<p><strong>When to use it:</strong></p>

<ul>
  <li>API endpoints that return the same response for many users</li>
  <li>Pages that don’t require authentication</li>
  <li>Responses that change infrequently</li>
</ul>

<p>Setup:</p>

<p>Add output caching services and middleware:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="nf">AddOutputCache</span><span class="p">();</span>

<span class="n">app</span><span class="p">.</span><span class="nf">UseOutputCache</span><span class="p">();</span>
</code></pre></div></div>

<p><br /></p>

<p>Apply to endpoints:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">app</span><span class="p">.</span><span class="nf">MapGet</span><span class="p">(</span><span class="s">"/products"</span><span class="p">,</span> <span class="k">async</span> <span class="p">(</span><span class="n">AppDbContext</span> <span class="n">db</span><span class="p">)</span> <span class="p">=&gt;</span>
<span class="p">{</span>
    <span class="k">return</span> <span class="k">await</span> <span class="n">db</span><span class="p">.</span><span class="n">Products</span><span class="p">.</span><span class="nf">ToListAsync</span><span class="p">();</span>
<span class="p">})</span>
<span class="p">.</span><span class="nf">CacheOutput</span><span class="p">(</span><span class="n">policy</span> <span class="p">=&gt;</span> <span class="n">policy</span><span class="p">.</span><span class="nf">Expire</span><span class="p">(</span><span class="n">TimeSpan</span><span class="p">.</span><span class="nf">FromMinutes</span><span class="p">(</span><span class="m">5</span><span class="p">)));</span>

<span class="n">app</span><span class="p">.</span><span class="nf">MapGet</span><span class="p">(</span><span class="s">"/products/{id}"</span><span class="p">,</span> <span class="k">async</span> <span class="p">(</span><span class="kt">int</span> <span class="n">id</span><span class="p">,</span> <span class="n">AppDbContext</span> <span class="n">db</span><span class="p">)</span> <span class="p">=&gt;</span>
<span class="p">{</span>
    <span class="k">return</span> <span class="k">await</span> <span class="n">db</span><span class="p">.</span><span class="n">Products</span><span class="p">.</span><span class="nf">FindAsync</span><span class="p">(</span><span class="n">id</span><span class="p">);</span>
<span class="p">})</span>
<span class="p">.</span><span class="nf">CacheOutput</span><span class="p">(</span><span class="n">policy</span> <span class="p">=&gt;</span> <span class="n">policy</span>
    <span class="p">.</span><span class="nf">Expire</span><span class="p">(</span><span class="n">TimeSpan</span><span class="p">.</span><span class="nf">FromMinutes</span><span class="p">(</span><span class="m">5</span><span class="p">))</span>
    <span class="p">.</span><span class="nf">SetVaryByQuery</span><span class="p">(</span><span class="s">"id"</span><span class="p">));</span>
</code></pre></div></div>

<p><br /></p>

<p>Cache invalidation with tags:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">app</span><span class="p">.</span><span class="nf">MapGet</span><span class="p">(</span><span class="s">"/products"</span><span class="p">,</span> <span class="k">async</span> <span class="p">(</span><span class="n">AppDbContext</span> <span class="n">db</span><span class="p">)</span> <span class="p">=&gt;</span>
<span class="p">{</span>
    <span class="k">return</span> <span class="k">await</span> <span class="n">db</span><span class="p">.</span><span class="n">Products</span><span class="p">.</span><span class="nf">ToListAsync</span><span class="p">();</span>
<span class="p">})</span>
<span class="p">.</span><span class="nf">CacheOutput</span><span class="p">(</span><span class="n">policy</span> <span class="p">=&gt;</span> <span class="n">policy</span><span class="p">.</span><span class="nf">Tag</span><span class="p">(</span><span class="s">"products"</span><span class="p">));</span>

<span class="n">app</span><span class="p">.</span><span class="nf">MapPost</span><span class="p">(</span><span class="s">"/products"</span><span class="p">,</span> <span class="k">async</span> <span class="p">(</span><span class="n">Product</span> <span class="n">product</span><span class="p">,</span>
    <span class="n">AppDbContext</span> <span class="n">db</span><span class="p">,</span> <span class="n">IOutputCacheStore</span> <span class="n">cache</span><span class="p">)</span> <span class="p">=&gt;</span>
<span class="p">{</span>
    <span class="n">db</span><span class="p">.</span><span class="n">Products</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="n">product</span><span class="p">);</span>
    <span class="k">await</span> <span class="n">db</span><span class="p">.</span><span class="nf">SaveChangesAsync</span><span class="p">();</span>

    <span class="k">await</span> <span class="n">cache</span><span class="p">.</span><span class="nf">EvictByTagAsync</span><span class="p">(</span><span class="s">"products"</span><span class="p">,</span> <span class="k">default</span><span class="p">);</span>

    <span class="k">return</span> <span class="n">Results</span><span class="p">.</span><span class="nf">Created</span><span class="p">(</span><span class="s">$"/products/</span><span class="p">{</span><span class="n">product</span><span class="p">.</span><span class="n">Id</span><span class="p">}</span><span class="s">"</span><span class="p">,</span> <span class="n">product</span><span class="p">);</span>
<span class="p">});</span>
</code></pre></div></div>

<p><br /></p>

<h2 id="choosing-the-right-caching-layer">Choosing the Right Caching Layer</h2>

<p>Here’s a decision tree to help you choose:</p>

<p><strong>Use in-memory caching when:</strong></p>

<ul>
  <li>Running on a single server</li>
  <li>Cache size is small (under 100MB)</li>
  <li>Losing cache on restart is acceptable</li>
  <li>You need to cache complex objects</li>
</ul>

<p><strong>Use distributed caching when:</strong></p>

<ul>
  <li>Running on multiple servers</li>
  <li>Cache must survive restarts</li>
  <li>Sharing cache across services</li>
  <li>Cache size could grow large</li>
</ul>

<p><strong>Use output caching when:</strong></p>

<ul>
  <li>Caching entire HTTP responses</li>
  <li>Same response for many users</li>
  <li>Can vary by query strings or headers</li>
  <li>Need maximum performance gains</li>
</ul>

<p><strong>Combine multiple layers:</strong></p>

<p>You can use all three together! For example:</p>

<ul>
  <li>Output caching for public product listings</li>
  <li>Distributed caching for user shopping carts</li>
  <li>In-memory caching for configuration data</li>
</ul>

<p><br /></p>

<h2 id="wrapping-up">Wrapping Up</h2>

<p>Understanding the three layers of caching in ASP.NET Core gives you powerful tools to optimize your applications:</p>

<ul>
  <li><strong>In-memory caching</strong> for simple, single-server scenarios</li>
  <li><strong>Distributed caching</strong> for shared cache across multiple servers</li>
  <li><strong>Output caching</strong> for maximum performance on entire HTTP responses</li>
</ul>

<p>Redis provides excellent backing storage for both distributed and output caching, and Aspire makes it trivial to add to your development environment.</p>

<p>Start with in-memory caching for quick wins, add Redis when you scale beyond a single instance, and layer on output caching for your most-hit endpoints. You will be surprised how much performance you can squeeze out with just a few lines of configuration.</p>

<p>And that’s it for today.</p>

<p>See you next Saturday.</p>

<hr />

<p><br /></p>

<p><strong>Whenever you’re ready, there are 3 ways I can help you:</strong></p>

<ol>
  <li>
    <p><strong><a href="https://juliocasal.com/courses/dotnetbootcamp">.NET Backend Developer Bootcamp</a></strong>: A complete path from ASP.NET Core fundamentals to building, containerizing, and deploying production-ready, cloud-native apps on Azure.</p>
  </li>
  <li>
    <p><strong><a href="https://dotnetmicroservices.com">Building Microservices With .NET</a></strong>: Transform the way you build .NET systems at scale.</p>
  </li>
  <li>
    <p><strong><a href="https://www.patreon.com/juliocasal" target="_blank">Get the full source code</a></strong>: Download the working project from this article, grab exclusive course discounts, and join a private .NET community.</p>
  </li>
</ol>]]></content><author><name></name></author><summary type="html"><![CDATA[Read time: 8 minutes]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://juliocasal.com/assets/images/CachingThreeLayers.jpg" /><media:content medium="image" url="https://juliocasal.com/assets/images/CachingThreeLayers.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The Result Pattern: Stop Throwing Exceptions for Validation</title><link href="https://juliocasal.com/blog/the-result-pattern.html" rel="alternate" type="text/html" title="The Result Pattern: Stop Throwing Exceptions for Validation" /><published>2026-02-21T00:00:00-08:00</published><updated>2026-02-21T00:00:00-08:00</updated><id>https://juliocasal.com/blog/the-result-pattern</id><content type="html" xml:base="https://juliocasal.com/blog/the-result-pattern.html"><![CDATA[<p><em>Read time: 7 minutes</em></p>

<p>Last week, I was reviewing a pull request from a developer on my team. The code worked. Tests passed. But every business rule violation was handled by throwing an exception.</p>

<p>Invalid email? <code class="language-plaintext highlighter-rouge">throw new ValidationException()</code>. Username taken? <code class="language-plaintext highlighter-rouge">throw new ConflictException()</code>. User not found? <code class="language-plaintext highlighter-rouge">throw new NotFoundException()</code>.</p>

<p>It works, but exceptions are meant for unexpected failures, not routine validation. There’s a better way.</p>

<p>Today, I’ll show you how the <strong>Result pattern</strong> gives you cleaner, faster, and more intentional error handling in ASP.NET Core.</p>

<p>Let’s start.</p>

<p><br /></p>

<h3 id="the-problem-exceptions-as-control-flow"><strong>The Problem: Exceptions as Control Flow</strong></h3>
<p>Here’s a pattern I see all the time. A service that throws exceptions for every business rule violation:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">class</span> <span class="nc">UserService</span><span class="p">(</span><span class="n">AppDbContext</span> <span class="n">db</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="n">User</span><span class="p">&gt;</span> <span class="nf">CreateUser</span><span class="p">(</span><span class="kt">string</span> <span class="n">name</span><span class="p">,</span> <span class="kt">string</span> <span class="n">email</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">if</span> <span class="p">(</span><span class="kt">string</span><span class="p">.</span><span class="nf">IsNullOrWhiteSpace</span><span class="p">(</span><span class="n">name</span><span class="p">))</span>
            <span class="k">throw</span> <span class="k">new</span> <span class="nf">ValidationException</span><span class="p">(</span><span class="s">"Name is required"</span><span class="p">);</span>

        <span class="k">if</span> <span class="p">(!</span><span class="n">email</span><span class="p">.</span><span class="nf">Contains</span><span class="p">(</span><span class="sc">'@'</span><span class="p">))</span>
            <span class="k">throw</span> <span class="k">new</span> <span class="nf">ValidationException</span><span class="p">(</span><span class="s">"Invalid email format"</span><span class="p">);</span>

        <span class="k">if</span> <span class="p">(</span><span class="k">await</span> <span class="n">db</span><span class="p">.</span><span class="n">Users</span><span class="p">.</span><span class="nf">AnyAsync</span><span class="p">(</span><span class="n">u</span> <span class="p">=&gt;</span> <span class="n">u</span><span class="p">.</span><span class="n">Email</span> <span class="p">==</span> <span class="n">email</span><span class="p">))</span>
            <span class="k">throw</span> <span class="k">new</span> <span class="nf">ConflictException</span><span class="p">(</span><span class="s">"Email already in use"</span><span class="p">);</span>

        <span class="kt">var</span> <span class="n">user</span> <span class="p">=</span> <span class="k">new</span> <span class="n">User</span> <span class="p">{</span> <span class="n">Name</span> <span class="p">=</span> <span class="n">name</span><span class="p">,</span> <span class="n">Email</span> <span class="p">=</span> <span class="n">email</span> <span class="p">};</span>
        <span class="n">db</span><span class="p">.</span><span class="n">Users</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="n">user</span><span class="p">);</span>
        <span class="k">await</span> <span class="n">db</span><span class="p">.</span><span class="nf">SaveChangesAsync</span><span class="p">();</span>

        <span class="k">return</span> <span class="n">user</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>
<p><br /></p>

<p>And then the endpoint has to catch all of them:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">app</span><span class="p">.</span><span class="nf">MapPost</span><span class="p">(</span><span class="s">"/users"</span><span class="p">,</span> <span class="k">async</span> <span class="p">(</span><span class="n">CreateUserRequest</span> <span class="n">request</span><span class="p">,</span> <span class="n">UserService</span> <span class="n">service</span><span class="p">)</span> <span class="p">=&gt;</span>
<span class="p">{</span>
    <span class="k">try</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">user</span> <span class="p">=</span> <span class="k">await</span> <span class="n">service</span><span class="p">.</span><span class="nf">CreateUser</span><span class="p">(</span><span class="n">request</span><span class="p">.</span><span class="n">Name</span><span class="p">,</span> <span class="n">request</span><span class="p">.</span><span class="n">Email</span><span class="p">);</span>
        <span class="k">return</span> <span class="n">Results</span><span class="p">.</span><span class="nf">Created</span><span class="p">(</span><span class="s">$"/users/</span><span class="p">{</span><span class="n">user</span><span class="p">.</span><span class="n">Id</span><span class="p">}</span><span class="s">"</span><span class="p">,</span> <span class="n">user</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">catch</span> <span class="p">(</span><span class="n">ValidationException</span> <span class="n">ex</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="n">Results</span><span class="p">.</span><span class="nf">BadRequest</span><span class="p">(</span><span class="n">ex</span><span class="p">.</span><span class="n">Message</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">catch</span> <span class="p">(</span><span class="n">ConflictException</span> <span class="n">ex</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="n">Results</span><span class="p">.</span><span class="nf">Conflict</span><span class="p">(</span><span class="n">ex</span><span class="p">.</span><span class="n">Message</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">});</span>
</code></pre></div></div>
<p><br /></p>

<p>Every new business rule means another custom exception class and another <code class="language-plaintext highlighter-rouge">catch</code> block. It gets messy fast.</p>

<p><br /></p>

<h3 id="why-this-is-a-problem"><strong>Why This Is a Problem</strong></h3>
<p>Three reasons:</p>

<ol>
  <li>
    <p><strong>Performance:</strong> Throwing exceptions is expensive. The runtime has to unwind the stack, capture a stack trace, and allocate memory. For something that happens on every invalid form submission, that’s wasteful.</p>
  </li>
  <li>
    <p><strong>Intent:</strong> When you see a <code class="language-plaintext highlighter-rouge">throw</code>, you expect something has gone seriously wrong. Using exceptions for “email already taken” dilutes their meaning. Is this a bug or a business rule? You can’t tell at a glance.</p>
  </li>
  <li>
    <p><strong>Exceptions are for exceptional things:</strong> A user entering an invalid email is not exceptional. It happens all the time.</p>
  </li>
</ol>

<p><br /></p>

<h3 id="the-solution-a-simple-result-type"><strong>The Solution: A Simple Result Type</strong></h3>
<p>Instead of throwing, we return a <code class="language-plaintext highlighter-rouge">Result&lt;T&gt;</code> that explicitly says: “this either worked, or here’s what went wrong.”</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">record</span> <span class="nc">Error</span><span class="p">(</span><span class="kt">string</span> <span class="n">Code</span><span class="p">,</span> <span class="kt">string</span> <span class="n">Description</span><span class="p">,</span> <span class="n">ErrorType</span> <span class="n">Type</span><span class="p">);</span>

<span class="k">public</span> <span class="k">enum</span> <span class="n">ErrorType</span>
<span class="p">{</span>
    <span class="n">Validation</span><span class="p">,</span>
    <span class="n">Conflict</span><span class="p">,</span>
    <span class="n">NotFound</span>
<span class="p">}</span>

<span class="k">public</span> <span class="k">class</span> <span class="nc">Result</span><span class="p">&lt;</span><span class="n">T</span><span class="p">&gt;</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="nf">Result</span><span class="p">(</span><span class="n">T</span> <span class="k">value</span><span class="p">)</span> <span class="p">{</span> <span class="n">Value</span> <span class="p">=</span> <span class="k">value</span><span class="p">;</span> <span class="n">IsSuccess</span> <span class="p">=</span> <span class="k">true</span><span class="p">;</span> <span class="p">}</span>
    <span class="k">private</span> <span class="nf">Result</span><span class="p">(</span><span class="n">Error</span> <span class="n">error</span><span class="p">)</span> <span class="p">{</span> <span class="n">Error</span> <span class="p">=</span> <span class="n">error</span><span class="p">;</span> <span class="n">IsSuccess</span> <span class="p">=</span> <span class="k">false</span><span class="p">;</span> <span class="p">}</span>

    <span class="k">public</span> <span class="kt">bool</span> <span class="n">IsSuccess</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="p">}</span>
    <span class="k">public</span> <span class="n">T</span><span class="p">?</span> <span class="n">Value</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="p">}</span>
    <span class="k">public</span> <span class="n">Error</span><span class="p">?</span> <span class="n">Error</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="p">}</span>

    <span class="k">public</span> <span class="k">static</span> <span class="n">Result</span><span class="p">&lt;</span><span class="n">T</span><span class="p">&gt;</span> <span class="nf">Success</span><span class="p">(</span><span class="n">T</span> <span class="k">value</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="k">new</span><span class="p">(</span><span class="k">value</span><span class="p">);</span>
    <span class="k">public</span> <span class="k">static</span> <span class="n">Result</span><span class="p">&lt;</span><span class="n">T</span><span class="p">&gt;</span> <span class="nf">Failure</span><span class="p">(</span><span class="n">Error</span> <span class="n">error</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="k">new</span><span class="p">(</span><span class="n">error</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>
<p><br /></p>

<p>That’s it. No NuGet packages. No frameworks. Just a class that makes success and failure explicit in your return type.</p>

<p><br /></p>

<h3 id="defining-your-errors"><strong>Defining Your Errors</strong></h3>
<p>Instead of scattering error messages across your code, define them in one place:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">static</span> <span class="k">class</span> <span class="nc">UserErrors</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">static</span> <span class="k">readonly</span> <span class="n">Error</span> <span class="n">NameRequired</span> <span class="p">=</span> <span class="k">new</span><span class="p">(</span>
        <span class="s">"User.NameRequired"</span><span class="p">,</span>
        <span class="s">"Name is required"</span><span class="p">,</span>
        <span class="n">ErrorType</span><span class="p">.</span><span class="n">Validation</span><span class="p">);</span>

    <span class="k">public</span> <span class="k">static</span> <span class="k">readonly</span> <span class="n">Error</span> <span class="n">InvalidEmail</span> <span class="p">=</span> <span class="k">new</span><span class="p">(</span>
        <span class="s">"User.InvalidEmail"</span><span class="p">,</span>
        <span class="s">"Invalid email format"</span><span class="p">,</span>
        <span class="n">ErrorType</span><span class="p">.</span><span class="n">Validation</span><span class="p">);</span>

    <span class="k">public</span> <span class="k">static</span> <span class="k">readonly</span> <span class="n">Error</span> <span class="n">EmailTaken</span> <span class="p">=</span> <span class="k">new</span><span class="p">(</span>
        <span class="s">"User.EmailTaken"</span><span class="p">,</span>
        <span class="s">"Email is already in use"</span><span class="p">,</span>
        <span class="n">ErrorType</span><span class="p">.</span><span class="n">Conflict</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>
<p><br /></p>

<p>Now every error has a code, a description, and a type. Clean, discoverable, and testable.</p>

<p><br /></p>

<h3 id="refactoring-the-service"><strong>Refactoring the Service</strong></h3>
<p>Now our service returns a <code class="language-plaintext highlighter-rouge">Result&lt;User&gt;</code> instead of throwing:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">class</span> <span class="nc">UserService</span><span class="p">(</span><span class="n">AppDbContext</span> <span class="n">db</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="n">Result</span><span class="p">&lt;</span><span class="n">User</span><span class="p">&gt;&gt;</span> <span class="nf">CreateUser</span><span class="p">(</span><span class="kt">string</span> <span class="n">name</span><span class="p">,</span> <span class="kt">string</span> <span class="n">email</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">if</span> <span class="p">(</span><span class="kt">string</span><span class="p">.</span><span class="nf">IsNullOrWhiteSpace</span><span class="p">(</span><span class="n">name</span><span class="p">))</span>
            <span class="k">return</span> <span class="n">Result</span><span class="p">&lt;</span><span class="n">User</span><span class="p">&gt;.</span><span class="nf">Failure</span><span class="p">(</span><span class="n">UserErrors</span><span class="p">.</span><span class="n">NameRequired</span><span class="p">);</span>

        <span class="k">if</span> <span class="p">(!</span><span class="n">email</span><span class="p">.</span><span class="nf">Contains</span><span class="p">(</span><span class="sc">'@'</span><span class="p">))</span>
            <span class="k">return</span> <span class="n">Result</span><span class="p">&lt;</span><span class="n">User</span><span class="p">&gt;.</span><span class="nf">Failure</span><span class="p">(</span><span class="n">UserErrors</span><span class="p">.</span><span class="n">InvalidEmail</span><span class="p">);</span>

        <span class="k">if</span> <span class="p">(</span><span class="k">await</span> <span class="n">db</span><span class="p">.</span><span class="n">Users</span><span class="p">.</span><span class="nf">AnyAsync</span><span class="p">(</span><span class="n">u</span> <span class="p">=&gt;</span> <span class="n">u</span><span class="p">.</span><span class="n">Email</span> <span class="p">==</span> <span class="n">email</span><span class="p">))</span>
            <span class="k">return</span> <span class="n">Result</span><span class="p">&lt;</span><span class="n">User</span><span class="p">&gt;.</span><span class="nf">Failure</span><span class="p">(</span><span class="n">UserErrors</span><span class="p">.</span><span class="n">EmailTaken</span><span class="p">);</span>

        <span class="kt">var</span> <span class="n">user</span> <span class="p">=</span> <span class="k">new</span> <span class="n">User</span> <span class="p">{</span> <span class="n">Name</span> <span class="p">=</span> <span class="n">name</span><span class="p">,</span> <span class="n">Email</span> <span class="p">=</span> <span class="n">email</span> <span class="p">};</span>
        <span class="n">db</span><span class="p">.</span><span class="n">Users</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="n">user</span><span class="p">);</span>
        <span class="k">await</span> <span class="n">db</span><span class="p">.</span><span class="nf">SaveChangesAsync</span><span class="p">();</span>

        <span class="k">return</span> <span class="n">Result</span><span class="p">&lt;</span><span class="n">User</span><span class="p">&gt;.</span><span class="nf">Success</span><span class="p">(</span><span class="n">user</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>
<p><br /></p>

<p>Notice how the method signature now tells you everything. It returns a <code class="language-plaintext highlighter-rouge">Result&lt;User&gt;</code>, meaning it might fail, and you have to handle that. No surprises.</p>

<p><br /></p>

<h3 id="mapping-results-to-http-responses"><strong>Mapping Results to HTTP Responses</strong></h3>
<p>The last piece is translating a <code class="language-plaintext highlighter-rouge">Result&lt;T&gt;</code> into the right HTTP status code. A small extension method does the trick:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">static</span> <span class="k">class</span> <span class="nc">ResultExtensions</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">static</span> <span class="n">IResult</span> <span class="n">ToHttpResult</span><span class="p">&lt;</span><span class="n">T</span><span class="p">&gt;(</span>
        <span class="k">this</span> <span class="n">Result</span><span class="p">&lt;</span><span class="n">T</span><span class="p">&gt;</span> <span class="n">result</span><span class="p">,</span>
        <span class="n">Func</span><span class="p">&lt;</span><span class="n">T</span><span class="p">,</span> <span class="n">IResult</span><span class="p">&gt;</span> <span class="n">onSuccess</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">IsSuccess</span><span class="p">)</span>
            <span class="k">return</span> <span class="nf">onSuccess</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">Value</span><span class="p">!);</span>

        <span class="k">return</span> <span class="n">result</span><span class="p">.</span><span class="n">Error</span><span class="p">!.</span><span class="n">Type</span> <span class="k">switch</span>
        <span class="p">{</span>
            <span class="n">ErrorType</span><span class="p">.</span><span class="n">Validation</span> <span class="p">=&gt;</span> <span class="n">Results</span><span class="p">.</span><span class="nf">BadRequest</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">Error</span><span class="p">),</span>
            <span class="n">ErrorType</span><span class="p">.</span><span class="n">Conflict</span> <span class="p">=&gt;</span> <span class="n">Results</span><span class="p">.</span><span class="nf">Conflict</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">Error</span><span class="p">),</span>
            <span class="n">ErrorType</span><span class="p">.</span><span class="n">NotFound</span> <span class="p">=&gt;</span> <span class="n">Results</span><span class="p">.</span><span class="nf">NotFound</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">Error</span><span class="p">),</span>
            <span class="n">_</span> <span class="p">=&gt;</span> <span class="n">Results</span><span class="p">.</span><span class="nf">StatusCode</span><span class="p">(</span><span class="m">500</span><span class="p">)</span>
        <span class="p">};</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>
<p><br /></p>

<p>And now your endpoint becomes beautifully simple:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">app</span><span class="p">.</span><span class="nf">MapPost</span><span class="p">(</span><span class="s">"/users"</span><span class="p">,</span> <span class="k">async</span> <span class="p">(</span><span class="n">CreateUserRequest</span> <span class="n">request</span><span class="p">,</span> <span class="n">UserService</span> <span class="n">service</span><span class="p">)</span> <span class="p">=&gt;</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">result</span> <span class="p">=</span> <span class="k">await</span> <span class="n">service</span><span class="p">.</span><span class="nf">CreateUser</span><span class="p">(</span><span class="n">request</span><span class="p">.</span><span class="n">Name</span><span class="p">,</span> <span class="n">request</span><span class="p">.</span><span class="n">Email</span><span class="p">);</span>
    <span class="k">return</span> <span class="n">result</span><span class="p">.</span><span class="nf">ToHttpResult</span><span class="p">(</span><span class="n">user</span> <span class="p">=&gt;</span> <span class="n">Results</span><span class="p">.</span><span class="nf">Created</span><span class="p">(</span><span class="s">$"/users/</span><span class="p">{</span><span class="n">user</span><span class="p">.</span><span class="n">Id</span><span class="p">}</span><span class="s">"</span><span class="p">,</span> <span class="n">user</span><span class="p">));</span>
<span class="p">});</span>
</code></pre></div></div>
<p><br /></p>

<p>No try/catch. No exception handlers. Just two clean lines that read exactly like what they do.</p>

<p><br /></p>

<h3 id="what-about-existing-libraries"><strong>What About Existing Libraries?</strong></h3>
<p>If you don’t want to roll your own, there are solid options:</p>

<ul>
  <li><strong><a href="https://github.com/altmann/FluentResults" target="_blank">FluentResults</a></strong>: lightweight, flexible, supports multiple errors and success messages</li>
  <li><strong><a href="https://github.com/amantinband/error-or" target="_blank">ErrorOr</a></strong>: uses discriminated unions, plays nicely with minimal APIs</li>
</ul>

<p>Both are great. But I’d still recommend understanding the pattern from scratch first (like we did above) before reaching for a library. It’s a simple pattern, and knowing how it works under the hood makes you a better consumer of any library.</p>

<p><br /></p>

<h3 id="the-takeaway"><strong>The Takeaway</strong></h3>
<p>Exceptions should be for exceptional things: unexpected failures, infrastructure errors, things that shouldn’t happen during normal operation.</p>

<p>For everything else (validation, business rules, expected failures), the Result pattern gives you:</p>

<ul>
  <li><strong>Faster code</strong> (no stack unwinding)</li>
  <li><strong>Clearer intent</strong> (the return type tells you it can fail)</li>
  <li><strong>Easier testing</strong> (assert on result values, not catch blocks)</li>
  <li><strong>Centralized error-to-HTTP mapping</strong> (one extension method, done)</li>
</ul>

<p>And that’s it for today.</p>

<p>See you next Saturday.</p>

<hr />

<p><br /></p>

<p><strong>Whenever you’re ready, there are 3 ways I can help you:</strong></p>

<ol>
  <li>
    <p><strong><a href="https://juliocasal.com/courses/dotnetbootcamp">.NET Backend Developer Bootcamp</a></strong>: A complete path from ASP.NET Core fundamentals to building, containerizing, and deploying production-ready, cloud-native apps on Azure.</p>
  </li>
  <li>
    <p><strong><a href="https://dotnetmicroservices.com">Building Microservices With .NET</a></strong>: Transform the way you build .NET systems at scale.</p>
  </li>
  <li>
    <p><strong><a href="https://www.patreon.com/juliocasal" target="_blank">Get the full source code</a></strong>: Download the working project from this article, grab exclusive course discounts, and join a private .NET community.</p>
  </li>
</ol>]]></content><author><name></name></author><summary type="html"><![CDATA[Read time: 7 minutes]]></summary></entry><entry><title type="html">Stop Using Task.Delay: Test .NET Workers the Right Way</title><link href="https://juliocasal.com/blog/stop-using-task-delay-test-dotnet-workers-the-right-way.html" rel="alternate" type="text/html" title="Stop Using Task.Delay: Test .NET Workers the Right Way" /><published>2025-12-20T00:00:00-08:00</published><updated>2025-12-20T00:00:00-08:00</updated><id>https://juliocasal.com/blog/stop-using-task-delay-test-dotnet-workers-the-right-way</id><content type="html" xml:base="https://juliocasal.com/blog/stop-using-task-delay-test-dotnet-workers-the-right-way.html"><![CDATA[<p><em>Read time: 9 minutes</em></p>
<p style="text-align: center; font-size: 1.2em;"><strong>The .NET Saturday is brought to you by:</strong></p>

<div style="background: linear-gradient(90deg, #e0eafc 0%, #cfdef3 100%); padding: 36px; margin: 24px 0; overflow: hidden; border-radius: 14px; box-shadow: 0 2px 12px rgba(80,120,200,0.08);">
  <p style="text-align: center; max-width: 600px; margin: 0 auto 18px auto;"><strong>Rider 2025.3 is here!</strong> Enjoy day-one support for .NET 10, C# 14, a sleek new UI, and faster startup speeds.</p>

  <div style="display: flex; justify-content: center;">
    <a href="https://blog.jetbrains.com/dotnet/2025/11/11/rider-2025-3-day-one-support-for-dotnet-10/?utm_source=newsletter_dot_net_saturday&amp;utm_medium=cpc&amp;utm_campaign=dec" target="_blank" style="background: linear-gradient(90deg, #4f8cff 0%, #235390 100%); color: #fff; padding: 14px 36px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 1.1em; box-shadow: 0 2px 8px rgba(80,120,200,0.10); transition: background 0.2s; text-align: center;">Try Rider 2025.3</a>
  </div>
</div>

<p>A few weeks ago, as I was preparing a suite of integration tests for the bootcamp’s Game Store app, I suddenly realized that writing a solid integration test for the queue processor is not a trivial task.</p>

<p>There’s plenty of documentation on how to write integration tests for .NET Web APIs, but for workers? Not so much.</p>

<p>Even worse, if your test needs to wait for a message to show up in a Service Bus queue so the worker processes it, you can end up with a very flaky and slow-moving test.</p>

<p>But it doesn’t have to be that way, since EF Core interceptors provide a really elegant way to get the job done without ever having to even touch your production code.</p>

<p>Today, I’ll show you the right way to do it.</p>

<p>Let’s start.</p>

<p>​</p>

<h3 id="the-scenario-under-test"><strong>The scenario under test</strong></h3>
<p>Our application includes a .NET worker responsible for reading <strong>OrderPaid</strong> messages from our Orders queue, fulfilling the order, and then updating its status to <strong>Completed</strong> in our database.</p>

<p><img src="/assets/images/2025-12-20/4ghDFAZYvbFtvU3CTR72ZN-sND4fBEyK87ZHfARBQzXMB.jpeg" alt="" /></p>

<p>​</p>

<p>We could write all sorts of unit tests around the code used by our queue processor, but I find it more interesting to ensure that this .NET worker can update the Order status in a real database after consuming a real message from a real queue.</p>

<p>For that, an integration test is the best option. But first, let’s take a quick look at the worker logic.</p>

<p>​</p>

<h3 id="the-worker"><strong>The worker</strong></h3>
<p>There are two main elements to our .NET worker. The first one is our top-level logic to consume messages from the queue, a Service Bus queue in this case:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">class</span> <span class="nc">OrdersQueueProcessor</span><span class="p">(</span>
    <span class="n">ServiceBusClient</span> <span class="n">serviceBusClient</span><span class="p">,</span>
    <span class="n">IServiceScopeFactory</span> <span class="n">serviceScopeFactory</span><span class="p">)</span> <span class="p">:</span> <span class="n">BackgroundService</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="n">ServiceBusProcessor</span><span class="p">?</span> <span class="n">processor</span><span class="p">;</span>

    <span class="k">protected</span> <span class="k">override</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">ExecuteAsync</span><span class="p">(</span><span class="n">CancellationToken</span> <span class="n">stoppingToken</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">processor</span> <span class="p">=</span> <span class="n">serviceBusClient</span><span class="p">.</span><span class="nf">CreateProcessor</span><span class="p">(</span>
            <span class="s">"orders"</span><span class="p">,</span>
            <span class="k">new</span> <span class="n">ServiceBusProcessorOptions</span>
            <span class="p">{</span>
                <span class="n">AutoCompleteMessages</span> <span class="p">=</span> <span class="k">false</span>
            <span class="p">});</span>

        <span class="n">processor</span><span class="p">.</span><span class="n">ProcessMessageAsync</span> <span class="p">+=</span> <span class="n">ProcessMessage</span><span class="p">;</span>
        <span class="n">processor</span><span class="p">.</span><span class="n">ProcessErrorAsync</span> <span class="p">+=</span> <span class="n">ProcessError</span><span class="p">;</span>

        <span class="k">await</span> <span class="n">processor</span><span class="p">.</span><span class="nf">StartProcessingAsync</span><span class="p">(</span><span class="n">stoppingToken</span><span class="p">);</span>

        <span class="k">await</span> <span class="n">Task</span><span class="p">.</span><span class="nf">Delay</span><span class="p">(</span><span class="n">Timeout</span><span class="p">.</span><span class="n">Infinite</span><span class="p">,</span> <span class="n">stoppingToken</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="k">private</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">ProcessMessage</span><span class="p">(</span><span class="n">ProcessMessageEventArgs</span> <span class="n">args</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">message</span> <span class="p">=</span> <span class="n">args</span><span class="p">.</span><span class="n">Message</span><span class="p">;</span>

        <span class="kt">var</span> <span class="n">messageBody</span> <span class="p">=</span> <span class="n">message</span><span class="p">.</span><span class="n">Body</span><span class="p">.</span><span class="nf">ToString</span><span class="p">();</span>

        <span class="k">if</span> <span class="p">(!</span><span class="n">message</span><span class="p">.</span><span class="n">ApplicationProperties</span><span class="p">.</span><span class="nf">TryGetValue</span><span class="p">(</span>
                <span class="s">"MessageType"</span><span class="p">,</span>
                <span class="k">out</span> <span class="kt">var</span> <span class="n">messageTypeObj</span><span class="p">)</span>
            <span class="p">||</span> <span class="n">messageTypeObj</span> <span class="k">is</span> <span class="n">not</span> <span class="kt">string</span> <span class="n">messageType</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="k">await</span> <span class="n">args</span><span class="p">.</span><span class="nf">DeadLetterMessageAsync</span><span class="p">(</span><span class="n">message</span><span class="p">);</span>
            <span class="k">return</span><span class="p">;</span>
        <span class="p">}</span>

        <span class="k">await</span> <span class="nf">HandleMessageByType</span><span class="p">(</span><span class="n">messageBody</span><span class="p">,</span> <span class="n">messageType</span><span class="p">,</span> <span class="n">args</span><span class="p">.</span><span class="n">CancellationToken</span><span class="p">);</span>

        <span class="k">await</span> <span class="n">args</span><span class="p">.</span><span class="nf">CompleteMessageAsync</span><span class="p">(</span><span class="n">message</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="k">private</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">HandleMessageByType</span><span class="p">(</span>
        <span class="kt">string</span> <span class="n">messageBody</span><span class="p">,</span>
        <span class="kt">string</span> <span class="n">messageType</span><span class="p">,</span>
        <span class="n">CancellationToken</span> <span class="n">cancellationToken</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">switch</span> <span class="p">(</span><span class="n">messageType</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="k">case</span> <span class="k">nameof</span><span class="p">(</span><span class="n">OrderPaid</span><span class="p">):</span>
                <span class="k">await</span> <span class="n">HandleMessageAsync</span><span class="p">&lt;</span><span class="n">OrderPaid</span><span class="p">&gt;(</span><span class="n">messageBody</span><span class="p">,</span> <span class="n">cancellationToken</span><span class="p">);</span>
                <span class="k">break</span><span class="p">;</span>
            <span class="k">default</span><span class="p">:</span>
                <span class="k">break</span><span class="p">;</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="k">private</span> <span class="k">async</span> <span class="n">Task</span> <span class="n">HandleMessageAsync</span><span class="p">&lt;</span><span class="n">T</span><span class="p">&gt;(</span>
        <span class="kt">string</span> <span class="n">messageBody</span><span class="p">,</span>
        <span class="n">CancellationToken</span> <span class="n">cancellationToken</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">message</span> <span class="p">=</span> <span class="n">JsonSerializer</span><span class="p">.</span><span class="n">Deserialize</span><span class="p">&lt;</span><span class="n">T</span><span class="p">&gt;(</span><span class="n">messageBody</span><span class="p">);</span>

        <span class="k">if</span> <span class="p">(</span><span class="n">message</span> <span class="k">is</span> <span class="k">null</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="k">return</span><span class="p">;</span>
        <span class="p">}</span>

        <span class="c1">// Delegate handling to the appropriate message handler</span>
        <span class="k">using</span> <span class="nn">var</span> <span class="n">scope</span> <span class="p">=</span> <span class="n">serviceScopeFactory</span><span class="p">.</span><span class="nf">CreateScope</span><span class="p">();</span>
        <span class="kt">var</span> <span class="n">handler</span> <span class="p">=</span> <span class="n">scope</span><span class="p">.</span><span class="n">ServiceProvider</span><span class="p">.</span><span class="n">GetRequiredService</span><span class="p">&lt;</span><span class="n">IMessageHandler</span><span class="p">&lt;</span><span class="n">T</span><span class="p">&gt;&gt;();</span>
        <span class="k">await</span> <span class="n">handler</span><span class="p">.</span><span class="nf">HandleAsync</span><span class="p">(</span><span class="n">message</span><span class="p">,</span> <span class="n">cancellationToken</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="k">private</span> <span class="n">Task</span> <span class="nf">ProcessError</span><span class="p">(</span><span class="n">ProcessErrorEventArgs</span> <span class="n">args</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="n">Task</span><span class="p">.</span><span class="n">CompletedTask</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>​</p>

<p>I did have to remove a bunch of error checks and logging lines from that code, or it would be too long for this article, but <a href="https://patreon.com/juliocasal" target="_blank">this week’s source code download</a> includes the complete version.</p>

<p>In essence, we start processing our orders queue in <strong>ExecuteAsync</strong>, and eventually, in <strong>HandleMessageAsync&lt;T&gt;</strong>, we deserialize the message and hand it over to a specialized handler.</p>

<p>That handler is over here:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">class</span> <span class="nc">OrderPaidHandler</span><span class="p">(</span>
    <span class="n">GameStoreContext</span> <span class="n">context</span><span class="p">,</span>
    <span class="n">ILogger</span><span class="p">&lt;</span><span class="n">OrderPaidHandler</span><span class="p">&gt;</span> <span class="n">logger</span>
<span class="p">)</span> <span class="p">:</span> <span class="n">IMessageHandler</span><span class="p">&lt;</span><span class="n">OrderPaid</span><span class="p">&gt;</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">HandleAsync</span><span class="p">(</span>
        <span class="n">OrderPaid</span> <span class="n">orderPaid</span><span class="p">,</span>
        <span class="n">CancellationToken</span> <span class="n">ct</span> <span class="p">=</span> <span class="k">default</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">orderId</span> <span class="p">=</span> <span class="n">orderPaid</span><span class="p">.</span><span class="n">OrderId</span><span class="p">;</span>

        <span class="kt">var</span> <span class="n">order</span> <span class="p">=</span> <span class="k">await</span> <span class="n">context</span><span class="p">.</span><span class="n">Orders</span>
                                <span class="p">.</span><span class="nf">Include</span><span class="p">(</span><span class="n">order</span> <span class="p">=&gt;</span> <span class="n">order</span><span class="p">.</span><span class="n">Items</span><span class="p">)</span>
                                <span class="p">.</span><span class="nf">FirstOrDefaultAsync</span><span class="p">(</span>
                                    <span class="n">order</span> <span class="p">=&gt;</span> <span class="n">order</span><span class="p">.</span><span class="n">Id</span> <span class="p">==</span> <span class="n">orderId</span><span class="p">,</span> <span class="n">ct</span><span class="p">);</span>

        <span class="k">if</span> <span class="p">(</span><span class="n">order</span> <span class="k">is</span> <span class="k">null</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="n">logger</span><span class="p">.</span><span class="nf">LogError</span><span class="p">(</span><span class="s">"Order not found."</span><span class="p">);</span>
            <span class="k">return</span><span class="p">;</span>
        <span class="p">}</span>

        <span class="k">if</span> <span class="p">(</span><span class="n">order</span><span class="p">.</span><span class="n">Status</span> <span class="p">!=</span> <span class="n">OrderStatus</span><span class="p">.</span><span class="n">Processing</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="n">logger</span><span class="p">.</span><span class="nf">LogWarning</span><span class="p">(</span>
                <span class="s">"Order {OrderId} is not in Processing status"</span><span class="p">,</span>
                <span class="n">order</span><span class="p">.</span><span class="n">Id</span><span class="p">);</span>
            <span class="k">return</span><span class="p">;</span>
        <span class="p">}</span>

        <span class="c1">// Fullfill order (e.g., assign game codes, update inventory, etc.)</span>

        <span class="n">order</span><span class="p">.</span><span class="n">Status</span> <span class="p">=</span> <span class="n">OrderStatus</span><span class="p">.</span><span class="n">Completed</span><span class="p">;</span>

        <span class="k">await</span> <span class="n">context</span><span class="p">.</span><span class="nf">SaveChangesAsync</span><span class="p">(</span><span class="n">ct</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>​</p>

<p>To keep it simple, all this handler does is update the Order status to <strong>Completed</strong> and persist that into the DB.</p>

<p>Now, what we need to test is this:</p>

<ol>
  <li><span>OrdersQueueProcessor can consume real OrderPaid messages</span></li>
  <li><span>OrdersQueueProcessor delegates order processing to OrderPaidHandler</span></li>
  <li><span>OrderPaidHandler saves the updated order to our real DB</span></li>
</ol>

<p>It’s not trivial, but there’s a way.</p>

<p>​</p>

<h3 id="making-the-worker-testable"><strong>Making the worker testable</strong></h3>
<p>When you create worker services in .NET you usually get this code in your <strong>Program.cs</strong> file:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">builder</span> <span class="p">=</span> <span class="n">Host</span><span class="p">.</span><span class="nf">CreateApplicationBuilder</span><span class="p">(</span><span class="n">args</span><span class="p">);</span>
<span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="n">AddHostedService</span><span class="p">&lt;</span><span class="n">Worker</span><span class="p">&gt;();</span>

<span class="kt">var</span> <span class="n">host</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">Build</span><span class="p">();</span>
<span class="n">host</span><span class="p">.</span><span class="nf">Run</span><span class="p">();</span>
</code></pre></div></div>

<p>​</p>

<p>But unfortunately, that won’t work for our integration test since we need a way to not just start the worker as part of the test (so no host.Run()), but also to customize the Worker services to align with the testing environment.</p>

<p>That’s the kind of stuff that a <strong>WebApplicationFactory</strong> would do for you, if you were testing a Web App. But this is not a web app, it’s a background worker.</p>

<p>To make the worker testable, you can move the bulk of the startup code to another class capable of building and customizing the host, like this:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">static</span> <span class="k">class</span> <span class="nc">WorkerHostBuilder</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">static</span> <span class="n">IHost</span> <span class="nf">Build</span><span class="p">(</span>
        <span class="kt">string</span><span class="p">[]?</span> <span class="n">args</span> <span class="p">=</span> <span class="k">null</span><span class="p">,</span>
        <span class="kt">string</span><span class="p">?</span> <span class="n">environmentName</span> <span class="p">=</span> <span class="k">null</span><span class="p">,</span>
        <span class="n">Action</span><span class="p">&lt;</span><span class="n">IConfigurationBuilder</span><span class="p">&gt;?</span> <span class="n">configure</span> <span class="p">=</span> <span class="k">null</span><span class="p">,</span>
        <span class="n">Action</span><span class="p">&lt;</span><span class="n">IServiceCollection</span><span class="p">&gt;?</span> <span class="n">testOverrides</span> <span class="p">=</span> <span class="k">null</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">settings</span> <span class="p">=</span> <span class="k">new</span> <span class="n">HostApplicationBuilderSettings</span> <span class="p">{</span> <span class="n">Args</span> <span class="p">=</span> <span class="n">args</span> <span class="p">};</span>
        <span class="k">if</span> <span class="p">(!</span><span class="kt">string</span><span class="p">.</span><span class="nf">IsNullOrWhiteSpace</span><span class="p">(</span><span class="n">environmentName</span><span class="p">))</span>
        <span class="p">{</span>
            <span class="n">settings</span><span class="p">.</span><span class="n">EnvironmentName</span> <span class="p">=</span> <span class="n">environmentName</span><span class="p">;</span>
        <span class="p">}</span>

        <span class="kt">var</span> <span class="n">builder</span> <span class="p">=</span> <span class="n">Host</span><span class="p">.</span><span class="nf">CreateApplicationBuilder</span><span class="p">(</span><span class="n">settings</span><span class="p">);</span>

        <span class="n">configure</span><span class="p">?.</span><span class="nf">Invoke</span><span class="p">(</span><span class="n">builder</span><span class="p">.</span><span class="n">Configuration</span><span class="p">);</span> <span class="c1">// Allow test overrides</span>

        <span class="n">builder</span><span class="p">.</span><span class="n">AddNpgsqlDbContext</span><span class="p">&lt;</span><span class="n">GameStoreContext</span><span class="p">&gt;(</span><span class="s">"GameStoreDB"</span><span class="p">);</span>

        <span class="n">builder</span><span class="p">.</span><span class="nf">AddAzureServiceBusClient</span><span class="p">(</span><span class="s">"serviceBus"</span><span class="p">);</span>

        <span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="n">AddScoped</span><span class="p">&lt;</span><span class="n">IMessageHandler</span><span class="p">&lt;</span><span class="n">OrderPaid</span><span class="p">&gt;,</span> <span class="n">OrderPaidHandler</span><span class="p">&gt;();</span>
        <span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="n">AddHostedService</span><span class="p">&lt;</span><span class="n">OrdersQueueProcessor</span><span class="p">&gt;();</span>

        <span class="n">testOverrides</span><span class="p">?.</span><span class="nf">Invoke</span><span class="p">(</span><span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">);</span> <span class="c1">// Allow test overrides</span>

        <span class="k">return</span> <span class="n">builder</span><span class="p">.</span><span class="nf">Build</span><span class="p">();</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>​</p>

<p>Notice how we not only register all our services here, but also open multiple doors for letting the caller specify a different environment, configuration settings, and even custom services to register.</p>

<p>And, with that in place, your Program.cs turns into just this:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">GameStore.Worker</span><span class="p">;</span>

<span class="kt">var</span> <span class="n">host</span> <span class="p">=</span> <span class="n">WorkerHostBuilder</span><span class="p">.</span><span class="nf">Build</span><span class="p">(</span><span class="n">args</span><span class="p">);</span>
<span class="n">host</span><span class="p">.</span><span class="nf">Run</span><span class="p">();</span>
</code></pre></div></div>

<p>​</p>

<p>No difference in behavior, but let’s see how our test can now take advantage of our new <strong>WorkerHostBuilder</strong>.</p>

<p>​</p>

<h3 id="the-integration-test"><strong>The integration test</strong></h3>
<p>There are a bunch of moving pieces required to prepare our integration test. So let’s go over them step by step.</p>

<p>First, the test initialization:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">class</span> <span class="nc">OrdersWorkerTests</span> <span class="p">:</span> <span class="n">IAsyncLifetime</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">PostgreSqlContainer</span> <span class="n">postgreContainer</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">PostgreSqlBuilder</span><span class="p">().</span><span class="nf">Build</span><span class="p">();</span>
    <span class="k">private</span> <span class="n">ServiceBusContainer</span><span class="p">?</span> <span class="n">serviceBusContainer</span><span class="p">;</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">Fixture</span> <span class="n">fixture</span> <span class="p">=</span> <span class="k">new</span><span class="p">();</span>

    <span class="k">private</span> <span class="k">static</span> <span class="n">CancellationToken</span> <span class="n">CancellationToken</span>
        <span class="p">=&gt;</span> <span class="n">TestContext</span><span class="p">.</span><span class="n">Current</span><span class="p">.</span><span class="n">CancellationToken</span><span class="p">;</span>

    <span class="k">public</span> <span class="k">async</span> <span class="n">ValueTask</span> <span class="nf">InitializeAsync</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="k">await</span> <span class="n">postgreContainer</span><span class="p">.</span><span class="nf">StartAsync</span><span class="p">(</span><span class="n">CancellationToken</span><span class="p">);</span>

        <span class="n">fixture</span><span class="p">.</span><span class="n">Customize</span><span class="p">&lt;</span><span class="n">DateTimeOffset</span><span class="p">&gt;(</span><span class="n">o</span> <span class="p">=&gt;</span> <span class="n">o</span><span class="p">.</span><span class="nf">FromFactory</span><span class="p">(()</span> <span class="p">=&gt;</span> <span class="n">DateTimeOffset</span><span class="p">.</span><span class="n">UtcNow</span><span class="p">));</span>

        <span class="kt">var</span> <span class="n">configFile</span> <span class="p">=</span> <span class="n">Path</span><span class="p">.</span><span class="nf">Combine</span><span class="p">(</span>
            <span class="n">AppContext</span><span class="p">.</span><span class="n">BaseDirectory</span><span class="p">,</span>
            <span class="s">"Messaging"</span><span class="p">,</span>
            <span class="s">"servicebus.config.json"</span><span class="p">);</span>

        <span class="n">serviceBusContainer</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">ServiceBusBuilder</span><span class="p">()</span>
                <span class="p">.</span><span class="nf">WithAcceptLicenseAgreement</span><span class="p">(</span><span class="k">true</span><span class="p">)</span>
                <span class="p">.</span><span class="nf">WithConfig</span><span class="p">(</span><span class="n">configFile</span><span class="p">)</span>
                <span class="p">.</span><span class="nf">Build</span><span class="p">();</span>

        <span class="k">await</span> <span class="n">serviceBusContainer</span><span class="p">.</span><span class="nf">StartAsync</span><span class="p">(</span><span class="n">CancellationToken</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>​</p>

<p>The key things to grasp from this code:</p>

<ol>
  <li><span>We use <strong>Testcontainers</strong> to standup a real PostgreSQL server and Azure Service Bus emulator as Docker containers.</span></li>
  <li><span>We initialize <strong>AutoFixture</strong>, which we’ll use later to create our order.</span></li>
  <li><span>We provide a small config file to Service Bus, which describes the orders queue it needs to create for our test.</span></li>
</ol>

<p>Now the first part of the test:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[</span><span class="n">Fact</span><span class="p">]</span>
<span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">Consume_OrderPaid_CompletesOrder</span><span class="p">()</span>
<span class="p">{</span>
    <span class="c1">// 1) Ensure database exists and create an order in Processing state</span>
    <span class="kt">var</span> <span class="n">dbConnString</span> <span class="p">=</span> <span class="n">postgreContainer</span><span class="p">.</span><span class="nf">GetConnectionString</span><span class="p">();</span>
    <span class="kt">var</span> <span class="n">sbConnString</span> <span class="p">=</span> <span class="n">serviceBusContainer</span><span class="p">!.</span><span class="nf">GetConnectionString</span><span class="p">();</span>

    <span class="kt">var</span> <span class="n">dbOptions</span> <span class="p">=</span> <span class="k">new</span> <span class="n">DbContextOptionsBuilder</span><span class="p">&lt;</span><span class="n">GameStoreContext</span><span class="p">&gt;()</span>
        <span class="p">.</span><span class="nf">UseNpgsql</span><span class="p">(</span><span class="n">dbConnString</span><span class="p">)</span>
        <span class="p">.</span><span class="n">Options</span><span class="p">;</span>

    <span class="kt">var</span> <span class="n">orderId</span> <span class="p">=</span> <span class="n">Guid</span><span class="p">.</span><span class="nf">NewGuid</span><span class="p">();</span>

    <span class="k">await</span> <span class="k">using</span> <span class="p">(</span><span class="kt">var</span> <span class="n">setupCtx</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">GameStoreContext</span><span class="p">(</span><span class="n">dbOptions</span><span class="p">))</span>
    <span class="p">{</span>
        <span class="k">await</span> <span class="n">setupCtx</span><span class="p">.</span><span class="n">Database</span><span class="p">.</span><span class="nf">MigrateAsync</span><span class="p">(</span><span class="n">CancellationToken</span><span class="p">);</span>

        <span class="kt">var</span> <span class="n">order</span> <span class="p">=</span> <span class="n">fixture</span><span class="p">.</span><span class="n">Build</span><span class="p">&lt;</span><span class="n">Order</span><span class="p">&gt;()</span>
            <span class="p">.</span><span class="nf">With</span><span class="p">(</span><span class="n">o</span> <span class="p">=&gt;</span> <span class="n">o</span><span class="p">.</span><span class="n">Id</span><span class="p">,</span> <span class="n">orderId</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">With</span><span class="p">(</span><span class="n">o</span> <span class="p">=&gt;</span> <span class="n">o</span><span class="p">.</span><span class="n">Status</span><span class="p">,</span> <span class="n">OrderStatus</span><span class="p">.</span><span class="n">Processing</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">With</span><span class="p">(</span><span class="n">o</span> <span class="p">=&gt;</span> <span class="n">o</span><span class="p">.</span><span class="n">Items</span><span class="p">,</span> <span class="p">[..</span> <span class="n">fixture</span><span class="p">.</span><span class="n">Build</span><span class="p">&lt;</span><span class="n">OrderItem</span><span class="p">&gt;()</span>
                                        <span class="p">.</span><span class="nf">With</span><span class="p">(</span><span class="n">i</span> <span class="p">=&gt;</span> <span class="n">i</span><span class="p">.</span><span class="n">Quantity</span><span class="p">,</span> <span class="m">2</span><span class="p">)</span>
                                        <span class="p">.</span><span class="nf">CreateMany</span><span class="p">(</span><span class="m">1</span><span class="p">)])</span>
            <span class="p">.</span><span class="nf">Create</span><span class="p">();</span>

        <span class="n">setupCtx</span><span class="p">.</span><span class="n">Orders</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="n">order</span><span class="p">);</span>
        <span class="k">await</span> <span class="n">setupCtx</span><span class="p">.</span><span class="nf">SaveChangesAsync</span><span class="p">(</span><span class="n">CancellationToken</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="c1">// More code...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>​</p>

<p>In this first part, we migrate our database using the connection string provided by our test container, and then we use AutoFixture to quickly prepare and persist an order in the correct state.</p>

<p>Next, <strong>the cool par</strong>t:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 2) Prepare an interceptor and start the worker host with DI overrides</span>
<span class="kt">var</span> <span class="n">probe</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">OrderCompletedInterceptor</span><span class="p">(</span><span class="n">orderId</span><span class="p">);</span>

<span class="k">using</span> <span class="nn">var</span> <span class="n">host</span> <span class="p">=</span> <span class="n">WorkerHostBuilder</span><span class="p">.</span><span class="nf">Build</span><span class="p">(</span>
    <span class="n">environmentName</span><span class="p">:</span> <span class="s">"Testing"</span><span class="p">,</span>
    <span class="n">configure</span><span class="p">:</span> <span class="n">configBuilder</span> <span class="p">=&gt;</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">overrides</span> <span class="p">=</span> <span class="k">new</span> <span class="n">Dictionary</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">,</span> <span class="kt">string</span><span class="p">?&gt;</span>
        <span class="p">{</span>
            <span class="p">[</span><span class="s">"ConnectionStrings:GameStoreDB"</span><span class="p">]</span> <span class="p">=</span> <span class="n">dbConnString</span><span class="p">,</span>
            <span class="p">[</span><span class="s">"ConnectionStrings:serviceBus"</span><span class="p">]</span> <span class="p">=</span> <span class="n">sbConnString</span>
        <span class="p">};</span>
        <span class="n">configBuilder</span><span class="p">.</span><span class="nf">AddInMemoryCollection</span><span class="p">(</span><span class="n">overrides</span><span class="p">);</span>
    <span class="p">},</span>
    <span class="n">testOverrides</span><span class="p">:</span> <span class="n">services</span> <span class="p">=&gt;</span>
    <span class="p">{</span>
        <span class="c1">// Remove existing DbContext registrations</span>
        <span class="n">services</span><span class="p">.</span><span class="n">RemoveAll</span><span class="p">&lt;</span><span class="n">DbContextOptions</span><span class="p">&lt;</span><span class="n">GameStoreContext</span><span class="p">&gt;&gt;();</span>
        <span class="n">services</span><span class="p">.</span><span class="n">RemoveAll</span><span class="p">&lt;</span><span class="n">GameStoreContext</span><span class="p">&gt;();</span>
        <span class="n">services</span><span class="p">.</span><span class="n">RemoveAll</span><span class="p">&lt;</span><span class="n">IDbContextFactory</span><span class="p">&lt;</span><span class="n">GameStoreContext</span><span class="p">&gt;&gt;();</span>

        <span class="c1">// Now, register DbContext with the interceptor so it can observe SaveChanges calls</span>
        <span class="n">services</span><span class="p">.</span><span class="n">AddDbContext</span><span class="p">&lt;</span><span class="n">GameStoreContext</span><span class="p">&gt;((</span><span class="n">sp</span><span class="p">,</span> <span class="n">options</span><span class="p">)</span> <span class="p">=&gt;</span>
        <span class="p">{</span>
            <span class="n">options</span><span class="p">.</span><span class="nf">UseNpgsql</span><span class="p">(</span><span class="n">dbConnString</span><span class="p">);</span>
            <span class="n">options</span><span class="p">.</span><span class="nf">AddInterceptors</span><span class="p">(</span><span class="n">probe</span><span class="p">);</span>
        <span class="p">});</span>
    <span class="p">}</span>
<span class="p">);</span>

<span class="k">await</span> <span class="n">host</span><span class="p">.</span><span class="nf">StartAsync</span><span class="p">(</span><span class="n">CancellationToken</span><span class="p">);</span>
</code></pre></div></div>

<p>​</p>

<p>That is where we take advantage of our new <strong>WorkerHostBuilder</strong>, by providing our own testing environment, configuration overrides, and our own logic to reconfigure the services injected into the worker.</p>

<p>But what is this <strong>OrderCompletedInterceptor</strong> object?</p>

<p>​</p>

<h3 id="the-interceptor"><strong>The interceptor</strong></h3>
<p>In the Entity Framework Core world, an interceptor is a class that can intercept, modify, and/or suppress EF Core operations.</p>

<p>It’s exactly what we need for our test, since we need to run some test-only logic exactly after our worker saves the order into the database:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">sealed</span> <span class="k">class</span> <span class="nc">OrderCompletedInterceptor</span><span class="p">(</span><span class="n">Guid</span> <span class="n">targetOrderId</span><span class="p">)</span>
    <span class="p">:</span> <span class="n">SaveChangesInterceptor</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">TaskCompletionSource</span> <span class="n">taskCompletionSource</span> <span class="p">=</span>
        <span class="k">new</span><span class="p">(</span><span class="n">TaskCreationOptions</span><span class="p">.</span><span class="n">RunContinuationsAsynchronously</span><span class="p">);</span>

    <span class="k">public</span> <span class="n">Task</span> <span class="nf">WaitAsync</span><span class="p">(</span><span class="n">TimeSpan</span> <span class="n">timeout</span><span class="p">)</span> <span class="p">=&gt;</span>
        <span class="n">Task</span><span class="p">.</span><span class="nf">WhenAny</span><span class="p">(</span><span class="n">taskCompletionSource</span><span class="p">.</span><span class="n">Task</span><span class="p">,</span> <span class="n">Task</span><span class="p">.</span><span class="nf">Delay</span><span class="p">(</span><span class="n">timeout</span><span class="p">))</span>
            <span class="p">.</span><span class="nf">ContinueWith</span><span class="p">(</span><span class="n">t</span> <span class="p">=&gt;</span> <span class="n">taskCompletionSource</span><span class="p">.</span><span class="n">Task</span><span class="p">.</span><span class="n">IsCompleted</span>
                <span class="p">?</span> <span class="n">Task</span><span class="p">.</span><span class="n">CompletedTask</span>
                <span class="p">:</span> <span class="k">throw</span> <span class="k">new</span> <span class="nf">TimeoutException</span><span class="p">(</span>
                    <span class="s">"Order did not reach Completed in time."</span><span class="p">));</span>

    <span class="k">public</span> <span class="k">override</span> <span class="k">async</span> <span class="n">ValueTask</span><span class="p">&lt;</span><span class="kt">int</span><span class="p">&gt;</span> <span class="nf">SavedChangesAsync</span><span class="p">(</span>
        <span class="n">SaveChangesCompletedEventData</span> <span class="n">eventData</span><span class="p">,</span>
        <span class="kt">int</span> <span class="n">result</span><span class="p">,</span>
        <span class="n">CancellationToken</span> <span class="n">cancellationToken</span> <span class="p">=</span> <span class="k">default</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">if</span> <span class="p">(!</span><span class="n">taskCompletionSource</span><span class="p">.</span><span class="n">Task</span><span class="p">.</span><span class="n">IsCompleted</span>
            <span class="p">&amp;&amp;</span> <span class="n">eventData</span><span class="p">.</span><span class="n">Context</span> <span class="k">is</span> <span class="n">GameStoreContext</span> <span class="n">ctx</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="kt">var</span> <span class="n">order</span> <span class="p">=</span> <span class="k">await</span> <span class="n">ctx</span><span class="p">.</span><span class="n">Orders</span><span class="p">.</span><span class="nf">AsNoTracking</span><span class="p">()</span>
                <span class="p">.</span><span class="nf">FirstOrDefaultAsync</span><span class="p">(</span>
                    <span class="n">o</span> <span class="p">=&gt;</span> <span class="n">o</span><span class="p">.</span><span class="n">Id</span> <span class="p">==</span> <span class="n">targetOrderId</span><span class="p">,</span> <span class="n">cancellationToken</span><span class="p">);</span>

            <span class="k">if</span> <span class="p">(</span><span class="n">order</span><span class="p">?.</span><span class="n">Status</span> <span class="p">==</span> <span class="n">OrderStatus</span><span class="p">.</span><span class="n">Completed</span><span class="p">)</span>
            <span class="p">{</span>
                <span class="n">taskCompletionSource</span><span class="p">.</span><span class="nf">TrySetResult</span><span class="p">();</span>
            <span class="p">}</span>
        <span class="p">}</span>

        <span class="k">return</span> <span class="k">await</span> <span class="k">base</span><span class="p">.</span><span class="nf">SavedChangesAsync</span><span class="p">(</span>
            <span class="n">eventData</span><span class="p">,</span>
            <span class="n">result</span><span class="p">,</span>
            <span class="n">cancellationToken</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>​</p>

<p>The key part of our interceptor is our <strong>SavedChangesAsync</strong> override, which will run just after our SaveChanges call in <strong>OrderPaidHandler</strong>.</p>

<p>What we do there is call <strong>TrySetResult</strong> on our <strong>TaskCompletionSource</strong>, so that the <strong>WaitAsync</strong> call can run to completion.</p>

<p>But who calls WaitAsync?</p>

<p>​</p>

<h3 id="completing-the-test"><strong>Completing the test</strong></h3>
<p>Going back to our integration test, we use a small publisher class I created to encapsulate the logic to publish the <strong>OrderPaid</strong> message to Service Bus:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 3) Publish OrderPaid message</span>
<span class="k">await</span> <span class="k">using</span> <span class="p">(</span><span class="kt">var</span> <span class="n">client</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">ServiceBusClient</span><span class="p">(</span><span class="n">sbConnString</span><span class="p">))</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">publisher</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">ServiceBusMessagePublisher</span><span class="p">(</span>
        <span class="n">client</span><span class="p">,</span>
        <span class="n">NullLogger</span><span class="p">&lt;</span><span class="n">ServiceBusMessagePublisher</span><span class="p">&gt;.</span><span class="n">Instance</span><span class="p">);</span>

    <span class="k">await</span> <span class="n">publisher</span><span class="p">.</span><span class="nf">PublishAsync</span><span class="p">(</span>
        <span class="k">new</span> <span class="nf">OrderPaid</span><span class="p">(</span><span class="n">orderId</span><span class="p">),</span>
        <span class="n">queueName</span><span class="p">:</span> <span class="s">"orders"</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>​</p>

<p>This puts things in motion, but it is at this point that we need to wait until that message is published, consumed by the worker, and the order is updated in the DB.</p>

<p>That’s when this next line comes in:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 4) Wait for the worker to complete the order</span>
<span class="k">await</span> <span class="n">probe</span><span class="p">.</span><span class="nf">WaitAsync</span><span class="p">(</span><span class="n">TimeSpan</span><span class="p">.</span><span class="nf">FromSeconds</span><span class="p">(</span><span class="m">30</span><span class="p">));</span>
</code></pre></div></div>

<p>​</p>

<p>Yes, that’s where we call <strong>WaitAsync</strong> on the interceptor. We set a timeout just as a failsafe, but we should move past that line as soon as the interceptor catches the SaveChanges call.</p>

<p>After that, all we need is our assertions:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 5) Assert – the order is in Completed state</span>
<span class="k">await</span> <span class="k">using</span> <span class="p">(</span><span class="kt">var</span> <span class="n">assertCtx</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">GameStoreContext</span><span class="p">(</span><span class="n">dbOptions</span><span class="p">))</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">updated</span> <span class="p">=</span> <span class="k">await</span> <span class="n">assertCtx</span><span class="p">.</span><span class="n">Orders</span>
        <span class="p">.</span><span class="nf">AsNoTracking</span><span class="p">()</span>
        <span class="p">.</span><span class="nf">Include</span><span class="p">(</span><span class="n">o</span> <span class="p">=&gt;</span> <span class="n">o</span><span class="p">.</span><span class="n">Items</span><span class="p">)</span>
        <span class="p">.</span><span class="nf">FirstOrDefaultAsync</span><span class="p">(</span><span class="n">o</span> <span class="p">=&gt;</span> <span class="n">o</span><span class="p">.</span><span class="n">Id</span> <span class="p">==</span> <span class="n">orderId</span><span class="p">,</span> <span class="n">CancellationToken</span><span class="p">);</span>

    <span class="n">updated</span><span class="p">.</span><span class="nf">ShouldNotBeNull</span><span class="p">();</span>
    <span class="n">updated</span><span class="p">!.</span><span class="n">Status</span><span class="p">.</span><span class="nf">ShouldBe</span><span class="p">(</span><span class="n">OrderStatus</span><span class="p">.</span><span class="n">Completed</span><span class="p">);</span>
<span class="p">}</span>

<span class="c1">// Cleanup</span>
<span class="k">await</span> <span class="n">host</span><span class="p">.</span><span class="nf">StopAsync</span><span class="p">(</span><span class="n">CancellationToken</span><span class="p">);</span>
</code></pre></div></div>

<p>​</p>

<p>And with that, we have a reliable and (relatively) fast way to verify this complete flow, while exercising all of our real code with real dependencies:</p>

<p><img src="/assets/images/2025-12-20/4ghDFAZYvbFtvU3CTR72ZN-84EXzxYP52HJ1uMXLmcKmX.jpeg" alt="" /></p>

<p>​</p>

<p>Mission accomplished!</p>

<p>​</p>

<h3 id="wrapping-up"><strong>Wrapping up</strong></h3>
<p>Testing background workers often feels like gambling with race conditions. You usually find yourself adding Task.Delay calls to your tests and hoping the worker finishes in time.</p>

<p>By using EF Core interceptors, you eliminate the guesswork. You stop waiting for a timer and start waiting for the actual completion signal from your database.</p>

<p><strong>Reliable tests rely on signals, not luck.</strong></p>

<p>This is how you turn a flaky, slow-moving test suite into a deterministic one that you can actually trust to deploy to production.</p>

<p>And that’s it for today.</p>

<p>See you next Saturday.</p>

<hr />

<p><br /></p>

<p><strong>Whenever you’re ready, there are 3 ways I can help you:</strong></p>

<ol>
  <li>
    <p><strong><a href="https://juliocasal.com/courses/dotnetbootcamp">.NET Backend Developer Bootcamp</a></strong>: A complete path from ASP.NET Core fundamentals to building, containerizing, and deploying production-ready, cloud-native apps on Azure.</p>
  </li>
  <li>
    <p><strong>​<a href="https://dotnetmicroservices.com">Building Microservices With .NET</a></strong>: Transform the way you build .NET systems at scale.</p>
  </li>
  <li>
    <p><strong>​<a href="https://www.patreon.com/juliocasal" target="_blank">​Get the full source code</a></strong>: Download the working project from this article, grab exclusive course discounts, and join a private .NET community.</p>
  </li>
</ol>]]></content><author><name></name></author><summary type="html"><![CDATA[Read time: 9 minutes The .NET Saturday is brought to you by:]]></summary></entry><entry><title type="html">How to Deploy a .NET + React Full Stack App to Azure with Aspire 13</title><link href="https://juliocasal.com/blog/how-to-deploy-a-net-react-full-stack-app-to-azure-with-aspire-13.html" rel="alternate" type="text/html" title="How to Deploy a .NET + React Full Stack App to Azure with Aspire 13" /><published>2025-11-29T00:00:00-08:00</published><updated>2025-11-29T00:00:00-08:00</updated><id>https://juliocasal.com/blog/how-to-deploy-a-net--react-full-stack-app-to-azure-with-aspire-13</id><content type="html" xml:base="https://juliocasal.com/blog/how-to-deploy-a-net-react-full-stack-app-to-azure-with-aspire-13.html"><![CDATA[<p><em>Read time: 13 minutes</em></p>
<p style="text-align: center; font-size: 1.2em;"><strong>The .NET Saturday is brought to you by:</strong></p>

<div style="background: linear-gradient(90deg, #e0eafc 0%, #cfdef3 100%); padding: 36px; margin: 24px 0; overflow: hidden; border-radius: 14px; box-shadow: 0 2px 12px rgba(80,120,200,0.08);">
  <p style="text-align: center; max-width: 600px; margin: 0 auto 18px auto;">Build faster with <strong>ABP’s modular .NET platform</strong>. Get the framework’s modules, templates, and tools with the Black Friday discount until Dec 1.</p>

  <div style="display: flex; justify-content: center;">
    <a href="https://abp.io?utm_source=newsletter&amp;utm_medium=affiliate&amp;utm_campaign=juliocasal_bf25" target="_blank" style="background: linear-gradient(90deg, #4f8cff 0%, #235390 100%); color: #fff; padding: 14px 36px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 1.1em; box-shadow: 0 2px 8px rgba(80,120,200,0.10); transition: background 0.2s; text-align: center;">Save 33% Today</a>
  </div>
</div>

<p>Building a full-stack application is not the hard part anymore. Most devs can spin up a React frontend, a .NET API, and a PostgreSQL database without too much drama.</p>

<p>The painful part is deploying that stack reliably in the cloud, wiring up all the application components without drowning in connection strings, environment variables, and weird configuration errors.</p>

<p>That is exactly where Aspire shines. It gives you a single, understandable application model that can provision and connect your backend, frontend, database, and identity provider for both local dev and Azure.</p>

<p>Today, we will take a complete .NET + React + Postgres + Keycloak application, and deploy the whole thing to Azure using <strong>Aspire 13</strong>.</p>

<p>Let’s dive in.</p>

<p>​</p>

<h3 id="the-full-stack-application"><strong>The full-stack application</strong></h3>
<p>Here’s a quick picture of the full-stack application we will deploy to the cloud:</p>

<p><img src="/assets/images/2025-11-29/4ghDFAZYvbFtvU3CTR72ZN-uWTDo1EaJprGg79VjxcUo8.jpeg" alt="" /></p>

<p>In essence, we have a <strong>React</strong> application that is supported by a <strong>.NET</strong> backend, which in turn stores all the data in a <strong>PostgreSQL</strong> database.</p>

<p>We also use <strong>Keycloak</strong> as the identity provider, which allows users to log in to the app from the browser via OpenID Connect (OIDC) and is also used by the backend to validate JWTs sent by the frontend.</p>

<p>Now, let’s prepare our application for deployment, starting with the database.</p>

<p>​</p>

<h3 id="adding-the-postgresql-database"><strong>Adding the PostgreSQL database</strong></h3>
<p>To make our lives easier, we’ll define our full-stack application as an <strong>Aspire</strong> application. If you are new to Aspire, I have a beginner’s guide <a href="https://juliocasal.com/blog/net-aspire-tutorial-build-production-ready-apps-from-day-1">here</a>.</p>

<p>After adding the <strong>AppHost</strong> project and installing the <strong>Azure PostgreSQL</strong> hosting integration (Aspire.Hosting.Azure.PostgreSQL NuGet package), here’s how we define our database:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">builder</span> <span class="p">=</span> <span class="n">DistributedApplication</span><span class="p">.</span><span class="nf">CreateBuilder</span><span class="p">(</span><span class="n">args</span><span class="p">);</span>

<span class="kt">var</span> <span class="n">postgres</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">AddAzurePostgresFlexibleServer</span><span class="p">(</span><span class="s">"postgres"</span><span class="p">)</span>
                    <span class="p">.</span><span class="nf">RunAsContainer</span><span class="p">(</span><span class="n">postgres</span> <span class="p">=&gt;</span>
                    <span class="p">{</span>
                        <span class="n">postgres</span><span class="p">.</span><span class="nf">WithDataVolume</span><span class="p">();</span>
                        <span class="n">postgres</span><span class="p">.</span><span class="nf">WithPgAdmin</span><span class="p">(</span><span class="n">pgAdmin</span> <span class="p">=&gt;</span>
                        <span class="p">{</span>
                            <span class="n">pgAdmin</span><span class="p">.</span><span class="nf">WithHostPort</span><span class="p">(</span><span class="m">5050</span><span class="p">);</span>
                        <span class="p">});</span>
                    <span class="p">});</span>

<span class="kt">var</span> <span class="n">templateAppDb</span> <span class="p">=</span> <span class="n">postgres</span><span class="p">.</span><span class="nf">AddDatabase</span><span class="p">(</span><span class="s">"TemplateAppDB"</span><span class="p">,</span> <span class="s">"TemplateApp"</span><span class="p">);</span>

<span class="n">builder</span><span class="p">.</span><span class="nf">Build</span><span class="p">().</span><span class="nf">Run</span><span class="p">();</span>
</code></pre></div></div>

<p>​</p>

<p>To unwrap that:</p>

<ul>
  <li><span>We add a PostgreSQL resource that will deploy as an <strong>Azure Postgres Flexible Server</strong> resource</span></li>
  <li><span>We enable running it as a container during local dev</span></li>
  <li><span>We also add a PgAdmin container to make it easier to work with it locally</span></li>
  <li><span>We add the TemplateApp database to be used by our .NET API</span></li>
</ul>

<p>Next, let’s add our identity provider.</p>

<p>​</p>

<h3 id="adding-keycloak"><strong>Adding Keycloak</strong></h3>
<p>I covered how to deploy Keycloak to Azure over <a href="https://juliocasal.com/blog/deploying-keycloak-to-azure-with-net-aspire">here</a>, so today I’ll only do a quick recap.</p>

<p>Here’s how you add Keycloak to your Aspire application model:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">keycloakPassword</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">AddParameter</span><span class="p">(</span>
                                <span class="s">"KeycloakPassword"</span><span class="p">,</span>
                                <span class="n">secret</span><span class="p">:</span> <span class="k">true</span><span class="p">,</span>
                                <span class="k">value</span><span class="p">:</span> <span class="s">"admin"</span><span class="p">);</span>
<span class="kt">int</span><span class="p">?</span> <span class="n">keycloakPort</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="n">ExecutionContext</span><span class="p">.</span><span class="n">IsRunMode</span> <span class="p">?</span> <span class="m">8080</span> <span class="p">:</span> <span class="k">null</span><span class="p">;</span>
<span class="kt">var</span> <span class="n">keycloak</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">AddKeycloak</span><span class="p">(</span>
                        <span class="s">"keycloak"</span><span class="p">,</span>
                        <span class="n">adminPassword</span><span class="p">:</span> <span class="n">keycloakPassword</span><span class="p">,</span>
                        <span class="n">port</span><span class="p">:</span> <span class="n">keycloakPort</span><span class="p">)</span>
                      <span class="p">.</span><span class="nf">WithLifetime</span><span class="p">(</span><span class="n">ContainerLifetime</span><span class="p">.</span><span class="n">Persistent</span><span class="p">);</span>

<span class="k">if</span> <span class="p">(</span><span class="n">builder</span><span class="p">.</span><span class="n">ExecutionContext</span><span class="p">.</span><span class="n">IsRunMode</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">keycloak</span><span class="p">.</span><span class="nf">WithDataVolume</span><span class="p">()</span>
            <span class="p">.</span><span class="nf">WithRealmImport</span><span class="p">(</span><span class="s">"./realms"</span><span class="p">);</span>
<span class="p">}</span>

<span class="k">if</span> <span class="p">(</span><span class="n">builder</span><span class="p">.</span><span class="n">ExecutionContext</span><span class="p">.</span><span class="n">IsPublishMode</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">postgresUser</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">AddParameter</span><span class="p">(</span><span class="s">"PostgresUser"</span><span class="p">,</span> <span class="k">value</span><span class="p">:</span> <span class="s">"postgres"</span><span class="p">);</span>
    <span class="kt">var</span> <span class="n">postgresPassword</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">AddParameter</span><span class="p">(</span><span class="s">"PostgresPassword"</span><span class="p">,</span> <span class="n">secret</span><span class="p">:</span> <span class="k">true</span><span class="p">);</span>
    <span class="n">postgres</span><span class="p">.</span><span class="nf">WithPasswordAuthentication</span><span class="p">(</span><span class="n">postgresUser</span><span class="p">,</span> <span class="n">postgresPassword</span><span class="p">);</span>

    <span class="kt">var</span> <span class="n">keycloakDb</span> <span class="p">=</span> <span class="n">postgres</span><span class="p">.</span><span class="nf">AddDatabase</span><span class="p">(</span><span class="s">"keycloakDB"</span><span class="p">,</span> <span class="s">"keycloak"</span><span class="p">);</span>

    <span class="kt">var</span> <span class="n">keycloakDbUrl</span> <span class="p">=</span> <span class="n">ReferenceExpression</span><span class="p">.</span><span class="nf">Create</span><span class="p">(</span>
        <span class="s">$"jdbc:postgresql://</span><span class="p">{</span><span class="n">postgres</span><span class="p">.</span><span class="n">Resource</span><span class="p">.</span><span class="n">HostName</span><span class="p">}</span><span class="s">/</span><span class="p">{</span><span class="n">keycloakDb</span><span class="p">.</span><span class="n">Resource</span><span class="p">.</span><span class="n">DatabaseName</span><span class="p">}</span><span class="s">"</span>
    <span class="p">);</span>

    <span class="n">keycloak</span><span class="p">.</span><span class="nf">WithEnvironment</span><span class="p">(</span><span class="s">"KC_HTTP_ENABLED"</span><span class="p">,</span> <span class="s">"true"</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">WithEnvironment</span><span class="p">(</span><span class="s">"KC_PROXY_HEADERS"</span><span class="p">,</span> <span class="s">"xforwarded"</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">WithEnvironment</span><span class="p">(</span><span class="s">"KC_HOSTNAME_STRICT"</span><span class="p">,</span> <span class="s">"false"</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">WithEnvironment</span><span class="p">(</span><span class="s">"KC_DB"</span><span class="p">,</span> <span class="s">"postgres"</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">WithEnvironment</span><span class="p">(</span><span class="s">"KC_DB_URL"</span><span class="p">,</span> <span class="n">keycloakDbUrl</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">WithEnvironment</span><span class="p">(</span><span class="s">"KC_DB_USERNAME"</span><span class="p">,</span> <span class="n">postgresUser</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">WithEnvironment</span><span class="p">(</span><span class="s">"KC_DB_PASSWORD"</span><span class="p">,</span> <span class="n">postgresPassword</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">WithEndpoint</span><span class="p">(</span><span class="s">"http"</span><span class="p">,</span> <span class="n">e</span> <span class="p">=&gt;</span>
            <span class="p">{</span>
                <span class="n">e</span><span class="p">.</span><span class="n">IsExternal</span> <span class="p">=</span> <span class="k">true</span><span class="p">;</span>
                <span class="n">e</span><span class="p">.</span><span class="n">UriScheme</span> <span class="p">=</span> <span class="s">"https"</span><span class="p">;</span>
            <span class="p">});</span>
<span class="p">}</span>
</code></pre></div></div>

<p>​</p>

<p>As you can see, to run it locally (IsRunMode), all we do is add a data volume (to persist changes) and import a realm from our realms folder (if available).</p>

<p>But to run it in the cloud, we have to do a few extra things to allow Keycloak to keep the realm data in our Azure PostgreSQL DB:</p>

<ol>
  <li><span>Prepare credentials that Keycloak can use to connect to the database</span></li>
  <li><span>Define a new DB for Keycloak</span></li>
  <li><span>Set the KC_HTTP_ENABLED, KC_PROXY_HEADERS, and KC_HOSTNAME_STRICT environment variables so Keycloak can run properly as an Azure Container App.</span></li>
  <li><span>Set all the environment variables needed for Keycloak to connect to the DB</span></li>
  <li><span>Make Keycloak’s endpoint external, so we can access it from our browser to configure it.</span></li>
</ol>

<p>Again, I explained all that in detail in <a href="https://juliocasal.com/blog/deploying-keycloak-to-azure-with-net-aspire">my previous post</a>.</p>

<p>Next, let’s add our .NET backend.</p>

<p>​</p>

<h3 id="adding-the-net-backend"><strong>Adding the .NET Backend</strong></h3>
<p>This is the easiest part, since Aspire includes excellent support for all things .NET.</p>

<p>Here’s the code:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">keycloakAuthority</span> <span class="p">=</span> <span class="n">ReferenceExpression</span><span class="p">.</span><span class="nf">Create</span><span class="p">(</span>
    <span class="s">$"</span><span class="p">{</span><span class="n">keycloak</span><span class="p">.</span><span class="nf">GetEndpoint</span><span class="p">(</span><span class="s">"http"</span><span class="p">).</span><span class="nf">Property</span><span class="p">(</span><span class="n">EndpointProperty</span><span class="p">.</span><span class="n">Url</span><span class="p">)}</span><span class="s">/realms/templateapp"</span>
<span class="p">);</span>

<span class="kt">var</span> <span class="n">api</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="n">AddProject</span><span class="p">&lt;</span><span class="n">TemplateApp_Api</span><span class="p">&gt;(</span><span class="s">"api"</span><span class="p">)</span>
                <span class="p">.</span><span class="nf">WithReference</span><span class="p">(</span><span class="n">templateAppDb</span><span class="p">)</span>
                <span class="p">.</span><span class="nf">WaitFor</span><span class="p">(</span><span class="n">templateAppDb</span><span class="p">)</span>
                <span class="p">.</span><span class="nf">WithEnvironment</span><span class="p">(</span><span class="s">"Auth__Authority"</span><span class="p">,</span> <span class="n">keycloakAuthority</span><span class="p">)</span>
                <span class="p">.</span><span class="nf">WaitFor</span><span class="p">(</span><span class="n">keycloak</span><span class="p">)</span>
                <span class="p">.</span><span class="nf">WithExternalHttpEndpoints</span><span class="p">()</span>
                <span class="p">.</span><span class="nf">WithHttpHealthCheck</span><span class="p">(</span><span class="s">"/health/ready"</span><span class="p">);</span>
</code></pre></div></div>

<p>​</p>

<p>The <strong>WithReference</strong> call shares the database connection info with our API (locally and in the cloud), and <strong>WaitFor</strong> is used to ensure the DB is ready before the API starts.</p>

<p>To get our API to talk to Keycloak (for JWT validation), we need to provide it with the <strong>Authority</strong>, which is the URL to the Keycloak server, including the realm configured for your application.</p>

<p>Using a <strong>ReferenceExpression</strong> lets Aspire resolve the final URL once Keycloak has been deployed to Azure and then hand it over to our API as an environment variable.</p>

<p>Also, the assumption here is that there will be a realm named <strong>templateapp</strong> there, which, for now, we’ll have to create or import manually once the deployment is complete.</p>

<p>Next, the tricky part: the frontend.</p>

<p>​</p>

<h3 id="adding-the-react-frontend"><strong>Adding the React frontend</strong></h3>
<p>I previously covered how to add a React app to Aspire ​<a href="https://juliocasal.com/blog/going-full-stack-with-dotnet-aspire">here</a>, but support for frontend frameworks, particularly for JavaScript-based ones, received a nice refresh in <strong>Aspire 13</strong>.</p>

<p>Since we are using <strong>Vite</strong> to build and run our React app, we can add it to our application model by installing the new JavaScript hosting integration (Aspire.Hosting.JavaScript NuGet package) and adding this code:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">frontend</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">AddViteApp</span><span class="p">(</span><span class="s">"frontend"</span><span class="p">,</span> <span class="s">"../TemplateApp.React"</span><span class="p">)</span>
                    <span class="p">.</span><span class="nf">WithReference</span><span class="p">(</span><span class="n">api</span><span class="p">)</span>
                    <span class="p">.</span><span class="nf">WaitFor</span><span class="p">(</span><span class="n">api</span><span class="p">)</span>
                    <span class="p">.</span><span class="nf">WithEndpoint</span><span class="p">(</span><span class="n">endpointName</span><span class="p">:</span> <span class="s">"http"</span><span class="p">,</span> <span class="n">endpoint</span> <span class="p">=&gt;</span>
                    <span class="p">{</span>
                        <span class="n">endpoint</span><span class="p">.</span><span class="n">Port</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="n">ExecutionContext</span><span class="p">.</span><span class="n">IsRunMode</span> <span class="p">?</span>
                                        <span class="m">5173</span> <span class="p">:</span> <span class="k">null</span><span class="p">;</span>
                    <span class="p">});</span>
</code></pre></div></div>

<p>​</p>

<p>The port specification there is just my personal preference, since I like to have my React app loaded in my browser with a stable port that I can refresh any time.</p>

<p>However, the challenge comes with trying to deploy the Vite app to Azure. Aspire can turn it easily into a container and deploy it as an Azure Container App, but here’s the challenge:</p>

<blockquote>
  <p>How do you provide the URLs of our .NET backend and of our Keycloak server to a Single Page Application (SPA) that runs entirely in the browser?</p>
</blockquote>

<p>You don’t know those URLs until Aspire completes provisioning the .NET API and Keycloak as Container Apps, and you can’t set environment variables on something that just runs in the browser.</p>

<p>But as with everything else, there’s a way.</p>

<p>​</p>

<h3 id="adding-a-yarp-web-server"><strong>Adding a YARP web server</strong></h3>
<p>There are a few ways to solve the Vite app cloud hosting problem, including the popular Backend For Frontend (BFF) pattern, but a simpler way is to add a <strong>YARP</strong> web server to the mix.</p>

<p><img src="/assets/images/2025-11-29/4ghDFAZYvbFtvU3CTR72ZN-oZLr418NhddMPnKsWEmVhF.jpeg" alt="" /></p>

<p>Here’s what we get by introducing YARP:</p>

<ul>
  <li><span>We can deploy our Vite/React app as static files inside the YARP server</span></li>
  <li><span>Any time the React app sends an outbound request to a location like “<strong>/api</strong>”, YARP can catch it and redirect to the actual location of the .NET backend</span></li>
</ul>

<p>To enable this, start by installing the <strong>YARP hosting integration</strong> (Aspire.Hosting.Yarp NuGet package) and then add this code:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="p">(</span><span class="n">builder</span><span class="p">.</span><span class="n">ExecutionContext</span><span class="p">.</span><span class="n">IsPublishMode</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">builder</span><span class="p">.</span><span class="nf">AddYarp</span><span class="p">(</span><span class="s">"frontend-server"</span><span class="p">)</span>
           <span class="p">.</span><span class="nf">WithConfiguration</span><span class="p">(</span><span class="n">c</span> <span class="p">=&gt;</span>
           <span class="p">{</span>
               <span class="c1">// Always proxy /api requests to backend</span>
               <span class="n">c</span><span class="p">.</span><span class="nf">AddRoute</span><span class="p">(</span><span class="s">"api/{**catch-all}"</span><span class="p">,</span> <span class="n">api</span><span class="p">)</span>
                <span class="p">.</span><span class="nf">WithTransformPathRemovePrefix</span><span class="p">(</span><span class="s">"/api"</span><span class="p">);</span>
           <span class="p">})</span>
           <span class="p">.</span><span class="nf">WithExternalHttpEndpoints</span><span class="p">()</span>
           <span class="p">.</span><span class="nf">PublishWithStaticFiles</span><span class="p">(</span><span class="n">frontend</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>​</p>

<p>As you can see, we only want to do this in <strong>Publish mode.</strong> During local development, YARP is not needed since Vite can take care of everything.</p>

<p>Notice how we enable an external endpoint on YARP, so we can reach the frontend from our browser and call <strong>PublishWithStaticFiles</strong> so that all the React frontend files get copied into the YARP container.</p>

<p>One more thing you would need to do, this time in your .NET API directly, is to expose an endpoint that can return the full Keycloak Authority URL:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">static</span> <span class="k">class</span> <span class="nc">GetConfigurationEndpoint</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">static</span> <span class="k">void</span> <span class="nf">MapGetConfiguration</span><span class="p">(</span><span class="k">this</span> <span class="n">IEndpointRouteBuilder</span> <span class="n">app</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="c1">// GET /config</span>
        <span class="n">app</span><span class="p">.</span><span class="nf">MapGet</span><span class="p">(</span><span class="s">"/"</span><span class="p">,</span> <span class="p">(</span><span class="n">IOptions</span><span class="p">&lt;</span><span class="n">AuthOptions</span><span class="p">&gt;</span> <span class="n">authOptions</span><span class="p">)</span> <span class="p">=&gt;</span>
        <span class="p">{</span>
            <span class="k">return</span> <span class="n">Results</span><span class="p">.</span><span class="nf">Json</span><span class="p">(</span><span class="k">new</span> <span class="nf">ConfigurationDto</span><span class="p">(</span><span class="n">authOptions</span><span class="p">.</span><span class="n">Value</span><span class="p">.</span><span class="n">Authority</span><span class="p">));</span>
        <span class="p">})</span>
        <span class="p">.</span><span class="n">Produces</span><span class="p">&lt;</span><span class="n">ConfigurationDto</span><span class="p">&gt;();</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>​</p>

<p>The frontend will call this endpoint the same way it makes any other backend call, and it will return the full location of Keycloak so it can start the OIDC authentication process.</p>

<p>Now, let’s try it out.</p>

<p>​</p>

<h3 id="deploying-the-full-stack-application"><strong>Deploying the full-stack application</strong></h3>
<p>Before kicking off the deployment, you can confirm everything runs locally with an <strong>aspire run</strong> call, which will take you to a local dashboard like this:</p>

<p><img src="/assets/images/2025-11-29/4ghDFAZYvbFtvU3CTR72ZN-hBUvdnBWv7eT6dsZgFCFA8.jpeg" alt="" /></p>

<p>​</p>

<p>Notice how both the <strong>Keycloak database</strong> and the <strong>YARP server</strong> are not present in the dashboard, since we only included them in <strong>Publish mode</strong>.</p>

<p>To deploy the application, we can use either the Azure Developer CLI (<strong>azd up</strong>) or the newer <strong>aspire deploy</strong> command of the Aspire CLI.</p>

<p>Either way, after a few minutes, you’ll end up with something like this in your Azure subscription:</p>

<p><img src="/assets/images/2025-11-29/4ghDFAZYvbFtvU3CTR72ZN-w1dEK7C3kwjLQGxsY5kK3e.jpeg" alt="" /></p>

<p>​</p>

<p>You can see our .NET API, YARP frontend, and Keycloak server there, along with our PostgreSQL database (and a bunch of other supporting infra we didn’t have to even think about).</p>

<p>From there, you should head to your <strong>Keycloak</strong> server to configure your <strong>templateapp</strong> realm:</p>

<p><img src="/assets/images/2025-11-29/4ghDFAZYvbFtvU3CTR72ZN-pa5qfEkKhTbLdWZjBeKWy8.jpeg" alt="" /></p>

<p>​</p>

<p>And then you can browse to your <strong>frontend-server,</strong> which corresponds to our YARP-hosted React application:</p>

<p><img src="/assets/images/2025-11-29/4ghDFAZYvbFtvU3CTR72ZN-rL7aiAJnr9VutqRHConFF9.jpeg" style="border: 1px solid;" /></p>

<p>​</p>

<p>Click on Login to authenticate via Keycloak:</p>

<p><img src="/assets/images/2025-11-29/4ghDFAZYvbFtvU3CTR72ZN-7tqQ1hda9EUDteeKHhgg9K.jpeg" alt="" /></p>

<p>​</p>

<p>And then start making changes as an authenticated user:</p>

<p><img src="/assets/images/2025-11-29/4ghDFAZYvbFtvU3CTR72ZN-7wSEeax31cBKvy3M76vQo9.jpeg" style="border: 1px solid;" /></p>

<p>​</p>

<p>Mission accomplished!</p>

<p>​</p>

<h3 id="wrapping-up"><strong>Wrapping Up</strong></h3>
<p>Everybody can build full-stack applications these days. Most devs stall when it is time to wire everything up in Azure without breaking things.</p>

<p>Aspire changes that. It lets you describe your full stack once, keep the connections and config in a single place, and reuse the same model for local dev and cloud deployments.</p>

<p>Instead of fighting connection strings and ad hoc scripts, you focus on the parts that actually move your product forward.</p>

<p>And that’s it for today.</p>

<p>See you next Saturday.</p>

<hr />

<p><br /></p>

<p><strong>Whenever you’re ready, there are 3 ways I can help you:</strong></p>

<ol>
  <li>
    <p><strong><a href="https://juliocasal.com/courses/dotnetbootcamp">.NET Backend Developer Bootcamp</a></strong>: A complete path from ASP.NET Core fundamentals to building, containerizing, and deploying production-ready, cloud-native apps on Azure.</p>
  </li>
  <li>
    <p><strong>​<a href="https://dotnetmicroservices.com">Building Microservices With .NET</a></strong>: Transform the way you build .NET systems at scale.</p>
  </li>
  <li>
    <p><strong>​<a href="https://www.patreon.com/juliocasal" target="_blank">​Get the full source code</a></strong>: Download the working project from this article, grab exclusive course discounts, and join a private .NET community.</p>
  </li>
</ol>]]></content><author><name></name></author><summary type="html"><![CDATA[Read time: 13 minutes The .NET Saturday is brought to you by:]]></summary></entry><entry><title type="html">3 Ways to Shrink Your Aspire AppHost</title><link href="https://juliocasal.com/blog/3-ways-to-shrink-your-aspire-apphost.html" rel="alternate" type="text/html" title="3 Ways to Shrink Your Aspire AppHost" /><published>2025-11-22T00:00:00-08:00</published><updated>2025-11-22T00:00:00-08:00</updated><id>https://juliocasal.com/blog/3-ways-to-shrink-your-aspire-apphost</id><content type="html" xml:base="https://juliocasal.com/blog/3-ways-to-shrink-your-aspire-apphost.html"><![CDATA[<p><em>Read time: 7 minutes</em></p>

<p style="text-align: center; font-size: 1.2em;"><strong>The .NET Saturday is brought to you by:</strong></p>

<div style="background: linear-gradient(90deg, #e0eafc 0%, #cfdef3 100%); padding: 36px; margin: 24px 0; overflow: hidden; border-radius: 14px; box-shadow: 0 2px 12px rgba(80,120,200,0.08);">
  <p style="text-align: center; max-width: 600px; margin: 0 auto 18px auto;"><a href="https://www.jetbrains.com/rider/?utm_source=newsletter_dot_net_saturday&amp;utm_medium=cpc&amp;utm_campaign=junie" target="_blank"><strong>JetBrains Rider​</strong></a> is better with an AI coding agent that can save you hours, if not days. Simply provide a precise prompt, and get a fully functional feature in return!</p>

  <div style="display: flex; justify-content: center;">
    <a href="https://www.jetbrains.com/rider/?utm_source=newsletter_dot_net_saturday&amp;utm_medium=cpc&amp;utm_campaign=junie" target="_blank" style="background: linear-gradient(90deg, #4f8cff 0%, #235390 100%); color: #fff; padding: 14px 36px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 1.1em; box-shadow: 0 2px 8px rgba(80,120,200,0.10); transition: background 0.2s; text-align: center;">Try it today</a>
  </div>
</div>

<p>Every Aspire release brings new capabilities, but the pattern I’ve seen more and more is how the latest versions actually remove code from your projects.</p>

<p>And I don’t mean bug fixes or performance tweaks. I’m talking about infrastructure boilerplate that’s been cluttering your AppHost since you started using Aspire.</p>

<p>The Aspire team has been quietly simplifying the APIs across the last few releases.</p>

<p>Verbose configurations that used to take multiple lines? Now one-liners. Workarounds you needed for Azure deployments? Built into the framework.</p>

<p>Today, I’ll walk you through three cleanups that will simplify your application model immediately as you upgrade to the latest Aspire bits.</p>

<p>Let’s dive in.</p>

<p>​</p>

<h3 id="upgrading-aspire"><strong>Upgrading Aspire</strong></h3>
<p>The easiest way to upgrade Aspire is by using the Aspire CLI. So we should start by upgrading the CLI itself (using PowerShell here):</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">iex</span><span class="w"> </span><span class="s2">"&amp; { </span><span class="si">$(</span><span class="n">irm</span><span class="w"> </span><span class="nx">https://aspire.dev/install.ps1</span><span class="p">)</span><span class="s2"> }"</span><span class="w">
</span></code></pre></div></div>

<p>​</p>

<p>This should be the last time you use that script to update your Aspire CLI. Starting with Aspire 13, the CLI includes the new <strong>–self</strong> flag for <strong>aspire update</strong>, which will let it easily self-update:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">aspire</span><span class="w"> </span><span class="nx">update</span><span class="w"> </span><span class="nt">--self</span><span class="w">
</span></code></pre></div></div>

<p>​</p>

<p>Next, use <strong>aspire update</strong> to update all Aspire dependencies across all your projects in one shot:</p>

<p><img src="/assets/images/2025-11-22/4ghDFAZYvbFtvU3CTR72ZN-qRMsryznLwbGvSeMYqqEhu.jpeg" alt="" /></p>

<p>​</p>

<p>You may also want to update your Aspire project templates:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">dotnet</span><span class="w"> </span><span class="nx">new</span><span class="w"> </span><span class="nx">install</span><span class="w"> </span><span class="nx">Aspire.ProjectTemplates</span><span class="w">
</span></code></pre></div></div>

<p>​</p>

<p>Next, let’s start taking advantage of the new bits.</p>

<p>​</p>

<h3 id="1-use-the-simpler-apphost-project-template"><strong>1. Use the simpler AppHost project template</strong></h3>
<p>The updated Aspire SDK supports a simplified project template for your AppHost. This was my Aspire 9.4 project:</p>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;Project</span> <span class="na">Sdk=</span><span class="s">"Microsoft.NET.Sdk"</span><span class="nt">&gt;</span>

  <span class="nt">&lt;Sdk</span> <span class="na">Name=</span><span class="s">"Aspire.AppHost.Sdk"</span> <span class="na">Version=</span><span class="s">"9.4.1"</span> <span class="nt">/&gt;</span>

  <span class="nt">&lt;PropertyGroup&gt;</span>
    <span class="nt">&lt;OutputType&gt;</span>Exe<span class="nt">&lt;/OutputType&gt;</span>
    <span class="nt">&lt;TargetFramework&gt;</span>net9.0<span class="nt">&lt;/TargetFramework&gt;</span>
    <span class="nt">&lt;ImplicitUsings&gt;</span>enable<span class="nt">&lt;/ImplicitUsings&gt;</span>
    <span class="nt">&lt;Nullable&gt;</span>enable<span class="nt">&lt;/Nullable&gt;</span>
    <span class="nt">&lt;UserSecretsId&gt;</span>92823f80-554d-4fd2-9ad2-b361574d1318<span class="nt">&lt;/UserSecretsId&gt;</span>
  <span class="nt">&lt;/PropertyGroup&gt;</span>

  <span class="nt">&lt;ItemGroup&gt;</span>
    <span class="nt">&lt;PackageReference</span> <span class="na">Include=</span><span class="s">"Aspire.Hosting.AppHost"</span> <span class="na">Version=</span><span class="s">"9.4.1"</span> <span class="nt">/&gt;</span>
    <span class="nt">&lt;PackageReference</span> <span class="na">Include=</span><span class="s">"Aspire.Hosting.Azure.AppContainers"</span> <span class="na">Version=</span><span class="s">"9.4.1"</span> <span class="nt">/&gt;</span>
    <span class="nt">&lt;PackageReference</span> <span class="na">Include=</span><span class="s">"Aspire.Hosting.Azure.ApplicationInsights"</span> <span class="na">Version=</span><span class="s">"9.4.1"</span> <span class="nt">/&gt;</span>
    <span class="nt">&lt;PackageReference</span> <span class="na">Include=</span><span class="s">"Aspire.Hosting.Azure.ServiceBus"</span> <span class="na">Version=</span><span class="s">"9.4.1"</span> <span class="nt">/&gt;</span>
    <span class="nt">&lt;PackageReference</span> <span class="na">Include=</span><span class="s">"Aspire.Hosting.Keycloak"</span> <span class="na">Version=</span><span class="s">"9.4.1-preview.1.25408.4"</span> <span class="nt">/&gt;</span>
    <span class="nt">&lt;PackageReference</span> <span class="na">Include=</span><span class="s">"Aspire.Hosting.Azure.PostgreSQL"</span> <span class="na">Version=</span><span class="s">"9.4.1"</span> <span class="nt">/&gt;</span>
  <span class="nt">&lt;/ItemGroup&gt;</span>

  <span class="nt">&lt;ItemGroup&gt;</span>
    <span class="nt">&lt;ProjectReference</span> <span class="na">Include=</span><span class="s">"..\TemplateApp.Api\TemplateApp.Api.csproj"</span> <span class="nt">/&gt;</span>
    <span class="nt">&lt;ProjectReference</span> <span class="na">Include=</span><span class="s">"..\TemplateApp.Worker\TemplateApp.Worker.csproj"</span> <span class="nt">/&gt;</span>
  <span class="nt">&lt;/ItemGroup&gt;</span>

<span class="nt">&lt;/Project&gt;</span>
</code></pre></div></div>

<p>​</p>

<p>And this is what it looks like after taking advantage of the updated template:</p>

<figure class="highlight"><pre><code class="language-xml" data-lang="xml"><span class="hll"><span class="nt">&lt;Project</span> <span class="na">Sdk=</span><span class="s">"Aspire.AppHost.Sdk/13.0.0"</span><span class="nt">&gt;</span>
</span>
  <span class="nt">&lt;PropertyGroup&gt;</span>
    <span class="nt">&lt;OutputType&gt;</span>Exe<span class="nt">&lt;/OutputType&gt;</span>
    <span class="nt">&lt;TargetFramework&gt;</span>net9.0<span class="nt">&lt;/TargetFramework&gt;</span>
    <span class="nt">&lt;ImplicitUsings&gt;</span>enable<span class="nt">&lt;/ImplicitUsings&gt;</span>
    <span class="nt">&lt;Nullable&gt;</span>enable<span class="nt">&lt;/Nullable&gt;</span>
    <span class="nt">&lt;UserSecretsId&gt;</span>92823f80-554d-4fd2-9ad2-b361574d1318<span class="nt">&lt;/UserSecretsId&gt;</span>
  <span class="nt">&lt;/PropertyGroup&gt;</span>

  <span class="nt">&lt;ItemGroup&gt;</span>
<span class="hll">    <span class="nt">&lt;PackageReference</span> <span class="na">Include=</span><span class="s">"Aspire.Hosting.Azure.AppContainers"</span> <span class="na">Version=</span><span class="s">"13.0.0"</span> <span class="nt">/&gt;</span>
</span><span class="hll">    <span class="nt">&lt;PackageReference</span> <span class="na">Include=</span><span class="s">"Aspire.Hosting.Azure.ApplicationInsights"</span> <span class="na">Version=</span><span class="s">"13.0.0"</span> <span class="nt">/&gt;</span>
</span><span class="hll">    <span class="nt">&lt;PackageReference</span> <span class="na">Include=</span><span class="s">"Aspire.Hosting.Azure.ServiceBus"</span> <span class="na">Version=</span><span class="s">"13.0.0"</span> <span class="nt">/&gt;</span>
</span><span class="hll">    <span class="nt">&lt;PackageReference</span> <span class="na">Include=</span><span class="s">"Aspire.Hosting.Keycloak"</span> <span class="na">Version=</span><span class="s">"13.0.0-preview.1.25560.3"</span> <span class="nt">/&gt;</span>
</span><span class="hll">    <span class="nt">&lt;PackageReference</span> <span class="na">Include=</span><span class="s">"Aspire.Hosting.Azure.PostgreSQL"</span> <span class="na">Version=</span><span class="s">"13.0.0"</span> <span class="nt">/&gt;</span>
</span>  <span class="nt">&lt;/ItemGroup&gt;</span>

  <span class="nt">&lt;ItemGroup&gt;</span>
    <span class="nt">&lt;ProjectReference</span> <span class="na">Include=</span><span class="s">"..\TemplateApp.Api\TemplateApp.Api.csproj"</span> <span class="nt">/&gt;</span>
    <span class="nt">&lt;ProjectReference</span> <span class="na">Include=</span><span class="s">"..\TemplateApp.Worker\TemplateApp.Worker.csproj"</span> <span class="nt">/&gt;</span>
  <span class="nt">&lt;/ItemGroup&gt;</span>

<span class="nt">&lt;/Project&gt;</span></code></pre></figure>

<p>​</p>

<p>Notice how your Aspire SDK is now specified directly in your <strong>&lt;Project&gt;</strong> tag, including the version, and the removal of <strong>Aspire.Hosting.AppHost</strong> package, now included with the SDK.</p>

<p>Next, let’s start the application model clean-up.</p>

<p>​</p>

<h3 id="2-use-the-new-azure-postgresql-resource-properties"><strong>2. Use the new Azure PostgreSQL resource properties</strong></h3>
<p>A few months ago, I covered <a href="https://juliocasal.com/blog/deploying-keycloak-to-azure-with-net-aspire">how to deploy Keycloak to Azure with Aspire</a>, which amazingly was doable with zero Bicep files, pure C#.</p>

<p>One of the key challenges was how to figure out the <strong>hostname</strong> of the deployed Azure PostgreSQL server, so that Keycloak can connect to it and use it as its database in the cloud.</p>

<p>The best we could do was this:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">postgres</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">AddAzurePostgresFlexibleServer</span><span class="p">(</span><span class="s">"postgres"</span><span class="p">)</span>
                    <span class="p">.</span><span class="nf">ConfigureInfrastructure</span><span class="p">(</span><span class="n">infra</span> <span class="p">=&gt;</span>
                    <span class="p">{</span>
                        <span class="kt">var</span> <span class="n">pg</span> <span class="p">=</span> <span class="n">infra</span><span class="p">.</span><span class="nf">GetProvisionableResources</span><span class="p">()</span>
                                      <span class="p">.</span><span class="n">OfType</span><span class="p">&lt;</span><span class="n">PostgreSqlFlexibleServer</span><span class="p">&gt;().</span><span class="nf">Single</span><span class="p">();</span>

                        <span class="n">infra</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="k">new</span> <span class="nf">ProvisioningOutput</span><span class="p">(</span><span class="s">"hostname"</span><span class="p">,</span> <span class="k">typeof</span><span class="p">(</span><span class="kt">string</span><span class="p">))</span>
                        <span class="p">{</span>
                            <span class="n">Value</span> <span class="p">=</span> <span class="n">pg</span><span class="p">.</span><span class="n">FullyQualifiedDomainName</span>
                        <span class="p">});</span>
                    <span class="p">});</span>

<span class="kt">var</span> <span class="n">keycloakDb</span> <span class="p">=</span> <span class="n">postgres</span><span class="p">.</span><span class="nf">AddDatabase</span><span class="p">(</span><span class="s">"keycloakDB"</span><span class="p">,</span> <span class="s">"keycloak"</span><span class="p">);</span>

<span class="kt">var</span> <span class="n">keycloakDbUrl</span> <span class="p">=</span> <span class="n">ReferenceExpression</span><span class="p">.</span><span class="nf">Create</span><span class="p">(</span>
    <span class="s">$"jdbc:postgresql://</span><span class="p">{</span><span class="n">postgres</span><span class="p">.</span><span class="nf">GetOutput</span><span class="p">(</span><span class="s">"hostname"</span><span class="p">)}</span><span class="s">/</span><span class="p">{</span><span class="n">keycloakDb</span><span class="p">.</span><span class="n">Resource</span><span class="p">.</span><span class="n">DatabaseName</span><span class="p">}</span><span class="s">"</span>
<span class="p">);</span>

<span class="kt">var</span> <span class="n">keycloak</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">AddKeycloak</span><span class="p">(</span><span class="s">"keycloak"</span><span class="p">)</span>
                      <span class="p">.</span><span class="nf">WithEnvironment</span><span class="p">(</span><span class="s">"KC_DB"</span><span class="p">,</span> <span class="s">"postgres"</span><span class="p">)</span>
                      <span class="p">.</span><span class="nf">WithEnvironment</span><span class="p">(</span><span class="s">"KC_DB_URL"</span><span class="p">,</span> <span class="n">keycloakDbUrl</span><span class="p">);</span>
</code></pre></div></div>

<p>​</p>

<p>Notice the tricky <strong>ConfigureInfrastructure</strong> call to add the <strong>hostname</strong> output that later would be used to build the JDBC PostgreSQL connection string that Keycloak would use.</p>

<p>But with the latest Aspire improvements, that hostname output is now a native property on the <strong>AzurePostgresFlexibleServerResource</strong>, allowing us to reduce all that to this:</p>

<figure class="highlight"><pre><code class="language-csharp" data-lang="csharp"><span class="kt">var</span> <span class="n">postgres</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">AddAzurePostgresFlexibleServer</span><span class="p">(</span><span class="s">"postgres"</span><span class="p">);</span>

<span class="kt">var</span> <span class="n">keycloakDb</span> <span class="p">=</span> <span class="n">postgres</span><span class="p">.</span><span class="nf">AddDatabase</span><span class="p">(</span><span class="s">"keycloakDB"</span><span class="p">,</span> <span class="s">"keycloak"</span><span class="p">);</span>

<span class="kt">var</span> <span class="n">keycloakDbUrl</span> <span class="p">=</span> <span class="n">ReferenceExpression</span><span class="p">.</span><span class="nf">Create</span><span class="p">(</span>
<span class="hll">    <span class="s">$"jdbc:postgresql://</span><span class="p">{</span><span class="n">postgres</span><span class="p">.</span><span class="n">Resource</span><span class="p">.</span><span class="n">HostName</span><span class="p">}</span><span class="s">/</span><span class="p">{</span><span class="n">keycloakDb</span><span class="p">.</span><span class="n">Resource</span><span class="p">.</span><span class="n">DatabaseName</span><span class="p">}</span><span class="s">"</span>
</span><span class="p">);</span>

<span class="kt">var</span> <span class="n">keycloak</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">AddKeycloak</span><span class="p">(</span><span class="s">"keycloak"</span><span class="p">)</span>
                      <span class="p">.</span><span class="nf">WithEnvironment</span><span class="p">(</span><span class="s">"KC_DB"</span><span class="p">,</span> <span class="s">"postgres"</span><span class="p">)</span>
                      <span class="p">.</span><span class="nf">WithEnvironment</span><span class="p">(</span><span class="s">"KC_DB_URL"</span><span class="p">,</span> <span class="n">keycloakDbUrl</span><span class="p">);</span></code></pre></figure>

<p>​</p>

<p>Neat!</p>

<p>And in Aspire 13.1, we expect new APIs to be able to retrieve that full JDBC connection string directly from the AzurePostgresFlexibleServerResource, reducing that code much more.</p>

<p>Now, let’s look at our health probes.</p>

<p>​</p>

<h3 id="3-use-the-native-http-health-probes-support"><strong>3. Use the native HTTP health probes support</strong></h3>
<p>I covered the use of health checks and probes with Aspire over <a href="https://juliocasal.com/blog/build-self-healing-apps-health-checks-and-probes-with-net-aspire">here</a>, and it works really well. But, honestly, it’s a lot of code to define two simple health probes in Azure Container Apps:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">api</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="n">AddProject</span><span class="p">&lt;</span><span class="n">TemplateApp_Api</span><span class="p">&gt;(</span><span class="s">"templateapp-api"</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">WithReference</span><span class="p">(</span><span class="n">templateAppDb</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">WaitFor</span><span class="p">(</span><span class="n">templateAppDb</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">WithExternalHttpEndpoints</span><span class="p">()</span>
            <span class="p">.</span><span class="nf">PublishAsAzureContainerApp</span><span class="p">((</span><span class="n">infra</span><span class="p">,</span> <span class="n">containerApp</span><span class="p">)</span> <span class="p">=&gt;</span>
            <span class="p">{</span>
                <span class="kt">var</span> <span class="n">container</span> <span class="p">=</span> <span class="n">containerApp</span><span class="p">.</span><span class="n">Template</span><span class="p">.</span><span class="n">Containers</span><span class="p">.</span><span class="nf">Single</span><span class="p">().</span><span class="n">Value</span><span class="p">;</span>

                <span class="n">container</span><span class="p">?.</span><span class="n">Probes</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="k">new</span> <span class="n">ContainerAppProbe</span>
                <span class="p">{</span>
                    <span class="n">ProbeType</span> <span class="p">=</span> <span class="n">ContainerAppProbeType</span><span class="p">.</span><span class="n">Liveness</span><span class="p">,</span>
                    <span class="n">HttpGet</span> <span class="p">=</span> <span class="k">new</span> <span class="n">ContainerAppHttpRequestInfo</span>
                    <span class="p">{</span>
                        <span class="n">Path</span> <span class="p">=</span> <span class="s">"/health/alive"</span><span class="p">,</span>
                        <span class="n">Port</span> <span class="p">=</span> <span class="n">healthPort</span><span class="p">,</span>
                        <span class="n">Scheme</span> <span class="p">=</span> <span class="n">ContainerAppHttpScheme</span><span class="p">.</span><span class="n">Http</span>
                    <span class="p">},</span>
                    <span class="n">PeriodSeconds</span> <span class="p">=</span> <span class="m">10</span>
                <span class="p">});</span>

                <span class="n">container</span><span class="p">?.</span><span class="n">Probes</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="k">new</span> <span class="n">ContainerAppProbe</span>
                <span class="p">{</span>
                    <span class="n">ProbeType</span> <span class="p">=</span> <span class="n">ContainerAppProbeType</span><span class="p">.</span><span class="n">Readiness</span><span class="p">,</span>
                    <span class="n">HttpGet</span> <span class="p">=</span> <span class="k">new</span> <span class="n">ContainerAppHttpRequestInfo</span>
                    <span class="p">{</span>
                        <span class="n">Path</span> <span class="p">=</span> <span class="s">"/health/ready"</span><span class="p">,</span>
                        <span class="n">Port</span> <span class="p">=</span> <span class="n">healthPort</span><span class="p">,</span>
                        <span class="n">Scheme</span> <span class="p">=</span> <span class="n">ContainerAppHttpScheme</span><span class="p">.</span><span class="n">Http</span>
                    <span class="p">},</span>
                    <span class="n">PeriodSeconds</span> <span class="p">=</span> <span class="m">10</span>
                <span class="p">});</span>
            <span class="p">})</span>
            <span class="p">.</span><span class="nf">WithEnvironment</span><span class="p">(</span><span class="s">"HTTP_PORTS"</span><span class="p">,</span> <span class="s">$"8080;</span><span class="p">{</span><span class="n">healthPort</span><span class="p">.</span><span class="nf">ToString</span><span class="p">()}</span><span class="s">"</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">WithHttpHealthCheck</span><span class="p">(</span><span class="s">"/health/ready"</span><span class="p">);</span>
</code></pre></div></div>

<p>​
With the latest Aspire updates, we can now turn all that into this:</p>

<figure class="highlight"><pre><code class="language-csharp" data-lang="csharp"><span class="hll"><span class="cp">#pragma warning disable ASPIREPROBES001</span>
</span><span class="kt">var</span> <span class="n">api</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="n">AddProject</span><span class="p">&lt;</span><span class="n">TemplateApp_Api</span><span class="p">&gt;(</span><span class="s">"templateapp-api"</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">WithReference</span><span class="p">(</span><span class="n">templateAppDb</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">WaitFor</span><span class="p">(</span><span class="n">templateAppDb</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">WithExternalHttpEndpoints</span><span class="p">()</span>
<span class="hll">            <span class="p">.</span><span class="nf">WithHttpEndpoint</span><span class="p">(</span>
</span><span class="hll">                <span class="n">name</span><span class="p">:</span> <span class="s">"health"</span><span class="p">,</span>
</span><span class="hll">                <span class="n">targetPort</span><span class="p">:</span> <span class="m">8081</span><span class="p">,</span>
</span><span class="hll">                <span class="n">isProxied</span><span class="p">:</span> <span class="k">false</span><span class="p">)</span>
</span><span class="hll">            <span class="p">.</span><span class="nf">WithHttpProbe</span><span class="p">(</span>
</span><span class="hll">                <span class="n">ProbeType</span><span class="p">.</span><span class="n">Liveness</span><span class="p">,</span>
</span><span class="hll">                <span class="s">"/health/alive"</span><span class="p">,</span>
</span><span class="hll">                <span class="n">periodSeconds</span><span class="p">:</span> <span class="m">10</span><span class="p">,</span>
</span><span class="hll">                <span class="n">endpointName</span><span class="p">:</span> <span class="s">"health"</span><span class="p">)</span>
</span><span class="hll">            <span class="p">.</span><span class="nf">WithHttpProbe</span><span class="p">(</span>
</span><span class="hll">                <span class="n">ProbeType</span><span class="p">.</span><span class="n">Readiness</span><span class="p">,</span>
</span><span class="hll">                <span class="s">"/health/ready"</span><span class="p">,</span>
</span><span class="hll">                <span class="n">periodSeconds</span><span class="p">:</span> <span class="m">10</span><span class="p">,</span>
</span><span class="hll">                <span class="n">endpointName</span><span class="p">:</span> <span class="s">"health"</span><span class="p">);</span>
</span><span class="hll"><span class="err">#</span><span class="n">pragma</span> <span class="n">warning</span> <span class="n">restore</span> <span class="n">ASPIREPROBES001</span>
</span></code></pre></figure>

<p>​</p>

<p>Let’s unpack that:</p>

<ul>
  <li><span>The <strong>WithHttpEndpoint</strong> call defines a new health endpoint that will listen on port 8081. It must go after <strong>WithExternalHttpEndpoints</strong> because it’s internal only, so only the health probes can reach it.</span></li>
  <li><span>The <strong>WithHttpProbe</strong> calls are the new way to define the probes without involving any Azure Container Apps specific syntax. </span></li>
  <li><span>The <strong>ASPIREPROBES001</strong> warning must be disabled since the new API is still experimental.</span></li>
</ul>

<p>​</p>

<p>And, on top of this, notice how we don’t need the separate <strong>WithHttpHealthCheck</strong> call, since WithHttpProbe will implicitly add it.</p>

<p>Much cleaner!</p>

<p>​</p>

<h3 id="wrapping-up"><strong>Wrapping Up</strong></h3>
<p>Aspire’s value isn’t just in what it adds, it’s in what it takes away.</p>

<p>Simpler project templates, native Azure resource properties, and cleaner APIs all point in the same direction: less ceremony, more clarity.</p>

<p><strong>The best frameworks get out of your way.</strong> That’s exactly what the Aspire team keeps delivering.</p>

<p>Upgrade, delete some code, and enjoy the cleaner application model.</p>

<p>And that’s it for today.</p>

<p>See you next Saturday.</p>

<hr />

<p><br /></p>

<p><strong>Whenever you’re ready, there are 3 ways I can help you:</strong></p>

<ol>
  <li>
    <p><strong><a href="https://juliocasal.com/courses/dotnetbootcamp">.NET Backend Developer Bootcamp</a></strong>: A complete path from ASP.NET Core fundamentals to building, containerizing, and deploying production-ready, cloud-native apps on Azure.</p>
  </li>
  <li>
    <p><strong>​<a href="https://dotnetmicroservices.com">Building Microservices With .NET</a></strong>: Transform the way you build .NET systems at scale.</p>
  </li>
  <li>
    <p><strong>​<a href="https://www.patreon.com/juliocasal" target="_blank">​Get the full source code</a></strong>: Download the working project from this article, grab exclusive course discounts, and join a private .NET community.</p>
  </li>
</ol>]]></content><author><name></name></author><summary type="html"><![CDATA[Read time: 7 minutes]]></summary></entry></feed>